profile-billing-form.tsx702 lines · main
| 1 | 'use client'; |
| 2 | |
| 3 | import { |
| 4 | useEffect, |
| 5 | useMemo, |
| 6 | useRef, |
| 7 | useState, |
| 8 | useTransition, |
| 9 | type FormEvent, |
| 10 | } from 'react'; |
| 11 | import { useRouter } from 'next/navigation'; |
| 12 | |
| 13 | import { COUNTRIES_EU, COUNTRIES_REST } from '../lib/countries'; |
| 14 | import { notifyDashboardChange } from './live-refresh'; |
| 15 | |
| 16 | type VatState = |
| 17 | | { status: 'idle' } |
| 18 | | { status: 'checking' } |
| 19 | | { status: 'valid'; name: string | null; address: string | null } |
| 20 | | { status: 'invalid'; reason: string } |
| 21 | | { status: 'unverifiable'; reason: string }; |
| 22 | |
| 23 | export interface ProfileInitial { |
| 24 | name: string; |
| 25 | legalName: string; |
| 26 | companyName: string; |
| 27 | companyRegistrationNumber: string; |
| 28 | vatId: string; |
| 29 | addressLine1: string; |
| 30 | addressLine2: string; |
| 31 | addressCity: string; |
| 32 | addressPostalCode: string; |
| 33 | addressRegion: string; |
| 34 | addressCountry: string; |
| 35 | dateOfBirth: string; |
| 36 | countryOfBirth: string; |
| 37 | timezone: string; |
| 38 | } |
| 39 | |
| 40 | interface Props { |
| 41 | initial: ProfileInitial; |
| 42 | currentImage: string | null; |
| 43 | displayName: string; |
| 44 | vatLocked: boolean; |
| 45 | save: ( |
| 46 | patch: Record<string, string | null>, |
| 47 | ) => Promise<{ ok: true } | { ok: false; error: string }>; |
| 48 | } |
| 49 | |
| 50 | type FieldKey = keyof ProfileInitial; |
| 51 | |
| 52 | // Full IANA timezone list. `Intl.supportedValuesOf('timeZone')` returns |
| 53 | // every zone the browser's tzdata knows about (~440 today). Sorted |
| 54 | // alphabetically so the dropdown is scannable. Falls back to a curated |
| 55 | // short list when the API is unavailable (very old browsers, SSR before |
| 56 | // React hydration on engines that don't expose it). |
| 57 | const TIMEZONES: readonly string[] = (() => { |
| 58 | try { |
| 59 | const all = Intl.supportedValuesOf('timeZone'); |
| 60 | return [...all].sort(); |
| 61 | } catch { |
| 62 | return [ |
| 63 | 'Europe/Brussels', |
| 64 | 'Europe/Amsterdam', |
| 65 | 'Europe/Paris', |
| 66 | 'Europe/Berlin', |
| 67 | 'Europe/London', |
| 68 | 'America/New_York', |
| 69 | 'America/Los_Angeles', |
| 70 | 'Asia/Tokyo', |
| 71 | 'Australia/Sydney', |
| 72 | 'UTC', |
| 73 | ]; |
| 74 | } |
| 75 | })(); |
| 76 | |
| 77 | function formatTzOffset(iana: string): string { |
| 78 | try { |
| 79 | const date = new Date(); |
| 80 | const f = new Intl.DateTimeFormat('en-US', { |
| 81 | timeZone: iana, |
| 82 | timeZoneName: 'longOffset', |
| 83 | }); |
| 84 | const parts = f.formatToParts(date); |
| 85 | const tzPart = parts.find((p) => p.type === 'timeZoneName')?.value ?? ''; |
| 86 | return tzPart.replace(/^GMT/, 'UTC') || 'UTC'; |
| 87 | } catch { |
| 88 | return ''; |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | function detectBrowserTimezone(): string { |
| 93 | try { |
| 94 | return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; |
| 95 | } catch { |
| 96 | return 'UTC'; |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | export function ProfileBillingForm({ |
| 101 | initial, |
| 102 | currentImage, |
| 103 | displayName, |
| 104 | vatLocked, |
| 105 | save, |
| 106 | }: Props) { |
| 107 | const router = useRouter(); |
| 108 | const [values, setValues] = useState<ProfileInitial>(initial); |
| 109 | const [error, setError] = useState<string | null>(null); |
| 110 | const [saved, setSaved] = useState(false); |
| 111 | const [pending, startTransition] = useTransition(); |
| 112 | const [vat, setVat] = useState<VatState>({ status: 'idle' }); |
| 113 | const vatDebounce = useRef<ReturnType<typeof setTimeout> | null>(null); |
| 114 | |
| 115 | useEffect(() => { |
| 116 | if (!values.timezone) { |
| 117 | setValues((v) => ({ ...v, timezone: detectBrowserTimezone() })); |
| 118 | } |
| 119 | // Intentional mount-only effect: detectBrowserTimezone is module-scoped and |
| 120 | // setValues uses a functional updater, so no closure-stale-value risk. |
| 121 | }, []); |
| 122 | |
| 123 | const tzOffset = useMemo(() => formatTzOffset(values.timezone || 'UTC'), [values.timezone]); |
| 124 | const tzChip = values.timezone |
| 125 | ? `${values.timezone}${tzOffset ? ` · ${tzOffset}` : ''}` |
| 126 | : '—'; |
| 127 | |
| 128 | function set(key: FieldKey, value: string) { |
| 129 | setValues((v) => ({ ...v, [key]: value })); |
| 130 | if (key === 'vatId') setVat({ status: 'idle' }); |
| 131 | } |
| 132 | |
| 133 | useEffect(() => { |
| 134 | if (vatDebounce.current) clearTimeout(vatDebounce.current); |
| 135 | const trimmed = values.vatId.trim(); |
| 136 | if (trimmed.length === 0) { |
| 137 | setVat({ status: 'idle' }); |
| 138 | return; |
| 139 | } |
| 140 | vatDebounce.current = setTimeout(async () => { |
| 141 | setVat({ status: 'checking' }); |
| 142 | try { |
| 143 | const res = await fetch(`/api/v1/billing/vat/check?id=${encodeURIComponent(trimmed)}`, { |
| 144 | credentials: 'include', |
| 145 | }); |
| 146 | if (!res.ok) { |
| 147 | setVat({ status: 'unverifiable', reason: `http_${res.status}` }); |
| 148 | return; |
| 149 | } |
| 150 | const data = (await res.json()) as |
| 151 | | { state: 'valid'; name: string | null; address: string | null } |
| 152 | | { state: 'invalid'; reason: string } |
| 153 | | { state: 'unverifiable'; reason: string }; |
| 154 | if (data.state === 'valid') { |
| 155 | setVat({ status: 'valid', name: data.name, address: data.address }); |
| 156 | } else if (data.state === 'invalid') { |
| 157 | setVat({ status: 'invalid', reason: data.reason }); |
| 158 | } else { |
| 159 | setVat({ status: 'unverifiable', reason: data.reason }); |
| 160 | } |
| 161 | } catch (err) { |
| 162 | setVat({ |
| 163 | status: 'unverifiable', |
| 164 | reason: err instanceof Error ? err.message : 'vat_check_failed', |
| 165 | }); |
| 166 | } |
| 167 | }, 600); |
| 168 | return () => { |
| 169 | if (vatDebounce.current) clearTimeout(vatDebounce.current); |
| 170 | }; |
| 171 | }, [values.vatId]); |
| 172 | |
| 173 | function dirty(): boolean { |
| 174 | return (Object.keys(values) as FieldKey[]).some((k) => values[k] !== initial[k]); |
| 175 | } |
| 176 | |
| 177 | function onSubmit(e: FormEvent<HTMLFormElement>) { |
| 178 | e.preventDefault(); |
| 179 | if (!dirty()) return; |
| 180 | setError(null); |
| 181 | setSaved(false); |
| 182 | const patch: Record<string, string | null> = {}; |
| 183 | (Object.keys(values) as FieldKey[]).forEach((k) => { |
| 184 | if (values[k] !== initial[k]) { |
| 185 | const trimmed = values[k].trim(); |
| 186 | patch[k] = trimmed.length === 0 ? null : trimmed; |
| 187 | } |
| 188 | }); |
| 189 | startTransition(async () => { |
| 190 | try { |
| 191 | const result = await save(patch); |
| 192 | if (result.ok) { |
| 193 | setSaved(true); |
| 194 | setTimeout(() => setSaved(false), 3000); |
| 195 | router.refresh(); |
| 196 | notifyDashboardChange(); |
| 197 | } else { |
| 198 | setError(result.error); |
| 199 | } |
| 200 | } catch (err) { |
| 201 | setError(err instanceof Error ? err.message : 'save failed'); |
| 202 | } |
| 203 | }); |
| 204 | } |
| 205 | |
| 206 | return ( |
| 207 | <form |
| 208 | onSubmit={onSubmit} |
| 209 | className="flex flex-col gap-0 rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-surface)]" |
| 210 | > |
| 211 | <header className="flex flex-col gap-2 px-6 pt-5 pb-3"> |
| 212 | <div className="flex flex-wrap items-center justify-between gap-3"> |
| 213 | <h3 className="text-base font-semibold text-[var(--color-text)]"> |
| 214 | Profile & billing details |
| 215 | </h3> |
| 216 | <span className="inline-flex shrink-0 items-center rounded-md border border-[var(--color-border-primary)] bg-[var(--color-primary-subtle)] px-2 py-1 font-mono text-[10px] uppercase tracking-wider text-[var(--color-primary)]"> |
| 217 | {tzChip} |
| 218 | </span> |
| 219 | </div> |
| 220 | <p className="max-w-xl text-xs text-[var(--color-text-muted)]"> |
| 221 | Your avatar, plus the address and identity info we're required to keep on file |
| 222 | for any paid subscription under EU regulation. |
| 223 | </p> |
| 224 | </header> |
| 225 | |
| 226 | <div className="flex flex-wrap items-center gap-5 px-6 pt-2 pb-5"> |
| 227 | <AvatarSlot currentImage={currentImage} displayName={displayName} /> |
| 228 | <div className="flex min-w-0 flex-1 flex-col gap-2"> |
| 229 | <span className="font-mono text-[10px] uppercase tracking-wider text-[var(--color-text-subtle)]"> |
| 230 | Profile picture |
| 231 | </span> |
| 232 | <AvatarActions currentImage={currentImage} /> |
| 233 | <p className="text-xs text-[var(--color-text-muted)]"> |
| 234 | PNG, JPEG, or WebP. Max 8 MB before resizing. We downscale to 256×256 in your browser. |
| 235 | </p> |
| 236 | </div> |
| 237 | </div> |
| 238 | |
| 239 | <div className="border-t border-[var(--color-border-subtle)]" /> |
| 240 | |
| 241 | <section className="flex flex-col gap-4 px-6 pt-5 pb-2"> |
| 242 | <div> |
| 243 | <h4 className="text-sm font-semibold text-[var(--color-text)]">Invoicing identity</h4> |
| 244 | <p className="mt-1 max-w-2xl text-xs text-[var(--color-text-muted)]"> |
| 245 | How you're addressed on invoices and in emails. Company and VAT ID are only |
| 246 | needed for B2B invoicing — leave blank if you're billing as an individual. |
| 247 | </p> |
| 248 | </div> |
| 249 | |
| 250 | <LabeledInput |
| 251 | label="Display name" |
| 252 | placeholder="e.g. Jane Doe" |
| 253 | hint="Shown in the dashboard header and product UI." |
| 254 | value={values.name} |
| 255 | onChange={(v) => set('name', v)} |
| 256 | /> |
| 257 | |
| 258 | <LabeledInput |
| 259 | label="Legal name" |
| 260 | placeholder="As on your ID or company registration" |
| 261 | hint="Used on invoices and for KYC. Required before any paid checkout." |
| 262 | value={values.legalName} |
| 263 | onChange={(v) => set('legalName', v)} |
| 264 | /> |
| 265 | |
| 266 | <div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> |
| 267 | <LabeledInput |
| 268 | label="Company name" |
| 269 | placeholder="Acme Ltd." |
| 270 | hint="Optional — leave blank for individuals." |
| 271 | value={values.companyName} |
| 272 | onChange={(v) => set('companyName', v)} |
| 273 | /> |
| 274 | <LabeledInput |
| 275 | label="Company registration no." |
| 276 | placeholder="e.g. SIREN 123456789" |
| 277 | hint="EU business register number. Separate from VAT ID — many micro-businesses register without VAT." |
| 278 | value={values.companyRegistrationNumber} |
| 279 | onChange={(v) => set('companyRegistrationNumber', v)} |
| 280 | /> |
| 281 | <div className="flex flex-col gap-1.5"> |
| 282 | <LabeledInput |
| 283 | label="VAT ID / tax ID" |
| 284 | placeholder="BE0123456789" |
| 285 | hint="EU VAT (e.g. BE0123456789) for reverse-charge B2B invoicing." |
| 286 | value={values.vatId} |
| 287 | onChange={(v) => set('vatId', v)} |
| 288 | readOnly={vatLocked} |
| 289 | /> |
| 290 | {vatLocked ? ( |
| 291 | <p className="text-xs text-[var(--color-text-subtle)]"> |
| 292 | VAT verified ✓ · locked. To change a verified VAT ID, email{' '} |
| 293 | <a |
| 294 | href="mailto:support@flndrn.com?subject=VAT%20change%20request" |
| 295 | className="text-[var(--color-text-link)] underline-offset-2 hover:underline" |
| 296 | > |
| 297 | support@flndrn.com |
| 298 | </a>{' '} |
| 299 | with the new ID + reason — we re-verify against VIES on our side. |
| 300 | </p> |
| 301 | ) : ( |
| 302 | <VatStatusLine state={vat} /> |
| 303 | )} |
| 304 | </div> |
| 305 | </div> |
| 306 | </section> |
| 307 | |
| 308 | <section className="flex flex-col gap-4 px-6 pt-6 pb-5"> |
| 309 | <div> |
| 310 | <h4 className="text-sm font-semibold text-[var(--color-text)]"> |
| 311 | Billing address & KYC |
| 312 | </h4> |
| 313 | <p className="mt-1 max-w-2xl text-xs text-[var(--color-text-muted)]"> |
| 314 | EU regulation requires us to collect this for any paid subscription. Polar also uses |
| 315 | it for VAT calculation on invoices. Saved fields are encrypted at rest on the briven |
| 316 | control plane. |
| 317 | </p> |
| 318 | </div> |
| 319 | |
| 320 | <LabeledInput |
| 321 | label="Address line 1" |
| 322 | value={values.addressLine1} |
| 323 | onChange={(v) => set('addressLine1', v)} |
| 324 | /> |
| 325 | <LabeledInput |
| 326 | label="Address line 2" |
| 327 | placeholder="Apartment, unit, etc. (optional)" |
| 328 | value={values.addressLine2} |
| 329 | onChange={(v) => set('addressLine2', v)} |
| 330 | /> |
| 331 | |
| 332 | <div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> |
| 333 | <LabeledInput |
| 334 | label="Postal code" |
| 335 | value={values.addressPostalCode} |
| 336 | onChange={(v) => set('addressPostalCode', v)} |
| 337 | /> |
| 338 | <LabeledInput |
| 339 | label="City" |
| 340 | value={values.addressCity} |
| 341 | onChange={(v) => set('addressCity', v)} |
| 342 | /> |
| 343 | </div> |
| 344 | |
| 345 | <div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> |
| 346 | <LabeledInput |
| 347 | label="State / province / region" |
| 348 | value={values.addressRegion} |
| 349 | onChange={(v) => set('addressRegion', v)} |
| 350 | /> |
| 351 | <LabeledSelect |
| 352 | label="Country (residency)" |
| 353 | value={values.addressCountry} |
| 354 | onChange={(v) => set('addressCountry', v)} |
| 355 | hint="Countries under US OFAC sanctions are disabled — we're unable to onboard customers from those jurisdictions." |
| 356 | > |
| 357 | <option value="">— select —</option> |
| 358 | <optgroup label="EU / EEA / UK / CH"> |
| 359 | {COUNTRIES_EU.map((c) => ( |
| 360 | <option key={c.code} value={c.code}> |
| 361 | {c.name} |
| 362 | </option> |
| 363 | ))} |
| 364 | </optgroup> |
| 365 | <optgroup label="Rest of world"> |
| 366 | {COUNTRIES_REST.map((c) => ( |
| 367 | <option key={c.code} value={c.code}> |
| 368 | {c.name} |
| 369 | </option> |
| 370 | ))} |
| 371 | </optgroup> |
| 372 | </LabeledSelect> |
| 373 | </div> |
| 374 | |
| 375 | <div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> |
| 376 | <LabeledInput |
| 377 | label="Date of birth" |
| 378 | type="date" |
| 379 | value={values.dateOfBirth} |
| 380 | onChange={(v) => set('dateOfBirth', v)} |
| 381 | /> |
| 382 | <LabeledInput |
| 383 | label="Country of birth (ISO 2-letter)" |
| 384 | value={values.countryOfBirth} |
| 385 | onChange={(v) => set('countryOfBirth', v.toUpperCase().slice(0, 2))} |
| 386 | maxLength={2} |
| 387 | pattern="[A-Z]{2}" |
| 388 | placeholder="BE" |
| 389 | /> |
| 390 | </div> |
| 391 | |
| 392 | <LabeledSelect |
| 393 | label="Timezone" |
| 394 | value={values.timezone} |
| 395 | onChange={(v) => set('timezone', v)} |
| 396 | hint="Used to schedule your weekly Pro digest at 09:00 your local time (instead of 09:00 UTC) and to render timestamps in alert emails in your timezone. Auto-detected from your browser; override if you're traveling or behind a VPN." |
| 397 | > |
| 398 | {TIMEZONES.map((tz) => { |
| 399 | const offset = formatTzOffset(tz); |
| 400 | return ( |
| 401 | <option key={tz} value={tz}> |
| 402 | {tz} |
| 403 | {offset ? ` · ${offset}` : ''} |
| 404 | </option> |
| 405 | ); |
| 406 | })} |
| 407 | </LabeledSelect> |
| 408 | </section> |
| 409 | |
| 410 | <div className="flex flex-wrap items-center gap-3 px-6 pb-6"> |
| 411 | <button |
| 412 | type="submit" |
| 413 | disabled={pending || !dirty()} |
| 414 | className="rounded-md bg-[var(--color-primary)] px-4 py-2 text-sm font-medium text-[var(--color-text-inverse)] transition hover:bg-[var(--color-primary-hover)] disabled:opacity-40" |
| 415 | > |
| 416 | {pending ? 'Saving…' : 'Save changes'} |
| 417 | </button> |
| 418 | <div className="text-xs"> |
| 419 | {error ? ( |
| 420 | <span role="alert" className="text-red-400"> |
| 421 | {error} |
| 422 | </span> |
| 423 | ) : saved ? ( |
| 424 | <span className="text-[var(--color-primary)]">saved ✓</span> |
| 425 | ) : null} |
| 426 | </div> |
| 427 | </div> |
| 428 | </form> |
| 429 | ); |
| 430 | } |
| 431 | |
| 432 | function AvatarSlot({ |
| 433 | currentImage, |
| 434 | displayName, |
| 435 | }: { |
| 436 | currentImage: string | null; |
| 437 | displayName: string; |
| 438 | }) { |
| 439 | if (currentImage) { |
| 440 | return ( |
| 441 | <img |
| 442 | src={currentImage} |
| 443 | alt="avatar" |
| 444 | width={80} |
| 445 | height={80} |
| 446 | className="size-20 rounded-full object-cover" |
| 447 | /> |
| 448 | ); |
| 449 | } |
| 450 | return ( |
| 451 | <span |
| 452 | aria-hidden |
| 453 | className="flex size-20 items-center justify-center rounded-full bg-[var(--color-primary-subtle)] font-mono text-2xl text-[var(--color-primary)]" |
| 454 | > |
| 455 | {getInitials(displayName)} |
| 456 | </span> |
| 457 | ); |
| 458 | } |
| 459 | |
| 460 | function AvatarActions({ currentImage }: { currentImage: string | null }) { |
| 461 | const router = useRouter(); |
| 462 | const fileRef = useRef<HTMLInputElement>(null); |
| 463 | const [error, setError] = useState<string | null>(null); |
| 464 | const [pending, startTransition] = useTransition(); |
| 465 | |
| 466 | async function onFile(file: File) { |
| 467 | setError(null); |
| 468 | if (!file.type.startsWith('image/')) { |
| 469 | setError('file must be an image'); |
| 470 | return; |
| 471 | } |
| 472 | if (file.size > 8 * 1024 * 1024) { |
| 473 | setError('image must be under 8 MB before resizing'); |
| 474 | return; |
| 475 | } |
| 476 | try { |
| 477 | const dataUri = await resizeToDataUri(file); |
| 478 | startTransition(async () => { |
| 479 | const res = await fetch('/api/v1/me/avatar', { |
| 480 | method: 'POST', |
| 481 | headers: { 'content-type': 'application/json' }, |
| 482 | credentials: 'include', |
| 483 | body: JSON.stringify({ dataUri }), |
| 484 | }); |
| 485 | if (!res.ok) { |
| 486 | const body = (await res.json().catch(() => ({}))) as { message?: string }; |
| 487 | setError(body.message ?? `upload failed: ${res.status}`); |
| 488 | return; |
| 489 | } |
| 490 | router.refresh(); |
| 491 | }); |
| 492 | } catch (err) { |
| 493 | setError(err instanceof Error ? err.message : 'upload failed'); |
| 494 | } |
| 495 | } |
| 496 | |
| 497 | function onRemove() { |
| 498 | setError(null); |
| 499 | startTransition(async () => { |
| 500 | const res = await fetch('/api/v1/me/avatar', { |
| 501 | method: 'DELETE', |
| 502 | credentials: 'include', |
| 503 | }); |
| 504 | if (!res.ok) { |
| 505 | const body = (await res.json().catch(() => ({}))) as { message?: string }; |
| 506 | setError(body.message ?? `remove failed: ${res.status}`); |
| 507 | return; |
| 508 | } |
| 509 | router.refresh(); |
| 510 | }); |
| 511 | } |
| 512 | |
| 513 | return ( |
| 514 | <div className="flex flex-wrap items-center gap-2"> |
| 515 | <button |
| 516 | type="button" |
| 517 | disabled={pending} |
| 518 | onClick={() => fileRef.current?.click()} |
| 519 | className="rounded-md bg-[var(--color-primary)] px-3 py-1.5 text-xs font-medium text-[var(--color-text-inverse)] transition hover:bg-[var(--color-primary-hover)] disabled:opacity-40" |
| 520 | > |
| 521 | {currentImage ? 'Replace picture' : 'Upload picture'} |
| 522 | </button> |
| 523 | {currentImage ? ( |
| 524 | <button |
| 525 | type="button" |
| 526 | disabled={pending} |
| 527 | onClick={onRemove} |
| 528 | className="rounded-md border border-[var(--color-border-subtle)] bg-transparent px-3 py-1.5 text-xs text-[var(--color-text-muted)] transition hover:border-[var(--color-border)] hover:text-[var(--color-error)] disabled:opacity-40" |
| 529 | > |
| 530 | Remove |
| 531 | </button> |
| 532 | ) : null} |
| 533 | <input |
| 534 | ref={fileRef} |
| 535 | type="file" |
| 536 | accept="image/png,image/jpeg,image/webp" |
| 537 | className="hidden" |
| 538 | onChange={(e) => { |
| 539 | const f = e.currentTarget.files?.[0]; |
| 540 | if (f) void onFile(f); |
| 541 | e.currentTarget.value = ''; |
| 542 | }} |
| 543 | /> |
| 544 | {error ? ( |
| 545 | <span role="alert" className="text-xs text-red-400"> |
| 546 | {error} |
| 547 | </span> |
| 548 | ) : null} |
| 549 | </div> |
| 550 | ); |
| 551 | } |
| 552 | |
| 553 | function VatStatusLine({ state }: { state: VatState }) { |
| 554 | if (state.status === 'idle') return null; |
| 555 | if (state.status === 'checking') { |
| 556 | return <p className="text-xs text-[var(--color-text-subtle)]">Checking with VIES…</p>; |
| 557 | } |
| 558 | if (state.status === 'valid') { |
| 559 | return ( |
| 560 | <p className="text-xs text-[var(--color-primary)]"> |
| 561 | Valid ✓ {state.name ? `· ${state.name}` : null} |
| 562 | {state.address ? ( |
| 563 | <span className="block text-[var(--color-text-subtle)]">{state.address}</span> |
| 564 | ) : null} |
| 565 | </p> |
| 566 | ); |
| 567 | } |
| 568 | if (state.status === 'invalid') { |
| 569 | return ( |
| 570 | <p role="alert" className="text-xs text-red-400"> |
| 571 | Not registered with VIES ({state.reason}) |
| 572 | </p> |
| 573 | ); |
| 574 | } |
| 575 | return ( |
| 576 | <p className="text-xs text-amber-400"> |
| 577 | Couldn't reach VIES ({state.reason}) — we'll re-check on save |
| 578 | </p> |
| 579 | ); |
| 580 | } |
| 581 | |
| 582 | function LabeledInput({ |
| 583 | label, |
| 584 | hint, |
| 585 | value, |
| 586 | onChange, |
| 587 | readOnly, |
| 588 | type, |
| 589 | placeholder, |
| 590 | maxLength, |
| 591 | pattern, |
| 592 | }: { |
| 593 | label: string; |
| 594 | hint?: string; |
| 595 | value: string; |
| 596 | onChange: (v: string) => void; |
| 597 | readOnly?: boolean; |
| 598 | type?: string; |
| 599 | placeholder?: string; |
| 600 | maxLength?: number; |
| 601 | pattern?: string; |
| 602 | }) { |
| 603 | return ( |
| 604 | <label className="flex flex-col gap-1.5"> |
| 605 | <span className="font-mono text-[10px] uppercase tracking-wider text-[var(--color-text-subtle)]"> |
| 606 | {label} |
| 607 | </span> |
| 608 | <input |
| 609 | type={type ?? 'text'} |
| 610 | value={value} |
| 611 | readOnly={readOnly} |
| 612 | placeholder={placeholder} |
| 613 | maxLength={maxLength} |
| 614 | pattern={pattern} |
| 615 | onChange={(e) => onChange(e.currentTarget.value)} |
| 616 | aria-readonly={readOnly ? 'true' : undefined} |
| 617 | className={`rounded-md border px-3 py-2 text-sm outline-none ${ |
| 618 | readOnly |
| 619 | ? 'cursor-not-allowed border-[var(--color-border-subtle)] bg-[var(--color-surface)] text-[var(--color-text-muted)]' |
| 620 | : 'border-[var(--color-border)] bg-[var(--color-surface-raised)] text-[var(--color-text)] focus:border-[var(--color-primary)]' |
| 621 | }`} |
| 622 | /> |
| 623 | {hint ? <span className="text-xs text-[var(--color-text-subtle)]">{hint}</span> : null} |
| 624 | </label> |
| 625 | ); |
| 626 | } |
| 627 | |
| 628 | function LabeledSelect({ |
| 629 | label, |
| 630 | hint, |
| 631 | value, |
| 632 | onChange, |
| 633 | children, |
| 634 | }: { |
| 635 | label: string; |
| 636 | hint?: string; |
| 637 | value: string; |
| 638 | onChange: (v: string) => void; |
| 639 | children: React.ReactNode; |
| 640 | }) { |
| 641 | return ( |
| 642 | <label className="flex flex-col gap-1.5"> |
| 643 | <span className="font-mono text-[10px] uppercase tracking-wider text-[var(--color-text-subtle)]"> |
| 644 | {label} |
| 645 | </span> |
| 646 | <select |
| 647 | value={value} |
| 648 | onChange={(e) => onChange(e.currentTarget.value)} |
| 649 | className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-3 py-2 text-sm text-[var(--color-text)] outline-none focus:border-[var(--color-primary)]" |
| 650 | > |
| 651 | {children} |
| 652 | </select> |
| 653 | {hint ? <span className="text-xs text-[var(--color-text-subtle)]">{hint}</span> : null} |
| 654 | </label> |
| 655 | ); |
| 656 | } |
| 657 | |
| 658 | function resizeToDataUri(file: File): Promise<string> { |
| 659 | return new Promise((resolve, reject) => { |
| 660 | const reader = new FileReader(); |
| 661 | reader.onerror = () => reject(new Error('could not read file')); |
| 662 | reader.onload = () => { |
| 663 | const img = new Image(); |
| 664 | img.onerror = () => reject(new Error('could not decode image')); |
| 665 | img.onload = () => { |
| 666 | const TARGET = 256; |
| 667 | const size = Math.min(img.naturalWidth, img.naturalHeight); |
| 668 | const sx = (img.naturalWidth - size) / 2; |
| 669 | const sy = (img.naturalHeight - size) / 2; |
| 670 | const canvas = document.createElement('canvas'); |
| 671 | canvas.width = TARGET; |
| 672 | canvas.height = TARGET; |
| 673 | const ctx = canvas.getContext('2d'); |
| 674 | if (!ctx) { |
| 675 | reject(new Error('canvas unavailable')); |
| 676 | return; |
| 677 | } |
| 678 | ctx.drawImage(img, sx, sy, size, size, 0, 0, TARGET, TARGET); |
| 679 | const webp = canvas.toDataURL('image/webp', 0.85); |
| 680 | if (webp.startsWith('data:image/webp')) { |
| 681 | resolve(webp); |
| 682 | return; |
| 683 | } |
| 684 | resolve(canvas.toDataURL('image/jpeg', 0.85)); |
| 685 | }; |
| 686 | img.src = reader.result as string; |
| 687 | }; |
| 688 | reader.readAsDataURL(file); |
| 689 | }); |
| 690 | } |
| 691 | |
| 692 | function getInitials(source: string): string { |
| 693 | const cleaned = source.trim(); |
| 694 | if (!cleaned) return '·'; |
| 695 | const parts = cleaned.includes('@') ? [cleaned.split('@')[0]!] : cleaned.split(/\s+/); |
| 696 | const letters = parts |
| 697 | .slice(0, 2) |
| 698 | .map((p) => p[0]) |
| 699 | .filter(Boolean) |
| 700 | .join(''); |
| 701 | return (letters || cleaned[0] || '·').toUpperCase(); |
| 702 | } |