chore: remove orphaned legacy dashboard routes; fix NotificationBell's broken API calls
All checks were successful
build-and-release / build (push) Successful in 1m28s
All checks were successful
build-and-release / build (push) Successful in 1m28s
An entire route/component cluster was built on a legacy sibling of DashboardShell (DashboardLayout.tsx) and called APIs via the bare api.get/post/patch/delete helper, which never prefixes /api/ — so every call 404s against the real ingress (which only routes /api/* to the backend). Confirmed orphaned: nothing in the live dashboard shell (DashboardShell.tsx / dashboard.tsx) links to any of it; the only cross-references are within the cluster itself. Some of it also targeted the apps/leads backend service removed in the companion backend commit. Removed: - src/routes/dashboard/wallet/ (buy.tsx, payu-return.tsx, invoices/*) - src/routes/dashboard/requests.tsx - src/routes/dashboard/leads/accepted/* - src/routes/dashboard/marketplace/* - src/components/dashboard/AcceptedLeadsView.tsx - src/components/DashboardLayout.tsx (only consumer was the above) - the unused `api` object in src/lib/api.ts (the unprefixed-path footgun itself — `request()`, which it wrapped, stays; it's used correctly elsewhere with explicit /api/ paths) Fixed rather than deleted: src/components/NotificationBell.tsx uses the same broken convention but IS live (rendered on every dashboard page via DashboardShell). Switched it to apiFetch with correct /api/me/notifications/* paths, matching the routes that actually exist in apps/users/src/handlers/notifications.rs. `tsc --noEmit` shows no errors under src/ after these changes (pre- existing node_modules/type-declaration noise unrelated to this change remains, as it did before).
This commit is contained in:
parent
142e185fc6
commit
18a9161e4f
13 changed files with 7 additions and 1495 deletions
|
|
@ -1,134 +0,0 @@
|
|||
import { type ParentProps, createMemo, createSignal, onMount } from "solid-js";
|
||||
import { useLocation, useNavigate } from "@solidjs/router";
|
||||
import DashboardShell from "~/components/DashboardShell";
|
||||
|
||||
const SIDEBAR_ITEMS = [
|
||||
"My Dashboard",
|
||||
"Leads",
|
||||
"My Requests",
|
||||
"Credits",
|
||||
"Settings",
|
||||
"Logout",
|
||||
];
|
||||
|
||||
const ROUTE_BY_LABEL: Record<string, string> = {
|
||||
"my dashboard": "/dashboard",
|
||||
leads: "/dashboard/leads/accepted",
|
||||
"my requests": "/dashboard/requests",
|
||||
credits: "/dashboard?nav=credits",
|
||||
settings: "/dashboard?nav=settings",
|
||||
logout: "/dashboard?nav=logout",
|
||||
};
|
||||
|
||||
function readUserName() {
|
||||
if (typeof window === "undefined") return "User";
|
||||
try {
|
||||
const raw =
|
||||
sessionStorage.getItem("nxtgauge_auth_user") ||
|
||||
sessionStorage.getItem("nxtgauge_user") ||
|
||||
localStorage.getItem("nxtgauge_auth_user") ||
|
||||
localStorage.getItem("nxtgauge_user");
|
||||
if (!raw) return "User";
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed?.full_name || parsed?.name || parsed?.email || "User";
|
||||
} catch {
|
||||
return "User";
|
||||
}
|
||||
}
|
||||
|
||||
export default function DashboardLayout(props: ParentProps) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [roleKey, setRoleKey] = createSignal("DEVELOPER");
|
||||
const [userName, setUserName] = createSignal("User");
|
||||
|
||||
const activeSidebar = createMemo(() => {
|
||||
const path = location.pathname || "";
|
||||
if (path.startsWith("/dashboard/requests")) return "My Requests";
|
||||
if (path.startsWith("/dashboard/leads")) return "Leads";
|
||||
if (path.startsWith("/dashboard/credits")) return "Credits";
|
||||
if (path.startsWith("/dashboard/settings")) return "Settings";
|
||||
return "My Dashboard";
|
||||
});
|
||||
|
||||
const handleSidebarSelect = (item: string) => {
|
||||
const target = ROUTE_BY_LABEL[item.toLowerCase()];
|
||||
if (target) navigate(target);
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const fromUrl = new URLSearchParams(window.location.search).get("role");
|
||||
if (fromUrl && fromUrl.trim()) {
|
||||
setRoleKey(fromUrl.trim().toUpperCase());
|
||||
return;
|
||||
}
|
||||
|
||||
const storageKeys: [string, Storage][] = [
|
||||
["nxtgauge_signup_profile_v1", localStorage],
|
||||
["nxtgauge_auth_user", localStorage],
|
||||
["nxtgauge_user", localStorage],
|
||||
["nxtgauge_signup_profile_v1", sessionStorage],
|
||||
["nxtgauge_auth_user", sessionStorage],
|
||||
["nxtgauge_user", sessionStorage],
|
||||
];
|
||||
|
||||
for (const [key, storage] of storageKeys) {
|
||||
try {
|
||||
const raw = storage.getItem(key);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
const candidate = String(
|
||||
parsed?.selectedProfessionalRole || parsed?.active_role || parsed?.roleKey || parsed?.role || ""
|
||||
)
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
if (candidate && candidate !== "PROFESSIONAL") {
|
||||
setRoleKey(candidate);
|
||||
if (parsed?.full_name || parsed?.name || parsed?.email) {
|
||||
setUserName(parsed.full_name || parsed.name || parsed.email || "User");
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// continue
|
||||
}
|
||||
}
|
||||
|
||||
const token = sessionStorage.getItem("nxtgauge_access_token");
|
||||
if (token) {
|
||||
try {
|
||||
const res = await fetch("/api/auth/session", {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
credentials: "include",
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const role = String(data?.active_role || data?.role || "").trim().toUpperCase();
|
||||
setRoleKey(role && role !== "PROFESSIONAL" ? role : "DEVELOPER");
|
||||
setUserName(data?.full_name || data?.name || data?.email || "User");
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<DashboardShell
|
||||
sidebarItems={SIDEBAR_ITEMS}
|
||||
activeSidebar={activeSidebar()}
|
||||
onSidebarSelect={handleSidebarSelect}
|
||||
roleKey={roleKey()}
|
||||
userName={userName()}
|
||||
>
|
||||
{props.children}
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { createSignal, createEffect, onCleanup, Show } from "solid-js";
|
||||
import { api } from "~/lib/api";
|
||||
import { apiFetch } from "~/lib/api";
|
||||
|
||||
const ORANGE = "#FF5E13";
|
||||
const NAVY = "#0D0D2A";
|
||||
|
|
@ -13,8 +13,8 @@ export default function NotificationBell() {
|
|||
createEffect(() => {
|
||||
const fetchUnreadCount = async () => {
|
||||
try {
|
||||
const res = await api.get("/me/notifications/unread-count");
|
||||
setUnreadCount(res.data?.unread_count || 0);
|
||||
const res = await apiFetch("/api/me/notifications/unread-count");
|
||||
setUnreadCount(res?.unread_count || 0);
|
||||
} catch (e) {
|
||||
// Silently fail
|
||||
}
|
||||
|
|
@ -32,8 +32,8 @@ export default function NotificationBell() {
|
|||
// Fetch notifications when dropdown opens
|
||||
const fetchNotifications = async () => {
|
||||
try {
|
||||
const res = await api.get("/me/notifications?limit=5");
|
||||
setNotifications(res.data?.data || []);
|
||||
const res = await apiFetch("/api/me/notifications?limit=5");
|
||||
setNotifications(res?.data || []);
|
||||
} catch (e) {
|
||||
setNotifications([]);
|
||||
}
|
||||
|
|
@ -49,7 +49,7 @@ export default function NotificationBell() {
|
|||
|
||||
const markAsRead = async (id: string) => {
|
||||
try {
|
||||
await api.patch(`/me/notifications/${id}/read`);
|
||||
await apiFetch(`/api/me/notifications/${id}/read`, { method: "PATCH" });
|
||||
// Update local state
|
||||
setNotifications((prev) => prev.map((n) => (n.id === id ? { ...n, is_read: true } : n)));
|
||||
setUnreadCount((prev) => Math.max(0, prev - 1));
|
||||
|
|
@ -60,7 +60,7 @@ export default function NotificationBell() {
|
|||
|
||||
const markAllAsRead = async () => {
|
||||
try {
|
||||
await api.patch("/me/notifications/read-all");
|
||||
await apiFetch("/api/me/notifications/read-all", { method: "PATCH" });
|
||||
setNotifications((prev) => prev.map((n) => ({ ...n, is_read: true })));
|
||||
setUnreadCount(0);
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -1,274 +0,0 @@
|
|||
import { createResource, createSignal, Show, For } from "solid-js";
|
||||
import { useNavigate, useParams } from "@solidjs/router";
|
||||
import DashboardLayout from "~/components/DashboardLayout";
|
||||
import { RequireAuth } from "~/lib/auth";
|
||||
import { api } from "~/lib/api";
|
||||
|
||||
interface AcceptedLead {
|
||||
id: string;
|
||||
requirement: {
|
||||
id: string;
|
||||
title: string;
|
||||
profession_key: string;
|
||||
location: string;
|
||||
budget: number;
|
||||
preferred_date: string;
|
||||
description: string;
|
||||
};
|
||||
customer_contact: {
|
||||
full_name: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
city: string;
|
||||
};
|
||||
tracecoins_deducted: number;
|
||||
accepted_at: string;
|
||||
}
|
||||
|
||||
export default function AcceptedLeadsView() {
|
||||
const navigate = useNavigate();
|
||||
const params = useParams();
|
||||
|
||||
// If ID param exists, show detail view
|
||||
const isDetailView = () => !!params.id;
|
||||
|
||||
const [leads] = createResource(async () => {
|
||||
const res = await api.get("/leads/accepted/me");
|
||||
return res.data?.data || [];
|
||||
});
|
||||
|
||||
const [selectedLead, setSelectedLead] = createSignal<AcceptedLead | null>(null);
|
||||
|
||||
// Fetch single lead if in detail view
|
||||
createResource(async () => {
|
||||
if (params.id) {
|
||||
try {
|
||||
const res = await api.get(`/leads/accepted/${params.id}`);
|
||||
setSelectedLead(res.data);
|
||||
} catch (e) {
|
||||
navigate("/dashboard/leads/accepted");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const formatDate = (date: string) => {
|
||||
return new Date(date).toLocaleDateString("en-IN", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
const formatPhone = (phone: string) => {
|
||||
return phone.replace(/(\d{5})(\d{5})/, "$1-$2");
|
||||
};
|
||||
|
||||
// Detail View
|
||||
if (isDetailView() && selectedLead()) {
|
||||
const lead = selectedLead()!;
|
||||
return (
|
||||
<RequireAuth><DashboardLayout>
|
||||
<div class="p-6 max-w-4xl mx-auto">
|
||||
<button
|
||||
onClick={() => navigate("/dashboard/leads/accepted")}
|
||||
class="mb-4 text-gray-600 hover:text-gray-900 flex items-center gap-2"
|
||||
>
|
||||
← Back to Accepted Leads
|
||||
</button>
|
||||
|
||||
<div class="bg-white border rounded-xl overflow-hidden">
|
||||
{/* Header */}
|
||||
<div class="bg-green-50 p-6 border-b">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<span class="inline-block px-3 py-1 bg-green-500 text-white text-sm rounded-full mb-2">
|
||||
Lead Accepted
|
||||
</span>
|
||||
<h1 class="text-2xl font-bold text-gray-900">{lead.requirement.title}</h1>
|
||||
<p class="text-gray-600 mt-1">Accepted on {formatDate(lead.accepted_at)}</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-sm text-gray-500">Tracecoins Deducted</p>
|
||||
<p class="text-2xl font-bold text-orange-600">{lead.tracecoins_deducted} TC</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6 grid md:grid-cols-2 gap-8">
|
||||
{/* Customer Contact Card */}
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
👤 Customer Contact
|
||||
</h2>
|
||||
<div class="bg-gray-50 rounded-xl p-6 space-y-4">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Name</p>
|
||||
<p class="text-lg font-semibold">{lead.customer_contact.full_name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Phone</p>
|
||||
<a
|
||||
href={`tel:${lead.customer_contact.phone}`}
|
||||
class="text-lg font-semibold text-orange-600 hover:underline"
|
||||
>
|
||||
{formatPhone(lead.customer_contact.phone)}
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Email</p>
|
||||
<a
|
||||
href={`mailto:${lead.customer_contact.email}`}
|
||||
class="text-lg font-semibold text-orange-600 hover:underline"
|
||||
>
|
||||
{lead.customer_contact.email}
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Location</p>
|
||||
<p class="font-medium">{lead.customer_contact.city}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex gap-3">
|
||||
<a
|
||||
href={`tel:${lead.customer_contact.phone}`}
|
||||
class="flex-1 px-4 py-3 bg-green-500 text-white rounded-lg text-center font-medium hover:bg-green-600 transition-colors"
|
||||
>
|
||||
📞 Call Customer
|
||||
</a>
|
||||
<a
|
||||
href={`https://wa.me/${lead.customer_contact.phone.replace(/\D/g, "")}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex-1 px-4 py-3 bg-green-600 text-white rounded-lg text-center font-medium hover:bg-green-700 transition-colors"
|
||||
>
|
||||
💬 WhatsApp
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Requirement Details */}
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
📋 Requirement Details
|
||||
</h2>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Service Type</p>
|
||||
<p class="font-medium capitalize">
|
||||
{lead.requirement.profession_key.toLowerCase().replace(/_/g, " ")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Location</p>
|
||||
<p class="font-medium">{lead.requirement.location}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Budget</p>
|
||||
<p class="font-medium">
|
||||
₹{lead.requirement.budget?.toLocaleString() || "Not specified"}
|
||||
</p>
|
||||
</div>
|
||||
{lead.requirement.preferred_date && (
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Preferred Date</p>
|
||||
<p class="font-medium">{formatDate(lead.requirement.preferred_date)}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Description</p>
|
||||
<p class="text-gray-700 mt-1">{lead.requirement.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div class="bg-gray-50 p-6 border-t">
|
||||
<p class="text-sm text-gray-500 text-center">
|
||||
Contact the customer to discuss the project details and finalize the engagement.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout></RequireAuth>
|
||||
);
|
||||
}
|
||||
|
||||
// List View
|
||||
return (
|
||||
<RequireAuth><DashboardLayout>
|
||||
<div class="p-6">
|
||||
<h1 class="text-2xl font-bold mb-6">Accepted Leads</h1>
|
||||
|
||||
{/* Loading State */}
|
||||
<Show when={leads.loading}>
|
||||
<div class="text-center py-12">
|
||||
<div class="animate-spin w-8 h-8 border-2 border-orange-500 border-t-transparent rounded-full mx-auto mb-4" />
|
||||
<p class="text-gray-500">Loading leads...</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Empty State */}
|
||||
<Show when={!leads.loading && (leads() || []).length === 0}>
|
||||
<div class="text-center py-12 bg-gray-50 rounded-xl">
|
||||
<div class="text-6xl mb-4">🤝</div>
|
||||
<h3 class="text-lg font-semibold text-gray-700 mb-2">No accepted leads yet</h3>
|
||||
<p class="text-gray-500 mb-4">
|
||||
Browse the marketplace and send requests to view customer contacts.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate("/dashboard/marketplace")}
|
||||
class="px-6 py-2 bg-orange-500 text-white rounded-lg hover:bg-orange-600 transition-colors"
|
||||
>
|
||||
Browse Marketplace
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Leads Grid */}
|
||||
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<For each={leads() || []}>
|
||||
{(lead: AcceptedLead) => (
|
||||
<div
|
||||
onClick={() => navigate(`/dashboard/leads/accepted/${lead.id}`)}
|
||||
class="bg-white border rounded-xl p-6 hover:shadow-lg transition-all cursor-pointer"
|
||||
>
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<span class="inline-block px-2 py-1 bg-green-100 text-green-800 text-xs rounded-full">
|
||||
Accepted
|
||||
</span>
|
||||
<span class="text-orange-600 font-semibold text-sm">
|
||||
-{lead.tracecoins_deducted} TC
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 class="font-semibold text-gray-900 mb-2 line-clamp-2">
|
||||
{lead.requirement.title}
|
||||
</h3>
|
||||
|
||||
<div class="space-y-2 text-sm">
|
||||
<p class="text-gray-600 flex items-center gap-2">
|
||||
<span>👤</span> {lead.customer_contact.full_name}
|
||||
</p>
|
||||
<p class="text-gray-600 flex items-center gap-2">
|
||||
<span>📍</span> {lead.requirement.location}
|
||||
</p>
|
||||
{lead.requirement.budget && (
|
||||
<p class="text-gray-600 flex items-center gap-2">
|
||||
<span>💰</span> ₹{lead.requirement.budget.toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="mt-4 pt-4 border-t">
|
||||
<p class="text-xs text-gray-500">Accepted {formatDate(lead.accepted_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout></RequireAuth>
|
||||
);
|
||||
}
|
||||
|
|
@ -59,19 +59,6 @@ export async function request<T = any>(
|
|||
return { data: data as T, status: res.status, headers: res.headers };
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T = any>(path: string, headers?: HeadersInit) =>
|
||||
request<T>(path, { method: "GET", headers }),
|
||||
post: <T = any>(path: string, body?: unknown, headers?: HeadersInit) =>
|
||||
request<T>(path, { method: "POST", body, headers }),
|
||||
put: <T = any>(path: string, body?: unknown, headers?: HeadersInit) =>
|
||||
request<T>(path, { method: "PUT", body, headers }),
|
||||
patch: <T = any>(path: string, body?: unknown, headers?: HeadersInit) =>
|
||||
request<T>(path, { method: "PATCH", body, headers }),
|
||||
delete: <T = any>(path: string, headers?: HeadersInit) =>
|
||||
request<T>(path, { method: "DELETE", headers }),
|
||||
};
|
||||
|
||||
export async function fetchProfile(rolePrefix: string): Promise<any> {
|
||||
return apiFetch(`/api/${rolePrefix}/profile/me`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
import AcceptedLeadsView from "~/components/dashboard/AcceptedLeadsView";
|
||||
|
||||
export default function AcceptedLeadDetailRoute() {
|
||||
return <AcceptedLeadsView />;
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import AcceptedLeadsView from "~/components/dashboard/AcceptedLeadsView";
|
||||
|
||||
export default function AcceptedLeadsIndexRoute() {
|
||||
return <AcceptedLeadsView />;
|
||||
}
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
import { createResource, Show } from "solid-js";
|
||||
import { useNavigate, useParams } from "@solidjs/router";
|
||||
import DashboardLayout from "~/components/DashboardLayout";
|
||||
import { RequireAuth } from "~/lib/auth";
|
||||
import { apiFetch } from "~/lib/api";
|
||||
|
||||
export default function MarketplaceRequirementDetailRoute() {
|
||||
const navigate = useNavigate();
|
||||
const params = useParams();
|
||||
|
||||
const [requirement] = createResource(() => params.id, async (id) => {
|
||||
return apiFetch(`/api/customers/requirements/${id}`);
|
||||
});
|
||||
|
||||
const formatDate = (date: string) => {
|
||||
return new Date(date).toLocaleDateString("en-IN", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<RequireAuth>
|
||||
<DashboardLayout>
|
||||
<div class="p-6 max-w-4xl mx-auto">
|
||||
<button
|
||||
onClick={() => navigate("/dashboard/marketplace")}
|
||||
class="mb-4 text-gray-600 hover:text-gray-900 flex items-center gap-2"
|
||||
>
|
||||
← Back to Marketplace
|
||||
</button>
|
||||
|
||||
<Show when={requirement.loading}>
|
||||
<div class="text-center py-12">
|
||||
<div class="animate-spin w-8 h-8 border-2 border-orange-500 border-t-transparent rounded-full mx-auto mb-4" />
|
||||
<p class="text-gray-500">Loading requirement...</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={requirement.error}>
|
||||
<div class="text-center py-12 bg-gray-50 rounded-xl">
|
||||
<h3 class="text-lg font-semibold text-gray-700 mb-2">Requirement not found</h3>
|
||||
<p class="text-gray-500">It may have been closed or removed.</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!requirement.loading && requirement()}>
|
||||
{(req: any) => {
|
||||
const r = req().data || req();
|
||||
return (
|
||||
<div class="bg-white border rounded-xl overflow-hidden">
|
||||
<div class="bg-orange-50 p-6 border-b">
|
||||
<h1 class="text-2xl font-bold text-gray-900">{r.title}</h1>
|
||||
<p class="text-gray-600 mt-1">Posted on {r.created_at ? formatDate(r.created_at) : "—"}</p>
|
||||
</div>
|
||||
|
||||
<div class="p-6 space-y-4">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Service Type</p>
|
||||
<p class="font-medium capitalize">
|
||||
{(r.profession_key || "").toLowerCase().replace(/_/g, " ")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Location</p>
|
||||
<p class="font-medium">{r.location}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Budget</p>
|
||||
<p class="font-medium">
|
||||
₹{r.budget?.toLocaleString() || "Not specified"}
|
||||
</p>
|
||||
</div>
|
||||
<Show when={r.preferred_date}>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Preferred Date</p>
|
||||
<p class="font-medium">{formatDate(r.preferred_date)}</p>
|
||||
</div>
|
||||
</Show>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Description</p>
|
||||
<p class="text-gray-700 mt-1">{r.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Show>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</RequireAuth>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
import DashboardLayout from "~/components/DashboardLayout";
|
||||
import { RequireAuth } from "~/lib/auth";
|
||||
import ExploreServicesPage from "~/components/dashboard/ExploreServicesPage";
|
||||
|
||||
export default function MarketplacePage() {
|
||||
return (
|
||||
<RequireAuth>
|
||||
<DashboardLayout>
|
||||
<ExploreServicesPage />
|
||||
</DashboardLayout>
|
||||
</RequireAuth>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,204 +0,0 @@
|
|||
import { createResource, createSignal, Show, For } from "solid-js";
|
||||
import { useNavigate } from "@solidjs/router";
|
||||
import DashboardLayout from "~/components/DashboardLayout";
|
||||
import { RequireAuth } from "~/lib/auth";
|
||||
import { api } from "~/lib/api";
|
||||
|
||||
interface LeadRequest {
|
||||
id: string;
|
||||
requirement: {
|
||||
id: string;
|
||||
title: string;
|
||||
location: string;
|
||||
budget: number;
|
||||
};
|
||||
status: "PENDING" | "ACCEPTED" | "REJECTED" | "EXPIRED";
|
||||
tracecoins_reserved: number;
|
||||
expires_at: string;
|
||||
requested_at: string;
|
||||
}
|
||||
|
||||
export default function MyRequestsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [activeTab, setActiveTab] = createSignal("ALL");
|
||||
|
||||
const [requests, { refetch }] = createResource(async () => {
|
||||
const res = await api.get("/leads/requests/me");
|
||||
return res.data?.data || [];
|
||||
});
|
||||
|
||||
const filteredRequests = () => {
|
||||
const all = requests() || [];
|
||||
if (activeTab() === "ALL") return all;
|
||||
return all.filter((r: LeadRequest) => r.status === activeTab());
|
||||
};
|
||||
|
||||
const cancelRequest = async (id: string) => {
|
||||
if (!confirm("Are you sure you want to cancel this request?")) return;
|
||||
|
||||
try {
|
||||
await api.delete(`/leads/requests/${id}`);
|
||||
refetch();
|
||||
} catch (e) {
|
||||
alert("Failed to cancel request");
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (date: string) => {
|
||||
return new Date(date).toLocaleDateString("en-IN", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
const getTimeRemaining = (expiresAt: string) => {
|
||||
const now = new Date();
|
||||
const expiry = new Date(expiresAt);
|
||||
const diff = expiry.getTime() - now.getTime();
|
||||
|
||||
if (diff <= 0) return "Expired";
|
||||
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
if (hours < 1) {
|
||||
const mins = Math.floor(diff / (1000 * 60));
|
||||
return `${mins}m remaining`;
|
||||
}
|
||||
return `${hours}h remaining`;
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const classes = {
|
||||
PENDING: "bg-yellow-100 text-yellow-800",
|
||||
ACCEPTED: "bg-green-100 text-green-800",
|
||||
REJECTED: "bg-red-100 text-red-800",
|
||||
EXPIRED: "bg-gray-100 text-gray-800",
|
||||
};
|
||||
return classes[status as keyof typeof classes] || classes.EXPIRED;
|
||||
};
|
||||
|
||||
return (
|
||||
<RequireAuth>
|
||||
<DashboardLayout>
|
||||
<div class="p-6">
|
||||
<h1 class="text-2xl font-bold mb-6">My Lead Requests</h1>
|
||||
|
||||
{/* Filter Tabs */}
|
||||
<div class="flex gap-2 mb-6">
|
||||
{["ALL", "PENDING", "ACCEPTED", "REJECTED", "EXPIRED", "CANCELLED"].map((tab) => (
|
||||
<button
|
||||
onClick={() => setActiveTab(tab)}
|
||||
class={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
activeTab() === tab
|
||||
? "bg-orange-500 text-white"
|
||||
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
{tab === "ALL" ? "All Requests" : tab.charAt(0) + tab.slice(1).toLowerCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
<Show when={requests.loading}>
|
||||
<div class="text-center py-12">
|
||||
<div class="animate-spin w-8 h-8 border-2 border-orange-500 border-t-transparent rounded-full mx-auto mb-4" />
|
||||
<p class="text-gray-500">Loading requests...</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Empty State */}
|
||||
<Show when={!requests.loading && filteredRequests().length === 0}>
|
||||
<div class="text-center py-12 bg-gray-50 rounded-xl">
|
||||
<div class="text-6xl mb-4">📋</div>
|
||||
<h3 class="text-lg font-semibold text-gray-700 mb-2">No requests found</h3>
|
||||
<p class="text-gray-500 mb-4">
|
||||
{activeTab() === "ALL"
|
||||
? "You haven't sent any lead requests yet."
|
||||
: `No ${activeTab().toLowerCase()} requests.`}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate("/dashboard/marketplace")}
|
||||
class="px-6 py-2 bg-orange-500 text-white rounded-lg hover:bg-orange-600 transition-colors"
|
||||
>
|
||||
Browse Marketplace
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Requests List */}
|
||||
<div class="space-y-4">
|
||||
<For each={filteredRequests()}>
|
||||
{(request: LeadRequest) => (
|
||||
<div class="bg-white border rounded-xl p-6 hover:shadow-md transition-shadow">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900">{request.requirement.title}</h3>
|
||||
<p class="text-gray-500 text-sm">{request.requirement.location}</p>
|
||||
</div>
|
||||
<span
|
||||
class={`px-3 py-1 rounded-full text-sm font-medium ${getStatusBadge(request.status)}`}
|
||||
>
|
||||
{request.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4 mb-4 text-sm">
|
||||
<div>
|
||||
<p class="text-gray-500">Budget</p>
|
||||
<p class="font-semibold">
|
||||
₹{request.requirement.budget?.toLocaleString() || "Not specified"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-500">Reserved</p>
|
||||
<p class="font-semibold text-orange-600">{request.tracecoins_reserved} TC</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-500">
|
||||
{request.status === "PENDING" ? "Expires" : "Requested"}
|
||||
</p>
|
||||
<p class="font-semibold">
|
||||
{request.status === "PENDING"
|
||||
? getTimeRemaining(request.expires_at)
|
||||
: formatDate(request.requested_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
onClick={() => navigate(`/dashboard/marketplace/${request.requirement.id}`)}
|
||||
class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
View Requirement
|
||||
</button>
|
||||
|
||||
<Show when={request.status === "PENDING"}>
|
||||
<button
|
||||
onClick={() => cancelRequest(request.id)}
|
||||
class="px-4 py-2 border border-red-300 text-red-600 rounded-lg hover:bg-red-50 transition-colors"
|
||||
>
|
||||
Cancel Request
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
<Show when={request.status === "ACCEPTED"}>
|
||||
<button
|
||||
onClick={() => navigate(`/dashboard/leads/accepted/${request.id}`)}
|
||||
class="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors"
|
||||
>
|
||||
View Contact Details
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</RequireAuth>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,198 +0,0 @@
|
|||
import { createResource, createSignal, Show, For } from "solid-js";
|
||||
import { useNavigate } from "@solidjs/router";
|
||||
import DashboardLayout from "~/components/DashboardLayout";
|
||||
import { RequireAuth } from "~/lib/auth";
|
||||
import { api } from "~/lib/api";
|
||||
import { openPayuCheckout } from "~/lib/payu";
|
||||
|
||||
interface Package {
|
||||
id: string;
|
||||
name: string;
|
||||
tracecoins_amount: number;
|
||||
price_inr: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export default function BuyTracecoinsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [selectedPackage, setSelectedPackage] = createSignal<Package | null>(null);
|
||||
const [loading, setLoading] = createSignal(false);
|
||||
const [error, setError] = createSignal("");
|
||||
const [success, setSuccess] = createSignal(false);
|
||||
|
||||
const resolveRoleKey = () => {
|
||||
if (typeof window === "undefined") return "DEVELOPER";
|
||||
const fromUrl = new URLSearchParams(window.location.search).get("role");
|
||||
if (fromUrl && fromUrl.trim()) return fromUrl.trim().toUpperCase();
|
||||
const keys = ["nxtgauge_signup_profile_v1", "nxtgauge_auth_user", "nxtgauge_user"];
|
||||
for (const key of keys) {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key) || window.sessionStorage.getItem(key);
|
||||
if (!raw) continue;
|
||||
const parsed = JSON.parse(raw);
|
||||
const candidate = String(
|
||||
parsed?.selectedProfessionalRole || parsed?.active_role || parsed?.roleKey || parsed?.role || ""
|
||||
)
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
if (candidate && candidate !== "PROFESSIONAL") return candidate;
|
||||
} catch {
|
||||
// ignore malformed storage payloads
|
||||
}
|
||||
}
|
||||
return "DEVELOPER";
|
||||
};
|
||||
|
||||
const [packages] = createResource(async () => {
|
||||
const roleKey = resolveRoleKey();
|
||||
const res = await api.get(`/packages?role=${encodeURIComponent(roleKey)}&roleKey=${encodeURIComponent(roleKey)}`);
|
||||
return res.data?.packages || [];
|
||||
});
|
||||
|
||||
const handlePurchase = async () => {
|
||||
const pkg = selectedPackage();
|
||||
if (!pkg) return;
|
||||
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const paymentData = await openPayuCheckout({
|
||||
amount: pkg.price_inr,
|
||||
currency: "INR",
|
||||
txnId: pkg.id,
|
||||
description: `${pkg.name} - ${pkg.tracecoins_amount} Tracecoins`,
|
||||
});
|
||||
|
||||
await api.post("/payments/verify", {
|
||||
txnid: paymentData.txnid,
|
||||
mihpayid: paymentData.mihpayid,
|
||||
status: paymentData.status,
|
||||
hash: paymentData.hash,
|
||||
amount: paymentData.amount,
|
||||
productinfo: paymentData.productinfo,
|
||||
firstname: paymentData.firstname,
|
||||
email: paymentData.email,
|
||||
phone: paymentData.phone,
|
||||
udf1: paymentData.udf1,
|
||||
udf2: paymentData.udf2,
|
||||
});
|
||||
|
||||
setSuccess(true);
|
||||
setTimeout(() => {
|
||||
navigate("/dashboard?nav=credits");
|
||||
}, 2000);
|
||||
} catch (e: any) {
|
||||
setError(e.message || "Payment failed. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatPrice = (paise: number) => {
|
||||
return `₹${(paise / 100).toLocaleString()}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<RequireAuth>
|
||||
<DashboardLayout>
|
||||
<div class="p-6 max-w-4xl mx-auto">
|
||||
<h1 class="text-2xl font-bold mb-2">Buy Tracecoins</h1>
|
||||
<p class="text-gray-600 mb-6">Purchase Tracecoins to send lead requests to customers</p>
|
||||
|
||||
<Show when={success()}>
|
||||
<div class="bg-green-50 border border-green-200 rounded-xl p-6 mb-6 text-center">
|
||||
<div class="text-5xl mb-3">🎉</div>
|
||||
<h2 class="text-xl font-bold text-green-800 mb-2">Payment Successful!</h2>
|
||||
<p class="text-green-700">Tracecoins have been added to your wallet.</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={error()}>
|
||||
<div class="bg-red-50 border border-red-200 rounded-xl p-4 mb-6">
|
||||
<p class="text-red-700">{error()}</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!success()}>
|
||||
<Show when={packages.loading}>
|
||||
<div class="text-center py-12">
|
||||
<div class="animate-spin w-8 h-8 border-2 border-orange-500 border-t-transparent rounded-full mx-auto mb-4" />
|
||||
<p class="text-gray-500">Loading packages...</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!packages.loading}>
|
||||
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
||||
<For each={packages() || []}>
|
||||
{(pkg: Package) => (
|
||||
<div
|
||||
onClick={() => setSelectedPackage(pkg)}
|
||||
class={`border-2 rounded-xl p-6 cursor-pointer transition-all ${
|
||||
selectedPackage()?.id === pkg.id
|
||||
? "border-orange-500 bg-orange-50"
|
||||
: "border-gray-200 hover:border-orange-300"
|
||||
}`}
|
||||
>
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<h3 class="font-bold text-lg">{pkg.name}</h3>
|
||||
<Show when={selectedPackage()?.id === pkg.id}>
|
||||
<span class="text-orange-500">✓</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<span class="text-3xl font-bold text-gray-900">{pkg.tracecoins_amount}</span>
|
||||
<span class="text-gray-500 ml-1">TC</span>
|
||||
</div>
|
||||
|
||||
<p class="text-2xl font-bold text-orange-600 mb-3">
|
||||
{formatPrice(pkg.price_inr)}
|
||||
</p>
|
||||
|
||||
<p class="text-sm text-gray-600">{pkg.description}</p>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<Show when={selectedPackage()}>
|
||||
<div class="bg-gray-50 rounded-xl p-6 mb-6">
|
||||
<h3 class="font-semibold mb-4">Order Summary</h3>
|
||||
<div class="flex justify-between mb-2">
|
||||
<span>{selectedPackage()?.name}</span>
|
||||
<span>{formatPrice(selectedPackage()?.price_inr || 0)}</span>
|
||||
</div>
|
||||
<div class="border-t pt-2 mt-2">
|
||||
<div class="flex justify-between font-bold text-lg">
|
||||
<span>Total</span>
|
||||
<span>{formatPrice(selectedPackage()?.price_inr || 0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4">
|
||||
<button
|
||||
onClick={() => navigate("/dashboard?nav=credits")}
|
||||
class="flex-1 px-6 py-3 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePurchase}
|
||||
disabled={loading()}
|
||||
class="flex-1 px-6 py-3 bg-orange-500 text-white rounded-lg hover:bg-orange-600 disabled:opacity-50"
|
||||
>
|
||||
<Show when={loading()} fallback="Pay Now">
|
||||
Processing...
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</RequireAuth>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,284 +0,0 @@
|
|||
import { createResource, Show, For } from "solid-js";
|
||||
import { useParams, useNavigate } from "@solidjs/router";
|
||||
import DashboardLayout from "~/components/DashboardLayout";
|
||||
import { api } from "~/lib/api";
|
||||
|
||||
interface Invoice {
|
||||
id: string;
|
||||
invoice_number: string;
|
||||
payment_id: string;
|
||||
user_id: string;
|
||||
status: string;
|
||||
currency: string;
|
||||
invoice_type: string;
|
||||
subtotal: number;
|
||||
discount_amount: number;
|
||||
cgst_rate: number;
|
||||
cgst_amount: number;
|
||||
sgst_rate: number;
|
||||
sgst_amount: number;
|
||||
igst_rate: number;
|
||||
igst_amount: number;
|
||||
total: number;
|
||||
reverse_charge: boolean;
|
||||
seller_name: string;
|
||||
seller_address: string;
|
||||
seller_gstin: string | null;
|
||||
seller_pan: string | null;
|
||||
seller_state_code: string | null;
|
||||
place_of_supply_state: string | null;
|
||||
customer_name: string | null;
|
||||
customer_email: string | null;
|
||||
customer_phone: string | null;
|
||||
customer_billing_address: string | null;
|
||||
customer_gstin: string | null;
|
||||
customer_state_code: string | null;
|
||||
discount_label: string | null;
|
||||
notes: string | null;
|
||||
pdf_object_key: string | null;
|
||||
issued_at: string;
|
||||
paid_at: string | null;
|
||||
voided_at: string | null;
|
||||
void_reason: string | null;
|
||||
}
|
||||
|
||||
interface LineItem {
|
||||
line_number: number;
|
||||
description: string;
|
||||
hsn_sac_code: string | null;
|
||||
quantity: number;
|
||||
unit_price_paise: number;
|
||||
tax_rate_percent: number;
|
||||
}
|
||||
|
||||
interface InvoiceResponse {
|
||||
invoice: Invoice;
|
||||
lines: LineItem[];
|
||||
totals: {
|
||||
subtotal: number;
|
||||
discount: number;
|
||||
taxable_value: number;
|
||||
cgst: number;
|
||||
sgst: number;
|
||||
igst: number;
|
||||
total_tax: number;
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
|
||||
export default function InvoiceDetailPage() {
|
||||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [data] = createResource(async () => {
|
||||
const res = await api.get(`/wallet/me/invoices/${params.id}`);
|
||||
return res.data as InvoiceResponse;
|
||||
});
|
||||
|
||||
const formatDate = (date: string) => {
|
||||
return new Date(date).toLocaleDateString("en-IN", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
const formatPrice = (paise: number) => {
|
||||
const rupees = paise / 100;
|
||||
return new Intl.NumberFormat("en-IN", {
|
||||
style: "currency",
|
||||
currency: "INR",
|
||||
minimumFractionDigits: 2,
|
||||
}).format(rupees);
|
||||
};
|
||||
|
||||
const downloadPdf = () => {
|
||||
window.open(`/api/wallet/me/invoices/${params.id}/html`, "_blank");
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div class="p-6 max-w-4xl mx-auto">
|
||||
<button
|
||||
onClick={() => navigate("/dashboard/wallet/invoices")}
|
||||
class="mb-4 text-gray-600 hover:text-gray-900 flex items-center gap-2"
|
||||
>
|
||||
← Back to Invoices
|
||||
</button>
|
||||
|
||||
<Show when={data.loading}>
|
||||
<div class="text-center py-12">
|
||||
<div class="animate-spin w-8 h-8 border-2 border-orange-500 border-t-transparent rounded-full mx-auto mb-4" />
|
||||
<p class="text-gray-500">Loading invoice...</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={data.error}>
|
||||
<div class="bg-red-50 border border-red-200 rounded-xl p-6 text-center">
|
||||
<p class="text-red-700">Failed to load invoice: {String(data.error)}</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={data()} keyed>
|
||||
{(d: InvoiceResponse) => (
|
||||
<div class="bg-white border rounded-xl overflow-hidden shadow-sm">
|
||||
<div class="bg-gray-900 text-white p-6">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">TAX INVOICE</h1>
|
||||
<p class="text-gray-400 mt-1 font-mono">{d.invoice.invoice_number}</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span
|
||||
class={`inline-block px-3 py-1 rounded-full text-sm font-medium ${
|
||||
d.invoice.status === "PAID"
|
||||
? "bg-green-500 text-white"
|
||||
: d.invoice.status === "ISSUED"
|
||||
? "bg-blue-500 text-white"
|
||||
: d.invoice.status === "VOID"
|
||||
? "bg-red-500 text-white"
|
||||
: "bg-yellow-500 text-white"
|
||||
}`}
|
||||
>
|
||||
{d.invoice.status}
|
||||
</span>
|
||||
<p class="text-gray-400 text-sm mt-2">Issued {formatDate(d.invoice.issued_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||
<div class="border rounded-lg p-4">
|
||||
<p class="text-xs uppercase text-gray-500 mb-2">From</p>
|
||||
<p class="font-semibold">{d.invoice.seller_name}</p>
|
||||
<p class="text-sm whitespace-pre-line mt-1">{d.invoice.seller_address}</p>
|
||||
<p class="text-xs mt-2">
|
||||
{d.invoice.seller_gstin && (
|
||||
<>GSTIN: {d.invoice.seller_gstin}<br /></>
|
||||
)}
|
||||
{d.invoice.seller_pan && <>PAN: {d.invoice.seller_pan}</>}
|
||||
</p>
|
||||
</div>
|
||||
<div class="border rounded-lg p-4">
|
||||
<p class="text-xs uppercase text-gray-500 mb-2">Bill To</p>
|
||||
<p class="font-semibold">{d.invoice.customer_name ?? "—"}</p>
|
||||
<p class="text-sm whitespace-pre-line mt-1">
|
||||
{d.invoice.customer_billing_address ?? "—"}
|
||||
</p>
|
||||
<p class="text-xs mt-2">
|
||||
{d.invoice.customer_gstin && (
|
||||
<>GSTIN: {d.invoice.customer_gstin}<br /></>
|
||||
)}
|
||||
{d.invoice.place_of_supply_state && (
|
||||
<>Place of Supply: {d.invoice.place_of_supply_state}</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="w-full text-sm mb-6">
|
||||
<thead>
|
||||
<tr class="text-left text-xs uppercase text-gray-500 border-b">
|
||||
<th class="py-2">#</th>
|
||||
<th class="py-2">Description</th>
|
||||
<th class="py-2">HSN/SAC</th>
|
||||
<th class="py-2 text-right">Qty</th>
|
||||
<th class="py-2 text-right">Rate</th>
|
||||
<th class="py-2 text-right">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={d.lines}>
|
||||
{(line) => (
|
||||
<tr class="border-b">
|
||||
<td class="py-2">{line.line_number}</td>
|
||||
<td class="py-2">{line.description}</td>
|
||||
<td class="py-2">{line.hsn_sac_code ?? "—"}</td>
|
||||
<td class="py-2 text-right">{line.quantity}</td>
|
||||
<td class="py-2 text-right">{formatPrice(line.unit_price_paise)}</td>
|
||||
<td class="py-2 text-right">
|
||||
{formatPrice(line.unit_price_paise * line.quantity)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="border-t pt-4 space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">Subtotal</span>
|
||||
<span>{formatPrice(d.totals.subtotal)}</span>
|
||||
</div>
|
||||
<Show when={d.totals.discount > 0}>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">Discount</span>
|
||||
<span>-{formatPrice(d.totals.discount)}</span>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">Taxable value</span>
|
||||
<span>{formatPrice(d.totals.taxable_value)}</span>
|
||||
</div>
|
||||
<Show when={d.totals.cgst > 0}>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">CGST ({(d.invoice.cgst_rate).toFixed(2)}%)</span>
|
||||
<span>{formatPrice(d.totals.cgst)}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">SGST ({(d.invoice.sgst_rate).toFixed(2)}%)</span>
|
||||
<span>{formatPrice(d.totals.sgst)}</span>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={d.totals.igst > 0}>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">IGST ({(d.invoice.igst_rate).toFixed(2)}%)</span>
|
||||
<span>{formatPrice(d.totals.igst)}</span>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex justify-between text-lg font-bold pt-2 border-t">
|
||||
<span>Total ({d.invoice.currency})</span>
|
||||
<span class="text-orange-600">{formatPrice(d.totals.total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={d.invoice.paid_at}>
|
||||
<div class="mt-6 p-4 bg-green-50 border border-green-200 rounded-lg text-sm">
|
||||
<p class="text-green-800 font-semibold">
|
||||
✓ Paid on {formatDate(d.invoice.paid_at!)}
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={d.invoice.voided_at}>
|
||||
<div class="mt-6 p-4 bg-red-50 border border-red-200 rounded-lg text-sm">
|
||||
<p class="text-red-800 font-semibold">✗ Invoice voided</p>
|
||||
<Show when={d.invoice.void_reason}>
|
||||
<p class="text-red-700 mt-1">Reason: {d.invoice.void_reason}</p>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="mt-6 flex gap-3">
|
||||
<button
|
||||
onClick={downloadPdf}
|
||||
class="flex-1 px-4 py-3 bg-orange-500 text-white rounded-lg hover:bg-orange-600 transition-colors"
|
||||
>
|
||||
Open Printable View
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.print()}
|
||||
class="px-4 py-3 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Print
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
import { createResource, Show, For } from "solid-js";
|
||||
import { A } from "@solidjs/router";
|
||||
import DashboardLayout from "~/components/DashboardLayout";
|
||||
import { api } from "~/lib/api";
|
||||
|
||||
interface InvoiceSummary {
|
||||
id: string;
|
||||
invoice_number: string;
|
||||
payment_id: string;
|
||||
status: string;
|
||||
currency: string;
|
||||
subtotal: number;
|
||||
cgst_amount: number;
|
||||
sgst_amount: number;
|
||||
igst_amount: number;
|
||||
total: number;
|
||||
issued_at: string;
|
||||
paid_at: string | null;
|
||||
pdf_object_key: string | null;
|
||||
package_name: string | null;
|
||||
}
|
||||
|
||||
interface InvoicesResponse {
|
||||
invoices: InvoiceSummary[];
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export default function InvoicesPage() {
|
||||
const [data] = createResource(async () => {
|
||||
const res = await api.get<InvoicesResponse>("/wallet/me/invoices");
|
||||
return res.data;
|
||||
});
|
||||
|
||||
const formatDate = (date: string) => {
|
||||
return new Date(date).toLocaleDateString("en-IN", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
const formatPrice = (paise: number) => {
|
||||
return new Intl.NumberFormat("en-IN", {
|
||||
style: "currency",
|
||||
currency: "INR",
|
||||
minimumFractionDigits: 0,
|
||||
}).format(paise / 100);
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div class="p-6 max-w-5xl mx-auto">
|
||||
<h1 class="text-2xl font-bold mb-2">Invoices</h1>
|
||||
<p class="text-gray-600 mb-6">All your Tracecoin purchase invoices</p>
|
||||
|
||||
<Show when={data.loading}>
|
||||
<div class="text-center py-12">
|
||||
<div class="animate-spin w-8 h-8 border-2 border-orange-500 border-t-transparent rounded-full mx-auto mb-4" />
|
||||
<p class="text-gray-500">Loading invoices...</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={data()} keyed>
|
||||
{(d) => (
|
||||
<Show
|
||||
when={d.invoices.length > 0}
|
||||
fallback={
|
||||
<div class="bg-white border rounded-xl p-12 text-center">
|
||||
<p class="text-gray-500 text-lg">No invoices yet.</p>
|
||||
<p class="text-gray-400 text-sm mt-2">
|
||||
Invoices are generated automatically when you purchase a Tracecoin package.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="bg-white border rounded-xl overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-xs uppercase text-gray-500 border-b bg-gray-50">
|
||||
<th class="px-4 py-3">Invoice #</th>
|
||||
<th class="px-4 py-3">Package</th>
|
||||
<th class="px-4 py-3">Date</th>
|
||||
<th class="px-4 py-3 text-right">Total</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={d.invoices}>
|
||||
{(inv) => (
|
||||
<tr class="border-b hover:bg-gray-50">
|
||||
<td class="px-4 py-3 font-mono text-sm">{inv.invoice_number}</td>
|
||||
<td class="px-4 py-3">{inv.package_name ?? "Tracecoin purchase"}</td>
|
||||
<td class="px-4 py-3 text-gray-600">{formatDate(inv.issued_at)}</td>
|
||||
<td class="px-4 py-3 text-right font-medium">
|
||||
{formatPrice(inv.total)}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class={`inline-block px-2 py-1 rounded-full text-xs font-medium ${
|
||||
inv.status === "PAID"
|
||||
? "bg-green-100 text-green-700"
|
||||
: inv.status === "ISSUED"
|
||||
? "bg-blue-100 text-blue-700"
|
||||
: inv.status === "VOID"
|
||||
? "bg-red-100 text-red-700"
|
||||
: "bg-gray-100 text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{inv.status}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<A
|
||||
href={`/dashboard/wallet/invoices/${inv.id}`}
|
||||
class="text-orange-600 hover:underline text-sm"
|
||||
>
|
||||
View →
|
||||
</A>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
import { createSignal, onMount, Show } from "solid-js";
|
||||
import { useNavigate } from "@solidjs/router";
|
||||
import DashboardLayout from "~/components/DashboardLayout";
|
||||
import { RequireAuth } from "~/lib/auth";
|
||||
import { api } from "~/lib/api";
|
||||
|
||||
type PayuResponseFields = {
|
||||
status: string;
|
||||
txnid: string;
|
||||
amount: string;
|
||||
mihpayid: string;
|
||||
hash: string;
|
||||
firstname: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
productinfo: string;
|
||||
udf1?: string;
|
||||
udf2?: string;
|
||||
udf3?: string;
|
||||
udf4?: string;
|
||||
udf5?: string;
|
||||
};
|
||||
|
||||
function readFormFields(): PayuResponseFields {
|
||||
if (typeof window === "undefined") {
|
||||
return emptyFields();
|
||||
}
|
||||
|
||||
const search = window.location.search.replace(/^\?/, "");
|
||||
const params = new URLSearchParams(search);
|
||||
|
||||
return {
|
||||
status: params.get("status") || "",
|
||||
txnid: params.get("txnid") || "",
|
||||
amount: params.get("amount") || "",
|
||||
mihpayid: params.get("mihpayid") || "",
|
||||
hash: params.get("hash") || "",
|
||||
firstname: params.get("firstname") || "",
|
||||
email: params.get("email") || "",
|
||||
phone: params.get("phone") || "",
|
||||
productinfo: params.get("productinfo") || "",
|
||||
udf1: params.get("udf1") || undefined,
|
||||
udf2: params.get("udf2") || undefined,
|
||||
udf3: params.get("udf3") || undefined,
|
||||
udf4: params.get("udf4") || undefined,
|
||||
udf5: params.get("udf5") || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function emptyFields(): PayuResponseFields {
|
||||
return {
|
||||
status: "",
|
||||
txnid: "",
|
||||
amount: "",
|
||||
mihpayid: "",
|
||||
hash: "",
|
||||
firstname: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
productinfo: "",
|
||||
};
|
||||
}
|
||||
|
||||
export default function PayuReturnPage() {
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = createSignal<"verifying" | "success" | "failure">("verifying");
|
||||
const [error, setError] = createSignal("");
|
||||
|
||||
onMount(async () => {
|
||||
const fields = readFormFields();
|
||||
if (!fields.txnid) {
|
||||
setStatus("failure");
|
||||
setError("Missing PayU transaction id. Please try again from your wallet.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await api.post("/payments/verify", fields);
|
||||
if (res?.data?.verified) {
|
||||
setStatus("success");
|
||||
setTimeout(() => {
|
||||
navigate("/dashboard?nav=credits");
|
||||
}, 2500);
|
||||
} else {
|
||||
setStatus("failure");
|
||||
setError(res?.data?.message || "PayU reported a non-success status.");
|
||||
}
|
||||
} catch (e: any) {
|
||||
setStatus("failure");
|
||||
setError(e?.message || "Failed to verify PayU payment.");
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<RequireAuth>
|
||||
<DashboardLayout>
|
||||
<div class="p-6 max-w-2xl mx-auto">
|
||||
<Show when={status() === "verifying"}>
|
||||
<div class="text-center py-16">
|
||||
<div class="animate-spin w-10 h-10 border-2 border-orange-500 border-t-transparent rounded-full mx-auto mb-4" />
|
||||
<h2 class="text-xl font-semibold mb-2">Verifying your PayU payment…</h2>
|
||||
<p class="text-gray-500">Please do not refresh this page.</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={status() === "success"}>
|
||||
<div class="bg-green-50 border border-green-200 rounded-xl p-8 text-center">
|
||||
<div class="text-5xl mb-3">🎉</div>
|
||||
<h2 class="text-2xl font-bold text-green-800 mb-2">Payment Successful</h2>
|
||||
<p class="text-green-700">Your Tracecoins have been credited. Redirecting to your wallet…</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={status() === "failure"}>
|
||||
<div class="bg-red-50 border border-red-200 rounded-xl p-6">
|
||||
<h2 class="text-xl font-bold text-red-800 mb-2">Payment Failed</h2>
|
||||
<p class="text-red-700 mb-4">{error()}</p>
|
||||
<button
|
||||
onClick={() => navigate("/dashboard/wallet/buy")}
|
||||
class="px-5 py-2 bg-orange-500 text-white rounded-lg hover:bg-orange-600"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</RequireAuth>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue