diff --git a/apps/marketing/next.config.js b/apps/marketing/next.config.js index b8c8684..31c272c 100644 --- a/apps/marketing/next.config.js +++ b/apps/marketing/next.config.js @@ -1,7 +1,7 @@ /** @type {import("next").NextConfig} */ const config = { - // Static export for marketing site - output: "export", + // Use static export in production by default; keep dev server dynamic + output: process.env.NEXT_OUTPUT ?? (process.env.NODE_ENV === "production" ? "export" : undefined), images: { // Required for static export if using images unoptimized: true, @@ -9,4 +9,3 @@ const config = { }; export default config; - diff --git a/apps/marketing/src/app/api/status/route.ts b/apps/marketing/src/app/api/status/route.ts new file mode 100644 index 0000000..904211d --- /dev/null +++ b/apps/marketing/src/app/api/status/route.ts @@ -0,0 +1,92 @@ +import type { NextRequest } from "next/server"; + +export const dynamic = "force-dynamic"; + +type StatusState = "operational" | "degraded" | "down" | "unknown"; + +function normalizeStatus(data: any): StatusState { + const overall = (data?.overallStatus || data?.status || "") + .toString() + .toLowerCase(); + if ( + overall.includes("up") || + overall.includes("ok") || + overall.includes("oper") + ) + return "operational"; + if (overall.includes("degrad") || overall.includes("partial")) + return "degraded"; + if ( + overall.includes("down") || + overall.includes("outage") || + overall.includes("incident") + ) + return "down"; + + // Status page incidents (default endpoint) + const sp = (data?.statusPage || data?.status_page || {}) as any; + const incidentRaw = sp?.incident ?? sp?.incidents ?? data?.incident ?? data?.incidents; + if (incidentRaw) { + const incidents: any[] = Array.isArray(incidentRaw) + ? incidentRaw + : [incidentRaw]; + const hasActive = incidents.some((i) => { + const resolved = + i?.resolved === true || + (typeof i?.status === "string" && i.status.toLowerCase().includes("resolv")) || + !!i?.endAt || + !!i?.end_at || + !!i?.endTime || + !!i?.end_time; + return !resolved; + }); + if (hasActive) return "down"; + } + + // Heartbeat map shape: { [monitorId: string]: Heartbeat[] } + if (data?.heartbeatList && typeof data.heartbeatList === "object") { + const lists = Object.values(data.heartbeatList); + const isAnyDown = lists.some( + (arr: any) => Array.isArray(arr) && arr.some((hb) => hb?.status === 0) + ); + if (isAnyDown) return "down"; + return "operational"; + } + + if (typeof data?.allUp === "boolean") + return data.allUp ? "operational" : "down"; + + return "unknown"; +} + +export async function GET(_req: NextRequest) { + const base = ( + process.env.STATUS_BASE_URL || "https://status.usesend.com" + ).replace(/\/$/, ""); + const candidates = [ + "/api/status-page/default", + "/api/status-page/heartbeat/default", + ]; + let status = "unknown"; + + for (const path of candidates) { + try { + const res = await fetch(`${base}${path}`, { cache: "no-store" }); + if (!res.ok) continue; + const data = await res.json().catch(() => null); + if (!data) continue; + status = normalizeStatus(data); + if (status !== "unknown") break; + } catch { + // try next candidate + continue; + } + } + + return Response.json( + { status }, + { + headers: { "Cache-Control": "s-maxage=30, stale-while-revalidate=300" }, + } + ); +} diff --git a/apps/marketing/src/components/StatusBadge.tsx b/apps/marketing/src/components/StatusBadge.tsx index e7650ae..1f8e114 100644 --- a/apps/marketing/src/components/StatusBadge.tsx +++ b/apps/marketing/src/components/StatusBadge.tsx @@ -4,66 +4,20 @@ import { useEffect, useMemo, useState } from "react"; type StatusState = "operational" | "degraded" | "down" | "unknown"; -// Best-effort fetcher for Uptime Kuma public status page JSON. -// Falls back gracefully if the endpoint or CORS is not available. -async function fetchUptimeStatus(baseUrl: string): Promise { - const candidates = [ - "/api/status-page/heartbeat/default", // specific uptime-kuma status page slug - ]; - - for (const path of candidates) { - try { - const res = await fetch(baseUrl.replace(/\/$/, "") + path, { - // Always fetch from the browser; avoid caching too aggressively - cache: "no-store", - }); - if (!res.ok) continue; - const data: any = await res.json().catch(() => null); - if (!data) continue; - - // Heuristics across possible Kuma payloads - // 1) overallStatus or status fields - const overall = (data.overallStatus || data.status || "") - .toString() - .toLowerCase(); - if ( - overall.includes("up") || - overall.includes("ok") || - overall.includes("oper") - ) - return "operational"; - if (overall.includes("degrad") || overall.includes("partial")) - return "degraded"; - if ( - overall.includes("down") || - overall.includes("outage") || - overall.includes("incident") - ) - return "down"; - - // 2) heartbeat style: if any monitor is down - if (Array.isArray(data.monitors) && Array.isArray(data.heartbeatList)) { - // If any lastHeartbeatStatus === 0 (down) => down - const isAnyDown = Object.values(data.heartbeatList).some( - (arr: any[]) => - Array.isArray(arr) && arr.some((hb) => hb?.status === 0) - ); - if (isAnyDown) return "down"; - return "operational"; - } - - // 3) Generic boolean hints - if (typeof data.allUp === "boolean") - return data.allUp ? "operational" : "down"; - - // Unknown but successful response - return "unknown"; - } catch { - // Try next candidate - continue; - } +// Fetch normalized status from the app's server API to bypass CORS. +async function fetchUptimeStatus(): Promise { + try { + const res = await fetch("/api/status", { cache: "no-store" }); + if (!res.ok) return "unknown"; + const data: any = await res.json().catch(() => null); + const s = (data?.status || "").toString().toLowerCase(); + console.log(data); + if (s === "operational" || s === "degraded" || s === "down") + return s as StatusState; + return "unknown"; + } catch { + return "unknown"; } - return "unknown"; } export function StatusBadge({ @@ -76,7 +30,8 @@ export function StatusBadge({ useEffect(() => { let mounted = true; const load = async () => { - const s = await fetchUptimeStatus(baseUrl); + const s = await fetchUptimeStatus(); + if (mounted) setStatus(s); }; load(); @@ -85,7 +40,7 @@ export function StatusBadge({ mounted = false; clearInterval(id); }; - }, [baseUrl]); + }, []); const dotClass = useMemo(() => { switch (status) {