2025-08-22 17:02:49 +09:00
|
|
|
"use client";
|
2025-09-18 16:39:57 +09:00
|
|
|
|
2025-09-27 18:16:35 +09:00
|
|
|
import { useState, useEffect } from "react";
|
2025-08-22 17:02:49 +09:00
|
|
|
import Link from "next/link";
|
2025-09-11 14:23:33 +09:00
|
|
|
import { useRouter } from "next/navigation";
|
2025-10-07 17:38:39 +09:00
|
|
|
import type { Activity, DashboardSummary } from "@customer-portal/domain/dashboard";
|
2025-12-16 16:08:17 +09:00
|
|
|
import { ChevronRightIcon } from "@heroicons/react/24/outline";
|
2025-08-22 17:02:49 +09:00
|
|
|
import {
|
2025-08-20 18:02:50 +09:00
|
|
|
CreditCardIcon as CreditCardIconSolid,
|
|
|
|
|
ServerIcon as ServerIconSolid,
|
|
|
|
|
ChatBubbleLeftRightIcon as ChatBubbleLeftRightIconSolid,
|
2025-08-28 16:57:57 +09:00
|
|
|
ClipboardDocumentListIcon as ClipboardDocumentListIconSolid,
|
2025-12-16 16:08:17 +09:00
|
|
|
Squares2X2Icon as Squares2X2IconSolid,
|
2025-08-22 17:02:49 +09:00
|
|
|
} from "@heroicons/react/24/solid";
|
2025-09-09 18:19:54 +09:00
|
|
|
import { format, formatDistanceToNow } from "date-fns";
|
2025-09-18 16:39:57 +09:00
|
|
|
|
|
|
|
|
import { useAuthStore } from "@/features/auth/services/auth.store";
|
|
|
|
|
import { useDashboardSummary } from "@/features/dashboard/hooks";
|
2025-12-16 16:08:17 +09:00
|
|
|
import { StatCard, ActivityTimeline } from "@/features/dashboard/components";
|
2025-09-20 11:35:40 +09:00
|
|
|
import { LoadingStats, LoadingTable } from "@/components/atoms";
|
|
|
|
|
import { ErrorState } from "@/components/atoms/error-state";
|
2025-12-16 13:54:31 +09:00
|
|
|
import { PageLayout } from "@/components/templates";
|
|
|
|
|
import { Button } from "@/components/atoms/button";
|
2025-10-20 14:01:29 +09:00
|
|
|
import { useFormatCurrency } from "@/lib/hooks/useFormatCurrency";
|
2025-11-05 15:47:06 +09:00
|
|
|
import { log } from "@/lib/logger";
|
2025-09-20 11:35:40 +09:00
|
|
|
import { useCreateInvoiceSsoLink } from "@/features/billing/hooks/useBilling";
|
2025-08-20 18:02:50 +09:00
|
|
|
|
2025-09-18 16:39:57 +09:00
|
|
|
export function DashboardView() {
|
2025-10-20 14:01:29 +09:00
|
|
|
const { formatCurrency } = useFormatCurrency();
|
2025-09-11 14:23:33 +09:00
|
|
|
const router = useRouter();
|
2025-09-27 18:16:35 +09:00
|
|
|
const { user, isAuthenticated, loading: authLoading, clearLoading } = useAuthStore();
|
|
|
|
|
|
|
|
|
|
// Clear auth loading state when dashboard loads (after successful login)
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
clearLoading();
|
|
|
|
|
}, [clearLoading]);
|
2025-08-20 18:02:50 +09:00
|
|
|
const { data: summary, isLoading: summaryLoading, error } = useDashboardSummary();
|
2025-09-18 16:39:57 +09:00
|
|
|
const upcomingInvoice = summary?.nextInvoice ?? null;
|
2025-09-20 11:35:40 +09:00
|
|
|
const createSsoLinkMutation = useCreateInvoiceSsoLink();
|
2025-08-29 13:26:57 +09:00
|
|
|
|
2025-08-20 18:02:50 +09:00
|
|
|
const [paymentLoading, setPaymentLoading] = useState(false);
|
|
|
|
|
const [paymentError, setPaymentError] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
// Handle Pay Now functionality
|
2025-08-22 17:02:49 +09:00
|
|
|
const handlePayNow = (invoiceId: number) => {
|
2025-08-20 18:02:50 +09:00
|
|
|
setPaymentLoading(true);
|
|
|
|
|
setPaymentError(null);
|
2025-08-22 17:02:49 +09:00
|
|
|
|
|
|
|
|
void (async () => {
|
|
|
|
|
try {
|
2025-09-20 11:35:40 +09:00
|
|
|
const ssoLink = await createSsoLinkMutation.mutateAsync({ invoiceId, target: "pay" });
|
2025-08-22 17:02:49 +09:00
|
|
|
window.open(ssoLink.url, "_blank", "noopener,noreferrer");
|
|
|
|
|
} catch (error) {
|
2025-09-25 17:42:36 +09:00
|
|
|
log.error("Failed to create payment link", {
|
|
|
|
|
invoiceId,
|
|
|
|
|
error: error instanceof Error ? error.message : String(error),
|
2025-09-20 11:35:40 +09:00
|
|
|
});
|
2025-08-22 17:02:49 +09:00
|
|
|
setPaymentError(error instanceof Error ? error.message : "Failed to open payment page");
|
|
|
|
|
} finally {
|
|
|
|
|
setPaymentLoading(false);
|
|
|
|
|
}
|
|
|
|
|
})();
|
2025-08-20 18:02:50 +09:00
|
|
|
};
|
|
|
|
|
|
2025-08-22 17:02:49 +09:00
|
|
|
// Handle activity item clicks
|
2025-08-20 18:02:50 +09:00
|
|
|
const handleActivityClick = (activity: Activity) => {
|
2025-08-22 17:02:49 +09:00
|
|
|
if (activity.type === "invoice_created" || activity.type === "invoice_paid") {
|
2025-08-20 18:02:50 +09:00
|
|
|
if (activity.relatedId) {
|
2025-09-11 14:23:33 +09:00
|
|
|
router.push(`/billing/invoices/${activity.relatedId}`);
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (authLoading || summaryLoading || !isAuthenticated) {
|
|
|
|
|
return (
|
2025-12-16 13:54:31 +09:00
|
|
|
<PageLayout title="Dashboard" description="Overview of your account" loading>
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
<LoadingStats />
|
|
|
|
|
<LoadingTable />
|
2025-09-11 14:23:33 +09:00
|
|
|
<p className="text-muted-foreground">Loading dashboard...</p>
|
2025-08-20 18:02:50 +09:00
|
|
|
</div>
|
2025-12-16 13:54:31 +09:00
|
|
|
</PageLayout>
|
2025-08-20 18:02:50 +09:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Handle error state
|
|
|
|
|
if (error) {
|
2025-12-16 13:54:31 +09:00
|
|
|
const errorMessage =
|
|
|
|
|
typeof error === "string"
|
|
|
|
|
? error
|
|
|
|
|
: error instanceof Error
|
|
|
|
|
? error.message
|
|
|
|
|
: typeof error === "object" && error && "message" in error
|
|
|
|
|
? String((error as { message?: unknown }).message)
|
|
|
|
|
: "An unexpected error occurred";
|
|
|
|
|
|
2025-08-20 18:02:50 +09:00
|
|
|
return (
|
2025-12-16 13:54:31 +09:00
|
|
|
<PageLayout title="Dashboard" description="Overview of your account">
|
|
|
|
|
<ErrorState title="Error loading dashboard" message={errorMessage} variant="page" />
|
|
|
|
|
</PageLayout>
|
2025-08-20 18:02:50 +09:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
2025-12-16 13:54:31 +09:00
|
|
|
<PageLayout
|
|
|
|
|
title="Dashboard"
|
|
|
|
|
description="Overview of your account"
|
|
|
|
|
actions={<TasksChip summaryLoading={summaryLoading} summary={summary} />}
|
|
|
|
|
>
|
|
|
|
|
{/* Greeting */}
|
2025-12-16 16:08:17 +09:00
|
|
|
<div className="mb-6">
|
|
|
|
|
<p className="text-sm font-medium text-muted-foreground">Welcome back</p>
|
|
|
|
|
<h2 className="text-2xl sm:text-3xl font-bold text-foreground truncate mt-1">
|
|
|
|
|
{user?.firstname || user?.email?.split("@")[0] || "User"}
|
|
|
|
|
</h2>
|
2025-12-16 13:54:31 +09:00
|
|
|
</div>
|
2025-08-20 18:02:50 +09:00
|
|
|
|
2025-12-16 16:08:17 +09:00
|
|
|
{/* Overview Stats Card */}
|
|
|
|
|
<div className="cp-card rounded-2xl">
|
|
|
|
|
<div className="mb-2">
|
|
|
|
|
<h3 className="text-sm font-semibold text-foreground">Overview</h3>
|
|
|
|
|
<p className="text-xs text-muted-foreground mt-0.5">Quick snapshot of your account</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 divide-y sm:divide-y-0 sm:divide-x divide-border/60 -mx-4 sm:-mx-0">
|
|
|
|
|
<StatCard
|
|
|
|
|
title="Recent Orders"
|
|
|
|
|
value={summary?.stats?.recentOrders ?? 0}
|
|
|
|
|
icon={ClipboardDocumentListIconSolid}
|
|
|
|
|
tone="primary"
|
|
|
|
|
href="/orders"
|
|
|
|
|
/>
|
|
|
|
|
<StatCard
|
|
|
|
|
title="Pending Invoices"
|
|
|
|
|
value={summary?.stats?.unpaidInvoices ?? 0}
|
|
|
|
|
icon={CreditCardIconSolid}
|
|
|
|
|
tone={(summary?.stats?.unpaidInvoices ?? 0) > 0 ? "warning" : "neutral"}
|
|
|
|
|
href="/billing/invoices"
|
|
|
|
|
/>
|
|
|
|
|
<StatCard
|
|
|
|
|
title="Active Services"
|
|
|
|
|
value={summary?.stats?.activeSubscriptions ?? 0}
|
|
|
|
|
icon={ServerIconSolid}
|
|
|
|
|
tone="info"
|
|
|
|
|
href="/subscriptions"
|
|
|
|
|
/>
|
|
|
|
|
<StatCard
|
|
|
|
|
title="Support Cases"
|
|
|
|
|
value={summary?.stats?.openCases ?? 0}
|
|
|
|
|
icon={ChatBubbleLeftRightIconSolid}
|
|
|
|
|
tone={(summary?.stats?.openCases ?? 0) > 0 ? "info" : "neutral"}
|
|
|
|
|
href="/support/cases"
|
|
|
|
|
/>
|
|
|
|
|
<StatCard
|
|
|
|
|
title="Browse Catalog"
|
|
|
|
|
value="→"
|
|
|
|
|
icon={Squares2X2IconSolid}
|
|
|
|
|
tone="primary"
|
|
|
|
|
href="/catalog"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2025-12-16 13:54:31 +09:00
|
|
|
</div>
|
2025-08-20 18:02:50 +09:00
|
|
|
|
2025-12-16 16:08:17 +09:00
|
|
|
{/* Billing Card - only shown when there's an upcoming invoice */}
|
|
|
|
|
{upcomingInvoice && (
|
|
|
|
|
<div className="cp-card rounded-2xl" id="attention">
|
|
|
|
|
<div className="flex items-center justify-between gap-4 mb-4">
|
|
|
|
|
<div>
|
|
|
|
|
<h3 className="text-sm font-semibold text-foreground">Upcoming Payment</h3>
|
|
|
|
|
<p className="text-xs text-muted-foreground mt-0.5">Invoice due soon</p>
|
|
|
|
|
</div>
|
|
|
|
|
<Link
|
|
|
|
|
href="/billing/invoices"
|
|
|
|
|
className="text-sm font-medium text-primary hover:text-primary-hover transition-colors"
|
2025-12-16 13:54:31 +09:00
|
|
|
>
|
2025-12-16 16:08:17 +09:00
|
|
|
View all invoices
|
|
|
|
|
</Link>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-4 rounded-xl bg-muted/30 border border-border/60">
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-warning/10 text-warning">
|
|
|
|
|
Due soon
|
|
|
|
|
</span>
|
|
|
|
|
<span className="text-sm text-muted-foreground">Invoice #{upcomingInvoice.id}</span>
|
2025-08-20 18:02:50 +09:00
|
|
|
</div>
|
2025-12-16 16:08:17 +09:00
|
|
|
<div className="mt-2 text-2xl font-bold text-foreground">
|
|
|
|
|
{formatCurrency(upcomingInvoice.amount, {
|
|
|
|
|
currency: upcomingInvoice.currency,
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
|
|
|
Due{" "}
|
|
|
|
|
{formatDistanceToNow(new Date(upcomingInvoice.dueDate), {
|
|
|
|
|
addSuffix: true,
|
|
|
|
|
})}{" "}
|
|
|
|
|
· {format(new Date(upcomingInvoice.dueDate), "MMMM d, yyyy")}
|
|
|
|
|
</p>
|
2025-08-20 18:02:50 +09:00
|
|
|
</div>
|
2025-12-16 16:08:17 +09:00
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
<Link
|
|
|
|
|
href={`/billing/invoices/${upcomingInvoice.id}`}
|
|
|
|
|
className="text-sm font-medium text-primary hover:text-primary-hover transition-colors"
|
|
|
|
|
>
|
|
|
|
|
View details
|
|
|
|
|
</Link>
|
|
|
|
|
<Button
|
|
|
|
|
onClick={() => handlePayNow(upcomingInvoice.id)}
|
|
|
|
|
isLoading={paymentLoading}
|
|
|
|
|
loadingText="Opening…"
|
|
|
|
|
rightIcon={!paymentLoading ? <ChevronRightIcon className="h-4 w-4" /> : undefined}
|
|
|
|
|
>
|
|
|
|
|
Pay now
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2025-12-16 13:54:31 +09:00
|
|
|
</div>
|
2025-12-16 16:08:17 +09:00
|
|
|
)}
|
2025-12-16 13:54:31 +09:00
|
|
|
|
2025-12-16 16:08:17 +09:00
|
|
|
{/* Payment Error Display */}
|
|
|
|
|
{paymentError && (
|
|
|
|
|
<ErrorState
|
|
|
|
|
title="Payment Error"
|
|
|
|
|
message={paymentError}
|
|
|
|
|
variant="inline"
|
|
|
|
|
onRetry={() => setPaymentError(null)}
|
|
|
|
|
retryLabel="Dismiss"
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Recent Activity Card */}
|
|
|
|
|
<div className="cp-card rounded-2xl">
|
|
|
|
|
<div className="flex items-center justify-between gap-4 mb-5">
|
|
|
|
|
<div>
|
|
|
|
|
<h3 className="text-sm font-semibold text-foreground">Recent Activity</h3>
|
|
|
|
|
<p className="text-xs text-muted-foreground mt-0.5">Your latest account updates</p>
|
2025-08-20 18:02:50 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2025-12-16 16:08:17 +09:00
|
|
|
|
|
|
|
|
<ActivityTimeline
|
|
|
|
|
activities={summary?.recentActivity || []}
|
|
|
|
|
onItemClick={handleActivityClick}
|
|
|
|
|
maxItems={8}
|
|
|
|
|
/>
|
2025-08-20 18:02:50 +09:00
|
|
|
</div>
|
2025-12-16 13:54:31 +09:00
|
|
|
</PageLayout>
|
2025-08-20 18:02:50 +09:00
|
|
|
);
|
|
|
|
|
}
|
2025-09-09 18:19:54 +09:00
|
|
|
|
2025-12-16 16:08:17 +09:00
|
|
|
// Helpers
|
2025-09-18 16:39:57 +09:00
|
|
|
function TasksChip({
|
|
|
|
|
summaryLoading,
|
|
|
|
|
summary,
|
|
|
|
|
}: {
|
|
|
|
|
summaryLoading: boolean;
|
|
|
|
|
summary: DashboardSummary | undefined;
|
|
|
|
|
}) {
|
2025-09-11 14:23:33 +09:00
|
|
|
const router = useRouter();
|
2025-09-09 18:19:54 +09:00
|
|
|
if (summaryLoading) return null;
|
|
|
|
|
const tasks: Array<{ label: string; href: string }> = [];
|
|
|
|
|
if (summary?.nextInvoice) tasks.push({ label: "Pay upcoming invoice", href: "#attention" });
|
|
|
|
|
const count = tasks.length;
|
|
|
|
|
if (count === 0) return null;
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => {
|
|
|
|
|
const first = tasks[0];
|
|
|
|
|
if (first.href.startsWith("#")) {
|
|
|
|
|
const el = document.querySelector(first.href);
|
|
|
|
|
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
|
|
|
} else {
|
2025-09-11 14:23:33 +09:00
|
|
|
router.push(first.href);
|
2025-09-09 18:19:54 +09:00
|
|
|
}
|
|
|
|
|
}}
|
2025-12-16 13:54:31 +09:00
|
|
|
className="inline-flex items-center rounded-full bg-muted text-foreground px-2.5 py-1 text-xs font-medium hover:bg-muted/80 transition-colors"
|
2025-09-09 18:19:54 +09:00
|
|
|
title={tasks.map(t => t.label).join(" • ")}
|
|
|
|
|
>
|
|
|
|
|
{count} tasks
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
}
|