| 1 | import { CircleAlert, Lock, Ticket } from "lucide-react"; |
| 2 | import { Form, Link, data, redirect } from "react-router"; |
| 3 | |
| 4 | import type { InvitePreview, User } from "@g1t/contracts"; |
| 5 | |
| 6 | import type { Route } from "./+types/invite"; |
| 7 | import { page } from "../lib/meta"; |
| 8 | import { Mark } from "../components/logo"; |
| 9 | import { ContinueWithGithub, OrDivider } from "../components/github"; |
| 10 | import { Honeypot } from "../components/honeypot"; |
| 11 | import { Avatar, ButtonLink, ErrorText, Field, Input, SubmitButton } from "../components/ui"; |
| 12 | import { githubSignInEnabled } from "../lib/github.server"; |
| 13 | import { identity } from "../lib/services.server"; |
| 14 | import { cleanCode, landingFor, looksAutomated, suggestUsername, welcomeCookie } from "../lib/invites"; |
| 15 | import { clientKey } from "../lib/registration.server"; |
| 16 | import { assertSameOrigin, getViewer, requireUser, roleIn, startSession } from "../lib/session.server"; |
| 17 | import { rememberWorkspace } from "../lib/workspace-choice"; |
| 18 | |
| 19 | export function meta(args: Route.MetaArgs) { |
| 20 | return page(args, { |
| 21 | title: "You're invited · g1t", |
| 22 | description: "An invite to g1t, where people and agents ship software together.", |
| 23 | }); |
| 24 | } |
| 25 | |
| 26 | /** |
| 27 | * Where an invite is used, from first click to landing inside: who sent |
| 28 | * it and what it is for, then signing up on this page (or signing in, for |
| 29 | * an address that has an account), and the workspace or repository it |
| 30 | * gives. Signing in or up elsewhere (GitHub, /login) comes back here with |
| 31 | * `?accept=1`, which finishes the job. |
| 32 | */ |
| 33 | export async function loader({ request, context, params }: Route.LoaderArgs) { |
| 34 | const code = cleanCode(params.code); |
| 35 | const viewer = getViewer(context); |
| 36 | const accepting = new URL(request.url).searchParams.get("accept") === "1"; |
| 37 | const checked = await identity.checkInvite(code, clientKey(request), { viewer, anyStatus: true }); |
| 38 | const invite = checked.ok ? checked.value : null; |
| 39 | const lands = invite ? landingFor(invite) : null; |
| 40 | |
| 41 | if (viewer && invite) { |
| 42 | // Used by this person already: just made the account with it (through |
| 43 | // GitHub), or an old link opened again. |
| 44 | if (invite.status === "redeemed" && invite.forViewer) throw landIn(request, invite, [], accepting); |
| 45 | // Back from signing in to accept an invite sent to their address. |
| 46 | if (accepting && invite.status === "pending" && invite.forViewer === true && lands && !alreadyIn(viewer, invite)) { |
| 47 | const accepted = await identity.acceptInvite(viewer, code); |
| 48 | if (accepted.ok) throw landIn(request, invite, [], true); |
| 49 | return { ...base(), acceptError: accepted.error.message }; |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | function base() { |
| 54 | return { |
| 55 | code, |
| 56 | invite, |
| 57 | error: checked.ok ? null : checked.error.message, |
| 58 | viewer: viewer ? { username: viewer.username, avatar: viewer.avatar ?? null } : null, |
| 59 | alreadyIn: viewer && invite ? alreadyIn(viewer, invite) : false, |
| 60 | github: false, |
| 61 | suggestion: suggestUsername(invite?.address), |
| 62 | started: Date.now(), |
| 63 | acceptError: null as string | null, |
| 64 | }; |
| 65 | } |
| 66 | const signingUp = !viewer && invite?.status === "pending" && invite.kind === "account" && !invite.hasAccount; |
| 67 | return { ...base(), github: signingUp ? await githubSignInEnabled() : false }; |
| 68 | } |
| 69 | |
| 70 | /** Whether the viewer is in what the invite gives already. */ |
| 71 | function alreadyIn(viewer: User, invite: InvitePreview): boolean { |
| 72 | if (invite.workspace) return roleIn(viewer, invite.workspace.slug) !== null; |
| 73 | return false; |
| 74 | } |
| 75 | |
| 76 | /** |
| 77 | * The redirect into what the invite gave, with the session (when one was |
| 78 | * just made), the welcome for that first view, and the workspace made the |
| 79 | * one the sidebar is about. |
| 80 | */ |
| 81 | function landIn(request: Request, invite: InvitePreview, cookies: string[], welcome: boolean): Response { |
| 82 | const secure = new URL(request.url).protocol === "https:"; |
| 83 | const target = landingFor(invite); |
| 84 | const headers = new Headers(); |
| 85 | for (const cookie of cookies) headers.append("set-cookie", cookie); |
| 86 | if (target && welcome) headers.append("set-cookie", welcomeCookie(target, secure)); |
| 87 | if (invite.workspace) headers.append("set-cookie", rememberWorkspace(invite.workspace.slug, secure)); |
| 88 | return redirect(target ? `/${target}` : "/", { headers }); |
| 89 | } |
| 90 | |
| 91 | export async function action({ request, context, params }: Route.ActionArgs) { |
| 92 | assertSameOrigin(request); |
| 93 | const code = cleanCode(params.code); |
| 94 | const form = await request.formData(); |
| 95 | const client = clientKey(request); |
| 96 | const checked = await identity.checkInvite(code, client, { viewer: getViewer(context) }); |
| 97 | if (!checked.ok) return data({ error: checked.error.message }, { status: 422 }); |
| 98 | const invite = checked.value; |
| 99 | |
| 100 | if (form.get("intent") === "register") { |
| 101 | if (looksAutomated(form)) return data({ error: "Something went wrong. Try again." }, { status: 422 }); |
| 102 | // An invite for one address makes the account with that address, |
| 103 | // whatever the form sent. |
| 104 | const email = invite.address ?? String(form.get("email") ?? ""); |
| 105 | const result = await identity.register( |
| 106 | String(form.get("username") ?? ""), |
| 107 | email, |
| 108 | String(form.get("password") ?? ""), |
| 109 | code, |
| 110 | client, |
| 111 | ); |
| 112 | if (!result.ok) return data({ error: result.error.message }, { status: 422 }); |
| 113 | throw landIn(request, invite, [startSession(result.value.sessionToken)], true); |
| 114 | } |
| 115 | |
| 116 | const user = requireUser(context, request); |
| 117 | const result = await identity.acceptInvite(user, code); |
| 118 | // Said with a 200, so the page loads again and shows the invite as it |
| 119 | // now stands (used up, revoked) beside the reason: after a 4xx answer |
| 120 | // React Router keeps the page's data as it was. |
| 121 | if (!result.ok) return { error: result.error.message }; |
| 122 | throw landIn(request, invite, [], true); |
| 123 | } |
| 124 | |
| 125 | /** Who sent it, and the workspace it joins, as faces. */ |
| 126 | function Faces({ invite }: { invite: InvitePreview }) { |
| 127 | const from = invite.invitedBy; |
| 128 | return ( |
| 129 | <div className="flex items-center gap-3"> |
| 130 | {from ? ( |
| 131 | <Avatar name={from.username} image={from.avatar} size={44} /> |
| 132 | ) : ( |
| 133 | <span className="inline-flex size-11 shrink-0 items-center justify-center rounded-full bg-accent/15 text-accent"> |
| 134 | <Ticket size={20} /> |
| 135 | </span> |
| 136 | )} |
| 137 | {invite.workspace && ( |
| 138 | <> |
| 139 | <span className="text-faint">→</span> |
| 140 | <Avatar name={invite.workspace.slug} image={invite.workspace.avatar} size={44} square /> |
| 141 | </> |
| 142 | )} |
| 143 | </div> |
| 144 | ); |
| 145 | } |
| 146 | |
| 147 | function senderName(invite: InvitePreview): string { |
| 148 | return invite.invitedBy ? (invite.invitedBy.name ?? invite.invitedBy.username) : "The g1t team"; |
| 149 | } |
| 150 | |
| 151 | /** "Chase Pierce invited you to join Flagon, Inc. on g1t", with the place in bold. */ |
| 152 | function Headline({ invite }: { invite: InvitePreview }) { |
| 153 | const from = senderName(invite); |
| 154 | if (invite.workspace) { |
| 155 | return ( |
| 156 | <> |
| 157 | {from} invited you to join <strong className="font-semibold text-fg">{invite.workspace.name}</strong> on g1t |
| 158 | </> |
| 159 | ); |
| 160 | } |
| 161 | if (invite.repository) { |
| 162 | return ( |
| 163 | <> |
| 164 | {from} invited you to collaborate on <strong className="font-mono font-semibold text-fg">{invite.repository.name}</strong> |
| 165 | </> |
| 166 | ); |
| 167 | } |
| 168 | return <>{from} invited you to g1t</>; |
| 169 | } |
| 170 | |
| 171 | function about(invite: InvitePreview, signedIn: boolean): string { |
| 172 | const signingUp = !signedIn && !invite.hasAccount && invite.kind === "account"; |
| 173 | if (invite.workspace) { |
| 174 | return `g1t is where people and agents ship software together. ${ |
| 175 | signingUp ? "Make your account below and you join" : "Accepting joins you to" |
| 176 | } ${invite.workspace.name} as a member.`; |
| 177 | } |
| 178 | if (invite.repository) { |
| 179 | return `g1t is where people and agents ship software together. ${ |
| 180 | signingUp ? "Make your account below and you get" : "Accepting gives you" |
| 181 | } the ${invite.repository.role} role on ${invite.repository.name}.`; |
| 182 | } |
| 183 | return "g1t is where people and agents ship software together: plan in issues, assign work to agents like teammates, and land it through checks that hold. It is invite-only for now; this invite gets you in."; |
| 184 | } |
| 185 | |
| 186 | /** What accepting is called on its button. */ |
| 187 | function joinLabel(invite: InvitePreview): string { |
| 188 | if (invite.workspace) return `Join ${invite.workspace.name}`; |
| 189 | if (invite.repository) return `Accept access to ${invite.repository.name}`; |
| 190 | return "Accept invite"; |
| 191 | } |
| 192 | |
| 193 | const LONG_DATE = new Intl.DateTimeFormat("en-US", { month: "long", day: "numeric", year: "numeric", timeZone: "UTC" }); |
| 194 | |
| 195 | type Loaded = Route.ComponentProps["loaderData"]; |
| 196 | |
| 197 | /** Signing up, on this page: the address the invite was sent to, a username, a password. */ |
| 198 | function SignUp({ loaded, error }: { loaded: Loaded; error: string | null }) { |
| 199 | const invite = loaded.invite!; |
| 200 | const here = `/invite/${loaded.code}`; |
| 201 | const back = `${here}?accept=1`; |
| 202 | const github = `/auth/github?${new URLSearchParams({ invite: loaded.code, next: back })}`; |
| 203 | return ( |
| 204 | <section aria-labelledby="sign-up" className="rounded-xl border border-line bg-surface/60 p-5 sm:p-6"> |
| 205 | <h2 id="sign-up" className="text-base font-semibold"> |
| 206 | Create your account |
| 207 | </h2> |
| 208 | <p className="mt-1 text-sm text-muted"> |
| 209 | {invite.workspace |
| 210 | ? `You land in ${invite.workspace.name} as soon as it is made.` |
| 211 | : invite.repository |
| 212 | ? `You land in ${invite.repository.name} as soon as it is made.` |
| 213 | : "It takes a minute."} |
| 214 | </p> |
| 215 | {loaded.github && ( |
| 216 | <div className="mt-5"> |
| 217 | <ContinueWithGithub href={github} /> |
| 218 | <OrDivider /> |
| 219 | </div> |
| 220 | )} |
| 221 | <Form method="post" className={`relative space-y-4 ${loaded.github ? "" : "mt-5"}`}> |
| 222 | <input type="hidden" name="intent" value="register" /> |
| 223 | <Honeypot started={loaded.started} /> |
| 224 | {invite.address ? ( |
| 225 | <Field label="Email" hint="Your invite was sent here, so this address is confirmed already."> |
| 226 | <span className="relative block"> |
| 227 | <Input name="email" type="email" value={invite.address} readOnly aria-readonly="true" autoComplete="email" /> |
| 228 | <Lock size={14} aria-hidden="true" className="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 text-faint" /> |
| 229 | </span> |
| 230 | </Field> |
| 231 | ) : ( |
| 232 | <Field label="Email" hint="We send a link to confirm it."> |
| 233 | <Input name="email" type="email" autoComplete="email" required maxLength={254} /> |
| 234 | </Field> |
| 235 | )} |
| 236 | <Field label="Username" hint="Lowercase letters, digits and hyphens. It is how you sign in and how others see you."> |
| 237 | <Input |
| 238 | name="username" |
| 239 | autoComplete="username" |
| 240 | required |
| 241 | autoFocus |
| 242 | maxLength={39} |
| 243 | defaultValue={loaded.suggestion} |
| 244 | pattern="[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9]))*" |
| 245 | /> |
| 246 | </Field> |
| 247 | <Field label="Password" hint="At least 10 characters."> |
| 248 | <Input name="password" type="password" autoComplete="new-password" required minLength={10} /> |
| 249 | </Field> |
| 250 | <ErrorText>{error}</ErrorText> |
| 251 | <div className="pt-1 *:w-full"> |
| 252 | <SubmitButton pending="Creating account…" match={{ intent: "register" }}> |
| 253 | {invite.workspace |
| 254 | ? `Create account and join ${invite.workspace.name}` |
| 255 | : invite.repository |
| 256 | ? "Create account and accept" |
| 257 | : "Create account"} |
| 258 | </SubmitButton> |
| 259 | </div> |
| 260 | </Form> |
| 261 | <p className="mt-5 text-center text-sm text-muted"> |
| 262 | Already on g1t?{" "} |
| 263 | <Link to={`/login?next=${encodeURIComponent(back)}`} className="text-fg underline underline-offset-4"> |
| 264 | Sign in to accept |
| 265 | </Link> |
| 266 | </p> |
| 267 | </section> |
| 268 | ); |
| 269 | } |
| 270 | |
| 271 | /** What to do next: join, sign in, sign out, or sign up. */ |
| 272 | function Next({ loaded, error }: { loaded: Loaded; error: string | null }) { |
| 273 | const invite = loaded.invite!; |
| 274 | const viewer = loaded.viewer; |
| 275 | const here = `/invite/${loaded.code}`; |
| 276 | const back = `${here}?accept=1`; |
| 277 | const joinable = invite.workspace !== null || invite.repository !== null; |
| 278 | |
| 279 | if (viewer) { |
| 280 | const signedInAs = ( |
| 281 | <p className="flex items-center gap-2 text-sm text-muted"> |
| 282 | <Avatar name={viewer.username} image={viewer.avatar} size={20} /> |
| 283 | Signed in as <span className="font-mono text-fg">{viewer.username}</span> |
| 284 | </p> |
| 285 | ); |
| 286 | // Its own intent, which /logout ignores, so only its button says it is working. |
| 287 | const signOut = (label: string, variant: "primary" | "quiet") => ( |
| 288 | <Form method="post" action={`/logout?next=${encodeURIComponent(here)}`}> |
| 289 | <input type="hidden" name="intent" value="sign-out" /> |
| 290 | <SubmitButton variant={variant} pending="Signing out…" match={{ intent: "sign-out" }}> |
| 291 | {label} |
| 292 | </SubmitButton> |
| 293 | </Form> |
| 294 | ); |
| 295 | if (invite.forViewer === false) { |
| 296 | return ( |
| 297 | <div className="space-y-4"> |
| 298 | {signedInAs} |
| 299 | <div className="rounded-md border border-warn/40 bg-warn/5 p-4 text-sm" role="status"> |
| 300 | <p className="font-medium text-fg">This invite is for {invite.address ?? invite.email}, not this account.</p> |
| 301 | <p className="mt-1 text-muted"> |
| 302 | Only an account with that address confirmed can use it. Sign out, then sign in or sign up with it. |
| 303 | </p> |
| 304 | </div> |
| 305 | <ErrorText>{error}</ErrorText> |
| 306 | <div className="flex flex-wrap gap-3">{signOut("Sign out and continue", "primary")}</div> |
| 307 | </div> |
| 308 | ); |
| 309 | } |
| 310 | if (loaded.alreadyIn && invite.workspace) { |
| 311 | return ( |
| 312 | <div className="space-y-4"> |
| 313 | {signedInAs} |
| 314 | <p className="text-sm text-muted">You are in {invite.workspace.name} already.</p> |
| 315 | <ButtonLink to={`/${invite.workspace.slug}`}>Go to {invite.workspace.name}</ButtonLink> |
| 316 | </div> |
| 317 | ); |
| 318 | } |
| 319 | if (!joinable) { |
| 320 | return ( |
| 321 | <div className="space-y-4"> |
| 322 | {signedInAs} |
| 323 | <div className="rounded-md border border-line bg-surface p-4 text-sm"> |
| 324 | <p> |
| 325 | You already have a g1t account, <span className="font-mono">{viewer.username}</span>, so this invite has |
| 326 | nothing more to give you. |
| 327 | </p> |
| 328 | <p className="mt-2 text-muted">Pass it on to whoever it was meant for, or keep it for someone else.</p> |
| 329 | </div> |
| 330 | <div className="flex flex-wrap gap-3">{signOut("Sign out to use it", "quiet")}</div> |
| 331 | </div> |
| 332 | ); |
| 333 | } |
| 334 | return ( |
| 335 | <> |
| 336 | {/* "Not you?" posts this form, so it does not carry the accept form's intent. */} |
| 337 | <Form id="invite-sign-out" method="post" action={`/logout?next=${encodeURIComponent(here)}`} hidden> |
| 338 | <input type="hidden" name="intent" value="sign-out" /> |
| 339 | </Form> |
| 340 | <Form method="post" className="space-y-4"> |
| 341 | <input type="hidden" name="intent" value="accept" /> |
| 342 | {signedInAs} |
| 343 | {invite.forViewer === null && ( |
| 344 | <p className="text-sm text-muted">This invite is for anyone with the link. Accepting uses it up.</p> |
| 345 | )} |
| 346 | <ErrorText>{error}</ErrorText> |
| 347 | <div className="flex flex-wrap items-center gap-3"> |
| 348 | <SubmitButton pending={invite.workspace ? "Joining…" : "Accepting…"} match={{ intent: "accept" }}> |
| 349 | {joinLabel(invite)} |
| 350 | </SubmitButton> |
| 351 | <span className="text-sm text-muted"> |
| 352 | Not you?{" "} |
| 353 | <SubmitButton |
| 354 | form="invite-sign-out" |
| 355 | pending="Signing out…" |
| 356 | match={{ intent: "sign-out" }} |
| 357 | className="inline-flex items-center gap-1 text-fg underline underline-offset-4 disabled:opacity-50" |
| 358 | > |
| 359 | Sign out |
| 360 | </SubmitButton> |
| 361 | </span> |
| 362 | </div> |
| 363 | </Form> |
| 364 | </> |
| 365 | ); |
| 366 | } |
| 367 | |
| 368 | // Someone already on g1t: sign in, and the invite is accepted on return. |
| 369 | if (invite.hasAccount || invite.kind === "workspace") { |
| 370 | return ( |
| 371 | <div className="space-y-4"> |
| 372 | <div className="rounded-md border border-line bg-surface p-4 text-sm"> |
| 373 | <p className="font-medium text-fg">{invite.address ?? invite.email ?? "This address"} has a g1t account.</p> |
| 374 | <p className="mt-1 text-muted"> |
| 375 | Sign in to it and {joinable ? `you ${invite.workspace ? `join ${invite.workspace.name}` : `get access to ${invite.repository!.name}`} straight away` : "the invite is accepted"}. |
| 376 | </p> |
| 377 | </div> |
| 378 | <ErrorText>{error}</ErrorText> |
| 379 | <ButtonLink to={`/login?next=${encodeURIComponent(back)}`}>Sign in to accept</ButtonLink> |
| 380 | </div> |
| 381 | ); |
| 382 | } |
| 383 | return <SignUp loaded={loaded} error={error} />; |
| 384 | } |
| 385 | |
| 386 | /** A code that cannot be used: why, and whom to ask. */ |
| 387 | function Dead({ loaded }: { loaded: Loaded }) { |
| 388 | const invite = loaded.invite; |
| 389 | const title = !invite |
| 390 | ? "This invite link does not work" |
| 391 | : invite.status === "expired" |
| 392 | ? "This invite has expired" |
| 393 | : invite.status === "revoked" |
| 394 | ? "This invite was withdrawn" |
| 395 | : "This invite has been used"; |
| 396 | const from = invite?.invitedBy; |
| 397 | return ( |
| 398 | <> |
| 399 | <h1 className="mt-6 flex items-center gap-2 text-2xl font-semibold tracking-tight"> |
| 400 | <CircleAlert size={22} className="shrink-0 text-warn" /> |
| 401 | {title} |
| 402 | </h1> |
| 403 | {invite ? ( |
| 404 | <p className="mt-3 text-sm leading-6 text-muted"> |
| 405 | It was {invite.workspace ? `an invite to join ${invite.workspace.name}` : invite.repository ? `an invite to ${invite.repository.name}` : "an invite to g1t"} |
| 406 | {invite.status === "expired" && `, and stopped working on ${LONG_DATE.format(new Date(invite.expiresAt))}`}.{" "} |
| 407 | {from ? "Ask the person who sent it for a new one." : "Ask for access and we will send a new one."} |
| 408 | </p> |
| 409 | ) : ( |
| 410 | <p className="mt-3 text-sm leading-6 text-muted">{loaded.error}</p> |
| 411 | )} |
| 412 | {from && ( |
| 413 | <div className="mt-5 flex items-center gap-3 rounded-lg border border-line bg-surface p-3"> |
| 414 | <Avatar name={from.username} image={from.avatar} size={36} /> |
| 415 | <p className="min-w-0 text-sm"> |
| 416 | Ask <span className="font-medium text-fg">{from.name ?? from.username}</span>{" "} |
| 417 | <Link to={`/u/${from.username}`} className="font-mono text-muted hover:text-fg"> |
| 418 | @{from.username} |
| 419 | </Link>{" "} |
| 420 | <span className="text-muted">for a new invite.</span> |
| 421 | </p> |
| 422 | </div> |
| 423 | )} |
| 424 | <div className="mt-8 flex flex-wrap gap-3"> |
| 425 | <ButtonLink to="/register#request">Request access</ButtonLink> |
| 426 | <ButtonLink to={loaded.viewer ? "/" : "/login"} variant="quiet"> |
| 427 | {loaded.viewer ? "Go to g1t" : "Sign in"} |
| 428 | </ButtonLink> |
| 429 | </div> |
| 430 | </> |
| 431 | ); |
| 432 | } |
| 433 | |
| 434 | export default function Invite({ loaderData, actionData }: Route.ComponentProps) { |
| 435 | const { invite } = loaderData; |
| 436 | const error = (actionData && "error" in actionData ? actionData.error : null) ?? loaderData.acceptError; |
| 437 | const usable = invite?.status === "pending"; |
| 438 | return ( |
| 439 | <main className="mx-auto flex max-w-md flex-col px-4 pt-16 pb-12 sm:pt-20"> |
| 440 | <Mark className="size-9" /> |
| 441 | {usable ? ( |
| 442 | <> |
| 443 | <div className="mt-8"> |
| 444 | <Faces invite={invite} /> |
| 445 | </div> |
| 446 | <h1 className="mt-6 text-2xl font-semibold tracking-tight text-balance text-fg-soft"> |
| 447 | <Headline invite={invite} /> |
| 448 | </h1> |
| 449 | <p className="mt-2 text-sm leading-6 text-muted">{about(invite, loaderData.viewer !== null)}</p> |
| 450 | <dl className="mt-5 space-y-1 text-sm"> |
| 451 | {invite.invitedBy && ( |
| 452 | <div className="flex gap-2"> |
| 453 | <dt className="w-24 shrink-0 text-faint">From</dt> |
| 454 | <dd className="font-mono text-fg-soft">@{invite.invitedBy.username}</dd> |
| 455 | </div> |
| 456 | )} |
| 457 | {invite.email && ( |
| 458 | <div className="flex gap-2"> |
| 459 | <dt className="w-24 shrink-0 text-faint">For</dt> |
| 460 | <dd className="font-mono break-all text-fg-soft">{invite.address ?? invite.email}</dd> |
| 461 | </div> |
| 462 | )} |
| 463 | <div className="flex gap-2"> |
| 464 | <dt className="w-24 shrink-0 text-faint">Works until</dt> |
| 465 | <dd className="text-fg-soft">{LONG_DATE.format(new Date(invite.expiresAt))}</dd> |
| 466 | </div> |
| 467 | </dl> |
| 468 | <div className="mt-8"> |
| 469 | <Next loaded={loaderData} error={error} /> |
| 470 | </div> |
| 471 | </> |
| 472 | ) : ( |
| 473 | <Dead loaded={loaderData} /> |
| 474 | )} |
| 475 | </main> |
| 476 | ); |
| 477 | } |