migration-lead-form.tsx280 lines · main
1'use client';
2
3import { useState } from 'react';
4
5interface Source {
6 slug: string;
7 name: string;
8}
9
10interface Props {
11 apiOrigin: string;
12 /** Pre-selected source slug for this page. Defaults to "" so the
13 * user has to pick. */
14 defaultSource?: string;
15 sources: readonly Source[];
16}
17
18type Urgency = 'exploring' | 'this_quarter' | 'this_month' | 'this_week';
19
20interface SubmittedState {
21 requestId: string;
22 source: string;
23}
24
25// Per-source URL shape hints. We don't reject — these are advisory.
26// The form requires *no* URL at all (optional field), so this only
27// runs when the user typed something that looks wrong.
28function validateSourceUrl(source: string, url: string): string | null {
29 let parsed: URL;
30 try {
31 parsed = new URL(url.startsWith('http') ? url : `https://${url}`);
32 } catch {
33 return 'that does not look like a URL. did you mean to leave it blank?';
34 }
35 if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
36 return 'URL must start with https://';
37 }
38 const host = parsed.host.toLowerCase();
39 switch (source) {
40 case 'convex':
41 // Convex deployments are https://*.convex.cloud (production) or
42 // https://*.convex.site (preview). Both are valid; nothing else is.
43 if (!host.endsWith('.convex.cloud') && !host.endsWith('.convex.site')) {
44 return 'convex deployments live on *.convex.cloud or *.convex.site — double-check the URL';
45 }
46 break;
47 case 'supabase':
48 if (!host.endsWith('.supabase.co') && !host.endsWith('.supabase.com')) {
49 return 'supabase projects are at *.supabase.co — double-check the URL';
50 }
51 break;
52 case 'firebase':
53 // Firebase project IDs are NOT URLs — they're just slugs. If the
54 // user pasted a URL, accept; if they typed a slug, accept that
55 // too (only check when they typed something).
56 if (parsed.protocol.startsWith('http') && !host.includes('firebase')) {
57 return 'firebase URLs / project IDs usually contain "firebase" — double-check';
58 }
59 break;
60 case 'hasura':
61 if (!host.endsWith('.hasura.app') && !host.includes('hasura')) {
62 return 'hasura cloud endpoints are at *.hasura.app — double-check the URL';
63 }
64 break;
65 default:
66 // mongodb / postgres / drizzle / prisma / nextauth — connection
67 // strings, not http URLs. We accept anything reasonable.
68 break;
69 }
70 return null;
71}
72
73/**
74 * Public, unauthenticated migration intake form. Embedded on /migrate
75 * + each /migrate/<source> page so prospects can request a migration
76 * before signing up. Posts to /v1/migration-leads on the api origin
77 * (rate-limited to 5/hr per IP) and renders an inline success state
78 * with the briven request id the customer can quote in follow-ups.
79 */
80export function MigrationLeadForm({ apiOrigin, defaultSource = '', sources }: Props) {
81 const [email, setEmail] = useState('');
82 const [source, setSource] = useState(defaultSource);
83 const [urgency, setUrgency] = useState<Urgency>('exploring');
84 const [sourceUrl, setSourceUrl] = useState('');
85 const [sourceNotes, setSourceNotes] = useState('');
86 const [busy, setBusy] = useState(false);
87 const [error, setError] = useState<string | null>(null);
88 const [submitted, setSubmitted] = useState<SubmittedState | null>(null);
89
90 async function submit(e: React.FormEvent) {
91 e.preventDefault();
92 if (busy) return;
93 // Client-side URL sanity check — catches the most common typos
94 // (missing protocol, wrong tld for the source) before they hit the
95 // operator inbox. Source-specific patterns; falls through to "any
96 // https:// URL is acceptable" for sources without a known shape.
97 const trimmedUrl = sourceUrl.trim();
98 if (trimmedUrl) {
99 const urlError = validateSourceUrl(source, trimmedUrl);
100 if (urlError) {
101 setError(urlError);
102 return;
103 }
104 }
105 setBusy(true);
106 setError(null);
107 try {
108 const res = await fetch(`${apiOrigin}/v1/migration-leads`, {
109 method: 'POST',
110 headers: { 'content-type': 'application/json' },
111 body: JSON.stringify({
112 contactEmail: email.trim(),
113 source: source || 'other',
114 sourceUrl: sourceUrl.trim() || null,
115 sourceNotes: sourceNotes.trim(),
116 urgency,
117 }),
118 });
119 if (res.status === 429) {
120 setError(
121 "too many requests from this address recently. wait an hour, or email migrations@flndrn.com directly.",
122 );
123 return;
124 }
125 if (!res.ok) {
126 const body = (await res.json().catch(() => null)) as { message?: string } | null;
127 setError(body?.message ?? `submit failed: ${res.status}`);
128 return;
129 }
130 const data = (await res.json()) as { requestId: string };
131 setSubmitted({ requestId: data.requestId, source: source || 'other' });
132 } catch (err) {
133 setError(err instanceof Error ? err.message : 'submit failed');
134 } finally {
135 setBusy(false);
136 }
137 }
138
139 if (submitted) {
140 return (
141 <div className="rounded-[var(--radius-lg)] border border-[var(--color-border-primary)] bg-[var(--color-primary-subtle)] p-6">
142 <p className="font-sans font-medium tracking-[-0.02em] text-[var(--color-text)] text-[var(--text-h4)]">
143 got it · we&apos;ll reach out within one business day
144 </p>
145 <p className="mt-3 leading-[1.6] text-[var(--color-text-muted)] text-[var(--text-small)]">
146 your request is queued. an operator will email you from{' '}
147 <code>migrations@flndrn.com</code> with the next steps — typically a short call
148 to confirm scope, then the actual move while you keep running on{' '}
149 {submitted.source}.
150 </p>
151 <p className="mt-4 font-mono text-xs text-[var(--color-text-subtle)]">
152 request id:{' '}
153 <code className="text-[var(--color-text-muted)]">{submitted.requestId}</code>
154 </p>
155 <p className="mt-3 font-mono text-[10px] text-[var(--color-text-subtle)]">
156 if you don&apos;t hear from us within one business day, email{' '}
157 <a
158 href="mailto:migrations@flndrn.com"
159 className="underline underline-offset-2 hover:text-[var(--color-text-muted)]"
160 >
161 migrations@flndrn.com
162 </a>{' '}
163 and quote the id above.
164 </p>
165 </div>
166 );
167 }
168
169 return (
170 <form
171 onSubmit={submit}
172 className="flex flex-col gap-4 rounded-[var(--radius-lg)] border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6"
173 >
174 <p className="font-sans font-medium tracking-[-0.02em] text-[var(--color-text)] text-[var(--text-h4)]">
175 request a migration · no signup needed
176 </p>
177 <p className="leading-[1.6] text-[var(--color-text-muted)] text-[var(--text-small)]">
178 leave us your email and we&apos;ll reach out within one business day. free during
179 beta. your current platform stays untouched until you say cutover.
180 </p>
181
182 <label className="flex flex-col gap-2">
183 <span className="font-mono text-xs text-[var(--color-text-muted)]">
184 email <span className="text-[var(--color-text-subtle)]">(required)</span>
185 </span>
186 <input
187 type="email"
188 required
189 maxLength={320}
190 value={email}
191 onChange={(e) => setEmail(e.target.value)}
192 placeholder="you@example.com"
193 className="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)]"
194 />
195 </label>
196
197 <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
198 <label className="flex flex-col gap-2">
199 <span className="font-mono text-xs text-[var(--color-text-muted)]">coming from</span>
200 <select
201 value={source}
202 onChange={(e) => setSource(e.target.value)}
203 className="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)]"
204 >
205 <option value="">— pick one —</option>
206 {sources.map((s) => (
207 <option key={s.slug} value={s.slug}>
208 {s.name}
209 </option>
210 ))}
211 <option value="other">other / not listed</option>
212 </select>
213 </label>
214
215 <label className="flex flex-col gap-2">
216 <span className="font-mono text-xs text-[var(--color-text-muted)]">timeline</span>
217 <select
218 value={urgency}
219 onChange={(e) => setUrgency(e.target.value as Urgency)}
220 className="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)]"
221 >
222 <option value="exploring">just exploring</option>
223 <option value="this_quarter">this quarter</option>
224 <option value="this_month">this month</option>
225 <option value="this_week">this week · time-sensitive</option>
226 </select>
227 </label>
228 </div>
229
230 <label className="flex flex-col gap-2">
231 <span className="font-mono text-xs text-[var(--color-text-muted)]">
232 source URL{' '}
233 <span className="text-[var(--color-text-subtle)]">(optional — helps us scope)</span>
234 </span>
235 <input
236 type="text"
237 maxLength={2000}
238 value={sourceUrl}
239 onChange={(e) => setSourceUrl(e.target.value)}
240 placeholder="https://my-deployment.convex.cloud or similar"
241 className="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)]"
242 />
243 </label>
244
245 <label className="flex flex-col gap-2">
246 <span className="font-mono text-xs text-[var(--color-text-muted)]">
247 anything else?{' '}
248 <span className="text-[var(--color-text-subtle)]">
249 (rough table count, auth provider, specific concerns)
250 </span>
251 </span>
252 <textarea
253 rows={4}
254 maxLength={8000}
255 value={sourceNotes}
256 onChange={(e) => setSourceNotes(e.target.value)}
257 placeholder="~12 tables, clerk for auth, ~500 DAU. would like to cut over before our launch on the 21st."
258 className="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)]"
259 />
260 </label>
261
262 {error ? (
263 <p className="font-mono text-xs text-[var(--color-error)]">{error}</p>
264 ) : null}
265
266 <div className="flex flex-wrap items-center gap-3">
267 <button
268 type="submit"
269 disabled={busy || !email.trim()}
270 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"
271 >
272 {busy ? 'sending…' : 'request migration'}
273 </button>
274 <span className="font-mono text-[11px] text-[var(--color-text-subtle)]">
275 we read what you send to triage. no marketing emails, ever.
276 </span>
277 </div>
278 </form>
279 );
280}