feat: Add AI Credits admin UI (Task 2)
- pricing.tsx: AI Credit Packages tab with list, create, edit, toggle - credit.tsx: AI Credits panel with Balance/Ledger/Adjust/Reconcile sub-tabs - Coupon code support in order creation - Integration with backend admin endpoints
This commit is contained in:
parent
575a5749f6
commit
c383382f93
2 changed files with 804 additions and 5 deletions
|
|
@ -34,7 +34,36 @@ type ReconcileRow = {
|
|||
};
|
||||
|
||||
export default function CreditPage() {
|
||||
const [activeTab, setActiveTab] = createSignal<'ledger' | 'adjust' | 'reconcile'>('ledger');
|
||||
const [activeTab, setActiveTab] = createSignal<'ledger' | 'adjust' | 'reconcile' | 'ai_credits'>('ledger');
|
||||
|
||||
// AI Credits sub-tab state
|
||||
const [aiCreditsSubTab, setAiCreditsSubTab] = createSignal<'balance' | 'adjust' | 'reconcile'>('balance');
|
||||
const [aiUserId, setAiUserId] = createSignal('');
|
||||
const [aiSearchedUserId, setAiSearchedUserId] = createSignal('');
|
||||
const [aiBalance, setAiBalance] = createSignal<any>(null);
|
||||
const [aiLedger, setAiLedger] = createSignal<any[]>([]);
|
||||
const [aiSearchLoading, setAiSearchLoading] = createSignal(false);
|
||||
const [aiSearchError, setAiSearchError] = createSignal('');
|
||||
const [aiLedgerPage, setAiLedgerPage] = createSignal(1);
|
||||
const [aiLedgerLimit, setAiLedgerLimit] = createSignal(20);
|
||||
const [aiLedgerTotal, setAiLedgerTotal] = createSignal(0);
|
||||
|
||||
// AI Adjust state
|
||||
const [aiAdjUserId, setAiAdjUserId] = createSignal('');
|
||||
const [aiAdjAmount, setAiAdjAmount] = createSignal(1);
|
||||
const [aiAdjType, setAiAdjType] = createSignal<'ADD' | 'DEDUCT'>('ADD');
|
||||
const [aiAdjReason, setAiAdjReason] = createSignal('');
|
||||
const [aiAdjIdempotencyKey, setAiAdjIdempotencyKey] = createSignal('');
|
||||
const [aiAdjLoading, setAiAdjLoading] = createSignal(false);
|
||||
const [aiAdjSuccess, setAiAdjSuccess] = createSignal('');
|
||||
const [aiAdjError, setAiAdjError] = createSignal('');
|
||||
|
||||
// AI Reconcile state
|
||||
const [aiReconFrom, setAiReconFrom] = createSignal('');
|
||||
const [aiReconTo, setAiReconTo] = createSignal('');
|
||||
const [aiReconLoading, setAiReconLoading] = createSignal(false);
|
||||
const [aiReconResults, setAiReconResults] = createSignal<any>(null);
|
||||
const [aiReconError, setAiReconError] = createSignal('');
|
||||
|
||||
// Balance & Ledger tab state
|
||||
const [userId, setUserId] = createSignal('');
|
||||
|
|
@ -149,12 +178,101 @@ export default function CreditPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const tabs: { key: 'ledger' | 'adjust' | 'reconcile'; label: string }[] = [
|
||||
const tabs: { key: 'ledger' | 'adjust' | 'reconcile' | 'ai_credits'; label: string }[] = [
|
||||
{ key: 'ledger', label: 'Balance & Ledger' },
|
||||
{ key: 'adjust', label: 'Reward / Deduct' },
|
||||
{ key: 'reconcile', label: 'Reconcile' },
|
||||
{ key: 'ai_credits', label: 'AI Credits' },
|
||||
];
|
||||
|
||||
// AI Credits handlers
|
||||
const handleAiSearch = async () => {
|
||||
const uid = aiUserId().trim();
|
||||
if (!uid) return;
|
||||
setAiSearchLoading(true);
|
||||
setAiSearchError('');
|
||||
setAiBalance(null);
|
||||
setAiLedger([]);
|
||||
setAiSearchedUserId(uid);
|
||||
try {
|
||||
const [balRes, ledRes] = await Promise.all([
|
||||
fetch(`${API}/api/admin/ai-credits/balance?userId=${encodeURIComponent(uid)}`, { headers: authHeaders(), credentials: 'include' }),
|
||||
fetch(`${API}/api/admin/ai-credits/ledger?userId=${encodeURIComponent(uid)}&page=${aiLedgerPage()}&limit=${aiLedgerLimit()}`, { headers: authHeaders(), credentials: 'include' }),
|
||||
]);
|
||||
if (!balRes.ok || !ledRes.ok) throw new Error('Failed to fetch');
|
||||
const balData = await balRes.json();
|
||||
const ledData = await ledRes.json();
|
||||
setAiBalance(balData);
|
||||
setAiLedger(ledData.data ?? []);
|
||||
setAiLedgerTotal(ledData.total ?? 0);
|
||||
} catch {
|
||||
setAiSearchError('Failed to fetch AI credits data for this user ID.');
|
||||
setAiBalance(null);
|
||||
setAiLedger([]);
|
||||
} finally {
|
||||
setAiSearchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAiAdjust = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
setAiAdjLoading(true);
|
||||
setAiAdjSuccess('');
|
||||
setAiAdjError('');
|
||||
try {
|
||||
// Generate idempotency key if not provided
|
||||
const idempotencyKey = aiAdjIdempotencyKey().trim() || crypto.randomUUID();
|
||||
const res = await fetch(`${API}/api/admin/ai-credits/adjust`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
user_id: aiAdjUserId(),
|
||||
amount: aiAdjAmount(),
|
||||
type: aiAdjType(),
|
||||
reason: aiAdjReason(),
|
||||
idempotency_key: idempotencyKey,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const d = await res.json().catch(() => ({}));
|
||||
throw new Error((d as any).error || 'Failed to adjust credits');
|
||||
}
|
||||
setAiAdjSuccess('AI credits adjusted successfully!');
|
||||
setAiAdjUserId('');
|
||||
setAiAdjAmount(1);
|
||||
setAiAdjType('ADD');
|
||||
setAiAdjReason('');
|
||||
setAiAdjIdempotencyKey('');
|
||||
} catch (err: any) {
|
||||
setAiAdjError(err.message || 'Failed to adjust credits');
|
||||
} finally {
|
||||
setAiAdjLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAiReconcile = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
setAiReconLoading(true);
|
||||
setAiReconError('');
|
||||
setAiReconResults(null);
|
||||
try {
|
||||
const fromDate = new Date(aiReconFrom()).toISOString();
|
||||
const toDate = new Date(aiReconTo()).toISOString();
|
||||
const res = await fetch(
|
||||
`${API}/api/admin/ai-credits/reconcile?from=${encodeURIComponent(fromDate)}&to=${encodeURIComponent(toDate)}`,
|
||||
{ headers: authHeaders(), credentials: 'include' }
|
||||
);
|
||||
if (!res.ok) throw new Error('Failed to reconcile');
|
||||
const data = await res.json();
|
||||
setAiReconResults(data);
|
||||
} catch (err: any) {
|
||||
setAiReconError(err.message || 'Failed to reconcile');
|
||||
} finally {
|
||||
setAiReconLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredLedger = createMemo(() => {
|
||||
let data = ledger();
|
||||
const q = ledgerSearch().toLowerCase().trim();
|
||||
|
|
@ -518,6 +636,307 @@ export default function CreditPage() {
|
|||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* AI Credits Tab */}
|
||||
<Show when={activeTab() === 'ai_credits'}>
|
||||
<div>
|
||||
{/* AI Credits Sub-tabs */}
|
||||
<div class="bg-white border-b border-gray-200 px-0 flex items-center gap-6 mb-6">
|
||||
{(['balance', 'adjust', 'reconcile'] as const).map((t) => (
|
||||
<button
|
||||
type="button"
|
||||
class={
|
||||
aiCreditsSubTab() === t
|
||||
? "py-2 border-b-2 border-orange-500 text-orange-600 text-sm font-medium"
|
||||
: "py-2 border-b-2 border-transparent text-gray-500 hover:text-gray-700 text-sm font-medium transition-colors"
|
||||
}
|
||||
onClick={() => setAiCreditsSubTab(t)}
|
||||
>
|
||||
{t === 'balance' ? 'Balance & Ledger' : t === 'adjust' ? 'Reward / Deduct' : 'Reconcile'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* AI Balance & Ledger */}
|
||||
<Show when={aiCreditsSubTab() === 'balance'}>
|
||||
<div style="display:flex;flex-direction:column;gap:24px">
|
||||
<section class="rounded-xl border border-gray-200 bg-white shadow-sm p-6">
|
||||
<h2 style="margin:0 0 16px;font-size:16px;font-weight:700;color:#1e293b">Search AI Credits Balance</h2>
|
||||
<div style="display:flex;gap:10px">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter User ID..."
|
||||
value={aiUserId()}
|
||||
onInput={(e) => setAiUserId(e.currentTarget.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleAiSearch()}
|
||||
class="rounded-lg border border-gray-200 px-3 py-2 text-sm"
|
||||
style="flex:1"
|
||||
/>
|
||||
<button
|
||||
class="btn-primary"
|
||||
onClick={handleAiSearch}
|
||||
disabled={aiSearchLoading()}
|
||||
>
|
||||
{aiSearchLoading() ? 'Searching...' : 'Search'}
|
||||
</button>
|
||||
</div>
|
||||
<Show when={aiSearchError()}>
|
||||
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700" style="margin-top:10px">{aiSearchError()}</div>
|
||||
</Show>
|
||||
</section>
|
||||
|
||||
<Show when={aiBalance() !== null}>
|
||||
<div style="display:grid;grid-template-columns:1fr 2fr;gap:20px">
|
||||
<div style="background:#7c3aed;border-radius:12px;padding:24px;color:#fff">
|
||||
<p style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:0.05em;color:#ddd6fe;margin:0 0 8px">Available AI Credits</p>
|
||||
<p style="font-size:36px;font-weight:900;margin:0">{aiBalance().available_credits ?? 0}</p>
|
||||
<p style="font-size:12px;color:#ddd6fe;margin:8px 0 0">User: {aiSearchedUserId()}</p>
|
||||
<div style="margin-top:16px;border-top:1px solid rgba(255,255,255,0.2);padding-top:12px">
|
||||
<p style="font-size:11px;color:#ddd6fe;margin:0 0 4px">Monthly: {aiBalance().monthly_credits_used ?? 0} / {aiBalance().monthly_credits_total ?? 0}</p>
|
||||
<p style="font-size:11px;color:#ddd6fe;margin:0 0 4px">Purchased: {aiBalance().purchased_credits_used ?? 0} / {aiBalance().purchased_credits_total ?? 0}</p>
|
||||
<p style="font-size:11px;color:#ddd6fe;margin:0">Bonus: {aiBalance().bonus_credits_used ?? 0} / {aiBalance().bonus_credits_total ?? 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-card" style="overflow:hidden">
|
||||
<h3 style="margin:0 0 16px;font-size:15px;font-weight:700;color:#0f172a">AI Credits Ledger</h3>
|
||||
<Show when={aiLedger().length === 0}>
|
||||
<p style="text-align:center;padding:32px;color:#94a3b8;font-style:italic">No transactions found for this account.</p>
|
||||
</Show>
|
||||
<Show when={aiLedger().length > 0}>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="data-table w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Credits</th>
|
||||
<th>Balance After</th>
|
||||
<th>Description</th>
|
||||
<th>Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={aiLedger()}>
|
||||
{(entry) => (
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td>
|
||||
<span
|
||||
style={`display:inline-block;padding:2px 8px;border-radius:999px;font-size:10px;font-weight:700;text-transform:uppercase;background:${entry.credits > 0 ? '#dcfce7' : '#fee2e2'};color:${entry.credits > 0 ? '#15803d' : '#b91c1c'}`}
|
||||
>
|
||||
{entry.credits > 0 ? 'CREDIT' : 'DEBIT'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="font-semibold text-slate-900" style={entry.credits > 0 ? 'color:#16a34a' : 'color:#dc2626'}>
|
||||
{entry.credits > 0 ? '+' : ''}{entry.credits}
|
||||
</td>
|
||||
<td class="text-slate-700">{entry.balance_after}</td>
|
||||
<td class="text-slate-500" style="font-size:12px">{entry.description || '—'}</td>
|
||||
<td class="text-slate-500" style="font-size:12px">
|
||||
{entry.created_at ? new Date(entry.created_at).toLocaleString() : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style="padding:10px 16px;font-size:12px;color:#64748b;border-top:1px solid #f1f5f9">
|
||||
Showing {aiLedger().length} of {aiLedgerTotal()} entries (Page {aiLedgerPage()})
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* AI Reward / Deduct */}
|
||||
<Show when={aiCreditsSubTab() === 'adjust'}>
|
||||
<section class="rounded-xl border border-gray-200 bg-white shadow-sm" style="max-width:460px">
|
||||
<h2 style="margin:0 0 6px;font-size:16px;font-weight:700;color:#1e293b">Reward or Adjust AI Credits</h2>
|
||||
<p style="margin:0 0 20px;font-size:13px;color:#64748b">
|
||||
Use this to reward AI credits for a valid support case, process a refund, or correct a balance manually.
|
||||
</p>
|
||||
<Show when={aiAdjSuccess()}>
|
||||
<div style="background:#dcfce7;border:1px solid #86efac;border-radius:6px;padding:10px 14px;margin-bottom:14px;font-size:14px;color:#15803d;font-weight:600">
|
||||
{aiAdjSuccess()}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={aiAdjError()}>
|
||||
<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">{aiAdjError()}</div>
|
||||
</Show>
|
||||
<form onSubmit={handleAiAdjust} style="display:flex;flex-direction:column;gap:14px">
|
||||
<div class="field">
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">User ID</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={aiAdjUserId()}
|
||||
onInput={(e) => setAiAdjUserId(e.currentTarget.value)}
|
||||
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div class="field">
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Amount</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
required
|
||||
value={aiAdjAmount()}
|
||||
onInput={(e) => setAiAdjAmount(Number(e.currentTarget.value))}
|
||||
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Type</label>
|
||||
<select
|
||||
value={aiAdjType()}
|
||||
onChange={(e) => setAiAdjType(e.currentTarget.value as 'ADD' | 'DEDUCT')}
|
||||
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box"
|
||||
>
|
||||
<option value="ADD">ADD</option>
|
||||
<option value="DEDUCT">DEDUCT</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Reason <span style="color:#dc2626">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={aiAdjReason()}
|
||||
onInput={(e) => setAiAdjReason(e.currentTarget.value)}
|
||||
placeholder="Required: Explain why credits are being adjusted"
|
||||
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Idempotency Key <span style="font-weight:400;color:#94a3b8">(optional)</span></label>
|
||||
<input
|
||||
type="text"
|
||||
value={aiAdjIdempotencyKey()}
|
||||
onInput={(e) => setAiAdjIdempotencyKey(e.currentTarget.value)}
|
||||
placeholder="Leave empty to auto-generate"
|
||||
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box"
|
||||
/>
|
||||
<p style="font-size:11px;color:#64748b;margin-top:4px">Prevents duplicate adjustments if resubmitted</p>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn-primary" type="submit" disabled={aiAdjLoading()}>
|
||||
{aiAdjLoading() ? 'Adjusting...' : 'Apply Adjustment'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</Show>
|
||||
|
||||
{/* AI Reconcile */}
|
||||
<Show when={aiCreditsSubTab() === 'reconcile'}>
|
||||
<div style="display:flex;flex-direction:column;gap:20px">
|
||||
<section class="rounded-xl border border-gray-200 bg-white shadow-sm" style="max-width:460px">
|
||||
<h2 style="margin:0 0 16px;font-size:16px;font-weight:700;color:#1e293b">AI Credits Reconciliation</h2>
|
||||
<Show when={aiReconError()}>
|
||||
<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">{aiReconError()}</div>
|
||||
</Show>
|
||||
<form onSubmit={handleAiReconcile} style="display:flex;flex-direction:column;gap:14px">
|
||||
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div class="field">
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Start Date</label>
|
||||
<input
|
||||
type="date"
|
||||
required
|
||||
value={aiReconFrom()}
|
||||
onInput={(e) => setAiReconFrom(e.currentTarget.value)}
|
||||
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">End Date</label>
|
||||
<input
|
||||
type="date"
|
||||
required
|
||||
value={aiReconTo()}
|
||||
onInput={(e) => setAiReconTo(e.currentTarget.value)}
|
||||
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn-primary" type="submit" disabled={aiReconLoading()}>
|
||||
{aiReconLoading() ? 'Running...' : 'Run Reconciliation'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<Show when={aiReconResults() !== null}>
|
||||
<div class="table-card">
|
||||
<h3 style="margin:0 0 16px;font-size:15px;font-weight:700;color:#0f172a">Summary by Entry Type</h3>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="data-table w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Entry Type</th>
|
||||
<th>Count</th>
|
||||
<th>Total Credits</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={aiReconResults()?.summary || []}>
|
||||
{(row) => (
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="font-semibold text-slate-900">{row.entry_type}</td>
|
||||
<td class="text-slate-700">{row.count}</td>
|
||||
<td class="text-slate-700">{row.total_credits}</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3 style="margin:24px 0 16px;font-size:15px;font-weight:700;color:#0f172a">Drift Check</h3>
|
||||
<Show when={(aiReconResults()?.drift_check?.issues_found ?? 0) === 0}>
|
||||
<p style="color:#16a34a;font-weight:600;font-size:14px;padding:16px">No discrepancies found. All wallets reconciled correctly.</p>
|
||||
</Show>
|
||||
<Show when={(aiReconResults()?.drift_check?.issues_found ?? 0) > 0}>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="data-table w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Ledger Sum</th>
|
||||
<th>Computed</th>
|
||||
<th>Drift</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={aiReconResults()?.drift_check?.issues || []}>
|
||||
{(row) => (
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td>
|
||||
<div class="font-semibold text-slate-900">{row.user_email}</div>
|
||||
<div style="font-size:11px;color:#64748b;font-family:monospace">{row.user_id}</div>
|
||||
</td>
|
||||
<td class="text-slate-700">{row.ledger_sum}</td>
|
||||
<td class="text-slate-700">{row.computed_available}</td>
|
||||
<td style={row.drift !== 0 ? 'color:#dc2626;font-weight:700' : 'color:#16a34a'}>
|
||||
{row.drift > 0 ? '+' : ''}{row.drift}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -37,6 +37,17 @@ type Package = {
|
|||
is_expired?: boolean;
|
||||
};
|
||||
|
||||
type AiCreditPackage = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
credits: number;
|
||||
price_inr: number;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
const PACKAGE_TYPES = [
|
||||
{ value: "TRACECOIN_BUNDLE", label: "Tracecoin Bundle" },
|
||||
{ value: "CONTACT_VIEWS", label: "Contact Views (Company)" },
|
||||
|
|
@ -92,7 +103,7 @@ export default function PricingPage() {
|
|||
const [rows, setRows] = createSignal<Package[]>([]);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [loadError, setLoadError] = createSignal("");
|
||||
const [view, setView] = createSignal<"packages" | "create">("packages");
|
||||
const [view, setView] = createSignal<"packages" | "create" | "ai_packages">("packages");
|
||||
|
||||
// Filters
|
||||
const [search, setSearch] = createSignal("");
|
||||
|
|
@ -125,6 +136,27 @@ export default function PricingPage() {
|
|||
const [cError, setCError] = createSignal("");
|
||||
const [roleDropdownOpen, setRoleDropdownOpen] = createSignal(false);
|
||||
|
||||
// AI Credit Packages state
|
||||
const [aiPackages, setAiPackages] = createSignal<AiCreditPackage[]>([]);
|
||||
const [aiPackagesLoading, setAiPackagesLoading] = createSignal(false);
|
||||
const [aiPackagesError, setAiPackagesError] = createSignal("");
|
||||
const [aiEditingId, setAiEditingId] = createSignal("");
|
||||
const [aiEditName, setAiEditName] = createSignal("");
|
||||
const [aiEditDescription, setAiEditDescription] = createSignal("");
|
||||
const [aiEditCredits, setAiEditCredits] = createSignal("");
|
||||
const [aiEditPrice, setAiEditPrice] = createSignal("");
|
||||
const [aiEditSaving, setAiEditSaving] = createSignal(false);
|
||||
const [aiEditError, setAiEditError] = createSignal("");
|
||||
const [aiTogglingId, setAiTogglingId] = createSignal("");
|
||||
// AI Create form
|
||||
const [aiCName, setAiCName] = createSignal("");
|
||||
const [aiCDescription, setAiCDescription] = createSignal("");
|
||||
const [aiCCredits, setAiCCredits] = createSignal("");
|
||||
const [aiCPrice, setAiCPrice] = createSignal("");
|
||||
const [aiCSaving, setAiCSaving] = createSignal(false);
|
||||
const [aiCError, setAiCError] = createSignal("");
|
||||
const [aiCreateView, setAiCreateView] = createSignal(false);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setLoadError("");
|
||||
|
|
@ -144,6 +176,114 @@ export default function PricingPage() {
|
|||
}
|
||||
};
|
||||
|
||||
// AI Credit Packages functions
|
||||
const loadAiPackages = async () => {
|
||||
setAiPackagesLoading(true);
|
||||
setAiPackagesError("");
|
||||
try {
|
||||
const res = await fetch(`${API}/api/admin/ai-credits/packages`, {
|
||||
headers: authHeaders(),
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) throw new Error(`Request failed (${res.status})`);
|
||||
const data = await res.json();
|
||||
setAiPackages(Array.isArray(data.packages) ? data.packages : []);
|
||||
} catch (err: any) {
|
||||
setAiPackagesError(err.message || "Could not load AI credit packages.");
|
||||
setAiPackages([]);
|
||||
} finally {
|
||||
setAiPackagesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startAiEdit = (pkg: AiCreditPackage) => {
|
||||
setAiEditingId(pkg.id);
|
||||
setAiEditName(pkg.name);
|
||||
setAiEditDescription(pkg.description || "");
|
||||
setAiEditCredits(String(pkg.credits));
|
||||
setAiEditPrice(String(pkg.price_inr / 100)); // Convert paise to rupees for display
|
||||
setAiEditError("");
|
||||
};
|
||||
|
||||
const cancelAiEdit = () => {
|
||||
setAiEditingId("");
|
||||
setAiEditError("");
|
||||
};
|
||||
|
||||
const saveAiEdit = async (id: string) => {
|
||||
try {
|
||||
setAiEditSaving(true);
|
||||
setAiEditError("");
|
||||
const priceInPaise = Math.round(Number(aiEditPrice()) * 100);
|
||||
const res = await fetch(`${API}/api/admin/ai-credits/packages/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: authHeaders(),
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
name: aiEditName(),
|
||||
description: aiEditDescription() || undefined,
|
||||
credits: Number(aiEditCredits()),
|
||||
price_inr: priceInPaise,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to save");
|
||||
setAiEditingId("");
|
||||
await loadAiPackages();
|
||||
} catch (err: any) {
|
||||
setAiEditError(err.message || "Failed to save");
|
||||
} finally {
|
||||
setAiEditSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleAiActive = async (pkg: AiCreditPackage) => {
|
||||
try {
|
||||
setAiTogglingId(pkg.id);
|
||||
await fetch(`${API}/api/admin/ai-credits/packages/${pkg.id}`, {
|
||||
method: "PATCH",
|
||||
headers: authHeaders(),
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ is_active: !pkg.is_active }),
|
||||
});
|
||||
await loadAiPackages();
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
setAiTogglingId("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleAiCreate = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
setAiCSaving(true);
|
||||
setAiCError("");
|
||||
const priceInPaise = Math.round(Number(aiCPrice()) * 100);
|
||||
const res = await fetch(`${API}/api/admin/ai-credits/packages`, {
|
||||
method: "POST",
|
||||
headers: authHeaders(),
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
name: aiCName(),
|
||||
description: aiCDescription() || undefined,
|
||||
credits: Number(aiCCredits()),
|
||||
price_inr: priceInPaise,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to create package");
|
||||
setAiCName("");
|
||||
setAiCDescription("");
|
||||
setAiCCredits("");
|
||||
setAiCPrice("");
|
||||
setAiCreateView(false);
|
||||
await loadAiPackages();
|
||||
} catch (err: any) {
|
||||
setAiCError(err.message || "Failed to create");
|
||||
} finally {
|
||||
setAiCSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => void load());
|
||||
|
||||
const filteredRows = createMemo(() => {
|
||||
|
|
@ -302,7 +442,7 @@ 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) => (
|
||||
{(["packages", "create", "ai_packages"] as const).map((t) => (
|
||||
<button
|
||||
type="button"
|
||||
class={
|
||||
|
|
@ -312,7 +452,7 @@ export default function PricingPage() {
|
|||
}
|
||||
onClick={() => setView(t)}
|
||||
>
|
||||
{t === "packages" ? "Packages" : "Create Package"}
|
||||
{t === "packages" ? "Packages" : t === "create" ? "Create Package" : "AI Credit Packages"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -774,6 +914,246 @@ export default function PricingPage() {
|
|||
</form>
|
||||
</section>
|
||||
</Show>
|
||||
|
||||
{/* AI Credit Packages Tab */}
|
||||
<Show when={view() === "ai_packages"}>
|
||||
<div style="position:relative;margin-top:1.5rem;margin-left:-24px;margin-right:-24px;border-radius:0;border-left:none;border-right:none;overflow:visible;border-top:1px solid #E5E7EB;border-bottom:1px solid #E5E7EB;background:white;box-shadow:0 1px 3px rgba(0,0,0,0.06)"
|
||||
onMount={() => loadAiPackages()}>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;padding:14px 20px;border-bottom:1px solid #F3F4F6;flex-wrap:wrap">
|
||||
<h2 style="margin:0;font-size:18px;font-weight:700;color:#111827">AI Credit Packages</h2>
|
||||
<button
|
||||
type="button"
|
||||
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #D1D5DB;background:#fff;padding:0 12px;font-size:12px;font-weight:600;color:#0f172a;cursor:pointer"
|
||||
onClick={() => setAiCreateView(true)}
|
||||
>
|
||||
+ Create Package
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={aiPackagesError()}>
|
||||
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700" style="margin:14px 20px">
|
||||
{aiPackagesError()}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={aiCreateView()}>
|
||||
<div style="background:#f8fafc;padding:16px 20px;border-bottom:1px solid #F3F4F6">
|
||||
<Show when={aiCError()}>
|
||||
<div class="mb-3 rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-700">
|
||||
{aiCError()}
|
||||
</div>
|
||||
</Show>
|
||||
<h3 style="margin:0 0 12px;font-size:14px;font-weight:600">Create AI Credit Package</h3>
|
||||
<form onSubmit={handleAiCreate} style="display:flex;gap:12px;flex-wrap:wrap;align-items:flex-end">
|
||||
<div>
|
||||
<label style="display:block;font-size:12px;font-weight:600;margin-bottom:4px">Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={aiCName()}
|
||||
onInput={(e) => setAiCName(e.currentTarget.value)}
|
||||
required
|
||||
placeholder="e.g. AI Starter Pack"
|
||||
style="padding:7px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:13px;width:180px"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:12px;font-weight:600;margin-bottom:4px">Description</label>
|
||||
<input
|
||||
type="text"
|
||||
value={aiCDescription()}
|
||||
onInput={(e) => setAiCDescription(e.currentTarget.value)}
|
||||
placeholder="Optional"
|
||||
style="padding:7px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:13px;width:200px"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:12px;font-weight:600;margin-bottom:4px">Credits *</label>
|
||||
<input
|
||||
type="number"
|
||||
value={aiCCredits()}
|
||||
onInput={(e) => setAiCCredits(e.currentTarget.value)}
|
||||
required
|
||||
min="1"
|
||||
placeholder="e.g. 100"
|
||||
style="padding:7px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:13px;width:110px"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:12px;font-weight:600;margin-bottom:4px">Price (₹) *</label>
|
||||
<input
|
||||
type="number"
|
||||
value={aiCPrice()}
|
||||
onInput={(e) => setAiCPrice(e.currentTarget.value)}
|
||||
required
|
||||
min="1"
|
||||
placeholder="e.g. 499"
|
||||
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" type="submit" disabled={aiCSaving()}>
|
||||
{aiCSaving() ? "Creating..." : "Create"}
|
||||
</button>
|
||||
<button
|
||||
type="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={() => setAiCreateView(false)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</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>Credits</th>
|
||||
<th>Price (₹)</th>
|
||||
<th>Status</th>
|
||||
<th class="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Show when={aiPackagesLoading()}>
|
||||
<tr>
|
||||
<td colspan="5" style="text-align:center;padding:32px;color:#64748b">
|
||||
Loading...
|
||||
</td>
|
||||
</tr>
|
||||
</Show>
|
||||
<Show when={!aiPackagesLoading() && aiPackages().length === 0}>
|
||||
<tr>
|
||||
<td colspan="5" style="text-align:center;padding:32px;color:#94a3b8">
|
||||
No AI credit packages found.
|
||||
</td>
|
||||
</tr>
|
||||
</Show>
|
||||
<Show when={!aiPackagesLoading() && aiPackages().length > 0}>
|
||||
<For each={aiPackages()}>
|
||||
{(pkg) => (
|
||||
<>
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="font-semibold text-slate-900">
|
||||
{pkg.name}
|
||||
</td>
|
||||
<td class="text-slate-700 font-medium">{pkg.credits}</td>
|
||||
<td class="text-slate-700">₹{(pkg.price_inr / 100).toLocaleString("en-IN")}</td>
|
||||
<td>
|
||||
<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>
|
||||
<td>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<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={() => startAiEdit(pkg)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
class={
|
||||
pkg.is_active
|
||||
? "inline-flex items-center rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-100 transition-colors"
|
||||
: "btn-primary"
|
||||
}
|
||||
disabled={aiTogglingId() === pkg.id}
|
||||
onClick={() => toggleAiActive(pkg)}
|
||||
>
|
||||
{aiTogglingId() === pkg.id
|
||||
? "..."
|
||||
: pkg.is_active
|
||||
? "Disable"
|
||||
: "Enable"}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<Show when={aiEditingId() === pkg.id}>
|
||||
<tr>
|
||||
<td colspan="5" style="background:#f8fafc;padding:16px">
|
||||
<Show when={aiEditError()}>
|
||||
<div class="mb-3 rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-700">
|
||||
{aiEditError()}
|
||||
</div>
|
||||
</Show>
|
||||
<div style="display:flex;gap:12px;flex-wrap:wrap;align-items:flex-end">
|
||||
<div>
|
||||
<label style="display:block;font-size:12px;font-weight:600;margin-bottom:4px">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={aiEditName()}
|
||||
onInput={(e) => setAiEditName(e.currentTarget.value)}
|
||||
style="padding:7px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:13px;width:180px"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:12px;font-weight:600;margin-bottom:4px">Description</label>
|
||||
<input
|
||||
type="text"
|
||||
value={aiEditDescription()}
|
||||
onInput={(e) => setAiEditDescription(e.currentTarget.value)}
|
||||
style="padding:7px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:13px;width:180px"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:12px;font-weight:600;margin-bottom:4px">Credits</label>
|
||||
<input
|
||||
type="number"
|
||||
value={aiEditCredits()}
|
||||
onInput={(e) => setAiEditCredits(e.currentTarget.value)}
|
||||
style="padding:7px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:13px;width:110px"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:12px;font-weight:600;margin-bottom:4px">Price (₹)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={aiEditPrice()}
|
||||
onInput={(e) => setAiEditPrice(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={aiEditSaving()} onClick={() => saveAiEdit(pkg.id)}>
|
||||
{aiEditSaving() ? "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={cancelAiEdit}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</Show>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Show when={!aiPackagesLoading()}>
|
||||
<div style="padding:10px 16px;font-size:12px;color:#64748b;border-top:1px solid #f1f5f9">
|
||||
{aiPackages().length} AI credit packages
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue