283 lines
10 KiB
TypeScript
Raw Normal View History

2025-08-22 17:02:49 +09:00
"use client";
import { useState, useEffect } from "react";
2025-08-22 17:02:49 +09:00
import Link from "next/link";
import { useRouter } from "next/navigation";
import type { Activity, DashboardSummary } from "@customer-portal/domain/dashboard";
import { ChevronRightIcon } from "@heroicons/react/24/outline";
2025-08-22 17:02:49 +09:00
import {
CreditCardIcon as CreditCardIconSolid,
ServerIcon as ServerIconSolid,
ChatBubbleLeftRightIcon as ChatBubbleLeftRightIconSolid,
2025-08-28 16:57:57 +09:00
ClipboardDocumentListIcon as ClipboardDocumentListIconSolid,
Squares2X2Icon as Squares2X2IconSolid,
2025-08-22 17:02:49 +09:00
} from "@heroicons/react/24/solid";
import { format, formatDistanceToNow } from "date-fns";
import { useAuthStore } from "@/features/auth/services/auth.store";
import { useDashboardSummary } from "@/features/dashboard/hooks";
import { StatCard, ActivityTimeline } from "@/features/dashboard/components";
import { LoadingStats, LoadingTable } from "@/components/atoms";
import { ErrorState } from "@/components/atoms/error-state";
import { PageLayout } from "@/components/templates";
import { Button } from "@/components/atoms/button";
import { useFormatCurrency } from "@/lib/hooks/useFormatCurrency";
import { log } from "@/lib/logger";
import { useCreateInvoiceSsoLink } from "@/features/billing/hooks/useBilling";
export function DashboardView() {
const { formatCurrency } = useFormatCurrency();
const router = useRouter();
const { user, isAuthenticated, loading: authLoading, clearLoading } = useAuthStore();
// Clear auth loading state when dashboard loads (after successful login)
useEffect(() => {
clearLoading();
}, [clearLoading]);
const { data: summary, isLoading: summaryLoading, error } = useDashboardSummary();
const upcomingInvoice = summary?.nextInvoice ?? null;
const createSsoLinkMutation = useCreateInvoiceSsoLink();
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) => {
setPaymentLoading(true);
setPaymentError(null);
2025-08-22 17:02:49 +09:00
void (async () => {
try {
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) {
log.error("Failed to create payment link", {
invoiceId,
error: error instanceof Error ? error.message : String(error),
});
2025-08-22 17:02:49 +09:00
setPaymentError(error instanceof Error ? error.message : "Failed to open payment page");
} finally {
setPaymentLoading(false);
}
})();
};
2025-08-22 17:02:49 +09:00
// Handle activity item clicks
const handleActivityClick = (activity: Activity) => {
2025-08-22 17:02:49 +09:00
if (activity.type === "invoice_created" || activity.type === "invoice_paid") {
if (activity.relatedId) {
router.push(`/billing/invoices/${activity.relatedId}`);
}
}
};
if (authLoading || summaryLoading || !isAuthenticated) {
return (
<PageLayout title="Dashboard" description="Overview of your account" loading>
<div className="space-y-6">
<LoadingStats />
<LoadingTable />
<p className="text-muted-foreground">Loading dashboard...</p>
</div>
</PageLayout>
);
}
// Handle error state
if (error) {
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";
return (
<PageLayout title="Dashboard" description="Overview of your account">
<ErrorState title="Error loading dashboard" message={errorMessage} variant="page" />
</PageLayout>
);
}
return (
<PageLayout
title="Dashboard"
description="Overview of your account"
actions={<TasksChip summaryLoading={summaryLoading} summary={summary} />}
>
{/* Greeting */}
<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>
</div>
{/* 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>
</div>
{/* 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"
>
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>
</div>
<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>
</div>
<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>
</div>
)}
{/* 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>
</div>
</div>
<ActivityTimeline
activities={summary?.recentActivity || []}
onItemClick={handleActivityClick}
maxItems={8}
/>
</div>
</PageLayout>
);
}
// Helpers
function TasksChip({
summaryLoading,
summary,
}: {
summaryLoading: boolean;
summary: DashboardSummary | undefined;
}) {
const router = useRouter();
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 {
router.push(first.href);
}
}}
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"
title={tasks.map(t => t.label).join(" • ")}
>
{count} tasks
</button>
);
}