profile-billing-form.tsx702 lines · main
1'use client';
2
3import {
4 useEffect,
5 useMemo,
6 useRef,
7 useState,
8 useTransition,
9 type FormEvent,
10} from 'react';
11import { useRouter } from 'next/navigation';
12
13import { COUNTRIES_EU, COUNTRIES_REST } from '../lib/countries';
14import { notifyDashboardChange } from './live-refresh';
15
16type 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
23export 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
40interface 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
50type 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).
57const 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
77function 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
92function detectBrowserTimezone(): string {
93 try {
94 return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
95 } catch {
96 return 'UTC';
97 }
98}
99
100export 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 &amp; 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&apos;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&apos;re addressed on invoices and in emails. Company and VAT ID are only
246 needed for B2B invoicing — leave blank if you&apos;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 &amp; 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
432function 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
460function 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
553function 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&apos;t reach VIES ({state.reason}) — we&apos;ll re-check on save
578 </p>
579 );
580}
581
582function 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
628function 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
658function 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
692function 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}