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