contact-form.tsx285 lines · main
1'use client';
2
3import { useState } from 'react';
4
5import { ROUTING_TAGS, SubjectTagsInput } from './subject-tags-input';
6
7interface Props {
8 apiOrigin: string;
9 /** Pre-selected topic (e.g. from a /contact?topic=privacy deep link). Falls
10 * back to 'general' when absent or not a known topic. */
11 initialTopic?: string;
12 /** Country auto-detected server-side from the visitor's IP. The field is
13 * LOCKED — the visitor can't edit it. `null` when it can't be resolved. */
14 initialCountry?: { code: string; name: string } | null;
15 /** Pre-fill the name field (e.g. the logged-in user's name on the
16 * in-dashboard support page). Stays editable. */
17 initialName?: string;
18 /** Pre-fill the email field (e.g. the logged-in user's email). Stays
19 * editable so they can reply from a different address if they want. */
20 initialEmail?: string;
21}
22
23type Topic =
24 | 'general'
25 | 'support'
26 | 'sales'
27 | 'self-host'
28 | 'security'
29 | 'privacy'
30 | 'legal'
31 | 'other';
32
33const TOPICS: readonly { value: Topic; label: string }[] = [
34 { value: 'general', label: 'general' },
35 { value: 'support', label: 'support' },
36 { value: 'sales', label: 'sales' },
37 { value: 'self-host', label: 'self-host' },
38 { value: 'security', label: 'security' },
39 { value: 'privacy', label: 'privacy' },
40 { value: 'legal', label: 'legal' },
41 { value: 'other', label: 'other' },
42];
43
44/** Coerce an untrusted URL value to a known topic; default 'general'. */
45function coerceTopic(value: string | undefined): Topic {
46 return TOPICS.some((t) => t.value === value) ? (value as Topic) : 'general';
47}
48
49interface SubmittedState {
50 requestId: string;
51 ticketNumber: string | null;
52}
53
54const FIELD_CLASS =
55 'rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 font-mono text-sm outline-none focus:border-[var(--color-primary)]';
56
57/**
58 * Public, unauthenticated contact form. Embedded on /contact so anyone
59 * can reach us before signing up. Posts to /v1/contact on the api origin
60 * (rate-limited 5/hr per IP) and renders an inline success state with the
61 * briven reference id. We collect the sender's email so we can reply
62 * privately — but we never render an email address back to the page.
63 *
64 * The `country` field is locked: it's pre-filled from a server-side geo-IP
65 * lookup and submitted with the message, but the visitor can't change it.
66 */
67export function ContactForm({
68 apiOrigin,
69 initialTopic,
70 initialCountry,
71 initialName,
72 initialEmail,
73}: Props) {
74 const [name, setName] = useState(initialName ?? '');
75 const [email, setEmail] = useState(initialEmail ?? '');
76 // Locked, read-only value sent to the backend. Never editable in the UI.
77 const country = initialCountry ?? null;
78 const [subject, setSubject] = useState('');
79 const [tags, setTags] = useState<string[]>([]);
80 // Topic still routes the ticket server-side; it now defaults from the page
81 // (e.g. 'support' on the dashboard) and from the #tags typed in the subject —
82 // the explicit dropdown was redundant, so it was removed.
83 const [topic] = useState<Topic>(coerceTopic(initialTopic));
84 const [message, setMessage] = useState('');
85 const [busy, setBusy] = useState(false);
86 const [error, setError] = useState<string | null>(null);
87 const [submitted, setSubmitted] = useState<SubmittedState | null>(null);
88
89 async function submit(e: React.FormEvent) {
90 e.preventDefault();
91 if (busy) return;
92 setBusy(true);
93 setError(null);
94 // Serialise the #tag chips + free text into the single subject string the
95 // api already accepts (the admin dashboard reads the #tags back out for
96 // routing). No backend change needed until structured tags land.
97 const subjectPayload = [tags.map((t) => `#${t}`).join(' '), subject.trim()]
98 .filter(Boolean)
99 .join(' ')
100 .trim();
101 try {
102 const res = await fetch(`${apiOrigin}/v1/contact`, {
103 method: 'POST',
104 headers: { 'content-type': 'application/json' },
105 body: JSON.stringify({
106 name: name.trim(),
107 email: email.trim(),
108 topic,
109 message: message.trim(),
110 // Optional extras — only sent when present so the backend's
111 // optional() schema stays happy on older clients.
112 ...(subjectPayload ? { subject: subjectPayload } : {}),
113 ...(country ? { country: country.name } : {}),
114 }),
115 });
116 if (res.status === 429) {
117 setError('too many messages from this address recently. wait an hour and try again.');
118 return;
119 }
120 if (!res.ok) {
121 const body = (await res.json().catch(() => null)) as { message?: string } | null;
122 setError(body?.message ?? `submit failed: ${res.status}`);
123 return;
124 }
125 const data = (await res.json()) as { requestId: string; ticketNumber?: string | null };
126 setSubmitted({ requestId: data.requestId, ticketNumber: data.ticketNumber ?? null });
127 } catch (err) {
128 setError(err instanceof Error ? err.message : 'submit failed');
129 } finally {
130 setBusy(false);
131 }
132 }
133
134 if (submitted) {
135 return (
136 <div className="rounded-[var(--radius-lg)] border border-[var(--color-border-primary)] bg-[var(--color-primary-subtle)] p-6">
137 <p className="font-sans font-medium tracking-[-0.02em] text-[var(--color-text)] text-[var(--text-h4)]">
138 got it · we&apos;ll get back to you within one business day
139 </p>
140 <p className="mt-3 leading-[1.6] text-[var(--color-text-muted)] text-[var(--text-small)]">
141 your message is queued. we&apos;ll reply to the address you gave us — keep an eye
142 on your inbox. no marketing emails, ever.
143 </p>
144 {submitted.ticketNumber ? (
145 <>
146 <p className="mt-4 font-mono text-sm font-medium text-[var(--color-text)]">
147 your ticket number:{' '}
148 <code className="text-[var(--color-primary)]">{submitted.ticketNumber}</code>
149 </p>
150 <p className="mt-1 font-mono text-xs text-[var(--color-text-muted)]">
151 we&apos;ve emailed it to you — use it to follow up or check your ticket status.
152 </p>
153 </>
154 ) : (
155 <p className="mt-4 font-mono text-xs text-[var(--color-text-subtle)]">
156 reference:{' '}
157 <code className="text-[var(--color-text-muted)]">{submitted.requestId}</code>
158 </p>
159 )}
160 <p className="mt-3 font-mono text-[10px] text-[var(--color-text-subtle)]">
161 if you don&apos;t hear back within one business day, wait an hour and send it again,
162 quoting the reference above.
163 </p>
164 </div>
165 );
166 }
167
168 return (
169 <form
170 onSubmit={submit}
171 className="flex flex-col gap-4 rounded-[var(--radius-lg)] border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6"
172 >
173 {/* 1 — name + email side by side */}
174 <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
175 <label className="flex flex-col gap-2">
176 <span className="font-mono text-xs text-[var(--color-text-muted)]">
177 name <span className="text-[var(--color-text-subtle)]">(required)</span>
178 </span>
179 <input
180 type="text"
181 required
182 maxLength={200}
183 value={name}
184 onChange={(e) => setName(e.target.value)}
185 placeholder="your name"
186 className={FIELD_CLASS}
187 />
188 </label>
189
190 <label className="flex flex-col gap-2">
191 <span className="font-mono text-xs text-[var(--color-text-muted)]">
192 email <span className="text-[var(--color-text-subtle)]">(required)</span>
193 </span>
194 <input
195 type="email"
196 required
197 maxLength={320}
198 value={email}
199 onChange={(e) => setEmail(e.target.value)}
200 placeholder="so we can reply"
201 className={FIELD_CLASS}
202 />
203 </label>
204 </div>
205
206 {/* 2 — country, full width + locked (auto-filled from geo-IP) */}
207 <label className="flex flex-col gap-2">
208 <span className="font-mono text-xs text-[var(--color-text-muted)]">country</span>
209 <input
210 type="text"
211 readOnly
212 disabled
213 aria-readonly="true"
214 value={country ? country.name : 'unknown'}
215 className="cursor-not-allowed rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 font-mono text-sm text-[var(--color-text-muted)] opacity-80 outline-none"
216 />
217 <span className="font-mono text-[11px] text-[var(--color-text-subtle)]">
218 based on your location — contact support to change.
219 </span>
220 </label>
221
222 {/* 3 — subject (#tag chips) with the available routing topics shown tight
223 beneath it, so the form reads as one clean block. */}
224 <div className="flex flex-col gap-2">
225 <SubjectTagsInput
226 tags={tags}
227 onTagsChange={setTags}
228 text={subject}
229 onTextChange={setSubject}
230 />
231
232 <div className="flex flex-col gap-1.5">
233 <span className="font-mono uppercase tracking-[0.12em] text-[var(--color-text-subtle)] text-[10px]">
234 topic
235 </span>
236 <div className="grid grid-cols-4 gap-2">
237 {ROUTING_TAGS.map((t) => (
238 <span
239 key={t}
240 className="font-mono text-[var(--color-text-subtle)] text-[var(--text-xs)]"
241 >
242 #{t}
243 </span>
244 ))}
245 </div>
246 </div>
247 </div>
248
249 {/* 4 — message */}
250 <label className="flex flex-col gap-2">
251 <span className="font-mono text-xs text-[var(--color-text-muted)]">
252 message{' '}
253 <span className="text-[var(--color-text-subtle)]">(required)</span>
254 </span>
255 <textarea
256 required
257 rows={6}
258 maxLength={8000}
259 value={message}
260 onChange={(e) => setMessage(e.target.value)}
261 placeholder="what can we help with?"
262 className={FIELD_CLASS}
263 />
264 </label>
265
266 {error ? (
267 <p className="font-mono text-xs text-[var(--color-error)]">{error}</p>
268 ) : null}
269
270 {/* 5 — submit */}
271 <div className="flex flex-wrap items-center gap-3">
272 <button
273 type="submit"
274 disabled={busy || !name.trim() || !email.trim() || !message.trim()}
275 className="inline-flex h-11 items-center justify-center rounded-[var(--radius-md)] bg-[var(--color-primary)] px-5 font-sans font-medium text-[var(--color-text-inverse)] transition-colors hover:bg-[var(--color-primary-hover)] disabled:cursor-not-allowed disabled:opacity-50"
276 >
277 {busy ? 'sending…' : 'send message'}
278 </button>
279 <span className="font-mono text-[11px] text-[var(--color-text-subtle)]">
280 we read what you send to help you. no marketing emails, ever.
281 </span>
282 </div>
283 </form>
284 );
285}