Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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