"use client"; import { useState } from "react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { Card } from "@/components/ui/card"; import type { OrgBilling } from "@/types"; interface Props { initial: OrgBilling | null; /** * Personal-account (individual customer) flag from the session. * Individuals get a "Full name" field instead of "Company name", * and the VAT input is hidden entirely — they don't have one and * showing the field would only confuse. The underlying column is * still `company_name` in the DB and the invoice PDF; for an * individual that field carries their full name, which is * exactly what should print on the invoice. */ isPersonal: boolean; } /** * Customer billing settings form. Drives PUT /api/settings/billing * which upserts org_billing for the caller's org. * * Validation is the same regex as the server-side zod schema for * the country field (ISO 3166-1 alpha-2). Other fields are checked * for required + max-length client-side; the server is the * authority and re-validates everything. * * On success we router.refresh() the page so the server component * re-fetches and any "create now" -> "edit" wording flips. */ export function BillingSettingsForm({ initial, isPersonal }: Props) { const t = useTranslations("settingsBilling"); const router = useRouter(); const [form, setForm] = useState({ companyName: initial?.companyName ?? "", contactName: initial?.contactName ?? "", streetAddress: initial?.streetAddress ?? "", postalCode: initial?.postalCode ?? "", city: initial?.city ?? "", country: initial?.country ?? "CH", vatNumber: initial?.vatNumber ?? "", billingEmail: initial?.billingEmail ?? "", notes: initial?.notes ?? "", }); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [savedFlash, setSavedFlash] = useState(false); const set = (field: keyof typeof form) => (e: React.ChangeEvent) => setForm((f) => ({ ...f, [field]: e.target.value })); const submit = async () => { setError(null); setSavedFlash(false); // Client-side gate on required fields — the server re-validates. if ( !form.companyName.trim() || !form.streetAddress.trim() || !form.postalCode.trim() || !form.city.trim() || !form.country.trim() || !form.billingEmail.trim() ) { setError(t("missingRequired")); return; } if (!/^[A-Za-z]{2}$/.test(form.country.trim())) { setError(t("invalidCountry")); return; } if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(form.billingEmail.trim())) { setError(t("invalidEmail")); return; } setBusy(true); try { const res = await fetch("/api/settings/billing", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ companyName: form.companyName.trim(), // Personal accounts don't have a contact-name field // (companyName IS their name); force null so stale state // from a previously-org-flagged account can't carry over. contactName: isPersonal ? null : form.contactName.trim() || null, streetAddress: form.streetAddress.trim(), postalCode: form.postalCode.trim(), city: form.city.trim(), country: form.country.trim().toUpperCase(), // Personal accounts never have a VAT number — force null // regardless of stale state, in case a value was stored // before the account got flagged as personal. vatNumber: isPersonal ? null : form.vatNumber.trim() || null, billingEmail: form.billingEmail.trim(), notes: form.notes.trim() || null, }), }); const data = await res.json().catch(() => ({})); if (!res.ok) { throw new Error(data.error ?? `HTTP ${res.status}`); } setSavedFlash(true); router.refresh(); } catch (e: any) { setError(e?.message ?? String(e)); } finally { setBusy(false); } }; return (
{!isPersonal && ( )}
setForm((f) => ({ ...f, country: e.target.value.toUpperCase().slice(0, 2), })) } maxLength={2} className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm uppercase font-mono" />
{!isPersonal && ( )}