- Updated various components to use consistent color tokens, improving visual coherence. - Refactored layout components to utilize the new PublicShell for better structure. - Enhanced error and status messaging styles for improved user feedback. - Standardized button usage across forms and modals for a unified interaction experience. - Introduced new UI design tokens and guidelines in documentation to support future development.
391 lines
14 KiB
TypeScript
391 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import Link from "next/link";
|
|
import { useRouter } from "next/navigation";
|
|
import type { Activity, DashboardSummary } from "@customer-portal/domain/dashboard";
|
|
import {
|
|
ServerIcon,
|
|
ChatBubbleLeftRightIcon,
|
|
ChevronRightIcon,
|
|
DocumentTextIcon,
|
|
ArrowTrendingUpIcon,
|
|
CalendarDaysIcon,
|
|
} from "@heroicons/react/24/outline";
|
|
import {
|
|
CreditCardIcon as CreditCardIconSolid,
|
|
ServerIcon as ServerIconSolid,
|
|
ChatBubbleLeftRightIcon as ChatBubbleLeftRightIconSolid,
|
|
ClipboardDocumentListIcon as ClipboardDocumentListIconSolid,
|
|
} 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, QuickAction, DashboardActivityItem } 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
|
|
const handlePayNow = (invoiceId: number) => {
|
|
setPaymentLoading(true);
|
|
setPaymentError(null);
|
|
|
|
void (async () => {
|
|
try {
|
|
const ssoLink = await createSsoLinkMutation.mutateAsync({ invoiceId, target: "pay" });
|
|
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),
|
|
});
|
|
setPaymentError(error instanceof Error ? error.message : "Failed to open payment page");
|
|
} finally {
|
|
setPaymentLoading(false);
|
|
}
|
|
})();
|
|
};
|
|
|
|
// Handle activity item clicks
|
|
const handleActivityClick = (activity: Activity) => {
|
|
if (activity.type === "invoice_created" || activity.type === "invoice_paid") {
|
|
// Use the related invoice ID for navigation
|
|
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="flex items-start justify-between gap-4">
|
|
<div className="min-w-0">
|
|
<div className="text-sm text-muted-foreground">Welcome back</div>
|
|
<div className="text-xl sm:text-2xl font-semibold text-foreground truncate">
|
|
{user?.firstname || user?.email?.split("@")[0] || "User"}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stats */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-[var(--cp-space-2xl)]">
|
|
<StatCard
|
|
title="Recent Orders"
|
|
value={summary?.stats?.recentOrders ?? 0}
|
|
icon={ClipboardDocumentListIconSolid}
|
|
gradient="from-primary to-primary-hover"
|
|
href="/orders"
|
|
/>
|
|
<StatCard
|
|
title="Pending Invoices"
|
|
value={summary?.stats?.unpaidInvoices || 0}
|
|
icon={CreditCardIconSolid}
|
|
gradient={
|
|
(summary?.stats?.unpaidInvoices ?? 0) > 0
|
|
? "from-warning to-warning"
|
|
: "from-muted-foreground to-foreground"
|
|
}
|
|
href="/billing/invoices"
|
|
zeroHint={{ text: "Set up auto-pay", href: "/billing/payments" }}
|
|
/>
|
|
<StatCard
|
|
title="Active Services"
|
|
value={summary?.stats?.activeSubscriptions || 0}
|
|
icon={ServerIconSolid}
|
|
gradient="from-info to-primary"
|
|
href="/subscriptions"
|
|
/>
|
|
<StatCard
|
|
title="Support Cases"
|
|
value={summary?.stats?.openCases || 0}
|
|
icon={ChatBubbleLeftRightIconSolid}
|
|
gradient={
|
|
(summary?.stats?.openCases ?? 0) > 0
|
|
? "from-info to-primary"
|
|
: "from-muted-foreground to-foreground"
|
|
}
|
|
href="/support/cases"
|
|
zeroHint={{ text: "Open a ticket", href: "/support/new" }}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-[var(--cp-space-3xl)]">
|
|
{/* Main Content Area */}
|
|
<div className="lg:col-span-2 space-y-[var(--cp-space-3xl)]">
|
|
{/* Upcoming Payment */}
|
|
{upcomingInvoice && (
|
|
<div
|
|
id="attention"
|
|
className="bg-card border border-warning/35 rounded-xl p-4 shadow-[var(--cp-shadow-1)]"
|
|
>
|
|
<div className="flex items-center gap-4">
|
|
<div className="flex-shrink-0">
|
|
<div className="w-10 h-10 rounded-md bg-gradient-to-r from-warning to-warning flex items-center justify-center">
|
|
<CalendarDaysIcon className="h-5 w-5 text-warning-foreground" />
|
|
</div>
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
|
<span className="font-semibold text-foreground">Upcoming Payment</span>
|
|
<span className="text-muted-foreground/60">•</span>
|
|
<span>Invoice #{upcomingInvoice.id}</span>
|
|
<span className="text-muted-foreground/60">•</span>
|
|
<span title={format(new Date(upcomingInvoice.dueDate), "MMMM d, yyyy")}>
|
|
Due{" "}
|
|
{formatDistanceToNow(new Date(upcomingInvoice.dueDate), {
|
|
addSuffix: true,
|
|
})}
|
|
</span>
|
|
</div>
|
|
<div className="mt-1 text-2xl font-bold text-foreground">
|
|
{formatCurrency(upcomingInvoice.amount, {
|
|
currency: upcomingInvoice.currency,
|
|
})}
|
|
</div>
|
|
<div className="mt-1 text-xs text-muted-foreground">
|
|
Exact due date: {format(new Date(upcomingInvoice.dueDate), "MMMM d, yyyy")}
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col items-end gap-2">
|
|
<Button
|
|
size="sm"
|
|
onClick={() => handlePayNow(upcomingInvoice.id)}
|
|
isLoading={paymentLoading}
|
|
loadingText="Opening…"
|
|
rightIcon={
|
|
!paymentLoading ? <ChevronRightIcon className="h-4 w-4" /> : undefined
|
|
}
|
|
>
|
|
Pay now
|
|
</Button>
|
|
<Link
|
|
href={`/billing/invoices/${upcomingInvoice.id}`}
|
|
className="text-primary hover:underline font-medium text-sm"
|
|
>
|
|
View invoice
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Payment Error Display */}
|
|
{paymentError && (
|
|
<ErrorState
|
|
title="Payment Error"
|
|
message={paymentError}
|
|
variant="inline"
|
|
onRetry={() => setPaymentError(null)}
|
|
retryLabel="Dismiss"
|
|
/>
|
|
)}
|
|
|
|
{/* Recent Activity */}
|
|
<RecentActivityCard
|
|
activities={summary?.recentActivity || []}
|
|
onItemClick={handleActivityClick}
|
|
/>
|
|
</div>
|
|
|
|
{/* Sidebar */}
|
|
<div className="space-y-[var(--cp-space-2xl)]">
|
|
<div className="bg-card text-card-foreground rounded-2xl shadow-[var(--cp-card-shadow)] border border-border overflow-hidden">
|
|
<div className="px-6 py-4 border-b border-border">
|
|
<h3 className="text-lg font-semibold text-foreground">Quick Actions</h3>
|
|
</div>
|
|
<div className="p-[var(--cp-space-2xl)] space-y-[var(--cp-space-lg)]">
|
|
<QuickAction
|
|
href="/billing/invoices"
|
|
title="View invoices"
|
|
description="Review and pay invoices"
|
|
icon={DocumentTextIcon}
|
|
iconColor="text-primary"
|
|
bgColor="bg-primary/10"
|
|
/>
|
|
<QuickAction
|
|
href="/subscriptions"
|
|
title="Manage services"
|
|
description="View active subscriptions"
|
|
icon={ServerIcon}
|
|
iconColor="text-primary"
|
|
bgColor="bg-primary/10"
|
|
/>
|
|
<QuickAction
|
|
href="/support/new"
|
|
title="Get support"
|
|
description="Open a support ticket"
|
|
icon={ChatBubbleLeftRightIcon}
|
|
iconColor="text-primary"
|
|
bgColor="bg-primary/10"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</PageLayout>
|
|
);
|
|
}
|
|
|
|
// Helpers and small components (local to dashboard)
|
|
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>
|
|
);
|
|
}
|
|
|
|
function RecentActivityCard({
|
|
activities,
|
|
onItemClick,
|
|
}: {
|
|
activities: Activity[];
|
|
onItemClick: (a: Activity) => void;
|
|
}) {
|
|
const [filter, setFilter] = useState<"all" | "billing" | "orders" | "support">("all");
|
|
const filtered = activities.filter(a => {
|
|
if (filter === "all") return true;
|
|
if (filter === "billing") return a.type === "invoice_created" || a.type === "invoice_paid";
|
|
if (filter === "orders") return a.type === "service_activated";
|
|
if (filter === "support") return a.type === "case_created" || a.type === "case_closed";
|
|
return true;
|
|
});
|
|
return (
|
|
<div className="bg-card text-card-foreground rounded-2xl shadow-[var(--cp-card-shadow)] border border-border overflow-hidden">
|
|
<div className="px-6 py-4 border-b border-border">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-lg font-semibold text-foreground">Recent Activity</h3>
|
|
<div className="flex items-center space-x-1 bg-muted rounded-lg p-1">
|
|
{(
|
|
[
|
|
{ k: "all", label: "All" },
|
|
{ k: "billing", label: "Billing" },
|
|
{ k: "orders", label: "Orders" },
|
|
{ k: "support", label: "Support" },
|
|
] as const
|
|
).map(opt => (
|
|
<button
|
|
key={opt.k}
|
|
onClick={() => setFilter(opt.k)}
|
|
className={`px-2.5 py-1 text-xs rounded-md font-medium ${
|
|
filter === opt.k
|
|
? "bg-card text-foreground shadow-[var(--cp-shadow-1)]"
|
|
: "text-muted-foreground hover:text-foreground"
|
|
}`}
|
|
>
|
|
{opt.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="p-6 max-h-[360px] overflow-y-auto">
|
|
{filtered.length > 0 ? (
|
|
<div className="space-y-4">
|
|
{filtered.slice(0, 10).map(activity => {
|
|
const isClickable =
|
|
activity.type === "invoice_created" || activity.type === "invoice_paid";
|
|
return (
|
|
<DashboardActivityItem
|
|
key={activity.id}
|
|
activity={activity}
|
|
onClick={isClickable ? () => onItemClick(activity) : undefined}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-12">
|
|
<ArrowTrendingUpIcon className="mx-auto h-12 w-12 text-muted-foreground/60" />
|
|
<h3 className="mt-2 text-sm font-medium text-foreground">No recent activity</h3>
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
Your account activity will appear here.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|