Fix auth headers on pricing/credit/invoice/coupon pages
- All 4 pages now send Bearer token from sessionStorage on every fetch - Pricing: fixed endpoint from /api/admin/packages → /api/admin/tracecoin-packages; added search, role filter, status filter, and sort (name/price/coins) - Coupon: added search by code/title and status filter; fixed refetch to use load() - Invoice: refactored from createResource to onMount+signals for consistent auth - Credit: authenticated balance, ledger, adjust, and reconcile fetch calls Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0ec64be905
commit
411827f837
4 changed files with 433 additions and 323 deletions
|
|
@ -1,7 +1,22 @@
|
|||
import { createResource, createSignal, Show, For } from 'solid-js';
|
||||
import { createSignal, createMemo, onMount, Show, For } from 'solid-js';
|
||||
|
||||
const API = '';
|
||||
|
||||
function getToken(): string {
|
||||
return typeof sessionStorage !== 'undefined'
|
||||
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
|
||||
: '';
|
||||
}
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
const token = getToken();
|
||||
return {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
'company',
|
||||
'customer',
|
||||
|
|
@ -30,16 +45,6 @@ type Coupon = {
|
|||
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: '',
|
||||
|
|
@ -53,13 +58,45 @@ const defaultForm = () => ({
|
|||
});
|
||||
|
||||
export default function CouponPage() {
|
||||
const [coupons, { refetch }] = createResource(loadCoupons);
|
||||
const [coupons, setCoupons] = createSignal<Coupon[]>([]);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [loadError, setLoadError] = createSignal('');
|
||||
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('');
|
||||
|
||||
// Filters
|
||||
const [search, setSearch] = createSignal('');
|
||||
const [statusFilter, setStatusFilter] = createSignal('all');
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true); setLoadError('');
|
||||
try {
|
||||
const res = await fetch(`${API}/api/admin/coupons`, { headers: authHeaders(), credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`Request failed (${res.status})`);
|
||||
const data = await res.json();
|
||||
setCoupons(Array.isArray(data) ? data : (data.coupons ?? []));
|
||||
} catch (err: any) {
|
||||
setLoadError(err.message || 'Could not load coupons.');
|
||||
setCoupons([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => void load());
|
||||
|
||||
const filteredCoupons = createMemo(() => {
|
||||
let r = coupons();
|
||||
const q = search().toLowerCase();
|
||||
if (q) r = r.filter((c) => c.code.toLowerCase().includes(q) || (c.title || '').toLowerCase().includes(q));
|
||||
if (statusFilter() === 'active') r = r.filter((c) => c.is_active);
|
||||
if (statusFilter() === 'inactive') r = r.filter((c) => !c.is_active);
|
||||
return r;
|
||||
});
|
||||
|
||||
const resetForm = () => {
|
||||
setForm(defaultForm());
|
||||
setFormError('');
|
||||
|
|
@ -108,12 +145,13 @@ export default function CouponPage() {
|
|||
const method = f.id ? 'PATCH' : 'POST';
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: authHeaders(),
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to save coupon');
|
||||
resetForm();
|
||||
refetch();
|
||||
await load();
|
||||
setActiveTab('list');
|
||||
} catch (err: unknown) {
|
||||
setFormError(err instanceof Error ? err.message : 'Failed to save');
|
||||
|
|
@ -127,11 +165,12 @@ export default function CouponPage() {
|
|||
setToggling(coupon.id);
|
||||
const res = await fetch(`${API}/api/admin/coupons/${coupon.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: authHeaders(),
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ is_active: !coupon.is_active }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to toggle');
|
||||
refetch();
|
||||
await load();
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
|
|
@ -166,6 +205,25 @@ export default function CouponPage() {
|
|||
|
||||
<div class="flex-1 p-6">
|
||||
<Show when={activeTab() === 'list'}>
|
||||
<div style="display:flex;gap:10px;align-items:center;margin-bottom:16px;flex-wrap:wrap">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by code or title..."
|
||||
value={search()}
|
||||
onInput={(e) => setSearch(e.currentTarget.value)}
|
||||
class="rounded-lg border border-gray-200 px-3 py-2 text-sm"
|
||||
style="min-width:200px;flex:1"
|
||||
/>
|
||||
<select value={statusFilter()} onChange={(e) => setStatusFilter(e.currentTarget.value)} class="rounded-lg border border-gray-200 px-3 py-2 text-sm">
|
||||
<option value="all">All Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
<button type="button" class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" onClick={load}>Refresh</button>
|
||||
</div>
|
||||
<Show when={loadError()}>
|
||||
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{loadError()}</div>
|
||||
</Show>
|
||||
<div class="table-card">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="data-table w-full text-sm">
|
||||
|
|
@ -181,17 +239,14 @@ export default function CouponPage() {
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Show when={coupons.loading}>
|
||||
<Show when={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}>
|
||||
<Show when={!loading() && filteredCoupons().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()}>
|
||||
<Show when={!loading() && filteredCoupons().length > 0}>
|
||||
<For each={filteredCoupons()}>
|
||||
{(item) => (
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="font-semibold text-slate-900" style="font-family:monospace">{item.code}</td>
|
||||
|
|
@ -223,6 +278,11 @@ export default function CouponPage() {
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Show when={!loading()}>
|
||||
<div style="padding:10px 16px;font-size:12px;color:#64748b;border-top:1px solid #f1f5f9">
|
||||
{filteredCoupons().length} of {coupons().length} coupons
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,22 @@
|
|||
import { createSignal, createResource, Show, For } from 'solid-js';
|
||||
import { createSignal, Show, For } from 'solid-js';
|
||||
|
||||
const API = '';
|
||||
|
||||
function getToken(): string {
|
||||
return typeof sessionStorage !== 'undefined'
|
||||
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
|
||||
: '';
|
||||
}
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
const token = getToken();
|
||||
return {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
type LedgerEntry = {
|
||||
id: string;
|
||||
transactionType: 'ADD' | 'DEDUCT';
|
||||
|
|
@ -57,8 +72,8 @@ export default function CreditPage() {
|
|||
setSearchedUserId(uid);
|
||||
try {
|
||||
const [balRes, ledRes] = await Promise.all([
|
||||
fetch(`${API}/api/admin/credits/balance?userId=${encodeURIComponent(uid)}`),
|
||||
fetch(`${API}/api/admin/credits/ledger?userId=${encodeURIComponent(uid)}`),
|
||||
fetch(`${API}/api/admin/credits/balance?userId=${encodeURIComponent(uid)}`, { headers: authHeaders(), credentials: 'include' }),
|
||||
fetch(`${API}/api/admin/credits/ledger?userId=${encodeURIComponent(uid)}`, { headers: authHeaders(), credentials: 'include' }),
|
||||
]);
|
||||
if (!balRes.ok || !ledRes.ok) throw new Error('Failed to fetch');
|
||||
const balData = await balRes.json();
|
||||
|
|
@ -82,7 +97,8 @@ export default function CreditPage() {
|
|||
try {
|
||||
const res = await fetch(`${API}/api/admin/credits/adjust`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: authHeaders(),
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
user_id: adjUserId(),
|
||||
amount: adjAmount(),
|
||||
|
|
@ -115,7 +131,8 @@ export default function CreditPage() {
|
|||
setReconResults(null);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API}/api/admin/credits/reconcile?from=${encodeURIComponent(reconFrom())}&to=${encodeURIComponent(reconTo())}`
|
||||
`${API}/api/admin/credits/reconcile?from=${encodeURIComponent(reconFrom())}&to=${encodeURIComponent(reconTo())}`,
|
||||
{ headers: authHeaders(), credentials: 'include' }
|
||||
);
|
||||
if (!res.ok) throw new Error('Failed to reconcile');
|
||||
const data = await res.json();
|
||||
|
|
|
|||
|
|
@ -1,25 +1,47 @@
|
|||
import { createResource, createSignal, createMemo, Show } from 'solid-js';
|
||||
import { createSignal, createMemo, onMount, Show } from 'solid-js';
|
||||
|
||||
const API = '';
|
||||
|
||||
async function loadInvoices(): Promise<any[]> {
|
||||
try {
|
||||
const res = await fetch(`${API}/api/admin/invoices`);
|
||||
if (!res.ok) throw new Error('Failed to load');
|
||||
const data = await res.json();
|
||||
return Array.isArray(data) ? data : (data.invoices || []);
|
||||
} catch {
|
||||
return [];
|
||||
function getToken(): string {
|
||||
return typeof sessionStorage !== 'undefined'
|
||||
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
|
||||
: '';
|
||||
}
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
const token = getToken();
|
||||
return {
|
||||
Accept: 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export default function InvoicePage() {
|
||||
const [invoices] = createResource(loadInvoices);
|
||||
const [invoices, setInvoices] = createSignal<any[]>([]);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [loadError, setLoadError] = createSignal('');
|
||||
const [search, setSearch] = createSignal('');
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true); setLoadError('');
|
||||
try {
|
||||
const res = await fetch(`${API}/api/admin/invoices`, { headers: authHeaders(), credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`Request failed (${res.status})`);
|
||||
const data = await res.json();
|
||||
setInvoices(Array.isArray(data) ? data : (data.invoices ?? []));
|
||||
} catch (err: any) {
|
||||
setLoadError(err.message || 'Could not load invoices.');
|
||||
setInvoices([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => void load());
|
||||
|
||||
const filteredInvoices = createMemo(() => {
|
||||
const q = search().toLowerCase();
|
||||
const all = invoices() ?? [];
|
||||
const all = invoices();
|
||||
if (!q) return all;
|
||||
return all.filter((inv) =>
|
||||
(inv.invoice_number || inv.id || '').toLowerCase().includes(q) ||
|
||||
|
|
@ -64,16 +86,16 @@ export default function InvoicePage() {
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Show when={invoices.loading}>
|
||||
<Show when={loading()}>
|
||||
<tr><td colspan="8" style="text-align:center;padding:32px;color:#64748b">Loading...</td></tr>
|
||||
</Show>
|
||||
<Show when={!invoices.loading && invoices.error}>
|
||||
<tr><td colspan="8" style="text-align:center;padding:32px;color:#b91c1c">Failed to load. Is the backend running?</td></tr>
|
||||
<Show when={!loading() && loadError()}>
|
||||
<tr><td colspan="8" style="text-align:center;padding:32px;color:#b91c1c">{loadError()}</td></tr>
|
||||
</Show>
|
||||
<Show when={!invoices.loading && !invoices.error && filteredInvoices().length === 0}>
|
||||
<Show when={!loading() && !loadError() && filteredInvoices().length === 0}>
|
||||
<tr><td colspan="8" style="text-align:center;padding:32px;color:#94a3b8">No records found.</td></tr>
|
||||
</Show>
|
||||
<Show when={!invoices.loading && !invoices.error && filteredInvoices().length > 0}>
|
||||
<Show when={!loading() && !loadError() && filteredInvoices().length > 0}>
|
||||
{filteredInvoices().map((item) => (
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="font-semibold text-slate-900" style="font-family:monospace">{item.invoice_number || item.id}</td>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,22 @@
|
|||
import { createResource, createSignal, Show, For } from 'solid-js';
|
||||
import { createSignal, createMemo, onMount, Show, For } from 'solid-js';
|
||||
|
||||
const API = '';
|
||||
|
||||
function getToken(): string {
|
||||
return typeof sessionStorage !== 'undefined'
|
||||
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
|
||||
: '';
|
||||
}
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
const token = getToken();
|
||||
return {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
type Package = {
|
||||
id: string;
|
||||
name: string;
|
||||
|
|
@ -13,47 +28,42 @@ type Package = {
|
|||
};
|
||||
|
||||
const ROLES = [
|
||||
'company',
|
||||
'customer',
|
||||
'job_seeker',
|
||||
'photographer',
|
||||
'video_editor',
|
||||
'graphic_designer',
|
||||
'social_media_manager',
|
||||
'fitness_trainer',
|
||||
'catering_services',
|
||||
'makeup_artist',
|
||||
'tutor',
|
||||
'developer',
|
||||
'company', 'customer', 'job_seeker', 'photographer', 'video_editor',
|
||||
'graphic_designer', 'social_media_manager', 'fitness_trainer',
|
||||
'catering_services', 'makeup_artist', 'tutor', 'developer', 'ugc_content_creator',
|
||||
];
|
||||
|
||||
async function loadPackages(): Promise<Package[]> {
|
||||
try {
|
||||
const res = await fetch(`${API}/api/admin/packages`);
|
||||
if (!res.ok) throw new Error('Failed to load');
|
||||
const data = await res.json();
|
||||
return Array.isArray(data) ? data : (data.packages || []);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
type SortMode = 'name_asc' | 'name_desc' | 'price_asc' | 'price_desc' | 'coins_asc' | 'coins_desc';
|
||||
|
||||
const SORT_LABELS: Record<SortMode, string> = {
|
||||
name_asc: 'Name A→Z', name_desc: 'Name Z→A',
|
||||
price_asc: 'Price ↑', price_desc: 'Price ↓',
|
||||
coins_asc: 'TraceCoins ↑', coins_desc: 'TraceCoins ↓',
|
||||
};
|
||||
|
||||
export default function PricingPage() {
|
||||
const [packages, { refetch }] = createResource(loadPackages);
|
||||
const [rows, setRows] = createSignal<Package[]>([]);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [loadError, setLoadError] = createSignal('');
|
||||
const [view, setView] = createSignal<'packages' | 'create'>('packages');
|
||||
|
||||
// Inline edit state
|
||||
// Filters
|
||||
const [search, setSearch] = createSignal('');
|
||||
const [roleFilter, setRoleFilter] = createSignal('all');
|
||||
const [statusFilter, setStatusFilter] = createSignal('all');
|
||||
const [sortBy, setSortBy] = createSignal<SortMode>('name_asc');
|
||||
const [sortOpen, setSortOpen] = createSignal(false);
|
||||
|
||||
// Inline edit
|
||||
const [editingId, setEditingId] = createSignal('');
|
||||
const [editName, setEditName] = createSignal('');
|
||||
const [editTracecoins, setEditTracecoins] = createSignal('');
|
||||
const [editPrice, setEditPrice] = createSignal('');
|
||||
const [editSaving, setEditSaving] = createSignal(false);
|
||||
const [editError, setEditError] = createSignal('');
|
||||
|
||||
// Toggle active state
|
||||
const [togglingId, setTogglingId] = createSignal('');
|
||||
|
||||
// Create form state
|
||||
// Create form
|
||||
const [cName, setCName] = createSignal('');
|
||||
const [cRole, setCRole] = createSignal(ROLES[0]);
|
||||
const [cTracecoins, setCTracecoins] = createSignal('');
|
||||
|
|
@ -62,89 +72,96 @@ export default function PricingPage() {
|
|||
const [cSaving, setCsaving] = createSignal(false);
|
||||
const [cError, setCError] = createSignal('');
|
||||
|
||||
const startEdit = (pkg: Package) => {
|
||||
setEditingId(pkg.id);
|
||||
setEditName(pkg.name);
|
||||
setEditTracecoins(String(pkg.tracecoin_amount));
|
||||
setEditPrice(String(pkg.price_inr));
|
||||
setEditError('');
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setLoadError('');
|
||||
try {
|
||||
const res = await fetch(`${API}/api/admin/tracecoin-packages`, {
|
||||
headers: authHeaders(),
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) throw new Error(`Request failed (${res.status})`);
|
||||
const data = await res.json();
|
||||
setRows(Array.isArray(data) ? data : (data.packages ?? []));
|
||||
} catch (err: any) {
|
||||
setLoadError(err.message || 'Could not load packages.');
|
||||
setRows([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingId('');
|
||||
onMount(() => void load());
|
||||
|
||||
const filteredRows = createMemo(() => {
|
||||
let r = rows();
|
||||
const q = search().toLowerCase();
|
||||
if (q) r = r.filter((p) => p.name.toLowerCase().includes(q) || p.role.toLowerCase().includes(q));
|
||||
if (roleFilter() !== 'all') r = r.filter((p) => p.role === roleFilter());
|
||||
if (statusFilter() === 'active') r = r.filter((p) => p.is_active);
|
||||
if (statusFilter() === 'inactive') r = r.filter((p) => !p.is_active);
|
||||
const sorted = [...r];
|
||||
const mode = sortBy();
|
||||
sorted.sort((a, b) => {
|
||||
if (mode === 'name_desc') return b.name.localeCompare(a.name);
|
||||
if (mode === 'price_asc') return a.price_inr - b.price_inr;
|
||||
if (mode === 'price_desc') return b.price_inr - a.price_inr;
|
||||
if (mode === 'coins_asc') return a.tracecoin_amount - b.tracecoin_amount;
|
||||
if (mode === 'coins_desc') return b.tracecoin_amount - a.tracecoin_amount;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
return sorted;
|
||||
});
|
||||
|
||||
const startEdit = (pkg: Package) => {
|
||||
setEditingId(pkg.id); setEditName(pkg.name);
|
||||
setEditTracecoins(String(pkg.tracecoin_amount)); setEditPrice(String(pkg.price_inr));
|
||||
setEditError('');
|
||||
};
|
||||
const cancelEdit = () => { setEditingId(''); setEditError(''); };
|
||||
|
||||
const saveEdit = async (id: string) => {
|
||||
try {
|
||||
setEditSaving(true);
|
||||
setEditError('');
|
||||
setEditSaving(true); setEditError('');
|
||||
const res = await fetch(`${API}/api/admin/tracecoin-packages/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: editName(),
|
||||
tracecoin_amount: Number(editTracecoins()),
|
||||
price_inr: Number(editPrice()),
|
||||
}),
|
||||
method: 'PATCH', headers: authHeaders(), credentials: 'include',
|
||||
body: JSON.stringify({ name: editName(), tracecoin_amount: Number(editTracecoins()), price_inr: Number(editPrice()) }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to save');
|
||||
setEditingId('');
|
||||
refetch();
|
||||
} catch (err: any) {
|
||||
setEditError(err.message || 'Failed to save');
|
||||
} finally {
|
||||
setEditSaving(false);
|
||||
}
|
||||
setEditingId(''); await load();
|
||||
} catch (err: any) { setEditError(err.message || 'Failed to save'); }
|
||||
finally { setEditSaving(false); }
|
||||
};
|
||||
|
||||
const toggleActive = async (pkg: Package) => {
|
||||
try {
|
||||
setTogglingId(pkg.id);
|
||||
const res = await fetch(`${API}/api/admin/tracecoin-packages/${pkg.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
await fetch(`${API}/api/admin/tracecoin-packages/${pkg.id}`, {
|
||||
method: 'PATCH', headers: authHeaders(), credentials: 'include',
|
||||
body: JSON.stringify({ is_active: !pkg.is_active }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to update');
|
||||
refetch();
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setTogglingId('');
|
||||
}
|
||||
await load();
|
||||
} catch { /* ignore */ } finally { setTogglingId(''); }
|
||||
};
|
||||
|
||||
const handleCreate = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
setCsaving(true);
|
||||
setCError('');
|
||||
setCsaving(true); setCError('');
|
||||
const body: Record<string, any> = {
|
||||
name: cName(),
|
||||
role: cRole(),
|
||||
tracecoin_amount: Number(cTracecoins()),
|
||||
price_inr: Number(cPrice()),
|
||||
name: cName(), role: cRole(),
|
||||
tracecoin_amount: Number(cTracecoins()), price_inr: Number(cPrice()),
|
||||
};
|
||||
if (cBonus()) body.bonus_percentage = Number(cBonus());
|
||||
const res = await fetch(`${API}/api/admin/tracecoin-packages`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'POST', headers: authHeaders(), credentials: 'include',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to create package');
|
||||
setCName('');
|
||||
setCRole(ROLES[0]);
|
||||
setCTracecoins('');
|
||||
setCPrice('');
|
||||
setCBonus('');
|
||||
setView('packages');
|
||||
refetch();
|
||||
} catch (err: any) {
|
||||
setCError(err.message || 'Failed to create');
|
||||
} finally {
|
||||
setCsaving(false);
|
||||
}
|
||||
setCName(''); setCRole(ROLES[0]); setCTracecoins(''); setCPrice(''); setCBonus('');
|
||||
setView('packages'); await load();
|
||||
} catch (err: any) { setCError(err.message || 'Failed to create'); }
|
||||
finally { setCsaving(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -156,61 +173,103 @@ export default function PricingPage() {
|
|||
|
||||
{/* Tabs */}
|
||||
<div class="bg-white border-b border-gray-200 px-6 flex items-center gap-8 sticky top-0 z-10">
|
||||
{(['packages', 'create'] as const).map((t) => (
|
||||
<button
|
||||
type="button"
|
||||
class={view() === 'packages' ? 'py-3 border-b-2 border-orange-500 text-orange-600 text-sm font-medium' : 'py-3 border-b-2 border-transparent text-gray-500 hover:text-gray-700 text-sm font-medium transition-colors'}
|
||||
onClick={() => setView('packages')}
|
||||
class={view() === t
|
||||
? 'py-3 border-b-2 border-orange-500 text-orange-600 text-sm font-medium'
|
||||
: 'py-3 border-b-2 border-transparent text-gray-500 hover:text-gray-700 text-sm font-medium transition-colors'}
|
||||
onClick={() => setView(t)}
|
||||
>
|
||||
Packages
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={view() === 'create' ? 'py-3 border-b-2 border-orange-500 text-orange-600 text-sm font-medium' : 'py-3 border-b-2 border-transparent text-gray-500 hover:text-gray-700 text-sm font-medium transition-colors'}
|
||||
onClick={() => setView('create')}
|
||||
>
|
||||
Create Package
|
||||
{t === 'packages' ? 'Packages' : 'Create Package'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div class="flex-1 p-6">
|
||||
{/* Packages list tab */}
|
||||
|
||||
{/* ── Packages list ── */}
|
||||
<Show when={view() === 'packages'}>
|
||||
<div style="display:flex;gap:10px;align-items:center;margin-bottom:16px;flex-wrap:wrap">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by name or role..."
|
||||
value={search()}
|
||||
onInput={(e) => setSearch(e.currentTarget.value)}
|
||||
class="rounded-lg border border-gray-200 px-3 py-2 text-sm"
|
||||
style="min-width:200px;flex:1"
|
||||
/>
|
||||
<select value={roleFilter()} onChange={(e) => setRoleFilter(e.currentTarget.value)} class="rounded-lg border border-gray-200 px-3 py-2 text-sm">
|
||||
<option value="all">All Roles</option>
|
||||
<For each={ROLES}>{(r) => <option value={r}>{r}</option>}</For>
|
||||
</select>
|
||||
<select value={statusFilter()} onChange={(e) => setStatusFilter(e.currentTarget.value)} class="rounded-lg border border-gray-200 px-3 py-2 text-sm">
|
||||
<option value="all">All Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
<div style="position:relative">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
onClick={() => setSortOpen(!sortOpen())}
|
||||
>
|
||||
Sort: {SORT_LABELS[sortBy()]}
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<Show when={sortOpen()}>
|
||||
<div style="position:absolute;top:calc(100% + 4px);right:0;background:white;border:1px solid #e5e7eb;border-radius:10px;box-shadow:0 4px 16px rgba(0,0,0,.1);z-index:50;min-width:170px;padding:4px">
|
||||
<For each={Object.entries(SORT_LABELS) as [SortMode, string][]}>
|
||||
{([key, label]) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSortBy(key); setSortOpen(false); }}
|
||||
style={`display:block;width:100%;text-align:left;padding:8px 12px;font-size:13px;border-radius:7px;border:none;cursor:pointer;background:${sortBy() === key ? '#fff7ed' : 'transparent'};color:${sortBy() === key ? '#c2410c' : '#374151'};font-weight:${sortBy() === key ? '600' : '400'}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<button type="button" class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" onClick={load}>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={loadError()}>
|
||||
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{loadError()}</div>
|
||||
</Show>
|
||||
|
||||
<div class="table-card">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="data-table w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Role</th>
|
||||
<th>TraceCoins</th>
|
||||
<th>Price (₹)</th>
|
||||
<th>Bonus (%)</th>
|
||||
<th>Status</th>
|
||||
<th class="text-right">Actions</th>
|
||||
<th>Name</th><th>Role</th><th>TraceCoins</th><th>Price (₹)</th><th>Bonus</th><th>Status</th><th class="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Show when={packages.loading}>
|
||||
<Show when={loading()}>
|
||||
<tr><td colspan="7" style="text-align:center;padding:32px;color:#64748b">Loading...</td></tr>
|
||||
</Show>
|
||||
<Show when={!packages.loading && packages.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={!packages.loading && !packages.error && (packages()?.length ?? 0) === 0}>
|
||||
<Show when={!loading() && filteredRows().length === 0}>
|
||||
<tr><td colspan="7" style="text-align:center;padding:32px;color:#94a3b8">No packages found.</td></tr>
|
||||
</Show>
|
||||
<Show when={!packages.loading && !packages.error && (packages()?.length ?? 0) > 0}>
|
||||
<For each={packages()}>
|
||||
<Show when={!loading() && filteredRows().length > 0}>
|
||||
<For each={filteredRows()}>
|
||||
{(pkg) => (
|
||||
<>
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="font-semibold text-slate-900">{pkg.name}</td>
|
||||
<td class="text-slate-500">{pkg.role}</td>
|
||||
<td class="text-slate-500">{pkg.tracecoin_amount}</td>
|
||||
<td class="text-slate-500">₹{(pkg.price_inr / 100).toFixed(2)}</td>
|
||||
<td><span style="display:inline-block;padding:2px 8px;border-radius:999px;font-size:11px;font-weight:600;background:#f1f5f9;color:#475569">{pkg.role}</span></td>
|
||||
<td class="text-slate-700 font-medium">{pkg.tracecoin_amount}</td>
|
||||
<td class="text-slate-700">₹{(pkg.price_inr / 100).toFixed(2)}</td>
|
||||
<td class="text-slate-500">{pkg.bonus_percentage != null ? `${pkg.bonus_percentage}%` : '—'}</td>
|
||||
<td>
|
||||
<span class={`inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600 ${pkg.is_active ? 'active' : ''}`}>
|
||||
<span style={`display:inline-flex;align-items:center;border-radius:9999px;border:1px solid ${pkg.is_active ? '#FFD8C2' : '#D1D5DB'};background:${pkg.is_active ? '#FFF1EB' : '#F3F4F6'};color:${pkg.is_active ? '#FF5E13' : '#4B5563'};padding:2px 10px;font-size:12px;font-weight:500`}>
|
||||
<span style={`display:inline-block;width:6px;height:6px;border-radius:50%;background:${pkg.is_active ? '#FF5E13' : '#9CA3AF'};margin-right:5px`} />
|
||||
{pkg.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
|
|
@ -231,40 +290,23 @@ export default function PricingPage() {
|
|||
<tr>
|
||||
<td colspan="7" style="background:#f8fafc;padding:16px">
|
||||
<Show when={editError()}>
|
||||
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700" style="margin-bottom:10px">{editError()}</div>
|
||||
<div class="mb-3 rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-700">{editError()}</div>
|
||||
</Show>
|
||||
<div style="display:flex;gap:12px;flex-wrap:wrap;align-items:flex-end">
|
||||
<div class="field">
|
||||
<div>
|
||||
<label style="display:block;font-size:12px;font-weight:600;margin-bottom:4px">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editName()}
|
||||
onInput={(e) => setEditName(e.currentTarget.value)}
|
||||
style="padding:7px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:13px;width:180px"
|
||||
/>
|
||||
<input type="text" value={editName()} onInput={(e) => setEditName(e.currentTarget.value)} style="padding:7px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:13px;width:180px" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<div>
|
||||
<label style="display:block;font-size:12px;font-weight:600;margin-bottom:4px">TraceCoins</label>
|
||||
<input
|
||||
type="number"
|
||||
value={editTracecoins()}
|
||||
onInput={(e) => setEditTracecoins(e.currentTarget.value)}
|
||||
style="padding:7px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:13px;width:110px"
|
||||
/>
|
||||
<input type="number" value={editTracecoins()} onInput={(e) => setEditTracecoins(e.currentTarget.value)} style="padding:7px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:13px;width:110px" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<div>
|
||||
<label style="display:block;font-size:12px;font-weight:600;margin-bottom:4px">Price (paise)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={editPrice()}
|
||||
onInput={(e) => setEditPrice(e.currentTarget.value)}
|
||||
style="padding:7px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:13px;width:110px"
|
||||
/>
|
||||
<input type="number" value={editPrice()} onInput={(e) => setEditPrice(e.currentTarget.value)} style="padding:7px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:13px;width:110px" />
|
||||
</div>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn-primary" disabled={editSaving()} onClick={() => saveEdit(pkg.id)}>
|
||||
{editSaving() ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
<button class="btn-primary" disabled={editSaving()} onClick={() => saveEdit(pkg.id)}>{editSaving() ? 'Saving...' : 'Save'}</button>
|
||||
<button class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" onClick={cancelEdit}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -278,82 +320,51 @@ export default function PricingPage() {
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Show when={!loading()}>
|
||||
<div style="padding:10px 16px;font-size:12px;color:#64748b;border-top:1px solid #f1f5f9">
|
||||
{filteredRows().length} of {rows().length} packages
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Create Package tab */}
|
||||
{/* ── Create Package ── */}
|
||||
<Show when={view() === 'create'}>
|
||||
<section class="rounded-xl border border-gray-200 bg-white shadow-sm" style="max-width:480px">
|
||||
<section class="rounded-xl border border-gray-200 bg-white shadow-sm p-6" style="max-width:480px">
|
||||
<h2 style="margin:0 0 20px;font-size:16px;font-weight:700">New Package</h2>
|
||||
<Show when={cError()}>
|
||||
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700" style="margin-bottom:14px">{cError()}</div>
|
||||
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{cError()}</div>
|
||||
</Show>
|
||||
<form onSubmit={handleCreate} style="display:flex;flex-direction:column;gap:14px">
|
||||
<div class="field">
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={cName()}
|
||||
onInput={(e) => setCName(e.currentTarget.value)}
|
||||
required
|
||||
placeholder="e.g. Starter Pack"
|
||||
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px"
|
||||
/>
|
||||
<input type="text" value={cName()} onInput={(e) => setCName(e.currentTarget.value)} required placeholder="e.g. Starter Pack" style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Role</label>
|
||||
<select
|
||||
value={cRole()}
|
||||
onChange={(e) => setCRole(e.currentTarget.value)}
|
||||
required
|
||||
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px"
|
||||
>
|
||||
<select value={cRole()} onChange={(e) => setCRole(e.currentTarget.value)} required style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box">
|
||||
<For each={ROLES}>{(r) => <option value={r}>{r}</option>}</For>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">TraceCoins</label>
|
||||
<input
|
||||
type="number"
|
||||
value={cTracecoins()}
|
||||
onInput={(e) => setCTracecoins(e.currentTarget.value)}
|
||||
required
|
||||
min="1"
|
||||
placeholder="e.g. 100"
|
||||
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Price INR (paise, e.g. 49900 = ₹499)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={cPrice()}
|
||||
onInput={(e) => setCPrice(e.currentTarget.value)}
|
||||
required
|
||||
min="1"
|
||||
placeholder="e.g. 49900"
|
||||
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Bonus Percentage (optional, e.g. 10 = 10% bonus coins)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={cBonus()}
|
||||
onInput={(e) => setCBonus(e.currentTarget.value)}
|
||||
min="0"
|
||||
placeholder="e.g. 10"
|
||||
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px"
|
||||
/>
|
||||
<input type="number" value={cTracecoins()} onInput={(e) => setCTracecoins(e.currentTarget.value)} required min="1" placeholder="e.g. 100" style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box" />
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn-primary" type="submit" disabled={cSaving()}>
|
||||
{cSaving() ? 'Creating...' : 'Create Package'}
|
||||
</button>
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Price INR (paise — e.g. 49900 = ₹499)</label>
|
||||
<input type="number" value={cPrice()} onInput={(e) => setCPrice(e.currentTarget.value)} required min="1" placeholder="e.g. 49900" style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box" />
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Bonus % <span style="font-weight:400;color:#94a3b8">(optional)</span></label>
|
||||
<input type="number" value={cBonus()} onInput={(e) => setCBonus(e.currentTarget.value)} min="0" placeholder="e.g. 10" style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box" />
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn-primary" type="submit" disabled={cSaving()}>{cSaving() ? 'Creating...' : 'Create Package'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</Show>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue