207 lines
7.4 KiB
TypeScript
207 lines
7.4 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
|
|
|
import { useAuthStore } from "@/features/auth/stores/auth.store";
|
|
import { useDashboardSummary, useDashboardTasks } from "@/features/dashboard/hooks";
|
|
import { TaskList, QuickStats, ActivityFeed } from "@/features/dashboard/components";
|
|
import { ErrorState } from "@/components/atoms/error-state";
|
|
import { PageLayout } from "@/components/templates";
|
|
import { cn } from "@/shared/utils";
|
|
import { InlineToast } from "@/components/atoms/inline-toast";
|
|
import { useInternetEligibility } from "@/features/services/hooks";
|
|
|
|
export function DashboardView() {
|
|
const { user, isAuthenticated, loading: authLoading, clearLoading } = useAuthStore();
|
|
const hideToastTimeout = useRef<number | null>(null);
|
|
const [eligibilityToast, setEligibilityToast] = useState<{
|
|
visible: boolean;
|
|
text: string;
|
|
tone: "info" | "success" | "warning" | "error";
|
|
}>({ visible: false, text: "", tone: "info" });
|
|
|
|
// Clear auth loading state when dashboard loads (after successful login)
|
|
useEffect(() => {
|
|
clearLoading();
|
|
}, [clearLoading]);
|
|
|
|
const { data: summary, isLoading: summaryLoading, error } = useDashboardSummary();
|
|
const { tasks, isLoading: tasksLoading, taskCount } = useDashboardTasks();
|
|
const { data: eligibility } = useInternetEligibility({ enabled: isAuthenticated });
|
|
|
|
// Combined loading state - AppShell handles unauthenticated redirect
|
|
const isLoading = authLoading || summaryLoading;
|
|
|
|
useEffect(() => {
|
|
if (!isAuthenticated || !user?.id) return;
|
|
const eligibilityData = eligibility as
|
|
| { status?: string; eligibility?: string }
|
|
| null
|
|
| undefined;
|
|
const status = eligibilityData?.status;
|
|
if (!status) return; // query not ready yet
|
|
|
|
const key = `cp:internet-eligibility:last:${user.id}`;
|
|
const last = localStorage.getItem(key);
|
|
|
|
if (status === "pending") {
|
|
localStorage.setItem(key, "PENDING");
|
|
return;
|
|
}
|
|
|
|
const eligibilityValue = eligibilityData?.eligibility;
|
|
if (status === "eligible" && typeof eligibilityValue === "string") {
|
|
const current = eligibilityValue.trim();
|
|
if (last === "PENDING") {
|
|
setEligibilityToast({
|
|
visible: true,
|
|
text: "We've finished reviewing your address — you can now choose personalized internet plans.",
|
|
tone: "success",
|
|
});
|
|
if (hideToastTimeout.current) window.clearTimeout(hideToastTimeout.current);
|
|
hideToastTimeout.current = window.setTimeout(() => {
|
|
setEligibilityToast(t => ({ ...t, visible: false }));
|
|
hideToastTimeout.current = null;
|
|
}, 3500);
|
|
}
|
|
localStorage.setItem(key, current);
|
|
}
|
|
}, [eligibility, isAuthenticated, user?.id]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (hideToastTimeout.current) window.clearTimeout(hideToastTimeout.current);
|
|
};
|
|
}, []);
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<PageLayout title="Dashboard" description="Overview of your account" loading>
|
|
<div className="space-y-8">
|
|
{/* Greeting skeleton */}
|
|
<div className="space-y-3">
|
|
<div className="h-4 cp-skeleton-shimmer rounded w-24" />
|
|
<div className="h-10 cp-skeleton-shimmer rounded w-56" />
|
|
<div className="h-4 cp-skeleton-shimmer rounded w-40" />
|
|
</div>
|
|
{/* Tasks skeleton */}
|
|
<div className="space-y-4">
|
|
<div className="h-24 cp-skeleton-shimmer rounded-2xl" />
|
|
<div className="h-24 cp-skeleton-shimmer rounded-2xl" />
|
|
</div>
|
|
{/* Bottom section skeleton */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
<div className="h-44 cp-skeleton-shimmer rounded-2xl" />
|
|
<div className="h-44 cp-skeleton-shimmer rounded-2xl" />
|
|
</div>
|
|
</div>
|
|
</PageLayout>
|
|
);
|
|
}
|
|
|
|
// Handle error state
|
|
if (error) {
|
|
const errorMessage = (() => {
|
|
if (typeof error === "string") return error;
|
|
if (error instanceof Error) return error.message;
|
|
if (typeof error === "object" && "message" in error) {
|
|
return String((error as { message?: unknown }).message);
|
|
}
|
|
return "An unexpected error occurred";
|
|
})();
|
|
|
|
return (
|
|
<PageLayout title="Dashboard" description="Overview of your account">
|
|
<ErrorState title="Error loading dashboard" message={errorMessage} variant="page" />
|
|
</PageLayout>
|
|
);
|
|
}
|
|
|
|
// Get user's display name
|
|
const displayName = user?.firstname || user?.email?.split("@")[0] || "there";
|
|
|
|
// Determine urgency level for task badge
|
|
const hasUrgentTask = tasks.some(t => t.tone === "critical");
|
|
|
|
return (
|
|
<PageLayout title="Dashboard" description="Overview of your account">
|
|
<InlineToast
|
|
visible={eligibilityToast.visible}
|
|
text={eligibilityToast.text}
|
|
tone={eligibilityToast.tone}
|
|
/>
|
|
{/* Greeting Section */}
|
|
<div className="mb-8">
|
|
<p
|
|
className="text-sm font-medium text-muted-foreground animate-in fade-in slide-in-from-bottom-2 duration-500"
|
|
style={{ animationDelay: "0ms" }}
|
|
>
|
|
Welcome back
|
|
</p>
|
|
<h2
|
|
className="text-3xl sm:text-4xl font-bold text-foreground mt-1 font-display animate-in fade-in slide-in-from-bottom-4 duration-500"
|
|
style={{ animationDelay: "50ms" }}
|
|
>
|
|
{displayName}
|
|
</h2>
|
|
|
|
{/* Task status badge */}
|
|
{taskCount > 0 ? (
|
|
<div
|
|
className="flex items-center gap-2 mt-3 animate-in fade-in slide-in-from-bottom-4 duration-500"
|
|
style={{ animationDelay: "100ms" }}
|
|
>
|
|
<span
|
|
className={cn(
|
|
"inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-sm font-medium",
|
|
hasUrgentTask ? "bg-danger/10 text-danger" : "bg-warning/10 text-warning"
|
|
)}
|
|
>
|
|
{hasUrgentTask && <ExclamationTriangleIcon className="h-4 w-4" />}
|
|
{taskCount === 1 ? "1 task needs attention" : `${taskCount} tasks need attention`}
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<p
|
|
className="text-sm text-muted-foreground mt-2 animate-in fade-in slide-in-from-bottom-4 duration-500"
|
|
style={{ animationDelay: "100ms" }}
|
|
>
|
|
Everything is up to date
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Tasks Section - Main focus area */}
|
|
<section
|
|
className="mb-10 animate-in fade-in slide-in-from-bottom-6 duration-600"
|
|
style={{ animationDelay: "150ms" }}
|
|
aria-labelledby="tasks-heading"
|
|
>
|
|
<h3 id="tasks-heading" className="sr-only">
|
|
Your Tasks
|
|
</h3>
|
|
<TaskList tasks={tasks} isLoading={tasksLoading} maxTasks={4} />
|
|
</section>
|
|
|
|
{/* Bottom Section: Quick Stats + Recent Activity */}
|
|
<section
|
|
className="grid grid-cols-1 lg:grid-cols-2 gap-6 cp-stagger-children"
|
|
aria-label="Account overview"
|
|
>
|
|
<QuickStats
|
|
activeSubscriptions={summary?.stats?.activeSubscriptions ?? 0}
|
|
openCases={summary?.stats?.openCases ?? 0}
|
|
recentOrders={summary?.stats?.recentOrders}
|
|
isLoading={summaryLoading}
|
|
/>
|
|
<ActivityFeed
|
|
activities={summary?.recentActivity || []}
|
|
maxItems={5}
|
|
isLoading={summaryLoading}
|
|
/>
|
|
</section>
|
|
</PageLayout>
|
|
);
|
|
}
|