barsa b99799c2fe Refactor UI components and enhance styling consistency across the portal
- 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.
2025-12-16 13:54:31 +09:00

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>
);
}