pr_01m47d15m3e54sn21z27rpy5n9/apps/web/app/components/footer.tsx

252 lines8,933 bytesCodeBlame

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 look1/**
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 */
7import { useEffect } from "react";
8import { Link, useFetcher } from "react-router";
9
10import type { User } from "@g1t/contracts";
11
12import { Mark } from "./logo";
13import { COMPANY, MAKER_PRODUCTS, copyright, listed } from "../lib/legal";
14import { 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 */
20export 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
29const 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. */
37export 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. */
50const 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. */
90const 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 */
101function 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., …" */
137export 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
152const 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 */
158export 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
225export 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}