contact-form.tsx285 lines · main
| 1 | 'use client'; |
| 2 | |
| 3 | import { useState } from 'react'; |
| 4 | |
| 5 | import { ROUTING_TAGS, SubjectTagsInput } from './subject-tags-input'; |
| 6 | |
| 7 | interface 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 | |
| 23 | type Topic = |
| 24 | | 'general' |
| 25 | | 'support' |
| 26 | | 'sales' |
| 27 | | 'self-host' |
| 28 | | 'security' |
| 29 | | 'privacy' |
| 30 | | 'legal' |
| 31 | | 'other'; |
| 32 | |
| 33 | const 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'. */ |
| 45 | function coerceTopic(value: string | undefined): Topic { |
| 46 | return TOPICS.some((t) => t.value === value) ? (value as Topic) : 'general'; |
| 47 | } |
| 48 | |
| 49 | interface SubmittedState { |
| 50 | requestId: string; |
| 51 | ticketNumber: string | null; |
| 52 | } |
| 53 | |
| 54 | const 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 | */ |
| 67 | export 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'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'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'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'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 | } |