336 lines
12 KiB
TypeScript
336 lines
12 KiB
TypeScript
|
|
import { createResource, createSignal, Show, For } from 'solid-js';
|
||
|
|
import AdminShell from '~/components/AdminShell';
|
||
|
|
|
||
|
|
const API = '/api/gateway';
|
||
|
|
|
||
|
|
const ROLE_OPTIONS = [
|
||
|
|
'company',
|
||
|
|
'customer',
|
||
|
|
'job_seeker',
|
||
|
|
'photographer',
|
||
|
|
'video_editor',
|
||
|
|
'graphic_designer',
|
||
|
|
'social_media_manager',
|
||
|
|
'fitness_trainer',
|
||
|
|
'catering_services',
|
||
|
|
'makeup_artist',
|
||
|
|
'tutor',
|
||
|
|
'developer',
|
||
|
|
];
|
||
|
|
|
||
|
|
type Coupon = {
|
||
|
|
id: string;
|
||
|
|
code: string;
|
||
|
|
title: string;
|
||
|
|
type: 'PERCENT' | 'FIXED';
|
||
|
|
value: number;
|
||
|
|
min_order_amount: number;
|
||
|
|
used_count: number;
|
||
|
|
usage_limit?: number;
|
||
|
|
is_active: boolean;
|
||
|
|
role_keys: string[];
|
||
|
|
};
|
||
|
|
|
||
|
|
async function loadCoupons(): Promise<Coupon[]> {
|
||
|
|
try {
|
||
|
|
const res = await fetch(`${API}/api/admin/coupons`);
|
||
|
|
if (!res.ok) throw new Error('Failed to load');
|
||
|
|
const data = await res.json();
|
||
|
|
return Array.isArray(data) ? data : (data.coupons || []);
|
||
|
|
} catch {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const defaultForm = () => ({
|
||
|
|
id: '',
|
||
|
|
code: '',
|
||
|
|
title: '',
|
||
|
|
type: 'PERCENT' as 'PERCENT' | 'FIXED',
|
||
|
|
value: 10,
|
||
|
|
min_order_amount: 0,
|
||
|
|
max_uses: '',
|
||
|
|
role_keys: ['company', 'customer'] as string[],
|
||
|
|
});
|
||
|
|
|
||
|
|
export default function CouponPage() {
|
||
|
|
const [coupons, { refetch }] = createResource(loadCoupons);
|
||
|
|
const [activeTab, setActiveTab] = createSignal<'list' | 'create'>('list');
|
||
|
|
const [form, setForm] = createSignal(defaultForm());
|
||
|
|
const [saving, setSaving] = createSignal(false);
|
||
|
|
const [toggling, setToggling] = createSignal('');
|
||
|
|
const [formError, setFormError] = createSignal('');
|
||
|
|
|
||
|
|
const resetForm = () => {
|
||
|
|
setForm(defaultForm());
|
||
|
|
setFormError('');
|
||
|
|
};
|
||
|
|
|
||
|
|
const startEdit = (coupon: Coupon) => {
|
||
|
|
setForm({
|
||
|
|
id: coupon.id,
|
||
|
|
code: coupon.code,
|
||
|
|
title: coupon.title || '',
|
||
|
|
type: coupon.type,
|
||
|
|
value: coupon.value,
|
||
|
|
min_order_amount: coupon.min_order_amount || 0,
|
||
|
|
max_uses: coupon.usage_limit != null ? String(coupon.usage_limit) : '',
|
||
|
|
role_keys: Array.isArray(coupon.role_keys) ? coupon.role_keys : [],
|
||
|
|
});
|
||
|
|
setActiveTab('create');
|
||
|
|
};
|
||
|
|
|
||
|
|
const toggleRole = (role: string) => {
|
||
|
|
const current = form().role_keys;
|
||
|
|
if (current.includes(role)) {
|
||
|
|
setForm({ ...form(), role_keys: current.filter((r) => r !== role) });
|
||
|
|
} else {
|
||
|
|
setForm({ ...form(), role_keys: [...current, role] });
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleSave = async (e: Event) => {
|
||
|
|
e.preventDefault();
|
||
|
|
try {
|
||
|
|
setSaving(true);
|
||
|
|
setFormError('');
|
||
|
|
const f = form();
|
||
|
|
const body: Record<string, unknown> = {
|
||
|
|
code: f.code.toUpperCase(),
|
||
|
|
title: f.title,
|
||
|
|
type: f.type,
|
||
|
|
value: Number(f.value),
|
||
|
|
min_order_amount: Number(f.min_order_amount),
|
||
|
|
role_keys: f.role_keys,
|
||
|
|
};
|
||
|
|
if (f.max_uses) body.max_uses = Number(f.max_uses);
|
||
|
|
|
||
|
|
const url = f.id ? `${API}/api/admin/coupons/${f.id}` : `${API}/api/admin/coupons`;
|
||
|
|
const method = f.id ? 'PATCH' : 'POST';
|
||
|
|
const res = await fetch(url, {
|
||
|
|
method,
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify(body),
|
||
|
|
});
|
||
|
|
if (!res.ok) throw new Error('Failed to save coupon');
|
||
|
|
resetForm();
|
||
|
|
refetch();
|
||
|
|
setActiveTab('list');
|
||
|
|
} catch (err: unknown) {
|
||
|
|
setFormError(err instanceof Error ? err.message : 'Failed to save');
|
||
|
|
} finally {
|
||
|
|
setSaving(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleToggle = async (coupon: Coupon) => {
|
||
|
|
try {
|
||
|
|
setToggling(coupon.id);
|
||
|
|
const res = await fetch(`${API}/api/admin/coupons/${coupon.id}`, {
|
||
|
|
method: 'PATCH',
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify({ is_active: !coupon.is_active }),
|
||
|
|
});
|
||
|
|
if (!res.ok) throw new Error('Failed to toggle');
|
||
|
|
refetch();
|
||
|
|
} catch {
|
||
|
|
// ignore
|
||
|
|
} finally {
|
||
|
|
setToggling('');
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<AdminShell>
|
||
|
|
<div class="page-actions">
|
||
|
|
<div>
|
||
|
|
<h1 class="page-title">Coupon Management</h1>
|
||
|
|
<p class="page-subtitle">Reusable coupon codes for package checkout</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Tabs */}
|
||
|
|
<div style="display:flex;border-bottom:2px solid #e2e8f0;margin-bottom:24px;gap:0;overflow-x:auto;">
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
class={`admin-tab${activeTab() === 'list' ? ' active' : ''}`}
|
||
|
|
onClick={() => setActiveTab('list')}
|
||
|
|
>
|
||
|
|
Coupons
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
class={`admin-tab${activeTab() === 'create' ? ' active' : ''}`}
|
||
|
|
onClick={() => { resetForm(); setActiveTab('create'); }}
|
||
|
|
>
|
||
|
|
{form().id ? 'Edit Coupon' : 'Create Coupon'}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<Show when={activeTab() === 'list'}>
|
||
|
|
<section class="card" style="padding: 0; overflow: hidden;">
|
||
|
|
<div class="table-wrap">
|
||
|
|
<table class="list-table">
|
||
|
|
<thead>
|
||
|
|
<tr>
|
||
|
|
<th>Code</th>
|
||
|
|
<th>Title</th>
|
||
|
|
<th>Type</th>
|
||
|
|
<th>Value</th>
|
||
|
|
<th>Max Uses</th>
|
||
|
|
<th>Status</th>
|
||
|
|
<th class="align-right">Actions</th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody>
|
||
|
|
<Show when={coupons.loading}>
|
||
|
|
<tr><td colspan="7" style="text-align:center;padding:32px;color:#64748b">Loading...</td></tr>
|
||
|
|
</Show>
|
||
|
|
<Show when={!coupons.loading && coupons.error}>
|
||
|
|
<tr><td colspan="7" style="text-align:center;padding:32px;color:#b91c1c">Failed to load. Is the backend running?</td></tr>
|
||
|
|
</Show>
|
||
|
|
<Show when={!coupons.loading && !coupons.error && coupons()?.length === 0}>
|
||
|
|
<tr><td colspan="7" style="text-align:center;padding:32px;color:#94a3b8">No coupons found.</td></tr>
|
||
|
|
</Show>
|
||
|
|
<Show when={!coupons.loading && !coupons.error && (coupons()?.length ?? 0) > 0}>
|
||
|
|
<For each={coupons()}>
|
||
|
|
{(item) => (
|
||
|
|
<tr>
|
||
|
|
<td style="font-weight:600;color:#0f172a;font-family:monospace">{item.code}</td>
|
||
|
|
<td style="color:#475569">{item.title || '—'}</td>
|
||
|
|
<td style="color:#475569">{item.type}</td>
|
||
|
|
<td style="color:#475569">{item.type === 'PERCENT' ? `${item.value}%` : `₹${item.value}`}</td>
|
||
|
|
<td style="color:#475569">{item.usage_limit != null ? item.usage_limit : '—'}</td>
|
||
|
|
<td>
|
||
|
|
<span class={`status-chip ${item.is_active ? 'active' : ''}`}>
|
||
|
|
{item.is_active ? 'Active' : 'Inactive'}
|
||
|
|
</span>
|
||
|
|
</td>
|
||
|
|
<td>
|
||
|
|
<div class="table-actions">
|
||
|
|
<button class="btn" onClick={() => startEdit(item)}>Edit</button>
|
||
|
|
<button
|
||
|
|
class="btn"
|
||
|
|
disabled={toggling() === item.id}
|
||
|
|
onClick={() => handleToggle(item)}
|
||
|
|
>
|
||
|
|
{toggling() === item.id ? '...' : (item.is_active ? 'Disable' : 'Enable')}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
)}
|
||
|
|
</For>
|
||
|
|
</Show>
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
</section>
|
||
|
|
</Show>
|
||
|
|
|
||
|
|
<Show when={activeTab() === 'create'}>
|
||
|
|
<section class="card" style="max-width:520px">
|
||
|
|
<h2 style="margin:0 0 20px;font-size:16px;font-weight:700">{form().id ? 'Edit Coupon' : 'Create Coupon'}</h2>
|
||
|
|
<Show when={formError()}>
|
||
|
|
<div class="error-box" style="margin-bottom:12px">{formError()}</div>
|
||
|
|
</Show>
|
||
|
|
<form onSubmit={handleSave} style="display:flex;flex-direction:column;gap:14px">
|
||
|
|
<div class="field">
|
||
|
|
<label>Code</label>
|
||
|
|
<input
|
||
|
|
type="text"
|
||
|
|
value={form().code}
|
||
|
|
onInput={(e) => setForm({ ...form(), code: e.currentTarget.value.toUpperCase() })}
|
||
|
|
required
|
||
|
|
placeholder="e.g. SAVE10"
|
||
|
|
style="text-transform:uppercase"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div class="field">
|
||
|
|
<label>Title</label>
|
||
|
|
<input
|
||
|
|
type="text"
|
||
|
|
value={form().title}
|
||
|
|
onInput={(e) => setForm({ ...form(), title: e.currentTarget.value })}
|
||
|
|
required
|
||
|
|
placeholder="e.g. 10% off for companies"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div class="field-grid-2">
|
||
|
|
<div class="field">
|
||
|
|
<label>Type</label>
|
||
|
|
<select
|
||
|
|
value={form().type}
|
||
|
|
onChange={(e) => setForm({ ...form(), type: e.currentTarget.value as 'PERCENT' | 'FIXED' })}
|
||
|
|
>
|
||
|
|
<option value="PERCENT">Percent (%)</option>
|
||
|
|
<option value="FIXED">Fixed (₹)</option>
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
<div class="field">
|
||
|
|
<label>Value</label>
|
||
|
|
<input
|
||
|
|
type="number"
|
||
|
|
value={form().value}
|
||
|
|
onInput={(e) => setForm({ ...form(), value: Number(e.currentTarget.value) })}
|
||
|
|
required
|
||
|
|
min="1"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="field-grid-2">
|
||
|
|
<div class="field">
|
||
|
|
<label>Min Order Amount (₹)</label>
|
||
|
|
<input
|
||
|
|
type="number"
|
||
|
|
value={form().min_order_amount}
|
||
|
|
onInput={(e) => setForm({ ...form(), min_order_amount: Number(e.currentTarget.value) })}
|
||
|
|
min="0"
|
||
|
|
placeholder="0"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div class="field">
|
||
|
|
<label>Max Uses (blank = unlimited)</label>
|
||
|
|
<input
|
||
|
|
type="number"
|
||
|
|
value={form().max_uses}
|
||
|
|
onInput={(e) => setForm({ ...form(), max_uses: e.currentTarget.value })}
|
||
|
|
min="1"
|
||
|
|
placeholder="Unlimited"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<p style="font-size:13px;font-weight:600;margin:0 0 8px;color:#1e293b">Applicable Roles</p>
|
||
|
|
<div style="display:flex;flex-wrap:wrap;gap:8px">
|
||
|
|
<For each={ROLE_OPTIONS}>
|
||
|
|
{(role) => {
|
||
|
|
const active = () => form().role_keys.includes(role);
|
||
|
|
return (
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={() => toggleRole(role)}
|
||
|
|
style={`border-radius:999px;padding:4px 14px;font-size:13px;cursor:pointer;border:1px solid ${active() ? '#fdba74' : '#cbd5e1'};background:${active() ? '#fff7ed' : '#fff'};color:${active() ? '#c2410c' : '#475569'}`}
|
||
|
|
>
|
||
|
|
{role}
|
||
|
|
</button>
|
||
|
|
);
|
||
|
|
}}
|
||
|
|
</For>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="actions">
|
||
|
|
<button class="btn navy" type="submit" disabled={saving()}>
|
||
|
|
{saving() ? 'Saving...' : (form().id ? 'Update Coupon' : 'Save Coupon')}
|
||
|
|
</button>
|
||
|
|
<Show when={form().id}>
|
||
|
|
<button type="button" class="btn" onClick={resetForm}>Cancel Edit</button>
|
||
|
|
</Show>
|
||
|
|
</div>
|
||
|
|
</form>
|
||
|
|
</section>
|
||
|
|
</Show>
|
||
|
|
</AdminShell>
|
||
|
|
);
|
||
|
|
}
|