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:
@@ -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>
|
||||
Reference in New Issue
Block a user