feat: wire Credits page to pricing management and payment APIs

Updated CreditsPage with full pricing and transaction support:
- Load pricing packages from /api/packages (role-specific)
- Create payment orders via /api/payments/create-order
- Display transaction history from wallet ledger + payments
- New tabbed interface: Overview, Buy Credits, Transactions
- Shows package details: name, price, tracecoins amount
- Format currency in INR with proper locale

Data sources:
- Packages: GET /api/packages?role={role_key}
- Wallet: GET /api/{prefix}/wallet/me
- Ledger: GET /api/{prefix}/wallet/me/ledger
- Payments: POST /api/payments/create-order
This commit is contained in:
Ashwin Kumar 2026-04-10 01:38:44 +02:00
parent c1c9e79945
commit 4795ef2910

View file

@ -1,37 +1,56 @@
import { For, Show, createSignal, onMount } from 'solid-js';
import { BTN_GHOST, CARD } from '~/components/DashboardShell';
import { PROFESSIONAL_ROLE_SET, ROLE_PREFIXES, type RoleKey } from './RoleDashboardShared';
import { For, Show, createSignal, onMount } from "solid-js";
import { BTN_GHOST, BTN_ORANGE, BTN_PRIMARY, CARD } from "~/components/DashboardShell";
import { PROFESSIONAL_ROLE_SET, ROLE_PREFIXES, type RoleKey } from "./RoleDashboardShared";
const API = '/api/gateway';
const API = "/api/gateway";
type Props = { roleKey: RoleKey };
type Package = {
id: string;
name: string;
role_key: string;
package_type: string;
tracecoins_amount: number;
price_inr: number;
description?: string;
};
type Payment = {
id: string;
amount_inr: number;
tracecoins_credited: number;
status: string;
created_at: string;
package_name?: string;
};
async function apiFetch(path: string, opts?: RequestInit) {
return fetch(`${API}${path}`, {
...opts,
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...(opts?.headers ?? {}) },
credentials: "include",
headers: { "Content-Type": "application/json", ...(opts?.headers ?? {}) },
});
}
export default function CreditsPage(props: Props) {
const [wallet, setWallet] = createSignal<any>(null);
const [ledger, setLedger] = createSignal<any[]>([]);
const [packages, setPackages] = createSignal<Package[]>([]);
const [payments, setPayments] = createSignal<Payment[]>([]);
const [loading, setLoading] = createSignal(true);
const [err, setErr] = createSignal('');
const [loadingPayments, setLoadingPayments] = createSignal(false);
const [err, setErr] = createSignal("");
const [msg, setMsg] = createSignal("");
const [activeTab, setActiveTab] = createSignal<"overview" | "buy" | "transactions">("overview");
const [busyPackageId, setBusyPackageId] = createSignal<string | null>(null);
const isProfessional = () => PROFESSIONAL_ROLE_SET.has(props.roleKey);
const prefix = () => ROLE_PREFIXES[props.roleKey];
const loadData = async () => {
setLoading(true);
setErr('');
const loadWalletData = async () => {
if (!isProfessional()) return;
try {
if (!isProfessional()) {
setWallet(null);
setLedger([]);
return;
}
const [walletRes, ledgerRes] = await Promise.all([
apiFetch(`/api/${prefix()}/wallet/me`),
apiFetch(`/api/${prefix()}/wallet/me/ledger?page=1&limit=20`),
@ -39,72 +58,584 @@ export default function CreditsPage(props: Props) {
const walletJson = await walletRes.json().catch(() => ({}));
const ledgerJson = await ledgerRes.json().catch(() => ({}));
if (walletRes.ok) setWallet(walletJson);
else setErr(walletJson.error || walletJson.message || 'Failed to load wallet.');
if (ledgerRes.ok) setLedger(Array.isArray(ledgerJson?.data) ? ledgerJson.data : []);
else if (!err()) setErr(ledgerJson.error || ledgerJson.message || 'Failed to load ledger.');
} catch {
setErr('Network error while loading credits.');
} finally {
setLoading(false);
// non-blocking
}
};
onMount(loadData);
const loadPackages = async () => {
try {
const res = await apiFetch(`/api/packages?role=${props.roleKey}`);
const data = await res.json().catch(() => ({}));
if (res.ok) {
setPackages(Array.isArray(data?.packages) ? data.packages : []);
}
} catch {
setPackages([]);
}
};
const loadPayments = async () => {
setLoadingPayments(true);
try {
// Try to load from payments service
const res = await apiFetch("/api/payments/history?page=1&limit=50");
const data = await res.json().catch(() => ({}));
if (res.ok) {
setPayments(Array.isArray(data?.payments) ? data.payments : []);
} else {
setPayments([]);
}
} catch {
setPayments([]);
} finally {
setLoadingPayments(false);
}
};
const loadAllData = async () => {
setLoading(true);
setErr("");
await Promise.all([loadWalletData(), loadPackages(), loadPayments()]);
setLoading(false);
};
onMount(loadAllData);
const buyPackage = async (pkg: Package) => {
setBusyPackageId(pkg.id);
setMsg("");
setErr("");
try {
// Create payment order
const res = await apiFetch("/api/payments/create-order", {
method: "POST",
body: JSON.stringify({
amount: pkg.price_inr,
currency: "INR",
package_id: pkg.id,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setErr(data.error || data.message || "Failed to create payment order.");
return;
}
// In production, this would open Razorpay checkout
// For now, show success message
setMsg(
`Order created for ${pkg.name}. Complete payment to receive ${pkg.tracecoins_amount} Tracecoins.`
);
// Refresh data after short delay
setTimeout(() => {
loadAllData();
}, 2000);
} catch {
setErr("Network error while creating payment order.");
} finally {
setBusyPackageId(null);
}
};
const formatDate = (dateStr: string) => {
try {
return new Date(dateStr).toLocaleString("en-IN");
} catch {
return dateStr;
}
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("en-IN", {
style: "currency",
currency: "INR",
minimumFractionDigits: 0,
}).format(amount);
};
return (
<div style={{ display: 'grid', gap: '14px', 'max-width': '980px' }}>
<div style={{ display: "grid", gap: "14px", "max-width": "980px" }}>
<div style={CARD}>
<p style={{ margin: '0', 'font-size': '22px', 'font-weight': '800', color: '#0D0D2A' }}>Credits</p>
<p style={{ margin: '4px 0 0', 'font-size': '13px', color: '#6B7280' }}>
{isProfessional() ? 'Track Tracecoin balance and usage history.' : 'Credits and billing summary for your account.'}
<p style={{ margin: "0", "font-size": "22px", "font-weight": "800", color: "#0D0D2A" }}>
Credits & Billing
</p>
<p style={{ margin: "4px 0 0", "font-size": "13px", color: "#6B7280" }}>
Manage your Tracecoin balance, purchase packages, and view transaction history.
</p>
</div>
<Show when={err()}>
<div style={{ ...CARD, border: '1px solid #FECACA', background: '#FEF2F2', padding: '12px 14px', color: '#B91C1C', 'font-size': '13px', 'font-weight': '600' }}>{err()}</div>
<div
style={{
...CARD,
border: "1px solid #FECACA",
background: "#FEF2F2",
padding: "12px 14px",
color: "#B91C1C",
"font-size": "13px",
"font-weight": "600",
}}
>
{err()}
</div>
</Show>
<Show when={msg()}>
<div
style={{
...CARD,
border: "1px solid #BBF7D0",
background: "#ECFDF5",
padding: "12px 14px",
color: "#065F46",
"font-size": "13px",
"font-weight": "600",
}}
>
{msg()}
</div>
</Show>
{/* Tabs */}
<div
style={{
display: "flex",
gap: "8px",
borderBottom: "1px solid #E5E7EB",
paddingBottom: "10px",
}}
>
<button
type="button"
onClick={() => setActiveTab("overview")}
style={{
padding: "8px 16px",
borderRadius: "8px",
border: "none",
background: activeTab() === "overview" ? "#FF5E13" : "transparent",
color: activeTab() === "overview" ? "#fff" : "#6B7280",
fontSize: "13px",
fontWeight: "600",
cursor: "pointer",
}}
>
Overview
</button>
<button
type="button"
onClick={() => setActiveTab("buy")}
style={{
padding: "8px 16px",
borderRadius: "8px",
border: "none",
background: activeTab() === "buy" ? "#FF5E13" : "transparent",
color: activeTab() === "buy" ? "#fff" : "#6B7280",
fontSize: "13px",
fontWeight: "600",
cursor: "pointer",
}}
>
Buy Credits
</button>
<button
type="button"
onClick={() => setActiveTab("transactions")}
style={{
padding: "8px 16px",
borderRadius: "8px",
border: "none",
background: activeTab() === "transactions" ? "#FF5E13" : "transparent",
color: activeTab() === "transactions" ? "#fff" : "#6B7280",
fontSize: "13px",
fontWeight: "600",
cursor: "pointer",
}}
>
Transactions
</button>
<button type="button" onClick={loadAllData} style={{ ...BTN_GHOST, marginLeft: "auto" }}>
Refresh
</button>
</div>
<Show when={loading()}>
<div style={{ ...CARD, 'text-align': 'center', color: '#9CA3AF' }}>Loading credits...</div>
<div style={{ ...CARD, "text-align": "center", color: "#9CA3AF" }}>Loading credits...</div>
</Show>
<Show when={!loading() && !isProfessional()}>
<div style={CARD}>
<p style={{ margin: '0', 'font-size': '15px', 'font-weight': '700', color: '#111827' }}>Billing Overview</p>
<p style={{ margin: '8px 0 0', 'font-size': '13px', color: '#6B7280' }}>
Your role does not use professional wallet endpoints. Billing and payments are managed through role-specific transactions.
</p>
</div>
</Show>
<Show when={!loading() && isProfessional()}>
<div style={{ ...CARD, display: 'grid', 'grid-template-columns': '1fr auto', gap: '10px', 'align-items': 'center' }}>
<div>
<p style={{ margin: '0', 'font-size': '12px', 'letter-spacing': '0.06em', 'text-transform': 'uppercase', color: '#6B7280' }}>Current Balance</p>
<p style={{ margin: '6px 0 0', 'font-size': '28px', 'font-weight': '800', color: '#111827' }}>
{wallet()?.balance ?? 0} <span style={{ 'font-size': '14px', color: '#6B7280', 'font-weight': '600' }}>Tracecoins</span>
{/* Overview Tab */}
<Show when={!loading() && activeTab() === "overview"}>
<Show when={!isProfessional()}>
<div style={CARD}>
<p style={{ margin: "0", "font-size": "15px", "font-weight": "700", color: "#111827" }}>
Billing Overview
</p>
<p style={{ margin: "8px 0 0", "font-size": "13px", color: "#6B7280" }}>
Your role does not use professional wallet endpoints. Billing and payments are managed
through role-specific transactions.
</p>
</div>
<button type="button" onClick={loadData} style={BTN_GHOST}>Refresh</button>
</div>
</Show>
<Show when={isProfessional()}>
<div
style={{
...CARD,
display: "grid",
"grid-template-columns": "1fr auto",
gap: "10px",
"align-items": "center",
}}
>
<div>
<p
style={{
margin: "0",
"font-size": "12px",
"letter-spacing": "0.06em",
"text-transform": "uppercase",
color: "#6B7280",
}}
>
Current Balance
</p>
<p
style={{
margin: "6px 0 0",
"font-size": "36px",
"font-weight": "800",
color: "#111827",
}}
>
{wallet()?.balance ?? 0}{" "}
<span style={{ "font-size": "16px", color: "#6B7280", "font-weight": "600" }}>
Tracecoins
</span>
</p>
</div>
<button type="button" onClick={() => setActiveTab("buy")} style={BTN_PRIMARY}>
Buy Credits
</button>
</div>
<div style={CARD}>
<p
style={{
margin: "0 0 10px",
"font-size": "16px",
"font-weight": "700",
color: "#111827",
}}
>
Recent Ledger
</p>
<Show when={ledger().length === 0}>
<p style={{ margin: "0", "font-size": "13px", color: "#6B7280" }}>
No ledger entries yet.
</p>
</Show>
<Show when={ledger().length > 0}>
<div style={{ display: "grid", gap: "8px" }}>
<For each={ledger().slice(0, 5)}>
{(item: any) => (
<div
style={{
border: "1px solid #E5E7EB",
"border-radius": "10px",
padding: "10px",
background: "#FCFCFD",
display: "flex",
"justify-content": "space-between",
gap: "10px",
"flex-wrap": "wrap",
}}
>
<div>
<p
style={{
margin: "0",
"font-size": "13px",
"font-weight": "700",
color: "#111827",
}}
>
{item.reason || item.type || "Transaction"}
</p>
<p style={{ margin: "4px 0 0", "font-size": "12px", color: "#6B7280" }}>
{item.created_at ? formatDate(item.created_at) : "—"}
</p>
</div>
<p
style={{
margin: "0",
"font-size": "13px",
"font-weight": "800",
color: Number(item.amount || 0) >= 0 ? "#15803D" : "#B91C1C",
}}
>
{Number(item.amount || 0) >= 0 ? "+" : ""}
{item.amount || 0}
</p>
</div>
)}
</For>
</div>
<Show when={ledger().length > 5}>
<button
type="button"
onClick={() => setActiveTab("transactions")}
style={{ ...BTN_GHOST, "margin-top": "10px", width: "100%" }}
>
View All Transactions
</button>
</Show>
</Show>
</div>
</Show>
</Show>
{/* Buy Credits Tab */}
<Show when={!loading() && activeTab() === "buy"}>
<div style={CARD}>
<p style={{ margin: '0 0 10px', 'font-size': '16px', 'font-weight': '700', color: '#111827' }}>Recent Ledger</p>
<Show when={ledger().length === 0}>
<p style={{ margin: '0', 'font-size': '13px', color: '#6B7280' }}>No ledger entries yet.</p>
<p
style={{
margin: "0 0 10px",
"font-size": "16px",
"font-weight": "700",
color: "#111827",
}}
>
Purchase Tracecoins
</p>
<p style={{ margin: "0 0 16px", "font-size": "13px", color: "#6B7280" }}>
Select a package to purchase Tracecoins. These can be used to unlock leads and premium
features.
</p>
<Show when={packages().length === 0}>
<p style={{ margin: "0", "font-size": "13px", color: "#6B7280" }}>
No packages available for your role.
</p>
</Show>
<Show when={ledger().length > 0}>
<div style={{ display: 'grid', gap: '8px' }}>
<Show when={packages().length > 0}>
<div
style={{
display: "grid",
"grid-template-columns": "repeat(auto-fill, minmax(240px, 1fr))",
gap: "14px",
}}
>
<For each={packages()}>
{(pkg) => (
<div
style={{
border: "2px solid #E5E7EB",
borderRadius: "12px",
padding: "16px",
background: "#fff",
display: "flex",
"flex-direction": "column",
gap: "8px",
}}
>
<div>
<p
style={{
margin: "0",
"font-size": "16px",
"font-weight": "800",
color: "#111827",
}}
>
{pkg.name}
</p>
<p
style={{
margin: "4px 0 0",
"font-size": "24px",
"font-weight": "800",
color: "#FF5E13",
}}
>
{formatCurrency(pkg.price_inr)}
</p>
</div>
<div
style={{
display: "flex",
"align-items": "center",
gap: "6px",
margin: "8px 0",
}}
>
<span style={{ fontSize: "20px" }}>🪙</span>
<span style={{ fontSize: "15px", fontWeight: "700", color: "#111827" }}>
{pkg.tracecoins_amount} Tracecoins
</span>
</div>
<Show when={pkg.description}>
<p style={{ margin: "0", "font-size": "12px", color: "#6B7280", flex: "1" }}>
{pkg.description}
</p>
</Show>
<button
type="button"
onClick={() => buyPackage(pkg)}
disabled={busyPackageId() === pkg.id}
style={{
...BTN_PRIMARY,
width: "100%",
marginTop: "8px",
opacity: busyPackageId() === pkg.id ? "0.7" : "1",
}}
>
{busyPackageId() === pkg.id ? "Processing..." : "Buy Now"}
</button>
</div>
)}
</For>
</div>
</Show>
</div>
</Show>
{/* Transactions Tab */}
<Show when={!loading() && activeTab() === "transactions"}>
<div style={CARD}>
<p
style={{
margin: "0 0 10px",
"font-size": "16px",
"font-weight": "700",
color: "#111827",
}}
>
Transaction History
</p>
<Show when={loadingPayments()}>
<p style={{ margin: "0", "font-size": "13px", color: "#9CA3AF" }}>
Loading transactions...
</p>
</Show>
<Show when={!loadingPayments() && payments().length === 0 && ledger().length === 0}>
<p style={{ margin: "0", "font-size": "13px", color: "#6B7280" }}>
No transactions found.
</p>
</Show>
<Show when={!loadingPayments() && (payments().length > 0 || ledger().length > 0)}>
<div style={{ display: "grid", gap: "8px" }}>
{/* Wallet Ledger */}
<For each={ledger()}>
{(item: any) => (
<div style={{ border: '1px solid #E5E7EB', 'border-radius': '10px', padding: '10px', background: '#FCFCFD', display: 'flex', 'justify-content': 'space-between', gap: '10px', 'flex-wrap': 'wrap' }}>
<div
style={{
border: "1px solid #E5E7EB",
"border-radius": "10px",
padding: "12px",
background: "#FCFCFD",
display: "flex",
"justify-content": "space-between",
"align-items": "center",
gap: "10px",
}}
>
<div>
<p style={{ margin: '0', 'font-size': '13px', 'font-weight': '700', color: '#111827' }}>{item.reason || item.type || 'Transaction'}</p>
<p style={{ margin: '4px 0 0', 'font-size': '12px', color: '#6B7280' }}>{item.created_at ? new Date(item.created_at).toLocaleString('en-IN') : '—'}</p>
<p
style={{
margin: "0",
"font-size": "13px",
"font-weight": "700",
color: "#111827",
}}
>
{item.reason || "Transaction"}
</p>
<p style={{ margin: "4px 0 0", "font-size": "12px", color: "#6B7280" }}>
{formatDate(item.created_at)}
</p>
</div>
<div style={{ textAlign: "right" }}>
<p
style={{
margin: "0",
"font-size": "14px",
"font-weight": "800",
color: Number(item.amount || 0) >= 0 ? "#15803D" : "#B91C1C",
}}
>
{Number(item.amount || 0) >= 0 ? "+" : ""}
{item.amount || 0} TC
</p>
<p style={{ margin: "2px 0 0", "font-size": "11px", color: "#9CA3AF" }}>
Ledger
</p>
</div>
</div>
)}
</For>
{/* Payments */}
<For each={payments()}>
{(payment) => (
<div
style={{
border: "1px solid #E5E7EB",
"border-radius": "10px",
padding: "12px",
background: "#FCFCFD",
display: "flex",
"justify-content": "space-between",
"align-items": "center",
gap: "10px",
}}
>
<div>
<p
style={{
margin: "0",
"font-size": "13px",
"font-weight": "700",
color: "#111827",
}}
>
{payment.package_name || "Credit Purchase"}
</p>
<p style={{ margin: "4px 0 0", "font-size": "12px", color: "#6B7280" }}>
{formatDate(payment.created_at)}
</p>
</div>
<div style={{ textAlign: "right" }}>
<p
style={{
margin: "0",
"font-size": "14px",
"font-weight": "800",
color: "#111827",
}}
>
{formatCurrency(payment.amount_inr)}
</p>
<p
style={{
margin: "2px 0 0",
"font-size": "12px",
color: payment.status === "SUCCESS" ? "#15803D" : "#D97706",
}}
>
{payment.status === "SUCCESS" ? "✓ Completed" : payment.status}
</p>
<Show when={payment.tracecoins_credited > 0}>
<p style={{ margin: "2px 0 0", "font-size": "11px", color: "#6B7280" }}>
+{payment.tracecoins_credited} TC
</p>
</Show>
</div>
<p style={{ margin: '0', 'font-size': '13px', 'font-weight': '800', color: Number(item.amount || 0) >= 0 ? '#15803D' : '#B91C1C' }}>
{Number(item.amount || 0) >= 0 ? '+' : ''}{item.amount || 0}
</p>
</div>
)}
</For>
@ -115,4 +646,3 @@ export default function CreditsPage(props: Props) {
</div>
);
}