diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..08677aa --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index 5ef6a52..7b8da95 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel diff --git a/app/api/discord-status/route.ts b/app/api/discord-status/route.ts new file mode 100644 index 0000000..6acd20a --- /dev/null +++ b/app/api/discord-status/route.ts @@ -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 | { + 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()); + } +} diff --git a/components/coworking-space/hero-section.tsx b/components/coworking-space/hero-section.tsx index ceed229..7bc53c7 100644 --- a/components/coworking-space/hero-section.tsx +++ b/components/coworking-space/hero-section.tsx @@ -2,6 +2,7 @@ import { motion } from "motion/react" import Image from "next/image" +import { useEffect, useMemo, useState } from "react" const mediaItems = [ { @@ -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 ( ) }