From 6e45c65753d555b47d3b00d11b196026a3019253 Mon Sep 17 00:00:00 2001 From: Zaquariah Holland Date: Sun, 8 Feb 2026 23:43:21 -0600 Subject: [PATCH 1/3] feat: toast status box --- app/api/discord-status/route.ts | 52 ++++++++++++ components/coworking-space/hero-section.tsx | 93 +++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 app/api/discord-status/route.ts diff --git a/app/api/discord-status/route.ts b/app/api/discord-status/route.ts new file mode 100644 index 0000000..1a6b003 --- /dev/null +++ b/app/api/discord-status/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from "next/server"; + +interface DiscordStatusResponse { + guildId?: string; + state?: string; + updatedAt?: number; +} + +function buildFallback() { + return { + ok: false, + online: false, + state: "unknown" as const, + updatedAt: null, + }; +} + +export async function GET() { + const token = process.env.STATUS_API_TOKEN; + + if (!token) { + return NextResponse.json(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 NextResponse.json(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 NextResponse.json({ + ok: true, + online, + state, + updatedAt: typeof data.updatedAt === "number" ? data.updatedAt : null, + }); + } catch { + return NextResponse.json(buildFallback()); + } +} diff --git a/components/coworking-space/hero-section.tsx b/components/coworking-space/hero-section.tsx index ceed229..2714ac6 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,82 @@ const mediaItems = [ ] export function HeroSection() { + 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()), 60_000) + const refreshTimer = setInterval(fetchStatus, 60_000) + + 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 ( ) } From 3ac482abaafbefc7eff7a99f9107901c89f3441c Mon Sep 17 00:00:00 2001 From: Zaquariah Holland Date: Sun, 8 Feb 2026 23:59:40 -0600 Subject: [PATCH 2/3] chore: added .env.example --- .env.example | 27 +++++++++++++++++++++++++++ .gitignore | 1 + 2 files changed, 28 insertions(+) create mode 100644 .env.example 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 From 3b2ac78df25ec230d99d5126f13eacbad1430388 Mon Sep 17 00:00:00 2001 From: Zaquariah Holland Date: Wed, 11 Feb 2026 22:08:16 -0600 Subject: [PATCH 3/3] feat: update interval --- app/api/discord-status/route.ts | 23 +++++++++++++++++---- components/coworking-space/hero-section.tsx | 5 +++-- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/app/api/discord-status/route.ts b/app/api/discord-status/route.ts index 1a6b003..6acd20a 100644 --- a/app/api/discord-status/route.ts +++ b/app/api/discord-status/route.ts @@ -6,6 +6,8 @@ interface DiscordStatusResponse { updatedAt?: number; } +const CACHE_CONTROL = "public, s-maxage=120, stale-while-revalidate=60"; + function buildFallback() { return { ok: false, @@ -15,11 +17,24 @@ function buildFallback() { }; } +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 NextResponse.json(buildFallback()); + return jsonWithCache(buildFallback()); } try { @@ -32,7 +47,7 @@ export async function GET() { cache: "no-store", }); - if (!response.ok) return NextResponse.json(buildFallback()); + if (!response.ok) return jsonWithCache(buildFallback()); const data = (await response.json()) as DiscordStatusResponse; const rawState = (data.state || "").toLowerCase(); @@ -40,13 +55,13 @@ export async function GET() { rawState === "open" ? "open" : rawState === "closed" ? "closed" : "unknown"; const online = state === "open"; - return NextResponse.json({ + return jsonWithCache({ ok: true, online, state, updatedAt: typeof data.updatedAt === "number" ? data.updatedAt : null, }); } catch { - return NextResponse.json(buildFallback()); + return jsonWithCache(buildFallback()); } } diff --git a/components/coworking-space/hero-section.tsx b/components/coworking-space/hero-section.tsx index 2714ac6..7bc53c7 100644 --- a/components/coworking-space/hero-section.tsx +++ b/components/coworking-space/hero-section.tsx @@ -48,6 +48,7 @@ const mediaItems = [ ] export function HeroSection() { + const POLL_INTERVAL_MS = 120_000 const [status, setStatus] = useState<{ state: "open" | "closed" | "unknown" updatedAt: number | null @@ -89,8 +90,8 @@ export function HeroSection() { fetchStatus() - const timeTimer = setInterval(() => setNow(Date.now()), 60_000) - const refreshTimer = setInterval(fetchStatus, 60_000) + const timeTimer = setInterval(() => setNow(Date.now()), POLL_INTERVAL_MS) + const refreshTimer = setInterval(fetchStatus, POLL_INTERVAL_MS) return () => { cancelled = true