diff --git a/apps/web/src/modules/mesh/invite-generator.tsx b/apps/web/src/modules/mesh/invite-generator.tsx index 5340f43..cb0b294 100644 --- a/apps/web/src/modules/mesh/invite-generator.tsx +++ b/apps/web/src/modules/mesh/invite-generator.tsx @@ -6,7 +6,9 @@ import { zodResolver } from "@hookform/resolvers/zod"; import QRCode from "qrcode"; import { + createEmailInviteInputSchema, createMyInviteInputSchema, + type CreateEmailInviteInput, type CreateMyInviteInput, } from "@turbostarter/api/schema"; import { handle } from "@turbostarter/api/utils"; @@ -33,26 +35,53 @@ import { api } from "~/lib/api/client"; interface GeneratedInvite { id: string; + /** Raw token (only set for link-mode results — empty string when email mode). */ token: string; - inviteLink: string; + /** Short code for the CLI command. Falls back to shortUrl display if null. */ + code: string | null; joinUrl: string; /** Short human-friendly URL, preferred for sharing. Null if the backend didn't mint one. */ shortUrl: string | null; expiresAt: Date; qrDataUrl: string; + /** When set, the invite was dispatched via email and a confirmation banner is shown. */ + sentToEmail?: string; } +type Mode = "link" | "email"; + +const qrOptions = { + width: 256, + margin: 1, + color: { dark: "#141413", light: "#ffffff" }, +} as const; + export const InviteGenerator = ({ meshId }: { meshId: string }) => { + const [mode, setMode] = useState("link"); const [result, setResult] = useState(null); const [copied, setCopied] = useState<"url" | "cli" | null>(null); const [showAdvanced, setShowAdvanced] = useState(false); - const form = useForm({ + // Two separate forms — simpler than conditional validation, clearer state + // boundaries, and each form owns its own submit + error surface. + const linkForm = useForm({ resolver: zodResolver(createMyInviteInputSchema), defaultValues: { role: "member", maxUses: 1, expiresInDays: 7 }, }); - const onSubmit = async (values: CreateMyInviteInput) => { + const emailForm = useForm({ + resolver: zodResolver(createEmailInviteInputSchema), + defaultValues: { + email: "", + role: "member", + maxUses: 1, + expiresInDays: 7, + }, + }); + + const activeForm = mode === "link" ? linkForm : emailForm; + + const onSubmitLink = async (values: CreateMyInviteInput) => { try { const res = (await handle(api.my.meshes[":id"].invites.$post)({ param: { id: meshId }, @@ -60,6 +89,7 @@ export const InviteGenerator = ({ meshId }: { meshId: string }) => { })) as { id: string; token: string; + code: string | null; inviteLink: string; joinUrl: string; shortUrl: string | null; @@ -70,42 +100,103 @@ export const InviteGenerator = ({ meshId }: { meshId: string }) => { // and short enough for the QR to stay low-density. Falls back to the // long token URL for legacy invites minted before the shortener shipped. const qrTarget = res.shortUrl ?? res.joinUrl; - const qrDataUrl = await QRCode.toDataURL(qrTarget, { - width: 256, - margin: 1, - color: { dark: "#141413", light: "#ffffff" }, - }); + const qrDataUrl = await QRCode.toDataURL(qrTarget, qrOptions); setResult({ id: res.id, token: res.token, - inviteLink: res.inviteLink, + code: res.code, joinUrl: res.joinUrl, shortUrl: res.shortUrl, expiresAt: new Date(res.expiresAt), qrDataUrl, }); } catch (e) { - form.setError("root", { + linkForm.setError("root", { message: e instanceof Error ? e.message : "Failed to generate invite.", }); } }; + const onSubmitEmail = async (values: CreateEmailInviteInput) => { + try { + // TODO(types): remove `as any` after RPC type regen picks up the new + // `.email` subroute registered in packages/api/src/modules/mesh/router.ts. + const res = (await handle( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (api.my.meshes[":id"].invites as any).email.$post, + )({ + param: { id: meshId }, + json: values, + })) as { + pendingInviteId: string; + code: string; + email: string; + shortUrl: string; + expiresAt: string; + }; + + const qrDataUrl = await QRCode.toDataURL(res.shortUrl, qrOptions); + + setResult({ + id: res.pendingInviteId, + token: "", + code: res.code, + joinUrl: res.shortUrl, + shortUrl: res.shortUrl, + expiresAt: new Date(res.expiresAt), + qrDataUrl, + sentToEmail: res.email, + }); + } catch (e) { + emailForm.setError("root", { + message: e instanceof Error ? e.message : "Failed to send invite.", + }); + } + }; + const copy = async (text: string, key: "url" | "cli") => { await navigator.clipboard.writeText(text); setCopied(key); setTimeout(() => setCopied(null), 2000); }; + const resetAll = () => { + setResult(null); + linkForm.reset(); + emailForm.reset(); + }; + if (result) { - // Prefer the short URL everywhere it exists. CLI command still uses the - // long token because the broker resolves by token — swapping CLI to short - // codes is part of the v2 protocol, not this URL-shortener change. + // Prefer the short URL everywhere it exists. CLI command uses the code + // when available (short, easy to paste); otherwise falls back to the + // shortUrl, which the CLI also accepts as an argument. const primaryUrl = result.shortUrl ?? result.joinUrl; - const cliCmd = `claudemesh join ${result.token}`; + const cliArg = result.code ?? result.shortUrl ?? ""; + const cliCmd = `claudemesh join ${cliArg}`; return (
+ {result.sentToEmail && ( +
+
+ +
+

+ Invite sent to {result.sentToEmail} +

+

+ Email delivery is stubbed in v0.1.x — the invite is valid. + Share the link directly if needed. +

+
+
+
+ )}
@@ -140,14 +231,7 @@ export const InviteGenerator = ({ meshId }: { meshId: string }) => { > {copied === "cli" ? "Copied ✓" : "Copy CLI command"} -
@@ -169,101 +253,196 @@ export const InviteGenerator = ({ meshId }: { meshId: string }) => { ); } - return ( -
- -

- One-time invite for a new member. Valid for 7 days. -

+ const ModeToggle = () => ( +
+ + +
+ ); - {/* Advanced options — hidden by default. Defaults ship 90% of users. */} -
- - {showAdvanced && ( -
- ( - - Role - - - - )} - /> - ( - - Max uses - - field.onChange(Number(e.target.value))} - /> - - - - )} - /> - ( - - Expires in (days) - - field.onChange(Number(e.target.value))} - /> - - - - )} - /> -
- )} + // Advanced block is rendered against whichever form is active. Because the + // two schemas share identical role/maxUses/expiresInDays shapes, the field + // components are structurally the same — we just bind to the active form. + const AdvancedBlock = () => ( +
+ + {showAdvanced && ( +
+ ( + + Role + + + + )} + /> + ( + + Max uses + + field.onChange(Number(e.target.value))} + /> + + + + )} + /> + ( + + Expires in (days) + + field.onChange(Number(e.target.value))} + /> + + + + )} + />
+ )} +
+ ); - {form.formState.errors.root && ( -

- {form.formState.errors.root.message} -

- )} - - - + return ( +
+ + + {mode === "link" ? ( +
+ +

+ One-time invite for a new member. Valid for 7 days. +

+ + + + {linkForm.formState.errors.root && ( +

+ {linkForm.formState.errors.root.message} +

+ )} + + + + ) : ( +
+ +

+ Send a one-time invite directly to an email address. Valid for 7 + days. +

+ + ( + + Email + + + + + + )} + /> + + + + {emailForm.formState.errors.root && ( +

+ {emailForm.formState.errors.root.message} +

+ )} + + + + )} +
); };