pr_01m47d24b0e6n91zwymwxg0vpx/apps/web/app/components/footer.tsx
Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 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 | } |