feat(frontend): add BusinessSettings component to admin page

Add BusinessSettings component with edit modal for managing VAT, gift card, voucher and contact settings. Add BusinessSettings and UpdateBusinessSettingsRequest types.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-06-22 12:55:48 +01:00
co-authored by Sisyphus
parent 5523672b6a
commit c4f1535ad4
3 changed files with 629 additions and 0 deletions
@@ -0,0 +1,601 @@
<script lang="ts">
import { authStore } from '$lib/stores/auth.svelte';
import { toast } from 'svelte-sonner';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import * as Label from '$lib/components/ui/label';
import * as Textarea from '$lib/components/ui/textarea';
import { Skeleton } from '$lib/components/ui/skeleton';
import * as Modal from '$lib/components/ui/dialog';
import { Checkbox } from '$lib/components/ui/checkbox';
// Using native select to avoid bits-ui Select type issues
import type { BusinessSettings } from '$lib/types/settings';
import { isValidUKPhone, toE164UK, normalisePhoneInput } from '$lib/utils/phone';
import { isValidEmail, normalizeEmail } from '$lib/utils/email';
let settings = $state<BusinessSettings | null>(null);
let loading = $state(true);
let showEditModal = $state(false);
let saving = $state(false);
let form = $state<Partial<BusinessSettings>>({});
let formErrors = $state<Record<string, string>>({});
const VOUCHER_TYPES = [
{ value: 'SPV', label: 'SPV (Standard Voucher - 20% VAT)' },
{ value: 'MPV', label: 'MPV (Multipurpose Voucher - 0% VAT)' }
] as const;
$effect(() => {
if (authStore.currentToken) {
fetchSettings();
}
});
async function fetchSettings() {
loading = true;
try {
const res = await fetch('/api/admin/settings', {
headers: { Authorization: `Bearer ${authStore.currentToken}` }
});
if (res.ok) {
settings = await res.json();
} else {
toast.error('Failed to load business settings');
}
} catch {
toast.error('Network error loading business settings');
} finally {
loading = false;
}
}
function openEditModal() {
if (!settings) return;
form = {
business_name: settings.business_name,
business_address: settings.business_address,
business_phone: settings.business_phone,
business_email: settings.business_email,
vat_registration_number: settings.vat_registration_number,
is_vat_registered: settings.is_vat_registered,
default_vat_rate: settings.default_vat_rate,
website_url: settings.website_url,
gift_card_expiry_months: settings.gift_card_expiry_months,
voucher_type: settings.voucher_type
};
formErrors = {};
showEditModal = true;
}
// ─── Per-field validators ────────────────────────────────────────
function validateBusinessName(value: unknown): string {
const v = value === null || value === undefined ? '' : String(value);
if (!v.trim()) return 'Business name is required';
if (v.trim().length > 255) return 'Must be 255 characters or fewer';
return '';
}
function validateBusinessAddress(value: unknown): string {
const v = value === null || value === undefined ? '' : String(value);
if (!v.trim()) return 'Business address is required';
return '';
}
function validatePhone(value: unknown): string {
const v = value === null || value === undefined ? '' : String(value);
const trimmed = v.trim();
if (!trimmed) return '';
if (trimmed.length > 20) return 'Must be 20 characters or fewer';
if (!isValidUKPhone(trimmed)) return 'Enter a valid UK phone (e.g. +44 20 7123 4567)';
return '';
}
function validateEmail(value: unknown): string {
const v = value === null || value === undefined ? '' : String(value);
const trimmed = v.trim();
if (!trimmed) return '';
if (trimmed.length > 254) return 'Must be 254 characters or fewer';
if (!isValidEmail(trimmed)) return 'Enter a valid email address';
return '';
}
function validateWebsiteUrl(value: unknown): string {
const v = value === null || value === undefined ? '' : String(value);
const trimmed = v.trim();
if (!trimmed) return '';
try {
const parsed = new URL(trimmed);
if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error();
if (!parsed.hostname.includes('.')) throw new Error();
if (parsed.hostname.endsWith('.')) throw new Error();
} catch {
return 'Enter a valid URL (e.g. https://example.com)';
}
return '';
}
function validateVatNumber(value: unknown): string {
const v = value === null || value === undefined ? '' : String(value);
const trimmed = v.trim();
if (!trimmed) return '';
if (trimmed.length > 20) return 'Must be 20 characters or fewer';
return '';
}
function validateVatRate(value: unknown): string {
if (value == null || value === '') return 'Required';
const num = Number(value);
if (isNaN(num)) return 'Must be a number';
if (num < 0 || num > 100) return 'Must be between 0 and 100';
return '';
}
function validateGiftCardExpiry(value: unknown): string {
if (value == null || value === '') return 'Required';
const num = Number(value);
if (isNaN(num)) return 'Must be a number';
if (num < 1) return 'Must be at least 1';
return '';
}
function validateVoucherType(value: unknown): string {
const v = value === null || value === undefined ? '' : String(value);
if (!v) return 'Voucher type is required';
return '';
}
// ─── Validation dispatch ─────────────────────────────────────────
function validateField(field: string, inputValue?: unknown) {
const val = inputValue ?? form[field as keyof typeof form];
if (field === 'business_name') formErrors.business_name = validateBusinessName(val);
else if (field === 'business_address') formErrors.business_address = validateBusinessAddress(val);
else if (field === 'business_phone') formErrors.business_phone = validatePhone(val);
else if (field === 'business_email') formErrors.business_email = validateEmail(val);
else if (field === 'website_url') formErrors.website_url = validateWebsiteUrl(val);
else if (field === 'vat_registration_number') formErrors.vat_registration_number = validateVatNumber(val);
else if (field === 'default_vat_rate') formErrors.default_vat_rate = validateVatRate(val);
else if (field === 'gift_card_expiry_months') formErrors.gift_card_expiry_months = validateGiftCardExpiry(val);
else if (field === 'voucher_type') formErrors.voucher_type = validateVoucherType(val);
}
function validateForm(): boolean {
formErrors = {
business_name: validateBusinessName(form.business_name),
business_address: validateBusinessAddress(form.business_address),
business_phone: validatePhone(form.business_phone),
business_email: validateEmail(form.business_email),
website_url: validateWebsiteUrl(form.website_url),
vat_registration_number: validateVatNumber(form.vat_registration_number),
default_vat_rate: validateVatRate(form.default_vat_rate),
gift_card_expiry_months: validateGiftCardExpiry(form.gift_card_expiry_months),
voucher_type: validateVoucherType(form.voucher_type)
};
return Object.values(formErrors).every((e) => !e);
}
async function saveSettings() {
if (!validateForm()) return;
saving = true;
try {
const patch: Record<string, unknown> = {};
if (!settings) return;
// Normalise email and phone before comparing/sending
let normalisedEmail = form.business_email?.trim() ?? null;
if (normalisedEmail) normalisedEmail = normalizeEmail(normalisedEmail);
const normalisedPhone = form.business_phone ? toE164UK(form.business_phone) ?? normalisePhoneInput(form.business_phone) : null;
const normalisedForm = {
...form,
business_email: normalisedEmail,
business_phone: normalisedPhone,
business_name: form.business_name?.trim() ?? '',
business_address: form.business_address?.trim() ?? '',
vat_registration_number: form.vat_registration_number?.trim() ?? null,
website_url: form.website_url?.trim() ?? null
};
const fields: (keyof BusinessSettings)[] = [
'business_name', 'business_address', 'business_phone', 'business_email',
'vat_registration_number', 'is_vat_registered', 'default_vat_rate',
'website_url', 'gift_card_expiry_months', 'voucher_type'
];
for (const field of fields) {
const newVal = normalisedForm[field] as unknown;
const oldVal = settings[field];
if (JSON.stringify(newVal) !== JSON.stringify(oldVal)) {
patch[field] = newVal;
}
}
if (Object.keys(patch).length === 0) {
toast.info('No changes to save');
showEditModal = false;
return;
}
const res = await fetch('/api/admin/settings', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify(patch)
});
if (res.ok) {
settings = await res.json();
toast.success('Business settings updated');
showEditModal = false;
} else {
const errText = await res.text();
toast.error(errText || 'Failed to update settings');
}
} catch {
toast.error('Network error saving settings');
} finally {
saving = false;
}
}
function formatNullable(val: string | null | undefined): string {
return val ?? '\u2014';
}
</script>
<Card.Root>
<Card.Header>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<Card.Title>Business Settings</Card.Title>
<Card.Description>
Business details, VAT configuration, and gift card defaults.
</Card.Description>
</div>
<Button onclick={openEditModal} disabled={loading || !settings}>
Edit Settings
</Button>
</div>
</Card.Header>
<Card.Content>
{#if loading}
<div class="space-y-4">
{#each Array(3) as _, i (i)}
<div class="space-y-2">
<Skeleton class="h-4 w-32" />
<Skeleton class="h-5 w-full" />
</div>
{/each}
</div>
{:else if !settings}
<p class="py-4 text-center text-sm text-muted-foreground">
Could not load business settings.
</p>
{:else}
<div class="grid gap-6 md:grid-cols-2">
<!-- Business Information -->
<div class="space-y-3">
<h3 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider">Business Information</h3>
<div class="space-y-2">
<div>
<span class="text-xs text-muted-foreground">Business Name</span>
<p class="text-sm font-medium">{settings.business_name}</p>
</div>
<div>
<span class="text-xs text-muted-foreground">Address</span>
<p class="text-sm whitespace-pre-wrap">{settings.business_address}</p>
</div>
<div>
<span class="text-xs text-muted-foreground">Phone</span>
<p class="text-sm font-medium">{formatNullable(settings.business_phone)}</p>
</div>
<div>
<span class="text-xs text-muted-foreground">Email</span>
<p class="text-sm font-medium">{formatNullable(settings.business_email)}</p>
</div>
<div>
<span class="text-xs text-muted-foreground">Website</span>
<p class="text-sm font-medium">
{#if settings.website_url}
<a
href={settings.website_url}
target="_blank"
rel="noopener noreferrer"
class="text-primary hover:underline"
>
{settings.website_url}
</a>
{:else}
{'\u2014'}
{/if}
</p>
</div>
</div>
</div>
<!-- VAT Configuration -->
<div class="space-y-3">
<h3 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider">VAT & Currency</h3>
<div class="space-y-2">
<div>
<span class="text-xs text-muted-foreground">VAT Registered</span>
<p class="text-sm font-medium">
<span
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium {settings.is_vat_registered
? 'bg-green-50 text-green-700 border border-green-200'
: 'bg-gray-50 text-gray-600 border border-gray-200'}"
>
{settings.is_vat_registered ? 'Registered' : 'Not Registered'}
</span>
</p>
</div>
<div>
<span class="text-xs text-muted-foreground">VAT Registration Number</span>
<p class="text-sm font-medium">{formatNullable(settings.vat_registration_number)}</p>
</div>
<div>
<span class="text-xs text-muted-foreground">Default VAT Rate</span>
<p class="text-sm font-medium">{settings.default_vat_rate}%</p>
</div>
<div>
<span class="text-xs text-muted-foreground">Currency</span>
<p class="text-sm font-medium">{settings.currency_code}</p>
</div>
</div>
</div>
<!-- Gift Card Configuration -->
<div class="space-y-3">
<h3 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider">Gift Cards</h3>
<div class="space-y-2">
<div>
<span class="text-xs text-muted-foreground">Expiry Period</span>
<p class="text-sm font-medium">{settings.gift_card_expiry_months} months</p>
</div>
<div>
<span class="text-xs text-muted-foreground">Voucher Type</span>
<p class="text-sm font-medium">
<span
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium {settings.voucher_type === 'MPV'
? 'bg-blue-50 text-blue-700 border border-blue-200'
: 'bg-purple-50 text-purple-700 border border-purple-200'}"
>
{settings.voucher_type === 'MPV' ? 'MPV (0% VAT)' : 'SPV (20% VAT)'}
</span>
</p>
</div>
</div>
</div>
</div>
{/if}
</Card.Content>
</Card.Root>
<!-- Edit Modal -->
<Modal.Root bind:open={showEditModal}>
<Modal.Content class="max-w-2xl max-h-[90vh] overflow-y-auto">
<Modal.Header>
<Modal.Title>Edit Business Settings</Modal.Title>
<Modal.Description>
Update your business information, VAT configuration, and gift card defaults.
</Modal.Description>
</Modal.Header>
<form class="space-y-6 py-4" onsubmit={(e) => { e.preventDefault(); saveSettings(); }}>
<!-- Business Information Section -->
<div class="space-y-4">
<h4 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider border-b pb-1">Business Information</h4>
<div class="space-y-2">
<Label.Root for="business_name">Business Name</Label.Root>
<Input
id="business_name"
bind:value={form.business_name}
placeholder="Crussell Nail Art Studio"
maxlength={255}
oninput={() => validateField('business_name')}
onblur={() => validateField('business_name')}
/>
{#if formErrors.business_name}
<p class="text-xs text-destructive">{formErrors.business_name}</p>
{/if}
</div>
<div class="space-y-2">
<Label.Root for="business_address">Business Address</Label.Root>
<Textarea.Textarea
id="business_address"
bind:value={form.business_address}
placeholder="123 High Street, London..."
rows={3}
oninput={() => validateField('business_address')}
onblur={() => validateField('business_address')}
/>
{#if formErrors.business_address}
<p class="text-xs text-destructive">{formErrors.business_address}</p>
{/if}
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="space-y-2">
<Label.Root for="business_phone">Phone</Label.Root>
<Input
id="business_phone"
type="tel"
bind:value={form.business_phone}
placeholder="+44 20 7123 4567"
maxlength={20}
oninput={() => validateField('business_phone')}
onblur={() => {
validateField('business_phone');
const cleaned = normalisePhoneInput(form.business_phone ?? '');
form.business_phone = cleaned || null;
}}
/>
{#if formErrors.business_phone}
<p class="text-xs text-destructive">{formErrors.business_phone}</p>
{/if}
</div>
<div class="space-y-2">
<Label.Root for="business_email">Email</Label.Root>
<Input
id="business_email"
type="email"
bind:value={form.business_email}
placeholder="hello@crussell.com"
maxlength={254}
oninput={() => validateField('business_email')}
onblur={() => {
validateField('business_email');
if (form.business_email) {
form.business_email = normalizeEmail(form.business_email);
}
}}
/>
{#if formErrors.business_email}
<p class="text-xs text-destructive">{formErrors.business_email}</p>
{/if}
</div>
</div>
<div class="space-y-2">
<Label.Root for="website_url">Website URL</Label.Root>
<Input
id="website_url"
type="url"
bind:value={form.website_url}
placeholder="https://crussell.com"
oninput={() => validateField('website_url')}
onblur={() => validateField('website_url')}
/>
{#if formErrors.website_url}
<p class="text-xs text-destructive">{formErrors.website_url}</p>
{/if}
</div>
</div>
<!-- VAT & Currency Section -->
<div class="space-y-4">
<h4 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider border-b pb-1">VAT & Currency</h4>
<div class="flex items-center justify-between rounded-lg border p-4">
<div class="space-y-0.5">
<Label.Root for="is_vat_registered">VAT Registered</Label.Root>
<p class="text-xs text-muted-foreground">
Enable if your business is registered for UK VAT
</p>
</div>
<Checkbox id="is_vat_registered" checked={form.is_vat_registered ?? false} onCheckedChange={(v: boolean) => {
form.is_vat_registered = v;
if (!v) {
form.vat_registration_number = null;
form.default_vat_rate = 20.00;
formErrors.vat_registration_number = '';
formErrors.default_vat_rate = '';
}
}} />
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 {!form.is_vat_registered ? 'opacity-50 pointer-events-none' : ''}">
<div class="space-y-2">
<Label.Root for="vat_registration_number">VAT Registration Number</Label.Root>
<Input
id="vat_registration_number"
bind:value={form.vat_registration_number}
placeholder="GB123456789"
maxlength={20}
disabled={!form.is_vat_registered}
oninput={() => validateField('vat_registration_number')}
onblur={() => validateField('vat_registration_number')}
/>
{#if formErrors.vat_registration_number}
<p class="text-xs text-destructive">{formErrors.vat_registration_number}</p>
{/if}
</div>
<div class="space-y-2">
<Label.Root for="default_vat_rate">Default VAT Rate (%)</Label.Root>
<Input
id="default_vat_rate"
type="number"
step="0.01"
min="0"
max="100"
bind:value={form.default_vat_rate}
disabled={!form.is_vat_registered}
oninput={() => validateField('default_vat_rate')}
onblur={() => validateField('default_vat_rate')}
/>
{#if formErrors.default_vat_rate}
<p class="text-xs text-destructive">{formErrors.default_vat_rate}</p>
{/if}
</div>
</div>
<div class="space-y-2">
<Label.Root for="currency_code">Currency</Label.Root>
<Input id="currency_code" value="GBP" disabled />
<p class="text-xs text-muted-foreground">Fixed to GBP for this business.</p>
</div>
</div>
<!-- Gift Card Section -->
<div class="space-y-4">
<h4 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider border-b pb-1">Gift Card Configuration</h4>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="space-y-2">
<Label.Root for="gift_card_expiry_months">Gift Card Expiry (months)</Label.Root>
<Input
id="gift_card_expiry_months"
type="number"
min="1"
bind:value={form.gift_card_expiry_months}
oninput={() => validateField('gift_card_expiry_months')}
onblur={() => validateField('gift_card_expiry_months')}
/>
{#if formErrors.gift_card_expiry_months}
<p class="text-xs text-destructive">{formErrors.gift_card_expiry_months}</p>
{/if}
</div>
<div class="space-y-2">
<Label.Root for="voucher_type">Voucher Type</Label.Root>
<select
id="voucher_type"
class="flex h-10 w-full rounded-md border border-input bg-white px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
bind:value={form.voucher_type}
onchange={() => validateField('voucher_type')}
>
<option value="" disabled>Select voucher type</option>
{#each VOUCHER_TYPES as vt}
<option value={vt.value}>{vt.label}</option>
{/each}
</select>
{#if formErrors.voucher_type}
<p class="text-xs text-destructive">{formErrors.voucher_type}</p>
{/if}
<p class="text-xs text-muted-foreground">
SPV = 20% VAT on sale. MPV = 0% VAT (VAT applied on redemption).
</p>
</div>
</div>
</div>
</form>
<Modal.Footer>
<Button variant="ghost" onclick={() => (showEditModal = false)} disabled={saving}>
Cancel
</Button>
<Button onclick={saveSettings} disabled={saving}>
{saving ? 'Saving...' : 'Save Changes'}
</Button>
</Modal.Footer>
</Modal.Content>
</Modal.Root>