340 lines
12 KiB
TypeScript
340 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 Discount = {
|
||
|
|
id: string;
|
||
|
|
title: string;
|
||
|
|
scope: 'ROLE' | 'PACKAGE';
|
||
|
|
role_key?: string;
|
||
|
|
package_id?: string;
|
||
|
|
type: 'PERCENT' | 'FIXED';
|
||
|
|
value: number;
|
||
|
|
is_active: boolean;
|
||
|
|
};
|
||
|
|
|
||
|
|
type Package = {
|
||
|
|
id: string;
|
||
|
|
name: string;
|
||
|
|
role_key?: string;
|
||
|
|
};
|
||
|
|
|
||
|
|
async function loadDiscounts(): Promise<Discount[]> {
|
||
|
|
try {
|
||
|
|
const res = await fetch(`${API}/api/admin/discounts`);
|
||
|
|
if (!res.ok) throw new Error('Failed to load');
|
||
|
|
const data = await res.json();
|
||
|
|
return Array.isArray(data) ? data : (data.discounts || []);
|
||
|
|
} catch {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function loadPackages(): Promise<Package[]> {
|
||
|
|
try {
|
||
|
|
const res = await fetch(`${API}/api/admin/tracecoin-packages`);
|
||
|
|
if (!res.ok) throw new Error('Failed to load');
|
||
|
|
const data = await res.json();
|
||
|
|
return Array.isArray(data) ? data : (data.packages || []);
|
||
|
|
} catch {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const defaultForm = () => ({
|
||
|
|
id: '',
|
||
|
|
title: '',
|
||
|
|
scope: 'ROLE' as 'ROLE' | 'PACKAGE',
|
||
|
|
role_key: 'company',
|
||
|
|
package_id: '',
|
||
|
|
type: 'PERCENT' as 'PERCENT' | 'FIXED',
|
||
|
|
value: 5,
|
||
|
|
});
|
||
|
|
|
||
|
|
export default function DiscountPage() {
|
||
|
|
const [discounts, { refetch: refetchDiscounts }] = createResource(loadDiscounts);
|
||
|
|
const [packages] = createResource(loadPackages);
|
||
|
|
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 getTarget = (item: Discount) => {
|
||
|
|
if (item.scope === 'ROLE') return item.role_key || '—';
|
||
|
|
const pkgs = packages();
|
||
|
|
if (pkgs) {
|
||
|
|
const pkg = pkgs.find((p) => p.id === item.package_id);
|
||
|
|
if (pkg) return pkg.name;
|
||
|
|
}
|
||
|
|
return item.package_id || '—';
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleSave = async (e: Event) => {
|
||
|
|
e.preventDefault();
|
||
|
|
try {
|
||
|
|
setSaving(true);
|
||
|
|
setFormError('');
|
||
|
|
const f = form();
|
||
|
|
const body: Record<string, unknown> = {
|
||
|
|
title: f.title,
|
||
|
|
scope: f.scope,
|
||
|
|
type: f.type,
|
||
|
|
value: Number(f.value),
|
||
|
|
};
|
||
|
|
if (f.scope === 'ROLE') {
|
||
|
|
body.role_key = f.role_key;
|
||
|
|
} else {
|
||
|
|
body.package_id = f.package_id;
|
||
|
|
}
|
||
|
|
|
||
|
|
const url = f.id ? `${API}/api/admin/discounts/${f.id}` : `${API}/api/admin/discounts`;
|
||
|
|
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 discount');
|
||
|
|
resetForm();
|
||
|
|
refetchDiscounts();
|
||
|
|
setActiveTab('list');
|
||
|
|
} catch (err: unknown) {
|
||
|
|
setFormError(err instanceof Error ? err.message : 'Failed to save');
|
||
|
|
} finally {
|
||
|
|
setSaving(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleToggle = async (discount: Discount) => {
|
||
|
|
try {
|
||
|
|
setToggling(discount.id);
|
||
|
|
const res = await fetch(`${API}/api/admin/discounts/${discount.id}`, {
|
||
|
|
method: 'PATCH',
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify({ is_active: !discount.is_active }),
|
||
|
|
});
|
||
|
|
if (!res.ok) throw new Error('Failed to toggle');
|
||
|
|
refetchDiscounts();
|
||
|
|
} catch {
|
||
|
|
// ignore
|
||
|
|
} finally {
|
||
|
|
setToggling('');
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<AdminShell>
|
||
|
|
<div class="page-actions">
|
||
|
|
<div>
|
||
|
|
<h1 class="page-title">Discount Management</h1>
|
||
|
|
<p class="page-subtitle">Automatic discounts applied before coupons</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')}
|
||
|
|
>
|
||
|
|
Discounts
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
class={`admin-tab${activeTab() === 'create' ? ' active' : ''}`}
|
||
|
|
onClick={() => { resetForm(); setActiveTab('create'); }}
|
||
|
|
>
|
||
|
|
{form().id ? 'Edit Discount' : 'Create Discount'}
|
||
|
|
</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>Title</th>
|
||
|
|
<th>Scope</th>
|
||
|
|
<th>Target</th>
|
||
|
|
<th>Type</th>
|
||
|
|
<th>Value</th>
|
||
|
|
<th>Status</th>
|
||
|
|
<th class="align-right">Actions</th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody>
|
||
|
|
<Show when={discounts.loading}>
|
||
|
|
<tr><td colspan="7" style="text-align:center;padding:32px;color:#64748b">Loading...</td></tr>
|
||
|
|
</Show>
|
||
|
|
<Show when={!discounts.loading && discounts.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={!discounts.loading && !discounts.error && discounts()?.length === 0}>
|
||
|
|
<tr><td colspan="7" style="text-align:center;padding:32px;color:#94a3b8">No discounts found.</td></tr>
|
||
|
|
</Show>
|
||
|
|
<Show when={!discounts.loading && !discounts.error && (discounts()?.length ?? 0) > 0}>
|
||
|
|
<For each={discounts()}>
|
||
|
|
{(item) => (
|
||
|
|
<tr>
|
||
|
|
<td style="font-weight:600;color:#0f172a">{item.title || '—'}</td>
|
||
|
|
<td style="color:#475569">{item.scope}</td>
|
||
|
|
<td style="color:#475569">{getTarget(item)}</td>
|
||
|
|
<td style="color:#475569">{item.type}</td>
|
||
|
|
<td style="color:#475569">{item.type === 'PERCENT' ? `${item.value}%` : `₹${item.value}`}</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"
|
||
|
|
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 Discount' : 'Create Discount'}</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>Title</label>
|
||
|
|
<input
|
||
|
|
type="text"
|
||
|
|
value={form().title}
|
||
|
|
onInput={(e) => setForm({ ...form(), title: e.currentTarget.value })}
|
||
|
|
required
|
||
|
|
placeholder="e.g. New user 10% off"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div class="field">
|
||
|
|
<label>Scope</label>
|
||
|
|
<div style="display:flex;gap:24px;margin-top:4px">
|
||
|
|
<label style="display:flex;align-items:center;gap:6px;font-size:14px;font-weight:400;cursor:pointer">
|
||
|
|
<input
|
||
|
|
type="radio"
|
||
|
|
name="scope"
|
||
|
|
value="ROLE"
|
||
|
|
checked={form().scope === 'ROLE'}
|
||
|
|
onChange={() => setForm({ ...form(), scope: 'ROLE' })}
|
||
|
|
/>
|
||
|
|
ROLE
|
||
|
|
</label>
|
||
|
|
<label style="display:flex;align-items:center;gap:6px;font-size:14px;font-weight:400;cursor:pointer">
|
||
|
|
<input
|
||
|
|
type="radio"
|
||
|
|
name="scope"
|
||
|
|
value="PACKAGE"
|
||
|
|
checked={form().scope === 'PACKAGE'}
|
||
|
|
onChange={() => setForm({ ...form(), scope: 'PACKAGE' })}
|
||
|
|
/>
|
||
|
|
PACKAGE
|
||
|
|
</label>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="field">
|
||
|
|
<label>Target</label>
|
||
|
|
<Show when={form().scope === 'ROLE'}>
|
||
|
|
<select
|
||
|
|
value={form().role_key}
|
||
|
|
onChange={(e) => setForm({ ...form(), role_key: e.currentTarget.value })}
|
||
|
|
>
|
||
|
|
<For each={ROLE_OPTIONS}>
|
||
|
|
{(role) => <option value={role}>{role}</option>}
|
||
|
|
</For>
|
||
|
|
</select>
|
||
|
|
</Show>
|
||
|
|
<Show when={form().scope === 'PACKAGE'}>
|
||
|
|
<select
|
||
|
|
value={form().package_id}
|
||
|
|
onChange={(e) => setForm({ ...form(), package_id: e.currentTarget.value })}
|
||
|
|
>
|
||
|
|
<Show when={packages.loading}>
|
||
|
|
<option value="">Loading packages...</option>
|
||
|
|
</Show>
|
||
|
|
<Show when={!packages.loading}>
|
||
|
|
<For each={packages()}>
|
||
|
|
{(pkg) => <option value={pkg.id}>{pkg.name}</option>}
|
||
|
|
</For>
|
||
|
|
</Show>
|
||
|
|
</select>
|
||
|
|
</Show>
|
||
|
|
</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="actions">
|
||
|
|
<button class="btn navy" type="submit" disabled={saving()}>
|
||
|
|
{saving() ? 'Saving...' : (form().id ? 'Update Discount' : 'Save Discount')}
|
||
|
|
</button>
|
||
|
|
<Show when={form().id}>
|
||
|
|
<button type="button" class="btn" onClick={resetForm}>Cancel Edit</button>
|
||
|
|
</Show>
|
||
|
|
</div>
|
||
|
|
</form>
|
||
|
|
</section>
|
||
|
|
</Show>
|
||
|
|
</AdminShell>
|
||
|
|
);
|
||
|
|
}
|