g1t/apps/web/app/components/footer.tsx
| 1 | /** |
| 2 | * The site's footer: a few links worth having, who makes g1t, and a row of |
| 3 | * the trust pages with the live status dot, the account, and the legal |
| 4 | * line. Marketing and signed-out pages show it; the app's account menu |
| 5 | * has its own rows (components/shell.tsx). |
| 6 | */ |
| 7 | import { useEffect, useState } from "react"; |
| 8 | import { Link } from "react-router"; |
| 9 | |
| 10 | import type { User } from "@g1t/contracts"; |
| 11 | |
| 12 | import { Mark } from "./logo"; |
| 13 | import { COMPANY, MAKER_PRODUCTS, copyright, listed } from "../lib/legal"; |
| 14 | import { type OverallState, STATUS_JSON_URL, STATUS_URL, STATUS_WORDS, type StatusReport, dotClass } from "../lib/status"; |
| 15 | |
| 16 | /** How long one fetched report is reused across the pages of one visit. */ |
| 17 | const STATUS_REUSE_MS = 60_000; |
| 18 | let lastStatus: { report: StatusReport; at: number } | null = null; |
| 19 | let pendingStatus: Promise<StatusReport | null> | null = null; |
| 20 | |
| 21 | function fetchStatus(): Promise<StatusReport | null> { |
| 22 | if (lastStatus && Date.now() - lastStatus.at < STATUS_REUSE_MS) return Promise.resolve(lastStatus.report); |
| 23 | pendingStatus ??= fetch(STATUS_JSON_URL, { headers: { accept: "application/json" } }) |
| 24 | .then((response) => (response.ok ? (response.json() as Promise<StatusReport>) : null)) |
| 25 | .then((report) => { |
| 26 | if (report?.overall) lastStatus = { report, at: Date.now() }; |
| 27 | return report?.overall ? report : null; |
| 28 | }) |
| 29 | // The status page being unreachable only means no dot. |
| 30 | .catch(() => null) |
| 31 | .finally(() => { |
| 32 | pendingStatus = null; |
| 33 | }); |
| 34 | return pendingStatus; |
| 35 | } |
| 36 | |
| 37 | /** |
| 38 | * The live status, from status.g1t.sh once the page is up (or `when` turns |
| 39 | * true), so no page waits on it. Kept a minute across pages. Null until it |
| 40 | * arrives, or if it cannot. |
| 41 | */ |
| 42 | export function useSiteStatus(when = true): StatusReport | null { |
| 43 | const [report, setReport] = useState<StatusReport | null>(() => lastStatus?.report ?? null); |
| 44 | useEffect(() => { |
| 45 | if (!when) return; |
| 46 | let live = true; |
| 47 | fetchStatus().then((fresh) => { |
| 48 | if (live && fresh) setReport(fresh); |
| 49 | }); |
| 50 | return () => { |
| 51 | live = false; |
| 52 | }; |
| 53 | }, [when]); |
| 54 | return report; |
| 55 | } |
| 56 | |
| 57 | /** The coloured dot, with words for screen readers. */ |
| 58 | export function StatusDot({ state, className = "" }: { state: OverallState | null; className?: string }) { |
| 59 | return ( |
| 60 | <span className={`relative inline-flex size-2 shrink-0 ${className}`}> |
| 61 | {state && state !== "up" && state !== "unknown" && ( |
| 62 | <span aria-hidden className={`absolute inset-0 animate-ping rounded-full opacity-60 ${dotClass(state)}`} /> |
| 63 | )} |
| 64 | <span aria-hidden className={`relative inline-flex size-2 rounded-full ${dotClass(state)}`} /> |
| 65 | <span className="sr-only">{state ? STATUS_WORDS[state] : "Checking status"}</span> |
| 66 | </span> |
| 67 | ); |
| 68 | } |
| 69 | |
| 70 | /** Where the footer's grid links go. Plain links: some are files or other hosts. */ |
| 71 | const FOOTER_LINKS: { title: string; links: [string, string][] }[] = [ |
| 72 | { |
| 73 | title: "Product", |
| 74 | links: [ |
| 75 | ["Explore", "/explore"], |
| 76 | ["Pricing", "/pricing"], |
| 77 | ["Working with g1t", "https://docs.g1t.sh/guides/working-with-g1t/"], |
| 78 | ["Bring your own agent", "https://docs.g1t.sh/guides/bring-your-own-agent/"], |
| 79 | ["Integrations", "https://docs.g1t.sh/guides/integrations/"], |
| 80 | ], |
| 81 | }, |
| 82 | { |
| 83 | title: "Developers", |
| 84 | links: [ |
| 85 | ["Documentation", "https://docs.g1t.sh/"], |
| 86 | ["API reference", "https://docs.g1t.sh/reference/api/"], |
| 87 | ["MCP tools", "https://docs.g1t.sh/reference/mcp/"], |
| 88 | ["llms.txt", "/llms.txt"], |
| 89 | ], |
| 90 | }, |
| 91 | { |
| 92 | title: "Company", |
| 93 | links: [ |
| 94 | ["Flagon, Inc.", COMPANY.url], |
| 95 | ["Support", "/support"], |
| 96 | ["Security", "/security"], |
| 97 | ["Status", STATUS_URL], |
| 98 | ], |
| 99 | }, |
| 100 | { |
| 101 | title: "Open source", |
| 102 | links: [ |
| 103 | ["Source on g1t", "/flagon-io/g1t"], |
| 104 | ["MIT license", "/flagon-io/g1t/blob/main/LICENSE"], |
| 105 | ["Run g1t yourself", "https://docs.g1t.sh/guides/self-hosting/"], |
| 106 | ], |
| 107 | }, |
| 108 | ]; |
| 109 | |
| 110 | /** The row's own links, after Status. */ |
| 111 | const ROW_LINKS: [string, string][] = [ |
| 112 | ["Support", "/support"], |
| 113 | ["Policies", "/policies"], |
| 114 | ["Privacy", "/policies/privacy"], |
| 115 | ["Security", "/security"], |
| 116 | ]; |
| 117 | |
| 118 | /** |
| 119 | * Flagon's mark, single-ink, as in Flagon's brand files |
| 120 | * (flagon-mark-mono.svg): a flagon, its brew a tone of the text colour. |
| 121 | */ |
| 122 | function FlagonMark({ className = "" }: { className?: string }) { |
| 123 | return ( |
| 124 | <svg viewBox="11.3 12.3 41 41" fill="none" aria-hidden="true" className={className}> |
| 125 | <defs> |
| 126 | <clipPath id="g1t-footer-flagon-body"> |
| 127 | <path d="M19.5 23 L34.5 23 L36.5 46 Q36.7 48 34.3 48 L19.7 48 Q17.3 48 17.5 46 Z" /> |
| 128 | </clipPath> |
| 129 | </defs> |
| 130 | <g clipPath="url(#g1t-footer-flagon-body)"> |
| 131 | <path |
| 132 | d="M13 27 q3.75 -0.7 7.5 0 t7.5 0 t7.5 0 t7.5 0 t7.5 0 L51 52 L13 52 Z" |
| 133 | fill="currentColor" |
| 134 | fillOpacity="0.22" |
| 135 | /> |
| 136 | </g> |
| 137 | <path |
| 138 | d="M37 27 L45.5 27 L48.5 30 L48.5 36.5 L45.5 39.5 L38 39.5" |
| 139 | stroke="currentColor" |
| 140 | strokeWidth="3.1" |
| 141 | strokeLinecap="round" |
| 142 | strokeLinejoin="round" |
| 143 | /> |
| 144 | <path |
| 145 | d="M17.5 21 L36.5 21 L38.6 47 Q39 50 36 50 L18 50 Q15 50 15.4 47 Z" |
| 146 | stroke="currentColor" |
| 147 | strokeWidth="3.1" |
| 148 | strokeLinejoin="round" |
| 149 | /> |
| 150 | <path d="M17 21 Q16.3 16.3 20 15.6 L33 15.6 Q37.2 16.2 37 21 Z" stroke="currentColor" strokeWidth="3" strokeLinejoin="round" /> |
| 151 | <path d="M36.7 17.1 L39.4 16.1" stroke="currentColor" strokeWidth="2.4" strokeLinejoin="round" /> |
| 152 | <circle cx="40.3" cy="15.8" r="1.4" fill="currentColor" /> |
| 153 | </svg> |
| 154 | ); |
| 155 | } |
| 156 | |
| 157 | /** "g1t is designed, built, and backed by Flagon, Inc., …" */ |
| 158 | export function MakerLine() { |
| 159 | const products = MAKER_PRODUCTS.map((product) => product.name); |
| 160 | return ( |
| 161 | <p className="text-sm leading-6 text-muted text-balance"> |
| 162 | <Mark tight className="mr-1.5 inline-block h-[0.85em] w-auto align-baseline" /> |
| 163 | <span className="font-semibold text-fg">g1t</span> is designed, built, and backed by{" "} |
| 164 | <a href={COMPANY.url} className="whitespace-nowrap font-semibold text-fg hover:underline hover:underline-offset-4"> |
| 165 | <FlagonMark className="mr-1 inline-block size-[0.95em] -translate-y-px align-middle" /> |
| 166 | {COMPANY.name} |
| 167 | </a> |
| 168 | {products.length > 0 ? `, the people behind ${listed(products)}.` : `, ${COMPANY.about}.`} |
| 169 | </p> |
| 170 | ); |
| 171 | } |
| 172 | |
| 173 | const ROW_LINK = "rounded-sm transition-colors hover:text-fg"; |
| 174 | |
| 175 | /** |
| 176 | * Status, the trust pages, the account and the legal line, separated by |
| 177 | * dots that wrap away cleanly on a phone. |
| 178 | */ |
| 179 | export function LegalRow({ user }: { user: User | null | undefined }) { |
| 180 | const status = useSiteStatus(); |
| 181 | const links = [ |
| 182 | <a key="status" href={STATUS_URL} className={`inline-flex items-center gap-1.5 ${ROW_LINK}`}> |
| 183 | <StatusDot state={status?.overall.state ?? null} /> |
| 184 | Status |
| 185 | </a>, |
| 186 | ...ROW_LINKS.map(([label, to]) => ( |
| 187 | <Link key={to} to={to} className={ROW_LINK}> |
| 188 | {label} |
| 189 | </Link> |
| 190 | )), |
| 191 | ]; |
| 192 | const account = [ |
| 193 | user ? ( |
| 194 | <Link key="me" to={`/u/${user.username}`} className={`font-medium text-fg/90 ${ROW_LINK}`}> |
| 195 | {user.username} |
| 196 | </Link> |
| 197 | ) : ( |
| 198 | <Link key="login" to="/login" className={ROW_LINK}> |
| 199 | Sign in |
| 200 | </Link> |
| 201 | ), |
| 202 | ]; |
| 203 | const legal = [ |
| 204 | ...account, |
| 205 | <span key="copyright" className="text-faint"> |
| 206 | {copyright()} |
| 207 | </span>, |
| 208 | ]; |
| 209 | const gap = "gap-x-3"; |
| 210 | const dot = ( |
| 211 | <span aria-hidden className="text-line-strong"> |
| 212 | · |
| 213 | </span> |
| 214 | ); |
| 215 | // Two runs, the links and the legal line, so that on a narrow screen the |
| 216 | // second starts its own line rather than a line starting with a dot. |
| 217 | const run = (items: React.ReactNode[]) => |
| 218 | items.map((item, index) => ( |
| 219 | <li key={index} className={`flex items-center ${gap}`}> |
| 220 | {index > 0 && dot} |
| 221 | {item} |
| 222 | </li> |
| 223 | )); |
| 224 | return ( |
| 225 | <div className={`flex flex-wrap items-center justify-center gap-y-1.5 ${gap} text-[0.8125rem] text-muted`}> |
| 226 | <ul className={`flex flex-wrap items-center justify-center gap-y-1.5 ${gap}`}>{run(links)}</ul> |
| 227 | {/* Between the two runs only while they share a line. */} |
| 228 | <span className="hidden md:inline">{dot}</span> |
| 229 | <ul className={`flex flex-wrap items-center justify-center gap-y-1.5 ${gap}`}>{run(legal)}</ul> |
| 230 | </div> |
| 231 | ); |
| 232 | } |
| 233 | |
| 234 | export function SiteFooter({ user }: { user: User | null | undefined }) { |
| 235 | return ( |
| 236 | <footer className="mt-24 border-t border-line"> |
| 237 | <nav aria-label="Footer" className="mx-auto grid max-w-6xl grid-cols-2 gap-x-8 gap-y-8 px-4 pt-12 pb-10 sm:grid-cols-4"> |
| 238 | {FOOTER_LINKS.map((group) => ( |
| 239 | <div key={group.title}> |
| 240 | <p className="text-sm font-medium">{group.title}</p> |
| 241 | <ul className="mt-3 space-y-2 text-sm text-muted"> |
| 242 | {group.links.map(([label, to]) => ( |
| 243 | <li key={to}> |
| 244 | <a href={to} className="transition-colors hover:text-fg"> |
| 245 | {label} |
| 246 | </a> |
| 247 | </li> |
| 248 | ))} |
| 249 | </ul> |
| 250 | </div> |
| 251 | ))} |
| 252 | </nav> |
| 253 | <div className="border-t border-line"> |
| 254 | <div className="mx-auto max-w-3xl space-y-4 px-4 py-10 text-center"> |
| 255 | <MakerLine /> |
| 256 | <LegalRow user={user} /> |
| 257 | </div> |
| 258 | </div> |
| 259 | </footer> |
| 260 | ); |
| 261 | } |