Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Public site URL (used for metadata/canonical/sitemap)
NEXT_PUBLIC_SITE_URL=http://localhost:3000

# Optional: Google Search Console verification token
GOOGLE_SITE_VERIFICATION=

# Firebase Admin (required for Firestore-backed routes)
# Keep as a single-line JSON string
GOOGLE_SERVICE_ACCOUNT_KEY={"type":"service_account","project_id":"your-project-id","private_key_id":"...","private_key":"-----BEGIN PRIVATE KEY-----\\n...\\n-----END PRIVATE KEY-----\\n","client_email":"...","client_id":"...","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token"}

# MAGEN bot protection
MAGEN_API_KEY=your_magen_api_key
MAGEN_SECRET_KEY=your_magen_secret_key
# Optional override
MAGEN_API_URL=https://api.magenminer.io/v1

# Admin bootstrap (one-time first admin setup endpoint)
ADMIN_SETUP_SECRET=your_random_long_secret

# Resend email provider
RESEND_API_KEY=your_resend_api_key

# Vercel Blob uploads (used by /api/upload)
BLOB_READ_WRITE_TOKEN=your_vercel_blob_rw_token

# Discord bot space status (used by /api/discord-status)
STATUS_API_TOKEN=your_discord_status_api_token
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ yarn-error.log*

# env files (can opt-in for committing if needed)
.env*
!.env.example

# vercel
.vercel
Expand Down
67 changes: 67 additions & 0 deletions app/api/discord-status/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { NextResponse } from "next/server";

interface DiscordStatusResponse {
guildId?: string;
state?: string;
updatedAt?: number;
}

const CACHE_CONTROL = "public, s-maxage=120, stale-while-revalidate=60";

function buildFallback() {
return {
ok: false,
online: false,
state: "unknown" as const,
updatedAt: null,
};
}

function jsonWithCache(payload: ReturnType<typeof buildFallback> | {
ok: true;
online: boolean;
state: "open" | "closed" | "unknown";
updatedAt: number | null;
}) {
return NextResponse.json(payload, {
headers: {
"Cache-Control": CACHE_CONTROL,
},
});
}

export async function GET() {
const token = process.env.STATUS_API_TOKEN;

if (!token) {
return jsonWithCache(buildFallback());
}

try {
const response = await fetch("https://devsa-discord-bot.onrender.com/status", {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
},
signal: AbortSignal.timeout(3000),
cache: "no-store",
});

if (!response.ok) return jsonWithCache(buildFallback());

const data = (await response.json()) as DiscordStatusResponse;
const rawState = (data.state || "").toLowerCase();
const state: "open" | "closed" | "unknown" =
rawState === "open" ? "open" : rawState === "closed" ? "closed" : "unknown";
const online = state === "open";

return jsonWithCache({
ok: true,
online,
state,
updatedAt: typeof data.updatedAt === "number" ? data.updatedAt : null,
});
} catch {
return jsonWithCache(buildFallback());
}
}
94 changes: 94 additions & 0 deletions components/coworking-space/hero-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { motion } from "motion/react"
import Image from "next/image"
import { useEffect, useMemo, useState } from "react"

const mediaItems = [
{
Expand Down Expand Up @@ -47,6 +48,83 @@ const mediaItems = [
]

export function HeroSection() {
const POLL_INTERVAL_MS = 120_000
const [status, setStatus] = useState<{
state: "open" | "closed" | "unknown"
updatedAt: number | null
}>({
state: "unknown",
updatedAt: null,
})
const [now, setNow] = useState(Date.now())

useEffect(() => {
let cancelled = false

const fetchStatus = async () => {
try {
const response = await fetch("/api/discord-status", { cache: "no-store" })
const data = await response.json()

if (!cancelled) {
const rawState = data?.state
const state =
rawState === "open" || rawState === "closed" || rawState === "unknown"
? rawState
: "unknown"

setStatus({
state,
updatedAt: typeof data?.updatedAt === "number" ? data.updatedAt : null,
})
}
} catch {
if (!cancelled) {
setStatus({
state: "unknown",
updatedAt: null,
})
}
}
}

fetchStatus()

const timeTimer = setInterval(() => setNow(Date.now()), POLL_INTERVAL_MS)
const refreshTimer = setInterval(fetchStatus, POLL_INTERVAL_MS)

return () => {
cancelled = true
clearInterval(timeTimer)
clearInterval(refreshTimer)
}
}, [])

const lastUpdatedText = useMemo(() => {
if (!status.updatedAt) return "Update unavailable"

const elapsedMs = Math.max(0, now - status.updatedAt)
const elapsedMinutes = Math.floor(elapsedMs / 60_000)

if (elapsedMinutes < 1) return "Updated just now"
if (elapsedMinutes < 60) return `Updated ${elapsedMinutes}m ago`

const elapsedHours = Math.floor(elapsedMinutes / 60)
if (elapsedHours < 24) return `Updated ${elapsedHours}h ago`

const elapsedDays = Math.floor(elapsedHours / 24)
return `Updated ${elapsedDays}d ago`
}, [now, status.updatedAt])

const isOpen = status.state === "open"
const isClosed = status.state === "closed"
const statusLabel = isOpen ? "Open" : isClosed ? "Closed" : "Unknown"
const indicatorClass = isOpen
? "bg-emerald-500 shadow-[0_0_10px_rgba(16,185,129,0.9)]"
: isClosed
? "bg-red-500"
: "bg-gray-400"

return (
<section className="relative" data-testid="coworking-homepage-container-carousel" id="carousel" data-bg-type="light">
<div className="-mt-px pt-[calc(1.5rem-var(--header-height))] md:pt-[calc(6rem-var(--header-height))] lg:pt-[calc(12rem-var(--header-height))] pb-6 md:pb-24 text-black bg-white">
Expand Down Expand Up @@ -90,6 +168,7 @@ export function HeroSection() {
and more!
</p>
</div>

</motion.div>
</div>
</div>
Expand Down Expand Up @@ -161,6 +240,21 @@ export function HeroSection() {
</div>
</div>
</div>

<div className="fixed bottom-4 right-4 z-50 max-w-[calc(100vw-2rem)] sm:bottom-6 sm:right-6">
<div className="flex items-center gap-3 rounded-xl border border-gray-200 bg-white/95 px-4 py-3 shadow-lg backdrop-blur-sm">
<span
aria-hidden="true"
className={`h-2.5 w-2.5 rounded-full ${indicatorClass}`}
/>
<div className="leading-tight">
<p className="text-sm font-semibold text-gray-900">
Coworking Space: {statusLabel}
</p>
<p className="text-xs text-gray-500">{lastUpdatedText}</p>
</div>
</div>
</div>
</section>
)
}