All checks were successful
build-and-release / build (push) Successful in 1m19s
- tsconfig.json: add @types/node for playwright/test configs - lib/api.ts: handle both array and wrapped packages response from tracecoin endpoint - lib/server/gateway.ts: use import.meta.env instead of process.env - routes/admin/pricing.tsx: fix packages() usage after api.ts fix - routes/admin/roles/*.tsx: fix Set<string> types and module filter types - routes/admin/users/[id]/edit.tsx: add phone? to local User type - routes/admin/users/details/[id].tsx: fix roleFilter param type narrowing - components/admin/DashboardDesignPreview.tsx: fix budget→budget, responses→responseTag - vite.config.ts: type proxy callbacks as any - test/setup.ts: fix IntersectionObserver mock, add vitest/globals reference - tests/e2e: fix PNG type mismatches, add as any for mock overrides - tests/vitest: fix global.createElement mock type, vitest globals reference - tests/tsconfig.json: test-specific tsconfig with node types Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1176 lines
No EOL
52 KiB
TypeScript
1176 lines
No EOL
52 KiB
TypeScript
import { createMemo, createResource, createSignal, onMount, Show, For } from 'solid-js';
|
||
import {
|
||
api,
|
||
AdminPackage,
|
||
CreatePackageInput,
|
||
UpdatePackageInput,
|
||
listAdminPackages,
|
||
createAdminPackage,
|
||
updateAdminPackage,
|
||
deleteAdminPackage,
|
||
} from '~/lib/api';
|
||
|
||
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)' },
|
||
{ value: 'JOB_POSTING', label: 'Job Posting (Company)' },
|
||
{ value: 'LEAD_REQUEST', label: 'Lead Request (Professional)' },
|
||
{ value: 'REQUIREMENT_SLOTS', label: 'Requirement Slots (Customer)' },
|
||
];
|
||
|
||
const ALL_ROLES = [
|
||
'COMPANY',
|
||
'CUSTOMER',
|
||
'JOB_SEEKER',
|
||
'PHOTOGRAPHER',
|
||
'VIDEO_EDITOR',
|
||
'GRAPHIC_DESIGNER',
|
||
'SOCIAL_MEDIA_MANAGER',
|
||
'FITNESS_TRAINER',
|
||
'CATERING_SERVICE',
|
||
'MAKEUP_ARTIST',
|
||
'TUTOR',
|
||
'DEVELOPER',
|
||
'UGC_CONTENT_CREATOR',
|
||
];
|
||
|
||
const ROLE_LABELS: Record<string, string> = {
|
||
COMPANY: 'Company',
|
||
CUSTOMER: 'Customer',
|
||
JOB_SEEKER: 'Job Seeker',
|
||
PHOTOGRAPHER: 'Photographer',
|
||
VIDEO_EDITOR: 'Video Editor',
|
||
GRAPHIC_DESIGNER: 'Graphic Designer',
|
||
SOCIAL_MEDIA_MANAGER: 'Social Media Manager',
|
||
FITNESS_TRAINER: 'Fitness Trainer',
|
||
CATERING_SERVICE: 'Catering Service',
|
||
MAKEUP_ARTIST: 'Makeup Artist',
|
||
TUTOR: 'Tutor',
|
||
DEVELOPER: 'Developer',
|
||
UGC_CONTENT_CREATOR: 'UGC Creator',
|
||
};
|
||
|
||
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 ↓',
|
||
};
|
||
|
||
const API = '';
|
||
|
||
function authHeaders(): Record<string, string> {
|
||
const token = typeof sessionStorage !== 'undefined'
|
||
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
|
||
: '';
|
||
return token ? { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } : { 'Content-Type': 'application/json' };
|
||
}
|
||
|
||
export default function PricingPage() {
|
||
const [packages, { refetch }] = createResource(listAdminPackages);
|
||
const [rows, setRows] = createSignal<AdminPackage[]>([]);
|
||
const [loading, setLoading] = createSignal(true);
|
||
const [loadError, setLoadError] = createSignal("");
|
||
const [view, setView] = createSignal<"packages" | "create" | "ai_packages">("packages");
|
||
|
||
const [search, setSearch] = createSignal('');
|
||
const [typeFilter, setTypeFilter] = createSignal('all');
|
||
const [statusFilter, setStatusFilter] = createSignal('all');
|
||
const [sortBy, setSortBy] = createSignal<SortMode>('name_asc');
|
||
const [sortOpen, setSortOpen] = createSignal(false);
|
||
|
||
const [editingId, setEditingId] = createSignal('');
|
||
const [editName, setEditName] = createSignal('');
|
||
const [editTracecoins, setEditTracecoins] = createSignal('');
|
||
const [editPrice, setEditPrice] = createSignal('');
|
||
const [editActive, setEditActive] = createSignal(true);
|
||
const [editSaving, setEditSaving] = createSignal(false);
|
||
const [editError, setEditError] = createSignal('');
|
||
const [togglingId, setTogglingId] = createSignal('');
|
||
const [deletingId, setDeletingId] = createSignal('');
|
||
|
||
const [cName, setCName] = createSignal('');
|
||
const [cDescription, setCDescription] = createSignal('');
|
||
const [cType, setCType] = createSignal('TRACECOIN_BUNDLE');
|
||
const [cRoles, setCRoles] = createSignal<string[]>([]);
|
||
const [cTracecoins, setCTracecoins] = createSignal('');
|
||
const [cPrice, setCPrice] = createSignal('');
|
||
const [cDuration, setCDuration] = createSignal('');
|
||
const [cValidFrom, setCValidFrom] = createSignal('');
|
||
const [cValidUntil, setCValidUntil] = createSignal('');
|
||
const [cPromotional, setCPromotional] = createSignal(false);
|
||
const [cSaving, setCsaving] = createSignal(false);
|
||
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("");
|
||
try {
|
||
const res = await fetch(`${API}/api/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.data ?? []));
|
||
} catch (err: any) {
|
||
setLoadError(err.message || "Could not load packages.");
|
||
setRows([]);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
// 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(); void loadAiPackages(); });
|
||
|
||
const filteredRows = createMemo(() => {
|
||
let r = packages() ?? [];
|
||
const q = search().toLowerCase();
|
||
if (q)
|
||
r = r.filter(
|
||
(p) =>
|
||
p.name.toLowerCase().includes(q) ||
|
||
p.package_type.toLowerCase().includes(q) ||
|
||
(p.applicable_roles ?? []).some((rl) => rl.toLowerCase().includes(q)),
|
||
);
|
||
if (typeFilter() !== 'all') r = r.filter((p) => p.package_type === typeFilter());
|
||
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 - b.price;
|
||
if (mode === 'price_desc') return b.price - a.price;
|
||
if (mode === 'coins_asc') return a.tracecoins_amount - b.tracecoins_amount;
|
||
if (mode === 'coins_desc') return b.tracecoins_amount - a.tracecoins_amount;
|
||
return a.name.localeCompare(b.name);
|
||
});
|
||
return sorted;
|
||
});
|
||
|
||
const startEdit = (pkg: AdminPackage) => {
|
||
setEditingId(pkg.id);
|
||
setEditName(pkg.name);
|
||
setEditTracecoins(String(pkg.tracecoins_amount));
|
||
setEditPrice(String(pkg.price_inr ?? pkg.price));
|
||
setEditActive(!!pkg.is_active);
|
||
setEditError('');
|
||
};
|
||
|
||
const cancelEdit = () => {
|
||
setEditingId('');
|
||
setEditError('');
|
||
};
|
||
|
||
const saveEdit = async (id: string) => {
|
||
setEditSaving(true);
|
||
setEditError('');
|
||
try {
|
||
const update: UpdatePackageInput = {
|
||
name: editName(),
|
||
tracecoins_amount: Number(editTracecoins()),
|
||
price_inr: Number(editPrice()),
|
||
is_active: editActive(),
|
||
};
|
||
await updateAdminPackage(id, update);
|
||
setEditingId('');
|
||
await refetch();
|
||
} catch (err: any) {
|
||
setEditError(err?.message || 'Failed to save');
|
||
} finally {
|
||
setEditSaving(false);
|
||
}
|
||
};
|
||
|
||
const toggleActive = async (pkg: AdminPackage) => {
|
||
setTogglingId(pkg.id);
|
||
try {
|
||
await updateAdminPackage(pkg.id, { is_active: !pkg.is_active });
|
||
await refetch();
|
||
} catch (err: any) {
|
||
console.error('toggleActive failed', err);
|
||
} finally {
|
||
setTogglingId('');
|
||
}
|
||
};
|
||
|
||
const deletePackage = async (pkg: AdminPackage) => {
|
||
if (!confirm(`Delete package "${pkg.name}"? This cannot be undone.`)) return;
|
||
setDeletingId(pkg.id);
|
||
try {
|
||
await deleteAdminPackage(pkg.id);
|
||
await refetch();
|
||
} catch (err: any) {
|
||
alert(err?.message || 'Failed to delete package.');
|
||
} finally {
|
||
setDeletingId('');
|
||
}
|
||
};
|
||
|
||
const toggleRole = (role: string) => {
|
||
const current = cRoles();
|
||
if (current.includes(role)) {
|
||
setCRoles(current.filter((r) => r !== role));
|
||
} else {
|
||
setCRoles([...current, role]);
|
||
}
|
||
};
|
||
|
||
const handleCreate = async (e: Event) => {
|
||
e.preventDefault();
|
||
setCsaving(true);
|
||
setCError('');
|
||
try {
|
||
const body: CreatePackageInput = {
|
||
name: cName(),
|
||
description: cDescription() || undefined,
|
||
package_type: cType(),
|
||
applicable_roles: cRoles(),
|
||
tracecoins_amount: Number(cTracecoins()),
|
||
price_inr: Number(cPrice()),
|
||
price: Number(cPrice()), // legacy field for old clients
|
||
is_promotional: cPromotional(),
|
||
is_active: true,
|
||
};
|
||
if (cDuration()) body.duration_days = Number(cDuration());
|
||
if (cValidFrom()) body.valid_from = new Date(cValidFrom()).toISOString();
|
||
if (cValidUntil()) body.valid_until = new Date(cValidUntil()).toISOString();
|
||
|
||
await createAdminPackage(body);
|
||
setCName('');
|
||
setCDescription('');
|
||
setCType('TRACECOIN_BUNDLE');
|
||
setCRoles([]);
|
||
setCTracecoins('');
|
||
setCPrice('');
|
||
setCDuration('');
|
||
setCValidFrom('');
|
||
setCValidUntil('');
|
||
setCPromotional(false);
|
||
setView('packages');
|
||
await refetch();
|
||
} catch (err: any) {
|
||
setCError(err?.message || 'Failed to create');
|
||
} finally {
|
||
setCsaving(false);
|
||
}
|
||
};
|
||
|
||
const formatDate = (dateStr?: string | null) => {
|
||
if (!dateStr) return '—';
|
||
return new Date(dateStr).toLocaleDateString('en-IN', {
|
||
day: '2-digit',
|
||
month: 'short',
|
||
year: 'numeric',
|
||
});
|
||
};
|
||
|
||
const getTypeLabel = (type: string) => {
|
||
return PACKAGE_TYPES.find((t) => t.value === type)?.label || type;
|
||
};
|
||
|
||
return (
|
||
<div class="w-full space-y-6 pb-8">
|
||
<div style="margin-bottom:1.5rem">
|
||
<h1 class="text-[28px] font-bold leading-tight text-[#111827]">Pricing Management</h1>
|
||
<p class="mt-1 text-[14px] text-[#6B7280]">
|
||
Create and manage TraceCoin packages for all roles
|
||
</p>
|
||
</div>
|
||
|
||
<Show when={packages.error}>
|
||
<div class="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||
Failed to load packages: {String(packages.error)}
|
||
</div>
|
||
</Show>
|
||
|
||
<div class="bg-white border-b border-gray-200 px-6 flex items-center gap-8 sticky top-0 z-10">
|
||
{(["packages", "create", "ai_packages"] as const).map((t) => (
|
||
<button
|
||
type="button"
|
||
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)}
|
||
>
|
||
{t === "packages" ? "Packages" : t === "create" ? "Create Package" : "AI Credit Packages"}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div>
|
||
<Show when={view() === 'packages'}>
|
||
<section
|
||
class="rounded-xl border border-gray-200 bg-white shadow-sm"
|
||
style="margin-top:1.5rem"
|
||
>
|
||
<div style="display:flex;align-items:center;gap:8px;padding:14px 20px;border-bottom:1px solid #F3F4F6;flex-wrap:wrap">
|
||
<input
|
||
type="text"
|
||
placeholder="Search by name or role..."
|
||
value={search()}
|
||
onInput={(e) => setSearch(e.currentTarget.value)}
|
||
style="height:34px;flex:1;min-width:220px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:13px;color:#111827;outline:none"
|
||
/>
|
||
<select
|
||
value={typeFilter()}
|
||
onChange={(e) => setTypeFilter(e.currentTarget.value)}
|
||
style="height:34px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;color:#374151"
|
||
>
|
||
<option value="all">All Types</option>
|
||
<For each={PACKAGE_TYPES}>{(t) => <option value={t.value}>{t.label}</option>}</For>
|
||
</select>
|
||
<select
|
||
value={statusFilter()}
|
||
onChange={(e) => setStatusFilter(e.currentTarget.value)}
|
||
style="height:34px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;color:#374151"
|
||
>
|
||
<option value="all">All Status</option>
|
||
<option value="active">Active</option>
|
||
<option value="inactive">Inactive</option>
|
||
</select>
|
||
<div style="position:relative">
|
||
<button
|
||
type="button"
|
||
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer"
|
||
onClick={() => setSortOpen(!sortOpen())}
|
||
>
|
||
Sort: {SORT_LABELS[sortBy()]}
|
||
</button>
|
||
<Show when={sortOpen()}>
|
||
<div style="position:absolute;top:38px;right:0;background:white;border:1px solid #e5e7eb;border-radius:12px;box-shadow:0 4px 16px rgba(0,0,0,.1);z-index:50;min-width:190px;padding:6px">
|
||
<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:8px;border:none;cursor:pointer;background:${sortBy() === key ? '#FFF1EB' : 'transparent'};color:${sortBy() === key ? '#FF5E13' : '#374151'};font-weight:${sortBy() === key ? '600' : '400'}`}
|
||
>
|
||
{label}
|
||
</button>
|
||
)}
|
||
</For>
|
||
</div>
|
||
</Show>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
class="btn-primary"
|
||
style="height:34px;padding:0 12px;font-size:12px"
|
||
onClick={() => void refetch()}
|
||
>
|
||
Refresh
|
||
</button>
|
||
</div>
|
||
|
||
<Show
|
||
when={!packages.loading}
|
||
fallback={
|
||
<p style="padding:32px;text-align:center;color:#64748b">Loading packages...</p>
|
||
}
|
||
>
|
||
<Show
|
||
when={filteredRows().length > 0}
|
||
fallback={
|
||
<p style="padding:32px;text-align:center;color:#94a3b8">
|
||
No packages found.
|
||
</p>
|
||
}
|
||
>
|
||
<div class="overflow-x-auto">
|
||
<table class="data-table w-full text-sm">
|
||
<thead>
|
||
<tr>
|
||
<th>Name</th>
|
||
<th>Type</th>
|
||
<th>Applicable Roles</th>
|
||
<th>TraceCoins</th>
|
||
<th>Price (₹)</th>
|
||
<th>Valid Period</th>
|
||
<th>Status</th>
|
||
<th class="text-right">Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<For each={filteredRows()}>
|
||
{(pkg) => (
|
||
<>
|
||
<tr class="hover:bg-slate-50">
|
||
<td class="font-semibold text-slate-900">
|
||
{pkg.name}
|
||
{pkg.is_promotional && (
|
||
<span
|
||
style="margin-left:6px;font-size:10px;background:#FEF3C7;color:#D97706;padding:1px 6px;border-radius:4px"
|
||
>
|
||
PROMO
|
||
</span>
|
||
)}
|
||
</td>
|
||
<td>
|
||
<span
|
||
style="display:inline-block;padding:2px 8px;border-radius:999px;font-size:11px;font-weight:600;background:#e0f2fe;color:#0369a1"
|
||
>
|
||
{getTypeLabel(pkg.package_type)}
|
||
</span>
|
||
</td>
|
||
<td>
|
||
<div style="display:flex;flex-wrap:wrap;gap:4px">
|
||
<For each={(pkg.applicable_roles ?? []).slice(0, 3)}>
|
||
{(role) => (
|
||
<span
|
||
style="display:inline-block;padding:1px 6px;border-radius:4px;font-size:10px;background:#f1f5f9;color:#475569"
|
||
>
|
||
{ROLE_LABELS[role] || role}
|
||
</span>
|
||
)}
|
||
</For>
|
||
{(pkg.applicable_roles ?? []).length > 3 && (
|
||
<span style="font-size:10px;color:#64748b">
|
||
+{pkg.applicable_roles.length - 3}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</td>
|
||
<td class="text-slate-700 font-medium">{pkg.tracecoins_amount}</td>
|
||
<td class="text-slate-700">₹{(pkg.price_inr ?? pkg.price).toLocaleString('en-IN')}</td>
|
||
<td style="font-size:12px;color:#64748b">
|
||
{pkg.valid_from || pkg.valid_until ? (
|
||
<>
|
||
{formatDate(pkg.valid_from)} - {formatDate(pkg.valid_until)}
|
||
</>
|
||
) : (
|
||
'Always'
|
||
)}
|
||
</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
|
||
type="button"
|
||
class="btn-secondary"
|
||
style="padding:6px 10px;font-size:12px"
|
||
onClick={() => startEdit(pkg)}
|
||
>
|
||
Edit
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class={pkg.is_active ? 'btn-secondary' : 'btn-primary'}
|
||
style={
|
||
pkg.is_active
|
||
? 'padding:6px 10px;font-size:12px;color:#b91c1c;border-color:#fca5a5'
|
||
: 'padding:6px 10px;font-size:12px'
|
||
}
|
||
disabled={togglingId() === pkg.id}
|
||
onClick={() => toggleActive(pkg)}
|
||
>
|
||
{togglingId() === pkg.id
|
||
? '...'
|
||
: pkg.is_active
|
||
? 'Disable'
|
||
: 'Enable'}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="btn-secondary"
|
||
style="padding:6px 10px;font-size:12px;color:#b91c1c;border-color:#fca5a5"
|
||
disabled={deletingId() === pkg.id}
|
||
onClick={() => deletePackage(pkg)}
|
||
>
|
||
{deletingId() === pkg.id ? '...' : 'Delete'}
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
<Show when={editingId() === pkg.id}>
|
||
<tr>
|
||
<td colspan="8" style="background:#f8fafc;padding:16px">
|
||
<Show when={editError()}>
|
||
<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>
|
||
<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:220px"
|
||
/>
|
||
</div>
|
||
<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"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label
|
||
style="display:block;font-size:12px;font-weight:600;margin-bottom:4px"
|
||
>
|
||
Price (₹)
|
||
</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"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label
|
||
style="display:block;font-size:12px;font-weight:600;margin-bottom:4px"
|
||
>
|
||
Active
|
||
</label>
|
||
<input
|
||
type="checkbox"
|
||
checked={editActive()}
|
||
onChange={(e) => setEditActive(e.currentTarget.checked)}
|
||
/>
|
||
</div>
|
||
<div style="display:flex;gap:8px">
|
||
<button
|
||
type="button"
|
||
class="btn-primary"
|
||
style="padding:8px 14px;font-size:13px"
|
||
disabled={editSaving()}
|
||
onClick={() => saveEdit(pkg.id)}
|
||
>
|
||
{editSaving() ? 'Saving...' : 'Save'}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="btn-secondary"
|
||
style="padding:8px 14px;font-size:13px"
|
||
onClick={cancelEdit}
|
||
>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
</Show>
|
||
</>
|
||
)}
|
||
</For>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div style="padding:10px 16px;font-size:12px;color:#64748b;border-top:1px solid #f1f5f9">
|
||
{filteredRows().length} of {(packages() ?? []).length} packages
|
||
</div>
|
||
</Show>
|
||
</Show>
|
||
</section>
|
||
</Show>
|
||
|
||
<Show when={view() === 'create'}>
|
||
<section
|
||
class="rounded-xl border border-gray-200 bg-white shadow-sm p-6"
|
||
style="max-width:600px;margin-top:1.5rem"
|
||
>
|
||
<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">
|
||
{cError()}
|
||
</div>
|
||
</Show>
|
||
<form onSubmit={handleCreate} style="display:flex;flex-direction:column;gap:14px">
|
||
<div>
|
||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">
|
||
Package Name *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={cName()}
|
||
onInput={(e) => setCName(e.currentTarget.value)}
|
||
required
|
||
placeholder="e.g. Christmas Special - 50 Tracecoins"
|
||
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">
|
||
Description
|
||
</label>
|
||
<textarea
|
||
value={cDescription()}
|
||
onInput={(e) => setCDescription(e.currentTarget.value)}
|
||
placeholder="Optional description..."
|
||
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box;min-height:60px;resize:vertical"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">
|
||
Package Type *
|
||
</label>
|
||
<select
|
||
value={cType()}
|
||
onChange={(e) => setCType(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={PACKAGE_TYPES}>
|
||
{(t) => <option value={t.value}>{t.label}</option>}
|
||
</For>
|
||
</select>
|
||
</div>
|
||
<div style="position:relative">
|
||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">
|
||
Applicable Roles *
|
||
</label>
|
||
<button
|
||
type="button"
|
||
onClick={() => setRoleDropdownOpen(!roleDropdownOpen())}
|
||
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box;text-align:left;background:white;cursor:pointer"
|
||
>
|
||
{cRoles().length === 0
|
||
? 'Select roles...'
|
||
: `${cRoles().length} role(s) selected`}
|
||
<span style="float:right">▼</span>
|
||
</button>
|
||
<Show when={roleDropdownOpen()}>
|
||
<div style="position:absolute;top:100%;left:0;right:0;background:white;border:1px solid #e2e8f0;border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,0.1);z-index:20;max-height:200px;overflow-y:auto;margin-top:4px">
|
||
<For each={ALL_ROLES}>
|
||
{(role) => (
|
||
<label style="display:flex;align-items:center;gap:8px;padding:8px 12px;cursor:pointer;font-size:13px">
|
||
<input
|
||
type="checkbox"
|
||
checked={cRoles().includes(role)}
|
||
onChange={() => toggleRole(role)}
|
||
/>
|
||
{ROLE_LABELS[role] || role}
|
||
</label>
|
||
)}
|
||
</For>
|
||
</div>
|
||
</Show>
|
||
<Show when={cRoles().length > 0}>
|
||
<div style="display:flex;flex-wrap:wrap;gap:4px;margin-top:8px">
|
||
<For each={cRoles()}>
|
||
{(role) => (
|
||
<span style="display:inline-flex;align-items:center;gap:4px;padding:2px 8px;background:#e0f2fe;color:#0369a1;border-radius:4px;font-size:12px">
|
||
{ROLE_LABELS[role] || role}
|
||
<button
|
||
type="button"
|
||
onClick={() => toggleRole(role)}
|
||
style="background:none;border:none;cursor:pointer;font-size:14px;padding:0;line-height:1"
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
)}
|
||
</For>
|
||
</div>
|
||
</Show>
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||
<div>
|
||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">
|
||
Tracecoins Amount *
|
||
</label>
|
||
<input
|
||
type="number"
|
||
value={cTracecoins()}
|
||
onInput={(e) => setCTracecoins(e.currentTarget.value)}
|
||
required
|
||
min="1"
|
||
placeholder="e.g. 50"
|
||
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">
|
||
Price (₹) *
|
||
</label>
|
||
<input
|
||
type="number"
|
||
value={cPrice()}
|
||
onInput={(e) => setCPrice(e.currentTarget.value)}
|
||
required
|
||
min="1"
|
||
placeholder="e.g. 499"
|
||
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">
|
||
Duration (days)
|
||
</label>
|
||
<input
|
||
type="number"
|
||
value={cDuration()}
|
||
onInput={(e) => setCDuration(e.currentTarget.value)}
|
||
min="1"
|
||
placeholder="e.g. 30 (leave empty for unlimited)"
|
||
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box"
|
||
/>
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||
<div>
|
||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">
|
||
Valid From
|
||
</label>
|
||
<input
|
||
type="date"
|
||
value={cValidFrom()}
|
||
onInput={(e) => setCValidFrom(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>
|
||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">
|
||
Valid Until
|
||
</label>
|
||
<input
|
||
type="date"
|
||
value={cValidUntil()}
|
||
onInput={(e) => setCValidUntil(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>
|
||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={cPromotional()}
|
||
onChange={(e) => setCPromotional(e.currentTarget.checked)}
|
||
/>
|
||
<span style="font-size:13px;font-weight:600">Promotional Package</span>
|
||
</label>
|
||
<p style="font-size:12px;color:#64748b;margin-top:4px">
|
||
Promotional packages appear first in listings
|
||
</p>
|
||
</div>
|
||
<div>
|
||
<button class="btn-primary" type="submit" disabled={cSaving()}>
|
||
{cSaving() ? 'Creating...' : 'Create Package'}
|
||
</button>
|
||
</div>
|
||
</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)">
|
||
<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>
|
||
);
|
||
} |