Skip to content
File

Blob: src/client/components/create-inbox.tsx

typescript198 lines
1import { useEffect, useState } from "react";
2import { Globe, Loader2, Sparkles } from "lucide-react";
3import { TurnstileWidget } from "@/client/components/turnstile-widget";
4import { Button, Card, ErrorBanner } from "@/client/components/ui";
5import { useTurnstileForm } from "@/client/hooks/useTurnstileForm";
6import {
7 TEMP_MAILBOX_TTL_HOURS,
8 createInbox,
9 getErrorMessage,
10 isTurnstileError,
11 listDomains,
12 type InboxSession,
13 type TempMailboxTtlHours,
14} from "../lib/api";
15 
16const TTL_OPTION_DETAILS: Record<TempMailboxTtlHours, { hint: string; label: string }> = {
17 24: { label: "24 hours", hint: "standard" },
18 48: { label: "48 hours", hint: "extended" },
19 72: { label: "72 hours", hint: "max" },
20};
21 
22const TTL_OPTIONS = TEMP_MAILBOX_TTL_HOURS.map((value) => ({
23 ...TTL_OPTION_DETAILS[value],
24 value,
25}));
26 
27interface CreateInboxProps {
28 onCreated: (session: InboxSession) => void;
29}
30 
31export function CreateInbox({ onCreated }: CreateInboxProps) {
32 const [domains, setDomains] = useState<string[]>([]);
33 const [selectedDomain, setSelectedDomain] = useState("");
34 const [ttlHours, setTtlHours] = useState<TempMailboxTtlHours>(TEMP_MAILBOX_TTL_HOURS[0]);
35 const [loading, setLoading] = useState(true);
36 const [submitting, setSubmitting] = useState(false);
37 const [error, setError] = useState<string | null>(null);
38 const { turnstileToken, setTurnstileToken, turnstileResetKey, handleTurnstileError, resetTurnstile } =
39 useTurnstileForm({
40 onTurnstileError: () => {
41 setError(null);
42 },
43 });
44 
45 useEffect(() => {
46 let active = true;
47 
48 const load = async () => {
49 setLoading(true);
50 setError(null);
51 
52 try {
53 const items = await listDomains();
54 if (!active) {
55 return;
56 }
57 
58 setDomains(items);
59 setSelectedDomain(items[0] ?? "");
60 } catch (nextError) {
61 if (active) {
62 setError(getErrorMessage(nextError));
63 }
64 } finally {
65 if (active) {
66 setLoading(false);
67 }
68 }
69 };
70 
71 void load();
72 return () => {
73 active = false;
74 };
75 }, []);
76 
77 const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
78 event.preventDefault();
79 if (!selectedDomain) {
80 setError("No active domains are configured yet.");
81 return;
82 }
83 if (!turnstileToken) {
84 setError("Complete human verification to continue.");
85 return;
86 }
87 
88 setSubmitting(true);
89 setError(null);
90 
91 try {
92 const session = await createInbox(selectedDomain, ttlHours, turnstileToken);
93 resetTurnstile();
94 onCreated(session);
95 } catch (nextError) {
96 setError(getErrorMessage(nextError));
97 if (isTurnstileError(nextError)) {
98 resetTurnstile();
99 }
100 } finally {
101 setSubmitting(false);
102 }
103 };
104 
105 return (
106 <Card variant="accent">
107 <span className="mb-2 inline-block text-xs font-semibold uppercase tracking-wider text-accent-400">
108 Instant Address
109 </span>
110 <h2 className="text-lg font-semibold tracking-tight text-zinc-100">Spin up a test inbox</h2>
111 <p className="mt-2 text-sm leading-relaxed text-zinc-400">
112 Get a real address. Paste it into your app. Watch the mail land the moment it's sent.
113 </p>
114 <p className="mt-2 text-xs leading-relaxed text-zinc-400">
115 Plus aliases (<span className="font-medium text-zinc-400">name+tag@domain</span>) all hit the same inbox; each
116 message shows the exact recipient.
117 </p>
118
119 <form className="mt-5 space-y-4" onSubmit={handleSubmit}>
120 <label className="block space-y-1.5">
121 <span className="flex items-center gap-1.5 text-sm font-medium text-zinc-400">
122 <Globe className="h-3.5 w-3.5" />
123 Domain
124 </span>
125 <select
126 value={selectedDomain}
127 onChange={(event) => setSelectedDomain(event.target.value)}
128 className="w-full rounded-lg border border-zinc-700/60 bg-zinc-800 px-4 py-2.5 text-sm text-zinc-100 outline-none focus:border-accent-500/50 focus:ring-1 focus:ring-accent-500/30"
129 >
130 {domains.length === 0 ? <option value="">No active domains</option> : null}
131 {domains.map((domain) => (
132 <option key={domain} value={domain}>
133 {domain}
134 </option>
135 ))}
136 </select>
137 </label>
138
139 <div className="space-y-2">
140 <span className="block text-sm font-medium text-zinc-400">Mailbox lifetime</span>
141 <div className="grid gap-2 sm:grid-cols-3">
142 {TTL_OPTIONS.map((option) => {
143 const selected = option.value === ttlHours;
144
145 return (
146 <button
147 key={option.value}
148 type="button"
149 onClick={() => setTtlHours(option.value)}
150 className={[
151 "rounded-lg border px-4 py-3 text-left",
152 selected
153 ? "border-accent-500/50 bg-accent-500/10 text-zinc-100"
154 : "border-zinc-700/60 bg-zinc-800 text-zinc-400 hover:bg-zinc-700",
155 ].join(" ")}
156 >
157 <strong className="block text-sm font-semibold">{option.label}</strong>
158 <span className="mt-1 block text-xs uppercase tracking-wider text-zinc-500">{option.hint}</span>
159 </button>
160 );
161 })}
162 </div>
163 </div>
164
165 <TurnstileWidget
166 action="create_inbox"
167 onError={handleTurnstileError}
168 onTokenChange={setTurnstileToken}
169 resetKey={turnstileResetKey}
170 />
171
172 <Button
173 variant="primary"
174 size="md"
175 className="w-full"
176 type="submit"
177 loading={submitting}
178 icon={submitting ? undefined : <Sparkles className="h-4 w-4" />}
179 disabled={loading || !selectedDomain || !turnstileToken}
180 >
181 {submitting ? "Creating..." : "Create inbox"}
182 </Button>
183 </form>
184
185 {loading ? (
186 <p className="mt-4 flex items-center gap-2 text-sm text-zinc-400" aria-busy="true">
187 <Loader2 className="h-3.5 w-3.5 animate-spin" />
188 Loading available domains...
189 </p>
190 ) : null}
191 {error ? <ErrorBanner className="mt-4">{error}</ErrorBanner> : null}
192 {!loading && domains.length === 0 ? (
193 <p className="mt-4 text-sm text-zinc-400">No domains available yet. An admin needs to add one first.</p>
194 ) : null}
195 </Card>
196 );
197}