migration-lead-form.tsx280 lines · main
| 1 | 'use client'; |
| 2 | |
| 3 | import { useState } from 'react'; |
| 4 | |
| 5 | interface Source { |
| 6 | slug: string; |
| 7 | name: string; |
| 8 | } |
| 9 | |
| 10 | interface 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 | |
| 18 | type Urgency = 'exploring' | 'this_quarter' | 'this_month' | 'this_week'; |
| 19 | |
| 20 | interface 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. |
| 28 | function 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 | */ |
| 80 | export 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'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'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'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 | } |