refactor: move discounts into admin panel as component, rebuild modal with proper field handling
- DiscountsManagement component integrated into admin/+page.svelte - Removed standalone /admin/discounts route - Modal rebuilt with toggle buttons for campaign type, conditional field groups - Milestone unit auto-restricted based on milestone type (bookings-only for count types, months/years for anniversary) - Mobile-first: card layout on mobile, table on desktop (md: breakpoint) - Native select elements instead of broken bits-ui Select components
This commit is contained in:
@@ -0,0 +1,622 @@
|
||||
<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 { Badge } from '$lib/components/ui/badge';
|
||||
|
||||
type Campaign = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
campaign_type: 'time_based' | 'milestone';
|
||||
discount_percent: number;
|
||||
scope: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
milestone_type: string;
|
||||
milestone_value: number;
|
||||
milestone_unit: string;
|
||||
status: 'draft' | 'active' | 'completed' | 'cancelled';
|
||||
max_redemptions: number;
|
||||
times_redeemed: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
type CampaignStats = {
|
||||
campaign: Campaign;
|
||||
total_discount_amount: number;
|
||||
booking_count: number;
|
||||
};
|
||||
|
||||
const MILESTONE_UNIT_MAP: Record<string, string[]> = {
|
||||
per_user_booking_count: ['bookings'],
|
||||
global_booking_count: ['bookings'],
|
||||
anniversary: ['months', 'years']
|
||||
};
|
||||
|
||||
const MILESTONE_LABELS: Record<string, string> = {
|
||||
per_user_booking_count: 'Per-user bookings',
|
||||
global_booking_count: 'Global bookings',
|
||||
anniversary: 'Anniversary'
|
||||
};
|
||||
|
||||
const SCOPE_LABELS: Record<string, string> = {
|
||||
all_bookings: 'All bookings',
|
||||
first_booking_only: 'First booking only',
|
||||
new_customers_only: 'New customers only'
|
||||
};
|
||||
|
||||
let campaigns = $state<Campaign[]>([]);
|
||||
let loading = $state(true);
|
||||
let actionInProgress = $state<string | null>(null);
|
||||
|
||||
let showModal = $state(false);
|
||||
let showStatsModal = $state(false);
|
||||
let submitting = $state(false);
|
||||
let statsLoading = $state(false);
|
||||
|
||||
let editingCampaign = $state<Campaign | null>(null);
|
||||
let statsData = $state<CampaignStats | null>(null);
|
||||
|
||||
let form = $state({
|
||||
name: '',
|
||||
description: '',
|
||||
campaign_type: 'time_based' as 'time_based' | 'milestone',
|
||||
discount_percent: 10,
|
||||
scope: 'all_bookings',
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
milestone_type: 'per_user_booking_count',
|
||||
milestone_value: 0,
|
||||
milestone_unit: 'bookings',
|
||||
max_redemptions: 0
|
||||
});
|
||||
|
||||
let errors = $state<Record<string, string>>({});
|
||||
|
||||
$effect(() => {
|
||||
if (form.milestone_type in MILESTONE_UNIT_MAP) {
|
||||
const allowed = MILESTONE_UNIT_MAP[form.milestone_type];
|
||||
if (!allowed.includes(form.milestone_unit)) {
|
||||
form.milestone_unit = allowed[0];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let isFormValid = $derived.by(() => {
|
||||
if (!form.name.trim()) return false;
|
||||
if (form.discount_percent <= 0 || form.discount_percent > 100) return false;
|
||||
if (form.campaign_type === 'time_based') {
|
||||
if (!form.start_date || !form.end_date) return false;
|
||||
if (new Date(form.end_date) <= new Date(form.start_date)) return false;
|
||||
}
|
||||
if (form.campaign_type === 'milestone') {
|
||||
if (form.milestone_value <= 0) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
async function loadCampaigns() {
|
||||
loading = true;
|
||||
try {
|
||||
const res = await fetch('/api/admin/discount-campaigns', {
|
||||
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
||||
});
|
||||
if (res.ok) campaigns = await res.json();
|
||||
else toast.error('Failed to load campaigns');
|
||||
} catch {
|
||||
toast.error('Failed to load campaigns');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
editingCampaign = null;
|
||||
form = {
|
||||
name: '',
|
||||
description: '',
|
||||
campaign_type: 'time_based',
|
||||
discount_percent: 10,
|
||||
scope: 'all_bookings',
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
milestone_type: 'per_user_booking_count',
|
||||
milestone_value: 0,
|
||||
milestone_unit: 'bookings',
|
||||
max_redemptions: 0
|
||||
};
|
||||
errors = {};
|
||||
showModal = true;
|
||||
}
|
||||
|
||||
function openEditModal(c: Campaign) {
|
||||
editingCampaign = c;
|
||||
form = {
|
||||
name: c.name,
|
||||
description: c.description || '',
|
||||
campaign_type: c.campaign_type,
|
||||
discount_percent: c.discount_percent,
|
||||
scope: c.scope || 'all_bookings',
|
||||
start_date: c.start_date ? new Date(c.start_date).toISOString().slice(0, 16) : '',
|
||||
end_date: c.end_date ? new Date(c.end_date).toISOString().slice(0, 16) : '',
|
||||
milestone_type: c.milestone_type || 'per_user_booking_count',
|
||||
milestone_value: c.milestone_value || 0,
|
||||
milestone_unit: c.milestone_unit || 'bookings',
|
||||
max_redemptions: c.max_redemptions || 0
|
||||
};
|
||||
errors = {};
|
||||
showModal = true;
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
errors = {};
|
||||
if (!form.name.trim()) errors.name = 'Required';
|
||||
if (form.discount_percent <= 0) errors.discount_percent = 'Must be > 0';
|
||||
if (form.discount_percent > 100) errors.discount_percent = 'Max 100';
|
||||
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)) {
|
||||
errors.end_date = 'Must be after start';
|
||||
}
|
||||
}
|
||||
if (form.campaign_type === 'milestone' && form.milestone_value <= 0) {
|
||||
errors.milestone_value = 'Must be > 0';
|
||||
}
|
||||
if (Object.keys(errors).length > 0) return;
|
||||
|
||||
submitting = true;
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim() || undefined,
|
||||
campaign_type: form.campaign_type,
|
||||
discount_percent: form.discount_percent
|
||||
};
|
||||
if (form.campaign_type === 'time_based') {
|
||||
payload.scope = form.scope;
|
||||
payload.start_date = new Date(form.start_date).toISOString();
|
||||
payload.end_date = new Date(form.end_date).toISOString();
|
||||
} else {
|
||||
payload.milestone_type = form.milestone_type;
|
||||
payload.milestone_value = form.milestone_value;
|
||||
payload.milestone_unit = form.milestone_unit;
|
||||
}
|
||||
if (form.max_redemptions > 0) payload.max_redemptions = form.max_redemptions;
|
||||
|
||||
const url = editingCampaign
|
||||
? `/api/admin/discount-campaigns/${editingCampaign.id}`
|
||||
: '/api/admin/discount-campaigns';
|
||||
const method = editingCampaign ? 'PUT' : 'POST';
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
toast.success(editingCampaign ? 'Campaign updated' : 'Campaign created');
|
||||
showModal = false;
|
||||
await loadCampaigns();
|
||||
} else {
|
||||
const text = await res.text();
|
||||
toast.error(text || 'Failed to save campaign');
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to save campaign');
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateStatus(c: Campaign, status: string) {
|
||||
actionInProgress = c.id;
|
||||
try {
|
||||
const res = await fetch(`/api/admin/discount-campaigns/${c.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
},
|
||||
body: JSON.stringify({ status })
|
||||
});
|
||||
if (res.ok) {
|
||||
toast.success(`Campaign ${status}`);
|
||||
await loadCampaigns();
|
||||
} else toast.error('Failed to update status');
|
||||
} catch {
|
||||
toast.error('Failed to update status');
|
||||
} finally {
|
||||
actionInProgress = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelCampaign(c: Campaign) {
|
||||
actionInProgress = c.id;
|
||||
try {
|
||||
const res = await fetch(`/api/admin/discount-campaigns/${c.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
||||
});
|
||||
if (res.ok) {
|
||||
toast.success('Campaign cancelled');
|
||||
await loadCampaigns();
|
||||
} else toast.error('Failed to cancel campaign');
|
||||
} catch {
|
||||
toast.error('Failed to cancel campaign');
|
||||
} finally {
|
||||
actionInProgress = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function viewStats(c: Campaign) {
|
||||
statsLoading = true;
|
||||
showStatsModal = true;
|
||||
try {
|
||||
const res = await fetch(`/api/admin/discount-campaigns/${c.id}/stats`, {
|
||||
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
||||
});
|
||||
if (res.ok) statsData = await res.json();
|
||||
else toast.error('Failed to load stats');
|
||||
} catch {
|
||||
toast.error('Failed to load stats');
|
||||
} finally {
|
||||
statsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function statusBadge(status: string) {
|
||||
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' },
|
||||
cancelled: { label: 'Cancelled', variant: 'destructive' }
|
||||
};
|
||||
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.label}</span>`;
|
||||
}
|
||||
|
||||
function typeLabel(c: Campaign): string {
|
||||
if (c.campaign_type === 'time_based') return 'Time-based';
|
||||
return MILESTONE_LABELS[c.milestone_type] || c.milestone_type;
|
||||
}
|
||||
|
||||
function redemptionDisplay(c: Campaign): string {
|
||||
const max = c.max_redemptions > 0 ? `/${c.max_redemptions}` : '/∞';
|
||||
return `${c.times_redeemed}${max}`;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadCampaigns();
|
||||
});
|
||||
</script>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<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>
|
||||
</div>
|
||||
<Button onclick={openCreateModal} class="w-full sm:w-auto">Create Campaign</Button>
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{#if loading}
|
||||
<div class="space-y-3">
|
||||
{#each Array(3) as _, i (i)}
|
||||
<Skeleton class="h-16 w-full" />
|
||||
{/each}
|
||||
</div>
|
||||
{:else if campaigns.length === 0}
|
||||
<div class="py-8 text-center text-sm text-gray-500">
|
||||
No campaigns yet. Create one to get started.
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Desktop table -->
|
||||
<div class="hidden w-full overflow-x-auto md:block">
|
||||
<table class="w-full table-auto border-collapse text-sm">
|
||||
<thead>
|
||||
<tr class="border-b text-left text-xs text-gray-500">
|
||||
<th class="py-3">Name</th>
|
||||
<th class="py-3">Type</th>
|
||||
<th class="py-3">Discount</th>
|
||||
<th class="py-3">Status</th>
|
||||
<th class="py-3">Redeemed</th>
|
||||
<th class="py-3 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each campaigns as c (c.id)}
|
||||
<tr class="border-b">
|
||||
<td class="py-3 font-medium">{c.name}</td>
|
||||
<td class="py-3 text-gray-600">{typeLabel(c)}</td>
|
||||
<td class="py-3">{c.discount_percent}%</td>
|
||||
<td class="py-3">{@html statusBadge(c.status)}</td>
|
||||
<td class="py-3 text-gray-600">{redemptionDisplay(c)}</td>
|
||||
<td class="py-3">
|
||||
<div class="flex justify-end gap-1">
|
||||
{#if c.status === 'draft'}
|
||||
<Button size="sm" variant="outline"
|
||||
disabled={actionInProgress === c.id}
|
||||
onclick={() => updateStatus(c, 'active')}>
|
||||
Activate
|
||||
</Button>
|
||||
{/if}
|
||||
{#if c.status === 'active'}
|
||||
<Button size="sm" variant="outline"
|
||||
disabled={actionInProgress === c.id}
|
||||
onclick={() => updateStatus(c, 'completed')}>
|
||||
Complete
|
||||
</Button>
|
||||
{/if}
|
||||
{#if c.status !== 'cancelled'}
|
||||
<Button size="sm" variant="destructive"
|
||||
disabled={actionInProgress === c.id}
|
||||
onclick={() => cancelCampaign(c)}>
|
||||
Cancel
|
||||
</Button>
|
||||
{/if}
|
||||
{#if c.status === 'active' || c.status === 'completed'}
|
||||
<Button size="sm" variant="ghost"
|
||||
onclick={() => viewStats(c)}>
|
||||
Stats
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Mobile cards -->
|
||||
<div class="space-y-3 md:hidden">
|
||||
{#each campaigns as c (c.id)}
|
||||
<div class="rounded-lg border p-4">
|
||||
<div class="mb-2 flex items-start justify-between">
|
||||
<div class="font-medium">{c.name}</div>
|
||||
{@html statusBadge(c.status)}
|
||||
</div>
|
||||
<div class="mb-3 grid grid-cols-2 gap-2 text-xs text-gray-600">
|
||||
<div>Type: {typeLabel(c)}</div>
|
||||
<div>Discount: {c.discount_percent}%</div>
|
||||
<div>Redeemed: {redemptionDisplay(c)}</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#if c.status === 'draft'}
|
||||
<Button size="sm" variant="outline"
|
||||
disabled={actionInProgress === c.id}
|
||||
onclick={() => updateStatus(c, 'active')}>
|
||||
Activate
|
||||
</Button>
|
||||
{/if}
|
||||
{#if c.status === 'active'}
|
||||
<Button size="sm" variant="outline"
|
||||
disabled={actionInProgress === c.id}
|
||||
onclick={() => updateStatus(c, 'completed')}>
|
||||
Complete
|
||||
</Button>
|
||||
{/if}
|
||||
{#if c.status !== 'cancelled'}
|
||||
<Button size="sm" variant="destructive"
|
||||
disabled={actionInProgress === c.id}
|
||||
onclick={() => cancelCampaign(c)}>
|
||||
Cancel
|
||||
</Button>
|
||||
{/if}
|
||||
{#if c.status === 'active' || c.status === 'completed'}
|
||||
<Button size="sm" variant="ghost"
|
||||
onclick={() => viewStats(c)}>
|
||||
Stats
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<!-- Create/Edit Modal -->
|
||||
<Modal.Root bind:open={showModal}>
|
||||
<Modal.Content class="sm:max-w-lg">
|
||||
<Modal.Header>
|
||||
<Modal.Title>{editingCampaign ? 'Edit Campaign' : 'Create Campaign'}</Modal.Title>
|
||||
<Modal.Description>Configure discount rules and campaign parameters</Modal.Description>
|
||||
</Modal.Header>
|
||||
|
||||
<div class="space-y-4 py-4">
|
||||
<!-- Name -->
|
||||
<div class="space-y-2">
|
||||
<Label.Root for="dc-name">Name *</Label.Root>
|
||||
<Input id="dc-name" bind:value={form.name} placeholder="e.g. Easter Sale" />
|
||||
{#if errors.name}<p class="text-xs text-red-500">{errors.name}</p>{/if}
|
||||
</div>
|
||||
|
||||
<!-- 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} />
|
||||
</div>
|
||||
|
||||
<!-- Campaign Type -->
|
||||
<div class="space-y-2">
|
||||
<Label.Root>Campaign Type *</Label.Root>
|
||||
<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'; }}>
|
||||
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'; }}>
|
||||
Milestone
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Discount Percent -->
|
||||
<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" 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}
|
||||
</div>
|
||||
|
||||
<!-- Time-based fields -->
|
||||
{#if form.campaign_type === 'time_based'}
|
||||
<div class="space-y-3 rounded-lg border p-3">
|
||||
<p class="text-xs font-medium text-gray-500">TIME-BASED SETTINGS</p>
|
||||
|
||||
<!-- Scope -->
|
||||
<div class="space-y-2">
|
||||
<Label.Root for="dc-scope">Scope</Label.Root>
|
||||
<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">
|
||||
<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>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Dates -->
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<Label.Root for="dc-start">Start Date *</Label.Root>
|
||||
<Input id="dc-start" type="datetime-local" bind:value={form.start_date} />
|
||||
{#if errors.start_date}<p class="text-xs text-red-500">{errors.start_date}</p>{/if}
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label.Root for="dc-end">End Date *</Label.Root>
|
||||
<Input id="dc-end" type="datetime-local" bind:value={form.end_date} />
|
||||
{#if errors.end_date}<p class="text-xs text-red-500">{errors.end_date}</p>{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Milestone fields -->
|
||||
{#if form.campaign_type === 'milestone'}
|
||||
<div class="space-y-3 rounded-lg border p-3">
|
||||
<p class="text-xs font-medium text-gray-500">MILESTONE SETTINGS</p>
|
||||
|
||||
<!-- Milestone Type -->
|
||||
<div class="space-y-2">
|
||||
<Label.Root for="dc-ms-type">Milestone Type *</Label.Root>
|
||||
<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">
|
||||
<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>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Milestone Value + Unit -->
|
||||
<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" 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>
|
||||
{#if form.milestone_type === 'anniversary'}
|
||||
<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">
|
||||
<option value="months">Months</option>
|
||||
<option value="years">Years</option>
|
||||
</select>
|
||||
{:else}
|
||||
<Input id="dc-ms-unit" value="bookings" disabled class="bg-gray-50" />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-gray-500">
|
||||
{#if form.milestone_type === 'per_user_booking_count'}
|
||||
Discount applies when a user completes their {form.milestone_value}th booking
|
||||
{: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
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Max Redemptions -->
|
||||
<div class="space-y-2">
|
||||
<Label.Root for="dc-max">Max Redemptions</Label.Root>
|
||||
<Input id="dc-max" type="number" min="0" bind:value={form.max_redemptions} class="w-32" />
|
||||
<p class="text-xs text-gray-500">0 = unlimited</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal.Footer>
|
||||
<Button variant="outline" onclick={() => { showModal = false; }}>Cancel</Button>
|
||||
<Button onclick={submitForm} disabled={!isFormValid || submitting}>
|
||||
{submitting ? 'Saving...' : (editingCampaign ? 'Update' : 'Create')}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
|
||||
<!-- Stats Modal -->
|
||||
<Modal.Root bind:open={showStatsModal}>
|
||||
<Modal.Content class="sm:max-w-sm">
|
||||
<Modal.Header>
|
||||
<Modal.Title>Campaign Stats</Modal.Title>
|
||||
</Modal.Header>
|
||||
{#if statsLoading}
|
||||
<div class="space-y-3 py-4">
|
||||
<Skeleton class="h-12 w-full" />
|
||||
<Skeleton class="h-12 w-full" />
|
||||
</div>
|
||||
{:else if statsData}
|
||||
<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>
|
||||
</div>
|
||||
<div class="rounded-lg bg-gray-50 p-4 text-center">
|
||||
<p class="text-sm text-gray-500">Bookings Discounted</p>
|
||||
<p class="text-2xl font-bold">{statsData.booking_count}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<Modal.Footer>
|
||||
<Button onclick={() => { showStatsModal = false; }}>Close</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
Reference in New Issue
Block a user