Skip to content
285 linesCodeBlameRaw
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 */
7import { useEffect, useState } from "react";
8import { Link } from "react-router";
9
10import type { User } from "@g1t/contracts";
11
12import { Mark } from "./logo";
13import { COMPANY, FOUNDER, MAKER_PRODUCTS, copyright, listed } from "../lib/legal";
14import { 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. */
17const STATUS_REUSE_MS = 60_000;
18let lastStatus: { report: StatusReport; at: number } | null = null;
19let pendingStatus: Promise<StatusReport | null> | null = null;
20
21function 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 */
42export 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. */
58export 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. */
71const FOOTER_LINKS: { title: string; links: [string, string][] }[] = [
72 {
73 title: "Product",
74 links: [
75 ["Explore", "/explore"],
76 ["Pricing", "/pricing"],
77 ["Chat", "https://docs.g1t.sh/guides/chat/"],
78 ["Agents", "https://docs.g1t.sh/guides/agents/"],
79 ["Working with g1t", "https://docs.g1t.sh/guides/working-with-g1t/"],
80 ["Bring your own agent", "https://docs.g1t.sh/guides/bring-your-own-agent/"],
81 ["Integrations", "https://docs.g1t.sh/guides/integrations/"],
82 ],
83 },
84 {
85 title: "Developers",
86 links: [
87 ["Documentation", "https://docs.g1t.sh/"],
88 ["API reference", "https://docs.g1t.sh/reference/api/"],
89 ["MCP tools", "https://docs.g1t.sh/reference/mcp/"],
90 ["llms.txt", "/llms.txt"],
91 ],
92 },
93 {
94 title: "Company",
95 links: [
96 ["Flagon, Inc.", COMPANY.url],
97 ["Support", "/support"],
98 ["Security", "/security"],
99 ["Status", STATUS_URL],
100 ],
101 },
102 {
103 title: "Open source",
104 links: [
105 ["Source on g1t", "/flagon-io/g1t"],
106 ["MIT license", "/flagon-io/g1t/blob/main/LICENSE"],
107 ["Run g1t yourself", "https://docs.g1t.sh/guides/self-hosting/"],
108 ],
109 },
110];
111
112/** The row's own links, after Status. */
113const ROW_LINKS: [string, string][] = [
114 ["Support", "/support"],
115 ["Policies", "/policies"],
116 ["Privacy", "/policies/privacy"],
117 ["Security", "/security"],
118];
119
120/**
121 * Flagon's mark, single-ink, as in Flagon's brand files
122 * (flagon-mark-mono.svg): a flagon, its brew a tone of the text colour.
123 */
124function FlagonMark({ className = "" }: { className?: string }) {
125 return (
126 <svg viewBox="11.3 12.3 41 41" fill="none" aria-hidden="true" className={className}>
127 <defs>
128 <clipPath id="g1t-footer-flagon-body">
129 <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" />
130 </clipPath>
131 </defs>
132 <g clipPath="url(#g1t-footer-flagon-body)">
133 <path
134 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"
135 fill="currentColor"
136 fillOpacity="0.22"
137 />
138 </g>
139 <path
140 d="M37 27 L45.5 27 L48.5 30 L48.5 36.5 L45.5 39.5 L38 39.5"
141 stroke="currentColor"
142 strokeWidth="3.1"
143 strokeLinecap="round"
144 strokeLinejoin="round"
145 />
146 <path
147 d="M17.5 21 L36.5 21 L38.6 47 Q39 50 36 50 L18 50 Q15 50 15.4 47 Z"
148 stroke="currentColor"
149 strokeWidth="3.1"
150 strokeLinejoin="round"
151 />
152 <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" />
153 <path d="M36.7 17.1 L39.4 16.1" stroke="currentColor" strokeWidth="2.4" strokeLinejoin="round" />
154 <circle cx="40.3" cy="15.8" r="1.4" fill="currentColor" />
155 </svg>
156 );
157}
158
159/** Who builds g1t, by name, with their photo and their own words. Nothing until FOUNDER is filled in. */
160function FounderBlock() {
161 if (!FOUNDER) return null;
162 return (
163 <figure className="mx-auto mb-6 flex max-w-xl flex-col items-center gap-4 text-center sm:flex-row sm:items-start sm:text-left">
164 <img src={FOUNDER.photo} alt="" width={56} height={56} className="size-14 shrink-0 rounded-full ring-1 ring-line" />
165 <div>
166 <blockquote className="text-sm leading-6 text-fg-soft">{FOUNDER.why}</blockquote>
167 <figcaption className="mt-2 text-sm text-muted">
168 <span className="font-medium text-fg">{FOUNDER.name}</span>, {FOUNDER.role}
169 {FOUNDER.links.map((link) => (
170 <a key={link.url} href={link.url} rel="me noopener" className="ml-3 hover:text-fg hover:underline hover:underline-offset-4">
171 {link.label}
172 </a>
173 ))}
174 </figcaption>
175 </div>
176 </figure>
177 );
178}
179
180/** "g1t is designed, built, and backed by Flagon, Inc., …" */
181export function MakerLine() {
182 const products = MAKER_PRODUCTS.map((product) => product.name);
183 return (
184 <p className="text-sm leading-6 text-muted text-balance">
185 <Mark tight className="mr-1.5 inline-block h-[0.85em] w-auto align-baseline" />
186 <span className="font-semibold text-fg">g1t</span> is designed, built, and backed by{" "}
187 <a href={COMPANY.url} className="whitespace-nowrap font-semibold text-fg hover:underline hover:underline-offset-4">
188 <FlagonMark className="mr-1 inline-block size-[0.95em] -translate-y-px align-middle" />
189 {COMPANY.name}
190 </a>
191 {products.length > 0 ? `, the people behind ${listed(products)}.` : `, ${COMPANY.about}.`}
192 </p>
193 );
194}
195
196const ROW_LINK = "rounded-sm transition-colors hover:text-fg";
197
198/**
199 * Status, the trust pages, the account and the legal line, separated by
200 * dots that wrap away cleanly on a phone.
201 */
202export function LegalRow({ user }: { user: User | null | undefined }) {
203 const status = useSiteStatus();
204 const links = [
205 <a key="status" href={STATUS_URL} className={`inline-flex items-center gap-1.5 ${ROW_LINK}`}>
206 <StatusDot state={status?.overall.state ?? null} />
207 Status
208 </a>,
209 ...ROW_LINKS.map(([label, to]) => (
210 <Link key={to} to={to} className={ROW_LINK}>
211 {label}
212 </Link>
213 )),
214 ];
215 const account = [
216 user ? (
217 <Link key="me" to={`/u/${user.username}`} className={`font-medium text-fg/90 ${ROW_LINK}`}>
218 {user.username}
219 </Link>
220 ) : (
221 <Link key="login" to="/login" className={ROW_LINK}>
222 Sign in
223 </Link>
224 ),
225 ];
226 const legal = [
227 ...account,
228 <span key="copyright" className="text-faint">
229 {copyright()}
230 </span>,
231 ];
232 const gap = "gap-x-3";
233 const dot = (
234 <span aria-hidden className="text-line-strong">
235 ·
236 </span>
237 );
238 // Two runs, the links and the legal line, so that on a narrow screen the
239 // second starts its own line rather than a line starting with a dot.
240 const run = (items: React.ReactNode[]) =>
241 items.map((item, index) => (
242 <li key={index} className={`flex items-center ${gap}`}>
243 {index > 0 && dot}
244 {item}
245 </li>
246 ));
247 return (
248 <div className={`flex flex-wrap items-center justify-center gap-y-1.5 ${gap} text-[0.8125rem] text-muted`}>
249 <ul className={`flex flex-wrap items-center justify-center gap-y-1.5 ${gap}`}>{run(links)}</ul>
250 {/* Between the two runs only while they share a line. */}
251 <span className="hidden md:inline">{dot}</span>
252 <ul className={`flex flex-wrap items-center justify-center gap-y-1.5 ${gap}`}>{run(legal)}</ul>
253 </div>
254 );
255}
256
257export function SiteFooter({ user }: { user: User | null | undefined }) {
258 return (
259 <footer className="mt-24 border-t border-line">
260 <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">
261 {FOOTER_LINKS.map((group) => (
262 <div key={group.title}>
263 <p className="text-sm font-medium">{group.title}</p>
264 <ul className="mt-3 space-y-2 text-sm text-muted">
265 {group.links.map(([label, to]) => (
266 <li key={to}>
267 <a href={to} className="transition-colors hover:text-fg">
268 {label}
269 </a>
270 </li>
271 ))}
272 </ul>
273 </div>
274 ))}
275 </nav>
276 <div className="border-t border-line">
277 <div className="mx-auto max-w-3xl space-y-4 px-4 py-10 text-center">
278 <FounderBlock />
279 <MakerLine />
280 <LegalRow user={user} />
281 </div>
282 </div>
283 </footer>
284 );
285}