"use client"; import { useState, type ReactNode } from "react"; import Link from "next/link"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { ArrowRight, MailCheck } from "lucide-react"; import { Button } from "@/components/ui/button"; import { ButtonSpinner } from "@/components/ui/button-spinner"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { StatusMessage } from "@/components/ui/status-message"; import { getApiErrorMessage } from "@/lib/api/error"; import { invitationsAPI } from "@/lib/api/invitations"; import { emailAuthSchema, type EmailAuthFormData, } from "@/lib/validations/auth"; import { cn } from "@/lib/utils"; // Which voice the form speaks in. `generic` is the only one that is true of every // instance and so the default; `waitlist` is the project speaking as the operator // of a copy it runs itself, and only `GET /config` saying so can pick it. export type InviteRequestVariant = "generic" | "waitlist"; interface InviteRequestFormProps { variant?: InviteRequestVariant; className?: string; } interface InviteRequestCopy { heading: string; blurb: string; button: string; successHeading: string; // Takes the rendered address rather than the string, so both voices set it in // the same weight and neither can forget to. successBody: (address: ReactNode) => ReactNode; } // The four strings and the success state move together: a heading that says // "Get Early Access" over a body that says "whoever runs this instance decides" // is two operators speaking at once. That is why this is a table of whole // variants and not four independently configurable strings - see "The request // form speaks in two voices" in DECISIONS.md. const COPY: Record = { generic: { heading: "Request an Invite", blurb: "This instance is not taking new accounts on its own. Leave your address and whoever runs it can invite you.", button: "Request an invite", successHeading: "Request received", successBody: (address) => ( <> If an invitation comes your way it will arrive at {address}. Whoever runs this instance decides who is invited, and when. ), }, waitlist: { heading: "Get Early Access", blurb: "Join the waitlist for a chance to be among the first to try OpenDiving. We'll notify you when your spot is ready. Just that, no spam.", button: "Join the waitlist", successHeading: "You're on the list", successBody: (address) => ( <>We'll email {address} when your spot is ready. ), }, }; /** * The hero's form on an instance that is not taking registrations: an address, * and a request for whoever runs the instance to invite it. * * It speaks in one of two voices, and `variant` says which. The landing page * decides that from `GET /config` and passes it down; the form deliberately does * not read the hook itself, so the page stays the one place the decision is made * and either voice renders in a test without an API. * * **`generic` is the default because every sentence of it has to be true of a * household instance as well as a hosted one.** The same copy renders for * somebody sharing a copy of OpenDiving with three dive buddies, so there is no * "beta", no "we", and no promise that an invitation is coming - the operator * decides who is invited and when, and this form can only say so. Any instance * whose `/config` did not say otherwise gets this voice, which is what makes it * safe as the default rather than merely conventional. * * **`waitlist` is the project speaking as the operator of its own copy**, the * role the privacy page already allows for: "where the project does run a copy, * it is that copy's operator as well, and every commitment this page makes of * the operator is one it makes in that role". It is chosen only where the API * answered `project_operated: true`. The copy makes two promises. "We'll notify * you" is backed by the API, which emails an invitation to an address the moment * it is invited, so the notification is the invitation itself; "no spam" is the * operator's promise, and nothing in the app enforces it. * * The API answers 202 for every address it is given - a first request, a repeat, * an address that already has an account, an address invited last week - and * never queries the user table on the way. This form must not try to be more * informative than that: one success state per voice, whatever was stored. * * There is no passkey ceremony and no Google button here, unlike `AuthForm`. Both * are ways of *signing in*, and this is not the sign-in surface; a returning * member takes the link below to `/signin`, where all of it still is. */ export function InviteRequestForm({ variant = "generic", className, }: InviteRequestFormProps) { const copy = COPY[variant]; const [error, setError] = useState(null); const [requested, setRequested] = useState(null); const { register, handleSubmit, formState: { errors, isSubmitting }, } = useForm({ // The sign-in form's schema, not one of its own: the two fields ask for the // same thing and a second definition is a second place for the message to // differ. resolver: zodResolver(emailAuthSchema), }); const onSubmit = async (data: EmailAuthFormData) => { try { setError(null); await invitationsAPI.requestInvite(data.email); setRequested(data.email); } catch (err) { setError( getApiErrorMessage(err, "Something went wrong. Please try again."), ); } }; return (
{requested ? ( // Replaces the form outright, the way `CheckEmailCard` replaces the // sign-in form: a filled-in field left on screen under a success message // invites a second submission that would tell the diver nothing new.

{copy.successHeading}

{copy.successBody( {requested}, )}

) : ( <>

{copy.heading}

{copy.blurb}

{error}
{errors.email && (

{errors.email.message}

)}
)} {/* Common to both voices: who may sign in does not depend on who runs the instance. Two audiences are named, a member and an invitee. The line used to name a third, the operator "setting this instance up", and does not: that reader exists for one moment, before the first account is created, and the install docs send them to Sign in for it. */}

Have an account or an invitation?{" "} Sign in .

); }