feat: implement patch test management and validation improvements
This commit is contained in:
@@ -125,7 +125,7 @@
|
||||
name: '',
|
||||
description: '',
|
||||
campaign_type: 'time_based',
|
||||
discount_percent: 5,
|
||||
discount_percent: 5,
|
||||
scope: 'all_bookings',
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
@@ -165,7 +165,11 @@
|
||||
if (form.campaign_type === 'time_based') {
|
||||
if (!form.start_date) errors.start_date = 'Required';
|
||||
if (!form.end_date) errors.end_date = 'Required';
|
||||
if (form.start_date && form.end_date && new Date(form.end_date) <= new Date(form.start_date)) {
|
||||
if (
|
||||
form.start_date &&
|
||||
form.end_date &&
|
||||
new Date(form.end_date) <= new Date(form.start_date)
|
||||
) {
|
||||
errors.end_date = 'Must be after start';
|
||||
}
|
||||
}
|
||||
@@ -279,7 +283,10 @@
|
||||
}
|
||||
|
||||
function statusBadge(status: string) {
|
||||
const map: Record<string, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }> = {
|
||||
const map: Record<
|
||||
string,
|
||||
{ label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }
|
||||
> = {
|
||||
draft: { label: 'Draft', variant: 'secondary' },
|
||||
active: { label: 'Active', variant: 'default' },
|
||||
completed: { label: 'Completed', variant: 'outline' },
|
||||
@@ -287,10 +294,13 @@
|
||||
};
|
||||
const s = map[status] || map.draft;
|
||||
return `<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
s.variant === 'default' ? 'bg-emerald-100 text-emerald-800' :
|
||||
s.variant === 'secondary' ? 'bg-gray-100 text-gray-800' :
|
||||
s.variant === 'destructive' ? 'bg-red-100 text-red-800' :
|
||||
'bg-blue-100 text-blue-800'
|
||||
s.variant === 'default'
|
||||
? 'bg-emerald-100 text-emerald-800'
|
||||
: s.variant === 'secondary'
|
||||
? 'bg-gray-100 text-gray-800'
|
||||
: s.variant === 'destructive'
|
||||
? 'bg-red-100 text-red-800'
|
||||
: 'bg-blue-100 text-blue-800'
|
||||
}">${s.label}</span>`;
|
||||
}
|
||||
|
||||
@@ -314,7 +324,8 @@
|
||||
<div class="flex w-full flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<Card.Title>Discount Campaigns</Card.Title>
|
||||
<Card.Description>Manage loyalty discounts, sales, and milestone campaigns</Card.Description>
|
||||
<Card.Description>Manage loyalty discounts, sales, and milestone campaigns</Card.Description
|
||||
>
|
||||
</div>
|
||||
<Button onclick={openCreateModal} class="w-full sm:w-auto">Create Campaign</Button>
|
||||
</div>
|
||||
@@ -355,31 +366,37 @@
|
||||
<td class="py-3">
|
||||
<div class="flex justify-end gap-1">
|
||||
{#if c.status === 'draft'}
|
||||
<Button size="sm" variant="outline"
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={actionInProgress === c.id}
|
||||
onclick={() => updateStatus(c, 'active')}>
|
||||
onclick={() => updateStatus(c, 'active')}
|
||||
>
|
||||
Activate
|
||||
</Button>
|
||||
{/if}
|
||||
{#if c.status === 'active'}
|
||||
<Button size="sm" variant="outline"
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={actionInProgress === c.id}
|
||||
onclick={() => updateStatus(c, 'completed')}>
|
||||
onclick={() => updateStatus(c, 'completed')}
|
||||
>
|
||||
Complete
|
||||
</Button>
|
||||
{/if}
|
||||
{#if c.status !== 'cancelled'}
|
||||
<Button size="sm" variant="destructive"
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={actionInProgress === c.id}
|
||||
onclick={() => cancelCampaign(c)}>
|
||||
onclick={() => cancelCampaign(c)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{/if}
|
||||
{#if c.status === 'active' || c.status === 'completed'}
|
||||
<Button size="sm" variant="ghost"
|
||||
onclick={() => viewStats(c)}>
|
||||
Stats
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onclick={() => viewStats(c)}>Stats</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
@@ -404,31 +421,37 @@
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#if c.status === 'draft'}
|
||||
<Button size="sm" variant="outline"
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={actionInProgress === c.id}
|
||||
onclick={() => updateStatus(c, 'active')}>
|
||||
onclick={() => updateStatus(c, 'active')}
|
||||
>
|
||||
Activate
|
||||
</Button>
|
||||
{/if}
|
||||
{#if c.status === 'active'}
|
||||
<Button size="sm" variant="outline"
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={actionInProgress === c.id}
|
||||
onclick={() => updateStatus(c, 'completed')}>
|
||||
onclick={() => updateStatus(c, 'completed')}
|
||||
>
|
||||
Complete
|
||||
</Button>
|
||||
{/if}
|
||||
{#if c.status !== 'cancelled'}
|
||||
<Button size="sm" variant="destructive"
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={actionInProgress === c.id}
|
||||
onclick={() => cancelCampaign(c)}>
|
||||
onclick={() => cancelCampaign(c)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{/if}
|
||||
{#if c.status === 'active' || c.status === 'completed'}
|
||||
<Button size="sm" variant="ghost"
|
||||
onclick={() => viewStats(c)}>
|
||||
Stats
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onclick={() => viewStats(c)}>Stats</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -457,7 +480,12 @@
|
||||
<!-- Description -->
|
||||
<div class="space-y-2">
|
||||
<Label.Root for="dc-desc">Description</Label.Root>
|
||||
<Textarea.Root id="dc-desc" bind:value={form.description} placeholder="Optional notes" rows={2} />
|
||||
<Textarea.Root
|
||||
id="dc-desc"
|
||||
bind:value={form.description}
|
||||
placeholder="Optional notes"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Campaign Type -->
|
||||
@@ -466,14 +494,26 @@
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 rounded-md border px-3 py-2 text-sm transition-colors {form.campaign_type === 'time_based' ? 'border-emerald-500 bg-emerald-50 text-emerald-700' : 'hover:bg-gray-50'}"
|
||||
onclick={() => { form.campaign_type = 'time_based'; }}>
|
||||
class="flex-1 rounded-md border px-3 py-2 text-sm transition-colors {form.campaign_type ===
|
||||
'time_based'
|
||||
? 'border-emerald-500 bg-emerald-50 text-emerald-700'
|
||||
: 'hover:bg-gray-50'}"
|
||||
onclick={() => {
|
||||
form.campaign_type = 'time_based';
|
||||
}}
|
||||
>
|
||||
Time-based
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 rounded-md border px-3 py-2 text-sm transition-colors {form.campaign_type === 'milestone' ? 'border-emerald-500 bg-emerald-50 text-emerald-700' : 'hover:bg-gray-50'}"
|
||||
onclick={() => { form.campaign_type = 'milestone'; }}>
|
||||
class="flex-1 rounded-md border px-3 py-2 text-sm transition-colors {form.campaign_type ===
|
||||
'milestone'
|
||||
? 'border-emerald-500 bg-emerald-50 text-emerald-700'
|
||||
: 'hover:bg-gray-50'}"
|
||||
onclick={() => {
|
||||
form.campaign_type = 'milestone';
|
||||
}}
|
||||
>
|
||||
Milestone
|
||||
</button>
|
||||
</div>
|
||||
@@ -483,10 +523,20 @@
|
||||
<div class="space-y-2">
|
||||
<Label.Root for="dc-pct">Discount Percent *</Label.Root>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input id="dc-pct" type="number" inputmode="numeric" min="1" max="100" bind:value={form.discount_percent} class="w-24" />
|
||||
<Input
|
||||
id="dc-pct"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
min="1"
|
||||
max="100"
|
||||
bind:value={form.discount_percent}
|
||||
class="w-24"
|
||||
/>
|
||||
<span class="text-sm text-gray-500">%</span>
|
||||
</div>
|
||||
{#if errors.discount_percent}<p class="text-xs text-red-500">{errors.discount_percent}</p>{/if}
|
||||
{#if errors.discount_percent}<p class="text-xs text-red-500">
|
||||
{errors.discount_percent}
|
||||
</p>{/if}
|
||||
</div>
|
||||
|
||||
<!-- Time-based fields -->
|
||||
@@ -500,7 +550,8 @@
|
||||
<select
|
||||
id="dc-scope"
|
||||
bind:value={form.scope}
|
||||
class="flex h-9 w-full rounded-md border border-gray-300 bg-transparent px-3 py-1 text-sm shadow-sm">
|
||||
class="flex h-9 w-full rounded-md border border-gray-300 bg-transparent px-3 py-1 text-sm shadow-sm"
|
||||
>
|
||||
<option value="all_bookings">All bookings</option>
|
||||
<option value="first_booking_only">First booking only</option>
|
||||
<option value="new_customers_only">New customers only</option>
|
||||
@@ -534,7 +585,8 @@
|
||||
<select
|
||||
id="dc-ms-type"
|
||||
bind:value={form.milestone_type}
|
||||
class="flex h-9 w-full rounded-md border border-gray-300 bg-transparent px-3 py-1 text-sm shadow-sm">
|
||||
class="flex h-9 w-full rounded-md border border-gray-300 bg-transparent px-3 py-1 text-sm shadow-sm"
|
||||
>
|
||||
<option value="per_user_booking_count">Per-user booking count</option>
|
||||
<option value="global_booking_count">Global booking count</option>
|
||||
<option value="anniversary">Anniversary</option>
|
||||
@@ -545,8 +597,16 @@
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="space-y-2">
|
||||
<Label.Root for="dc-ms-val">Value *</Label.Root>
|
||||
<Input id="dc-ms-val" type="number" inputmode="numeric" min="1" bind:value={form.milestone_value} />
|
||||
{#if errors.milestone_value}<p class="text-xs text-red-500">{errors.milestone_value}</p>{/if}
|
||||
<Input
|
||||
id="dc-ms-val"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
min="1"
|
||||
bind:value={form.milestone_value}
|
||||
/>
|
||||
{#if errors.milestone_value}<p class="text-xs text-red-500">
|
||||
{errors.milestone_value}
|
||||
</p>{/if}
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label.Root for="dc-ms-unit">Unit</Label.Root>
|
||||
@@ -554,7 +614,8 @@
|
||||
<select
|
||||
id="dc-ms-unit"
|
||||
bind:value={form.milestone_unit}
|
||||
class="flex h-9 w-full rounded-md border border-gray-300 bg-transparent px-3 py-1 text-sm shadow-sm">
|
||||
class="flex h-9 w-full rounded-md border border-gray-300 bg-transparent px-3 py-1 text-sm shadow-sm"
|
||||
>
|
||||
<option value="months">Months</option>
|
||||
<option value="years">Years</option>
|
||||
</select>
|
||||
@@ -570,7 +631,8 @@
|
||||
{:else if form.milestone_type === 'global_booking_count'}
|
||||
Discount applies on the {form.milestone_value}th completed booking across all users
|
||||
{:else}
|
||||
Discount applies on a user's first booking after {form.milestone_value} {form.milestone_unit} since their first visit
|
||||
Discount applies on a user's first booking after {form.milestone_value}
|
||||
{form.milestone_unit} since their first visit
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
@@ -579,15 +641,27 @@
|
||||
<!-- Max Redemptions -->
|
||||
<div class="space-y-2">
|
||||
<Label.Root for="dc-max">Max Redemptions (campaign total)</Label.Root>
|
||||
<Input id="dc-max" type="number" inputmode="numeric" min="0" bind:value={form.max_redemptions} class="w-32" />
|
||||
<Input
|
||||
id="dc-max"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
min="0"
|
||||
bind:value={form.max_redemptions}
|
||||
class="w-32"
|
||||
/>
|
||||
<p class="text-xs text-gray-500">0 = unlimited across all users</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal.Footer>
|
||||
<Button variant="outline" onclick={() => { showModal = false; }}>Cancel</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => {
|
||||
showModal = false;
|
||||
}}>Cancel</Button
|
||||
>
|
||||
<Button onclick={submitForm} disabled={!isFormValid || submitting}>
|
||||
{submitting ? 'Saving...' : (editingCampaign ? 'Update' : 'Create')}
|
||||
{submitting ? 'Saving...' : editingCampaign ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Content>
|
||||
@@ -608,7 +682,9 @@
|
||||
<div class="space-y-4 py-4">
|
||||
<div class="rounded-lg bg-gray-50 p-4 text-center">
|
||||
<p class="text-sm text-gray-500">Total Discount Given</p>
|
||||
<p class="text-2xl font-bold text-emerald-600">£{statsData.total_discount_amount.toFixed(2)}</p>
|
||||
<p class="text-2xl font-bold text-emerald-600">
|
||||
£{statsData.total_discount_amount.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-gray-50 p-4 text-center">
|
||||
<p class="text-sm text-gray-500">Bookings Discounted</p>
|
||||
@@ -617,7 +693,11 @@
|
||||
</div>
|
||||
{/if}
|
||||
<Modal.Footer>
|
||||
<Button onclick={() => { showStatsModal = false; }}>Close</Button>
|
||||
<Button
|
||||
onclick={() => {
|
||||
showStatsModal = false;
|
||||
}}>Close</Button
|
||||
>
|
||||
</Modal.Footer>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
|
||||
Reference in New Issue
Block a user