refactor: remove unused billing and payment components, enhance animation capabilities

- Deleted loading and page components for invoices and payment methods to streamline the billing section.
- Updated AnimatedContainer, InlineToast, and other components to utilize framer-motion for improved animations.
- Refactored AppShell and Sidebar components to enhance layout and integrate new animation features.
- Adjusted various sections across the portal to ensure consistent animation behavior and visual appeal.
This commit is contained in:
barsa 2026-03-06 14:48:34 +09:00
parent be3388cf58
commit 7502068ea9
38 changed files with 1256 additions and 785 deletions

View File

@ -1,16 +0,0 @@
import { RouteLoading } from "@/components/molecules/RouteLoading";
import { CreditCardIcon } from "@heroicons/react/24/outline";
import { LoadingTable } from "@/components/atoms/loading-skeleton";
export default function AccountInvoicesLoading() {
return (
<RouteLoading
icon={<CreditCardIcon />}
title="Invoices"
description="Manage and view your billing invoices"
mode="content"
>
<LoadingTable rows={6} columns={5} />
</RouteLoading>
);
}

View File

@ -1,5 +0,0 @@
import InvoicesListContainer from "@/features/billing/views/InvoicesList";
export default function AccountInvoicesPage() {
return <InvoicesListContainer />;
}

View File

@ -0,0 +1,5 @@
import { BillingOverview } from "@/features/billing/views/BillingOverview";
export default function AccountBillingPage() {
return <BillingOverview />;
}

View File

@ -1,19 +0,0 @@
import { RouteLoading } from "@/components/molecules/RouteLoading";
import { CreditCardIcon } from "@heroicons/react/24/outline";
import { LoadingCard } from "@/components/atoms/loading-skeleton";
export default function AccountPaymentsLoading() {
return (
<RouteLoading
icon={<CreditCardIcon />}
title="Payment Methods"
description="Manage your payment methods"
mode="content"
>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<LoadingCard />
<LoadingCard />
</div>
</RouteLoading>
);
}

View File

@ -1,5 +0,0 @@
import PaymentMethodsContainer from "@/features/billing/views/PaymentMethods";
export default function AccountPaymentMethodsPage() {
return <PaymentMethodsContainer />;
}

View File

@ -1,22 +1,43 @@
"use client";
import { motion, type Variants } from "framer-motion";
import { cn } from "@/shared/utils";
interface AnimatedContainerProps {
children: React.ReactNode;
className?: string;
/** Animation type */
animation?: "fade-up" | "fade-scale" | "slide-left" | "none";
/** Whether to stagger children animations */
stagger?: boolean;
/** Delay before animation starts in ms */
delay?: number;
}
/**
* Reusable animation wrapper component
* Provides consistent entrance animations for page content
*/
const fadeUp: Variants = {
hidden: { opacity: 0, y: 16 },
visible: { opacity: 1, y: 0, transition: { duration: 0.3, ease: "easeOut" } },
};
const fadeScale: Variants = {
hidden: { opacity: 0, scale: 0.95 },
visible: { opacity: 1, scale: 1, transition: { duration: 0.2, ease: "easeOut" } },
};
const slideLeft: Variants = {
hidden: { opacity: 0, x: -24 },
visible: { opacity: 1, x: 0, transition: { duration: 0.3, ease: "easeOut" } },
};
const noneVariant: Variants = {
hidden: {},
visible: {},
};
const variantMap = {
"fade-up": fadeUp,
"fade-scale": fadeScale,
"slide-left": slideLeft,
none: noneVariant,
} as const;
export function AnimatedContainer({
children,
className,
@ -24,19 +45,19 @@ export function AnimatedContainer({
stagger = false,
delay = 0,
}: AnimatedContainerProps) {
const animationClass = {
"fade-up": "cp-animate-in",
"fade-scale": "cp-animate-scale-in",
"slide-left": "cp-animate-slide-left",
none: "",
}[animation];
const variants = variantMap[animation];
return (
<div
className={cn(animationClass, stagger && "cp-stagger-children", className)}
style={delay > 0 ? { animationDelay: `${delay}ms` } : undefined}
<motion.div
className={cn(className)}
initial="hidden"
animate="visible"
variants={variants}
transition={
stagger ? { staggerChildren: 0.1, delayChildren: delay / 1000 } : { delay: delay / 1000 }
}
>
{children}
</div>
</motion.div>
);
}

View File

@ -1,21 +1,18 @@
import type { HTMLAttributes } from "react";
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { cn } from "@/shared/utils";
type Tone = "info" | "success" | "warning" | "error";
interface InlineToastProps extends HTMLAttributes<HTMLDivElement> {
interface InlineToastProps {
visible: boolean;
text: string;
tone?: Tone;
className?: string;
}
export function InlineToast({
visible,
text,
tone = "info",
className = "",
...rest
}: InlineToastProps) {
export function InlineToast({ visible, text, tone = "info", className = "" }: InlineToastProps) {
const toneClasses = {
success: "bg-success-bg border-success-border text-success",
warning: "bg-warning-bg border-warning-border text-warning",
@ -24,13 +21,14 @@ export function InlineToast({
}[tone];
return (
<div
className={cn(
"fixed bottom-6 right-6 z-50",
visible ? "cp-toast-enter" : "cp-toast-exit pointer-events-none",
className
)}
{...rest}
<AnimatePresence>
{visible && (
<motion.div
className={cn("fixed bottom-6 right-6 z-50", className)}
initial={{ opacity: 0, x: "100%", scale: 0.9 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: "100%", scale: 0.9 }}
transition={{ duration: 0.3, ease: [0.175, 0.885, 0.32, 1.275] }}
>
<div
className={cn(
@ -40,6 +38,8 @@ export function InlineToast({
>
<span>{text}</span>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@ -1,3 +1,6 @@
"use client";
import { motion } from "framer-motion";
import { ReactNode } from "react";
interface AnimatedCardProps {
@ -8,6 +11,9 @@ interface AnimatedCardProps {
disabled?: boolean | undefined;
}
const SHADOW_BASE = "0 1px 3px 0 rgb(0 0 0 / 0.06), 0 1px 2px -1px rgb(0 0 0 / 0.06)";
const SHADOW_ELEVATED = "0 4px 6px -1px rgb(0 0 0 / 0.07), 0 2px 4px -2px rgb(0 0 0 / 0.07)";
export function AnimatedCard({
children,
className = "",
@ -15,27 +21,30 @@ export function AnimatedCard({
onClick,
disabled = false,
}: AnimatedCardProps) {
const baseClasses =
"bg-card text-card-foreground rounded-xl border shadow-[var(--cp-shadow-1)] transition-shadow duration-[var(--cp-duration-normal)]";
const baseClasses = "bg-card text-card-foreground rounded-xl border";
const variantClasses: Record<"default" | "highlighted" | "success" | "static", string> = {
default: "border-border hover:shadow-[var(--cp-shadow-2)]",
highlighted: "border-primary/35 ring-1 ring-primary/15 hover:shadow-[var(--cp-shadow-2)]",
success: "border-success/25 ring-1 ring-success/15 hover:shadow-[var(--cp-shadow-2)]",
static: "border-border shadow-[var(--cp-shadow-1)]", // No hover animations for static containers
default: "border-border",
highlighted: "border-primary/35 ring-1 ring-primary/15",
success: "border-success/25 ring-1 ring-success/15",
static: "border-border",
};
const interactiveClasses = onClick && !disabled ? "cursor-pointer" : "";
const disabledClasses = disabled ? "opacity-50 cursor-not-allowed" : "";
const isStatic = variant === "static" || disabled;
return (
<div
<motion.div
className={`${baseClasses} ${variantClasses[variant]} ${interactiveClasses} ${disabledClasses} ${className}`}
initial={{ boxShadow: SHADOW_BASE }}
whileHover={isStatic ? {} : { boxShadow: SHADOW_ELEVATED }}
transition={{ duration: 0.2 }}
onClick={disabled ? undefined : onClick}
>
{children}
</div>
</motion.div>
);
}

View File

@ -2,12 +2,14 @@
import { useState, useEffect, useRef } from "react";
import { usePathname, useRouter } from "next/navigation";
import { motion, AnimatePresence } from "framer-motion";
import { useAuthStore, useAuthSession } from "@/features/auth/stores/auth.store";
import { accountService } from "@/features/account/api/account.api";
import { Bars3Icon } from "@heroicons/react/24/outline";
import { Sidebar } from "./Sidebar";
import { baseNavigation } from "./navigation";
import { Logo } from "@/components/atoms/logo";
import { NotificationBell } from "@/features/notifications";
interface AppShellProps {
children: React.ReactNode;
@ -108,8 +110,6 @@ function useSidebarExpansion(pathname: string) {
setExpandedItems(prev => {
const next = new Set(prev);
if (pathname.startsWith("/account/subscriptions")) next.add("Subscriptions");
if (pathname.startsWith("/account/billing")) next.add("Billing");
if (pathname.startsWith("/account/support")) next.add("Support");
const result = [...next];
if (result.length === prev.length && result.every(v => prev.includes(v))) return prev;
return result;
@ -166,13 +166,24 @@ export function AppShell({ children }: AppShellProps) {
<>
<div className="h-screen flex overflow-hidden bg-background">
{/* Mobile sidebar overlay */}
<AnimatePresence>
{sidebarOpen && (
<div className="fixed inset-0 flex z-50 md:hidden">
<div
className="fixed inset-0 bg-black/50 animate-in fade-in duration-300"
<motion.div
className="fixed inset-0 bg-black/50"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
onClick={() => setSidebarOpen(false)}
/>
<div className="relative flex-1 flex flex-col max-w-xs w-full bg-sidebar border-r border-sidebar-border animate-in slide-in-from-left duration-300 shadow-2xl">
<motion.div
className="relative flex-1 flex flex-col max-w-xs w-full bg-sidebar border-r border-sidebar-border shadow-2xl"
initial={{ x: "-100%" }}
animate={{ x: 0 }}
exit={{ x: "-100%" }}
transition={{ duration: 0.3, ease: "easeOut" }}
>
<div className="absolute top-0 right-0 -mr-12 pt-2" />
<Sidebar
navigation={navigation}
@ -191,9 +202,10 @@ export function AppShell({ children }: AppShellProps) {
}
profileReady={!!(user?.firstname || user?.lastname)}
/>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
{/* Desktop sidebar */}
<div className="hidden md:flex md:flex-shrink-0">
@ -219,19 +231,22 @@ export function AppShell({ children }: AppShellProps) {
{/* Main content */}
<div className="flex flex-col w-0 flex-1 overflow-hidden bg-background">
{/* Mobile-only hamburger bar */}
<div className="md:hidden flex items-center h-16 px-3 border-b border-border/40 bg-background">
{/* Header bar */}
<div className="flex items-center h-16 px-3 md:px-6 border-b border-border/40 bg-background flex-shrink-0">
{/* Mobile hamburger + logo */}
<button
type="button"
className="flex items-center justify-center w-10 h-10 -ml-1 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/60 active:bg-muted transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-primary/20"
className="md:hidden flex items-center justify-center w-10 h-10 -ml-1 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/60 active:bg-muted transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-primary/20"
onClick={() => setSidebarOpen(true)}
aria-label="Open navigation"
>
<Bars3Icon className="h-5 w-5" />
</button>
<div className="ml-2">
<div className="md:hidden ml-2">
<Logo size={20} />
</div>
<div className="flex-1" />
<NotificationBell />
</div>
{/* Main content area */}

View File

@ -3,9 +3,9 @@
import Link from "next/link";
import { memo } from "react";
import { useRouter } from "next/navigation";
import { motion, AnimatePresence } from "framer-motion";
import { useAuthStore } from "@/features/auth/stores/auth.store";
import { Logo } from "@/components/atoms/logo";
import { NotificationBell } from "@/features/notifications";
import type { NavigationChild, NavigationItem } from "./navigation";
import type { ComponentType, SVGProps } from "react";
@ -97,12 +97,11 @@ function SidebarProfile({
const initials = getSidebarInitials(user, profileReady, displayName);
return (
<div className="px-3 pb-4">
<div className="flex items-center gap-2 px-2 py-2 rounded-lg bg-white/10">
<div className="px-3 py-3">
<Link
href="/account/settings"
prefetch
className="flex items-center gap-2 flex-1 min-w-0 group"
className="flex items-center gap-2.5 px-2 py-2 rounded-lg bg-white/10 hover:bg-white/15 transition-colors group"
>
<div className="h-7 w-7 rounded-lg bg-white/20 flex items-center justify-center text-[11px] font-bold text-white flex-shrink-0">
{initials}
@ -111,11 +110,6 @@ function SidebarProfile({
{displayName}
</span>
</Link>
<NotificationBell
dropdownPosition="right"
className="flex-shrink-0 [&_button]:text-white/70 [&_button]:hover:text-white [&_button]:hover:bg-white/10 [&_button]:p-1.5 [&_button]:rounded-md"
/>
</div>
</div>
);
}
@ -142,12 +136,8 @@ export const Sidebar = memo(function Sidebar({
</div>
</div>
<div className="pt-4">
<SidebarProfile user={user} profileReady={profileReady} />
</div>
<div className="flex-1 flex flex-col pb-4 overflow-y-auto">
<nav className="flex-1 px-3 space-y-1">
<nav className="flex-1 px-3 pt-4 space-y-1">
{navigation
.filter(item => !item.isLogout)
.map(item => (
@ -161,6 +151,7 @@ export const Sidebar = memo(function Sidebar({
</div>
))}
</nav>
<SidebarProfile user={user} profileReady={profileReady} />
{navigation
.filter(item => item.isLogout)
.map(item => (
@ -205,8 +196,9 @@ function ExpandableNavItem({
{isActive && <ActiveIndicator />}
<NavIcon icon={item.icon} isActive={isActive} />
<span className="flex-1">{item.name}</span>
<motion.div animate={{ rotate: isExpanded ? 90 : 0 }} transition={{ duration: 0.2 }}>
<svg
className={`h-4 w-4 transition-transform duration-200 ease-out ${isExpanded ? "rotate-90" : ""} ${
className={`h-4 w-4 ${
isActive ? "text-white" : "text-white/70 group-hover:text-white"
}`}
viewBox="0 0 20 20"
@ -218,12 +210,17 @@ function ExpandableNavItem({
clipRule="evenodd"
/>
</svg>
</motion.div>
</button>
<div
className={`overflow-hidden transition-all duration-300 ease-out ${
isExpanded ? "max-h-96 opacity-100" : "max-h-0 opacity-0"
}`}
<AnimatePresence initial={false}>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: "easeOut" }}
style={{ overflow: "hidden" }}
>
<div className="mt-0.5 ml-[30px] space-y-0.5 border-l border-white/15 pl-3">
{item.children?.map((child: NavigationChild) => {
@ -248,7 +245,9 @@ function ExpandableNavItem({
);
})}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
@ -324,6 +323,10 @@ const NavigationItem = memo(function NavigationItem({
return <LogoutNavItem item={item} onLogout={handleLogout} />;
}
const isActive = item.href ? pathname === item.href : false;
const isActive = (() => {
if (!item.href) return false;
if (item.href === "/account") return pathname === item.href;
return pathname.startsWith(item.href);
})();
return <SimpleNavItem item={item} isActive={isActive} router={router} />;
});

View File

@ -26,27 +26,13 @@ export interface NavigationItem {
export const baseNavigation: NavigationItem[] = [
{ name: "Dashboard", href: "/account", icon: HomeIcon },
{ name: "Orders", href: "/account/orders", icon: ClipboardDocumentListIcon },
{
name: "Billing",
icon: CreditCardIcon,
children: [
{ name: "Invoices", href: "/account/billing/invoices" },
{ name: "Payment Methods", href: "/account/billing/payments" },
],
},
{ name: "Billing", href: "/account/billing", icon: CreditCardIcon },
{
name: "Subscriptions",
href: "/account/subscriptions",
icon: ServerIcon,
},
{ name: "Services", href: "/account/services", icon: Squares2X2Icon },
{
name: "Support",
icon: ChatBubbleLeftRightIcon,
children: [
{ name: "Cases", href: "/account/support" },
{ name: "New Case", href: "/account/support/new" },
],
},
{ name: "Support", href: "/account/support", icon: ChatBubbleLeftRightIcon },
{ name: "Log out", href: "#", icon: ArrowRightStartOnRectangleIcon, isLogout: true },
];

View File

@ -1,30 +1,27 @@
"use client";
import { cn } from "@/shared/utils";
import { AnimatePresence, motion } from "framer-motion";
interface AnimatedSectionProps {
/** Whether to show the section */
show: boolean;
/** Content to animate */
children: React.ReactNode;
/** Delay in ms before animation starts (default: 0) */
delay?: number;
}
/**
* Wrapper component that provides smooth height and opacity transitions.
* Uses CSS grid for smooth height animation.
*/
export function AnimatedSection({ show, children, delay = 0 }: AnimatedSectionProps) {
return (
<div
className={cn(
"grid transition-all duration-500 ease-out",
show ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
)}
style={{ transitionDelay: show ? `${delay}ms` : "0ms" }}
<AnimatePresence initial={false}>
{show && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.5, ease: "easeOut", delay: delay / 1000 }}
style={{ overflow: "hidden" }}
>
<div className="overflow-hidden">{children}</div>
</div>
{children}
</motion.div>
)}
</AnimatePresence>
);
}

View File

@ -157,7 +157,7 @@ const BillingSummary = forwardRef<HTMLDivElement, BillingSummaryProps>(
</div>
{!compact && (
<Link
href="/account/billing/invoices"
href="/account/billing"
className="inline-flex items-center text-sm text-primary hover:text-primary-hover font-medium"
>
View All
@ -184,7 +184,7 @@ const BillingSummary = forwardRef<HTMLDivElement, BillingSummaryProps>(
{compact && (
<div className="mt-4 pt-4 border-t border-border">
<Link
href="/account/billing/invoices"
href="/account/billing"
className="inline-flex items-center text-sm text-primary hover:text-primary-hover font-medium"
>
View All Invoices

View File

@ -0,0 +1,177 @@
"use client";
import { useState } from "react";
import { CreditCardIcon } from "@heroicons/react/24/outline";
import { PageLayout } from "@/components/templates/PageLayout";
import { ErrorBoundary } from "@/components/molecules";
import { useSession } from "@/features/auth/hooks";
import { useAuthStore } from "@/features/auth/stores/auth.store";
import { isApiError } from "@/core/api";
import { openSsoLink } from "@/features/billing/utils/sso";
import { usePaymentRefresh } from "@/features/billing/hooks/usePaymentRefresh";
import {
PaymentMethodCard,
usePaymentMethods,
useCreatePaymentMethodsSsoLink,
} from "@/features/billing";
import type { PaymentMethodList } from "@customer-portal/domain/payments";
import { InlineToast } from "@/components/atoms/inline-toast";
import { Button } from "@/components/atoms/button";
import { Skeleton } from "@/components/atoms/loading-skeleton";
import { InvoicesList } from "@/features/billing/components/InvoiceList/InvoiceList";
import { logger } from "@/core/logger";
function PaymentMethodsSkeleton() {
return (
<div className="bg-card rounded-xl border border-border shadow-[var(--cp-shadow-1)] p-6">
<div className="flex items-center justify-between mb-6">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-10 w-32" />
</div>
<div className="space-y-4">
{Array.from({ length: 2 }).map((_, i) => (
<div key={i} className="bg-muted rounded-lg p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Skeleton className="h-12 w-12 rounded-lg" />
<div className="space-y-2">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-4 w-24" />
</div>
</div>
<Skeleton className="h-9 w-28" />
</div>
</div>
))}
</div>
</div>
);
}
function PaymentMethodsSection({
paymentMethodsData,
onManage,
isPending,
}: {
paymentMethodsData: PaymentMethodList;
onManage: () => void;
isPending: boolean;
}) {
const hasMethods = paymentMethodsData.paymentMethods.length > 0;
return (
<div className="bg-card rounded-xl border border-border shadow-[var(--cp-shadow-1)] overflow-hidden">
<div className="px-6 py-5 border-b border-border">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-foreground">Payment Methods</h2>
<p className="text-sm text-muted-foreground mt-0.5">
{hasMethods
? `${paymentMethodsData.paymentMethods.length} payment method${paymentMethodsData.paymentMethods.length === 1 ? "" : "s"} on file`
: "No payment methods on file"}
</p>
</div>
<Button onClick={onManage} disabled={isPending} size="default">
{isPending ? "Opening..." : "Manage Cards"}
</Button>
</div>
</div>
{hasMethods && (
<div className="p-6">
<div className="space-y-4">
{paymentMethodsData.paymentMethods.map(paymentMethod => (
<PaymentMethodCard key={paymentMethod.id} paymentMethod={paymentMethod} />
))}
</div>
</div>
)}
</div>
);
}
export function BillingOverview() {
const [error, setError] = useState<string | null>(null);
const { isAuthenticated } = useSession();
const paymentMethodsQuery = usePaymentMethods();
const {
data: paymentMethodsData,
isLoading: isLoadingPaymentMethods,
isFetching: isFetchingPaymentMethods,
error: paymentMethodsError,
} = paymentMethodsQuery;
const createPaymentMethodsSsoLink = useCreatePaymentMethodsSsoLink();
const { hasCheckedAuth } = useAuthStore();
const paymentRefresh = usePaymentRefresh({
refetch: async () => {
const result = await paymentMethodsQuery.refetch();
return { data: result.data };
},
hasMethods: data => Boolean(data && (data.totalCount > 0 || data.paymentMethods.length > 0)),
attachFocusListeners: true,
});
const openPaymentMethods = async () => {
if (!isAuthenticated) {
setError("Please log in to access payment methods.");
return;
}
setError(null);
try {
const ssoLink = await createPaymentMethodsSsoLink.mutateAsync();
openSsoLink(ssoLink.url, { newTab: true });
} catch (err: unknown) {
logger.error("Failed to open payment methods", err);
if (
isApiError(err) &&
"response" in err &&
typeof err.response === "object" &&
err.response !== null &&
"status" in err.response &&
err.response.status === 401
) {
setError("Authentication failed. Please log in again.");
} else {
setError("Unable to access payment methods. Please try again later.");
}
}
};
const isPaymentLoading = !hasCheckedAuth || isLoadingPaymentMethods || isFetchingPaymentMethods;
const combinedError = (() => {
if (error) return new Error(error);
if (paymentMethodsError instanceof Error) return paymentMethodsError;
if (paymentMethodsError) return new Error(String(paymentMethodsError));
return null;
})();
return (
<PageLayout icon={<CreditCardIcon />} title="Billing" error={combinedError}>
<ErrorBoundary>
<InlineToast
visible={paymentRefresh.toast.visible}
text={paymentRefresh.toast.text}
tone={paymentRefresh.toast.tone}
/>
<div className="space-y-8">
{isPaymentLoading && <PaymentMethodsSkeleton />}
{!isPaymentLoading && paymentMethodsData && (
<PaymentMethodsSection
paymentMethodsData={paymentMethodsData}
onManage={() => void openPaymentMethods()}
isPending={createPaymentMethodsSsoLink.isPending}
/>
)}
<div>
<h2 className="text-lg font-semibold text-foreground mb-4">Invoices</h2>
<InvoicesList />
</div>
</div>
</ErrorBoundary>
</PageLayout>
);
}
export default BillingOverview;

View File

@ -81,7 +81,7 @@ export function InvoiceDetailContainer() {
<PageLayout
icon={<DocumentTextIcon />}
title="Invoice"
backLink={{ label: "Back to Invoices", href: "/account/billing/invoices" }}
backLink={{ label: "Back to Billing", href: "/account/billing" }}
>
<ErrorState
title="Error loading invoice"
@ -96,7 +96,7 @@ export function InvoiceDetailContainer() {
<PageLayout
icon={<DocumentTextIcon />}
title={`Invoice #${invoice.id}`}
backLink={{ label: "Back to Invoices", href: "/account/billing/invoices" }}
backLink={{ label: "Back to Billing", href: "/account/billing" }}
>
<div>
<div className="bg-card text-card-foreground rounded-xl shadow-[var(--cp-shadow-1)] border border-border overflow-hidden">

View File

@ -1,6 +1,7 @@
"use client";
import { useRouter } from "next/navigation";
import { motion } from "framer-motion";
import {
DocumentTextIcon,
CheckCircleIcon,
@ -13,6 +14,16 @@ import type { Activity } from "@customer-portal/domain/dashboard";
import { formatActivityDate, getActivityNavigationPath } from "../utils/dashboard.utils";
import { cn } from "@/shared/utils";
const containerVariants = {
hidden: {},
visible: { transition: { staggerChildren: 0.05 } },
};
const itemVariants = {
hidden: { opacity: 0, x: -8 },
visible: { opacity: 1, x: 0, transition: { duration: 0.2, ease: "easeOut" as const } },
};
interface ActivityFeedProps {
activities: Activity[];
maxItems?: number;
@ -182,15 +193,18 @@ export function ActivityFeed({
{visibleActivities.length === 0 ? (
<EmptyActivity />
) : (
<div className="bg-surface border border-border/60 rounded-xl p-4 cp-stagger-children">
<motion.div
className="bg-surface border border-border/60 rounded-xl p-4"
initial="hidden"
animate="visible"
variants={containerVariants}
>
{visibleActivities.map((activity, index) => (
<ActivityItem
key={activity.id}
activity={activity}
isLast={index === visibleActivities.length - 1}
/>
<motion.div key={activity.id} variants={itemVariants}>
<ActivityItem activity={activity} isLast={index === visibleActivities.length - 1} />
</motion.div>
))}
</div>
</motion.div>
)}
</div>
);

View File

@ -1,6 +1,7 @@
"use client";
import Link from "next/link";
import { motion } from "framer-motion";
import {
ServerIcon,
ChatBubbleLeftRightIcon,
@ -9,6 +10,16 @@ import {
} from "@heroicons/react/24/outline";
import { cn } from "@/shared/utils";
const containerVariants = {
hidden: {},
visible: { transition: { staggerChildren: 0.05 } },
};
const itemVariants = {
hidden: { opacity: 0, y: 16 },
visible: { opacity: 1, y: 0, transition: { duration: 0.2, ease: "easeOut" as const } },
};
interface QuickStatsProps {
activeSubscriptions: number;
openCases: number;
@ -132,7 +143,13 @@ export function QuickStats({
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-foreground">Account Overview</h3>
</div>
<div className="space-y-3 cp-stagger-children">
<motion.div
className="space-y-3"
initial="hidden"
animate="visible"
variants={containerVariants}
>
<motion.div variants={itemVariants}>
<StatItem
icon={ServerIcon}
label="Active Services"
@ -141,6 +158,8 @@ export function QuickStats({
tone="primary"
emptyText="No active services"
/>
</motion.div>
<motion.div variants={itemVariants}>
<StatItem
icon={ChatBubbleLeftRightIcon}
label="Open Support Cases"
@ -149,7 +168,9 @@ export function QuickStats({
tone={openCases > 0 ? "warning" : "info"}
emptyText="No open cases"
/>
</motion.div>
{recentOrders !== undefined && (
<motion.div variants={itemVariants}>
<StatItem
icon={ClipboardDocumentListIcon}
label="Recent Orders"
@ -158,8 +179,9 @@ export function QuickStats({
tone="success"
emptyText="No recent orders"
/>
</motion.div>
)}
</div>
</motion.div>
</div>
);
}

View File

@ -63,7 +63,7 @@ function AllCaughtUp() {
</Link>
<Link
href="/account/billing/invoices"
href="/account/billing"
className="group flex flex-col items-center gap-2 p-4 rounded-xl bg-surface/80 backdrop-blur-sm border border-border/60 hover:border-info/40 hover:shadow-lg transition-all"
>
<div className="w-10 h-10 rounded-lg bg-info/10 flex items-center justify-center group-hover:bg-info/20 transition-colors">

View File

@ -1,6 +1,7 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { motion } from "framer-motion";
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
import { useAuthStore } from "@/features/auth/stores/auth.store";
@ -12,6 +13,16 @@ import { cn } from "@/shared/utils";
import { InlineToast } from "@/components/atoms/inline-toast";
import { useInternetEligibility } from "@/features/services/hooks";
const gridContainerVariants = {
hidden: {},
visible: { transition: { staggerChildren: 0.05 } },
};
const gridItemVariants = {
hidden: { opacity: 0, y: 16 },
visible: { opacity: 1, y: 0, transition: { duration: 0.3, ease: "easeOut" as const } },
};
function DashboardSkeleton() {
return (
<PageLayout title="Dashboard" loading>
@ -54,13 +65,20 @@ function DashboardGreeting({
}) {
return (
<div className="mb-8">
<h2 className="text-2xl sm:text-3xl font-bold text-foreground font-heading tracking-tight animate-in fade-in slide-in-from-bottom-2 duration-500">
<motion.h2
className="text-2xl sm:text-3xl font-bold text-foreground font-heading tracking-tight"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
Welcome back, {displayName}
</h2>
</motion.h2>
{taskCount > 0 ? (
<div
className="flex items-center gap-2 mt-2 animate-in fade-in slide-in-from-bottom-4 duration-500"
style={{ animationDelay: "50ms" }}
<motion.div
className="flex items-center gap-2 mt-2"
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.05 }}
>
<span
className={cn(
@ -71,14 +89,16 @@ function DashboardGreeting({
{hasUrgentTask && <ExclamationTriangleIcon className="h-3.5 w-3.5" />}
{taskCount === 1 ? "1 task needs attention" : `${taskCount} tasks need attention`}
</span>
</div>
</motion.div>
) : (
<p
className="text-sm text-muted-foreground mt-1.5 animate-in fade-in slide-in-from-bottom-4 duration-500"
style={{ animationDelay: "50ms" }}
<motion.p
className="text-sm text-muted-foreground mt-1.5"
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.05 }}
>
Everything is up to date
</p>
</motion.p>
)}
</div>
);
@ -178,32 +198,41 @@ function DashboardContent({
taskCount={taskCount}
hasUrgentTask={tasks.some(t => t.tone === "critical")}
/>
<section
className="mb-10 animate-in fade-in slide-in-from-bottom-6 duration-600"
style={{ animationDelay: "150ms" }}
<motion.section
className="mb-10"
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.15 }}
aria-labelledby="tasks-heading"
>
<h3 id="tasks-heading" className="sr-only">
Your Tasks
</h3>
<TaskList tasks={tasks} isLoading={tasksLoading} maxTasks={4} />
</section>
<section
className="grid grid-cols-1 lg:grid-cols-2 gap-6 cp-stagger-children"
</motion.section>
<motion.section
className="grid grid-cols-1 lg:grid-cols-2 gap-6"
initial="hidden"
animate="visible"
variants={gridContainerVariants}
aria-label="Account overview"
>
<motion.div variants={gridItemVariants}>
<QuickStats
activeSubscriptions={summary?.stats?.activeSubscriptions ?? 0}
openCases={summary?.stats?.openCases ?? 0}
recentOrders={summary?.stats?.recentOrders}
isLoading={summaryLoading}
/>
</motion.div>
<motion.div variants={gridItemVariants}>
<ActivityFeed
activities={summary?.recentActivity || []}
maxItems={5}
isLoading={summaryLoading}
/>
</section>
</motion.div>
</motion.section>
</PageLayout>
);
}

View File

@ -1,8 +1,8 @@
"use client";
import { useRef } from "react";
import { motion, useInView } from "framer-motion";
import { Mail, MapPin, MessageSquare, PhoneCall, Train } from "lucide-react";
import { cn } from "@/shared/utils";
import { useInView } from "@/features/landing-page/hooks";
import { ContactForm } from "@/features/support/components";
function ContactFormSection() {
@ -95,16 +95,17 @@ function MapAndAddress() {
}
export function ContactSection() {
const [ref, isInView] = useInView<HTMLElement>();
const ref = useRef<HTMLElement>(null);
const isInView = useInView(ref, { once: true, amount: 0.1 });
return (
<section
<motion.section
id="contact"
ref={ref}
className={cn(
"bg-surface-sunken/30 py-14 sm:py-16 transition-all duration-700",
isInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
)}
initial={{ opacity: 0, y: 32 }}
animate={isInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 32 }}
transition={{ duration: 0.7, ease: "easeOut" as const }}
className="bg-surface-sunken/30 py-14 sm:py-16"
>
<div className="max-w-6xl mx-auto px-6 sm:px-10 lg:px-14 space-y-6">
<h2 className="text-2xl sm:text-3xl font-extrabold text-foreground font-heading">
@ -117,6 +118,6 @@ export function ContactSection() {
</div>
</div>
</div>
</section>
</motion.section>
);
}

View File

@ -1,24 +1,25 @@
"use client";
import { useRef } from "react";
import { motion, useInView } from "framer-motion";
import { ArrowRight } from "lucide-react";
import { Button } from "@/components/atoms/button";
import { cn } from "@/shared/utils";
import { useInView } from "@/features/landing-page/hooks";
interface HeroSectionProps {
heroCTARef: React.RefObject<HTMLDivElement | null>;
}
export function HeroSection({ heroCTARef }: HeroSectionProps) {
const [heroRef, heroInView] = useInView<HTMLDivElement>();
const heroRef = useRef<HTMLDivElement>(null);
const heroInView = useInView(heroRef, { once: true, amount: 0.1 });
return (
<div
<motion.div
ref={heroRef}
className={cn(
"relative flex-1 flex items-center py-16 sm:py-20 lg:py-24 overflow-hidden transition-all duration-700",
heroInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
)}
initial={{ opacity: 0, y: 32 }}
animate={heroInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 32 }}
transition={{ duration: 0.7, ease: "easeOut" as const }}
className="relative flex-1 flex items-center py-16 sm:py-20 lg:py-24 overflow-hidden"
>
{/* Gradient Background */}
<div className="absolute inset-0 bg-gradient-to-br from-surface-sunken via-background to-info-bg/80" />
@ -70,6 +71,6 @@ export function HeroSection({ heroCTARef }: HeroSectionProps) {
</Button>
</div>
</div>
</div>
</motion.div>
);
}

View File

@ -1,21 +1,22 @@
"use client";
import { useRef } from "react";
import Image from "next/image";
import { motion, useInView } from "framer-motion";
import { Download } from "lucide-react";
import { cn } from "@/shared/utils";
import { useInView } from "@/features/landing-page/hooks";
import { supportDownloads } from "@/features/landing-page/data";
export function SupportDownloadsSection() {
const [ref, isInView] = useInView();
const ref = useRef<HTMLElement>(null);
const isInView = useInView(ref, { once: true, amount: 0.1 });
return (
<section
<motion.section
ref={ref}
className={cn(
"py-14 sm:py-16 transition-all duration-700",
isInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
)}
initial={{ opacity: 0, y: 32 }}
animate={isInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 32 }}
transition={{ duration: 0.7, ease: "easeOut" as const }}
className="py-14 sm:py-16"
>
<div className="mx-auto max-w-5xl px-6 sm:px-10 lg:px-14">
<h2 className="text-center text-2xl sm:text-3xl font-extrabold text-foreground tracking-tight mb-2 font-heading">
@ -60,6 +61,6 @@ export function SupportDownloadsSection() {
))}
</div>
</div>
</section>
</motion.section>
);
}

View File

@ -1,10 +1,11 @@
"use client";
import { useRef } from "react";
import { motion, useInView } from "framer-motion";
import { Clock, CreditCard, Globe, Users } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { cn } from "@/shared/utils";
import { useCountUp } from "@/shared/hooks";
import { useInView } from "@/features/landing-page/hooks";
const numberFormatter = new Intl.NumberFormat();
@ -66,16 +67,17 @@ function AnimatedValue({
}
export function TrustStrip() {
const [ref, inView] = useInView<HTMLDivElement>();
const ref = useRef<HTMLDivElement>(null);
const inView = useInView(ref, { once: true, amount: 0.1 });
return (
<div
<motion.div
ref={ref}
aria-label="Company statistics"
className={cn(
"relative py-10 sm:py-12 overflow-hidden transition-all duration-700",
inView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
)}
initial={{ opacity: 0, y: 32 }}
animate={inView ? { opacity: 1, y: 0 } : { opacity: 0, y: 32 }}
transition={{ duration: 0.7, ease: "easeOut" as const }}
className="relative py-10 sm:py-12 overflow-hidden"
>
{/* Gradient background */}
<div className="absolute inset-0 bg-gradient-to-r from-surface-sunken via-background to-info-bg/30" />
@ -114,6 +116,6 @@ export function TrustStrip() {
))}
</div>
</div>
</div>
</motion.div>
);
}

View File

@ -1,10 +1,10 @@
"use client";
import { useRef } from "react";
import Image from "next/image";
import Link from "next/link";
import { motion, useInView } from "framer-motion";
import { ArrowRight, BadgeCheck } from "lucide-react";
import { cn } from "@/shared/utils";
import { useInView } from "@/features/landing-page/hooks";
const trustPoints = [
"Full English support, no Japanese needed",
@ -13,15 +13,15 @@ const trustPoints = [
];
export function WhyUsSection() {
const [ref, isInView] = useInView<HTMLDivElement>();
const ref = useRef<HTMLDivElement>(null);
const isInView = useInView(ref, { once: true, amount: 0.1 });
return (
<div
<motion.div
ref={ref}
className={cn(
"transition-all duration-700",
isInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
)}
initial={{ opacity: 0, y: 32 }}
animate={isInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 32 }}
transition={{ duration: 0.7, ease: "easeOut" as const }}
>
<div className="max-w-6xl mx-auto px-6 sm:px-10 lg:px-14 py-14 sm:py-16">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-10 lg:gap-14 items-center">
@ -63,6 +63,6 @@ export function WhyUsSection() {
</div>
</div>
</div>
</div>
</motion.div>
);
}

View File

@ -1,35 +1,27 @@
import { useEffect, useRef, useState } from "react";
import { useRef } from "react";
import { useInView as useFramerInView } from "framer-motion";
const DEFAULT_OPTIONS: IntersectionObserverInit = {};
interface UseInViewOptions {
threshold?: number | number[];
root?: Element | null;
}
const DEFAULT_OPTIONS: UseInViewOptions = {};
/**
* useInView - Intersection Observer hook for scroll-triggered animations
* Returns a ref and boolean indicating if element is in viewport.
* Once the element becomes visible, it stays marked as "in view" (trigger once).
* Scroll-triggered visibility hook (trigger once).
* Wraps framer-motion's useInView.
*/
export function useInView<T extends HTMLElement = HTMLElement>(
options: IntersectionObserverInit = DEFAULT_OPTIONS
options: UseInViewOptions = DEFAULT_OPTIONS
) {
const ref = useRef<T>(null!);
const [isInView, setIsInView] = useState(false);
useEffect(() => {
const element = ref.current;
if (!element) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) {
setIsInView(true);
observer.disconnect(); // triggerOnce
}
},
{ threshold: 0.1, ...options }
);
observer.observe(element);
return () => observer.disconnect();
}, [options]);
const isInView = useFramerInView(ref, {
once: true,
amount: typeof options.threshold === "number" ? options.threshold : 0.1,
...(options.root ? { root: { current: options.root } } : undefined),
});
return [ref, isInView] as const;
}

View File

@ -1,4 +1,7 @@
"use client";
import Image from "next/image";
import { motion } from "framer-motion";
import { ServiceCard } from "@/components/molecules";
import type { LucideIcon } from "lucide-react";
import {
@ -20,6 +23,16 @@ import {
BriefcaseBusiness,
} from "lucide-react";
const staggerContainerVariants = {
hidden: {},
visible: { transition: { staggerChildren: 0.05 } },
};
const fadeUpItemVariants = {
hidden: { opacity: 0, y: 16 },
visible: { opacity: 1, y: 0, transition: { duration: 0.3, ease: "easeOut" as const } },
};
/* ─── Data ─── */
const services = [
@ -132,14 +145,28 @@ function HeroSection() {
/>
<div className="relative mx-auto max-w-6xl px-6 py-16 sm:px-8 sm:py-20 lg:py-24">
<div className="grid grid-cols-1 items-center gap-12 lg:grid-cols-2">
<div className="cp-stagger-children space-y-6">
<span className="inline-block rounded-full bg-primary/10 px-4 py-1.5 text-sm font-semibold tracking-wide text-primary">
<motion.div
className="space-y-6"
initial="hidden"
animate="visible"
variants={staggerContainerVariants}
>
<motion.span
className="inline-block rounded-full bg-primary/10 px-4 py-1.5 text-sm font-semibold tracking-wide text-primary"
variants={fadeUpItemVariants}
>
Since 2002
</span>
<h1 className="text-display-lg font-extrabold leading-[1.1] tracking-tight font-heading text-foreground">
</motion.span>
<motion.h1
className="text-display-lg font-extrabold leading-[1.1] tracking-tight font-heading text-foreground"
variants={fadeUpItemVariants}
>
Your Trusted IT Partner <span className="cp-gradient-text">in Japan</span>
</h1>
<div className="max-w-lg space-y-4 text-base leading-relaxed text-muted-foreground sm:text-lg">
</motion.h1>
<motion.div
className="max-w-lg space-y-4 text-base leading-relaxed text-muted-foreground sm:text-lg"
variants={fadeUpItemVariants}
>
<p>
Assist Solutions has been the go-to IT partner for expats and international
businesses in Japan for over two decades. We understand the unique challenges of
@ -150,8 +177,8 @@ function HeroSection() {
English service. No Japanese required we handle everything from contracts to
installation.
</p>
</div>
</div>
</motion.div>
</motion.div>
<div className="relative mx-auto h-[380px] w-full max-w-md lg:h-[440px] lg:max-w-none">
<Image
src="/assets/images/about-us.png"
@ -172,13 +199,26 @@ function ServicesSection() {
return (
<section className="full-bleed bg-background py-16 sm:py-20">
<div className="mx-auto max-w-6xl px-6 sm:px-8">
<div className="cp-stagger-children mb-10 max-w-xl">
<h2 className="text-display-sm font-bold font-heading text-foreground">What We Do</h2>
<p className="mt-3 leading-relaxed text-muted-foreground">
<motion.div
className="mb-10 max-w-xl"
initial="hidden"
animate="visible"
variants={staggerContainerVariants}
>
<motion.h2
className="text-display-sm font-bold font-heading text-foreground"
variants={fadeUpItemVariants}
>
What We Do
</motion.h2>
<motion.p
className="mt-3 leading-relaxed text-muted-foreground"
variants={fadeUpItemVariants}
>
End-to-end IT services designed for the international community in Japan all in
English.
</p>
</div>
</motion.p>
</motion.div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{services.map(service => (
@ -201,21 +241,36 @@ function ValuesSection() {
return (
<section className="full-bleed bg-surface-sunken py-16 sm:py-20">
<div className="mx-auto max-w-6xl px-6 sm:px-8">
<div className="cp-stagger-children mb-10 text-center">
<h2 className="text-display-sm font-bold font-heading text-foreground">Our Values</h2>
<p className="mx-auto mt-3 max-w-lg leading-relaxed text-muted-foreground">
<motion.div
className="mb-10 text-center"
initial="hidden"
animate="visible"
variants={staggerContainerVariants}
>
<motion.h2
className="text-display-sm font-bold font-heading text-foreground"
variants={fadeUpItemVariants}
>
Our Values
</motion.h2>
<motion.p
className="mx-auto mt-3 max-w-lg leading-relaxed text-muted-foreground"
variants={fadeUpItemVariants}
>
These principles guide how we serve customers, support our community, and advance our
craft every day.
</p>
</div>
</motion.p>
</motion.div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
{values.map(value => {
const Icon = value.icon;
return (
<div
<motion.div
key={value.title}
className="group cp-card-hover-lift rounded-2xl border border-border/40 bg-card p-5"
className="group rounded-2xl border border-border/40 bg-card p-5"
whileHover={{ y: -2 }}
transition={{ duration: 0.2, ease: "easeOut" as const }}
>
<div
className={`mb-3 inline-flex h-10 w-10 items-center justify-center rounded-lg ${value.accent}`}
@ -226,7 +281,7 @@ function ValuesSection() {
{value.title}
</h3>
<p className="text-sm leading-relaxed text-muted-foreground">{value.text}</p>
</div>
</motion.div>
);
})}
</div>
@ -239,9 +294,19 @@ function CorporateSection() {
return (
<section className="full-bleed bg-background py-16 sm:py-20">
<div className="mx-auto max-w-6xl px-6 sm:px-8">
<div className="cp-stagger-children mb-10">
<h2 className="text-display-sm font-bold font-heading text-foreground">Corporate Data</h2>
</div>
<motion.div
className="mb-10"
initial="hidden"
animate="visible"
variants={staggerContainerVariants}
>
<motion.h2
className="text-display-sm font-bold font-heading text-foreground"
variants={fadeUpItemVariants}
>
Corporate Data
</motion.h2>
</motion.div>
<div className="grid grid-cols-1 gap-10 lg:grid-cols-5">
<CorporateDetails />

View File

@ -1,6 +1,7 @@
"use client";
import { memo, useState, useRef, useCallback, useEffect } from "react";
import { AnimatePresence } from "framer-motion";
import { BellIcon } from "@heroicons/react/24/outline";
import { useUnreadNotificationCount } from "../hooks/useNotifications";
import { NotificationDropdown } from "./NotificationDropdown";
@ -84,7 +85,15 @@ export const NotificationBell = memo(function NotificationBell({
)}
</button>
<NotificationDropdown isOpen={isOpen} onClose={closeDropdown} position={dropdownPosition} />
<AnimatePresence>
{isOpen && (
<NotificationDropdown
isOpen={isOpen}
onClose={closeDropdown}
position={dropdownPosition}
/>
)}
</AnimatePresence>
</div>
);
});

View File

@ -2,6 +2,7 @@
import { memo } from "react";
import Link from "next/link";
import { motion } from "framer-motion";
import { CheckIcon } from "@heroicons/react/24/outline";
import { BellSlashIcon } from "@heroicons/react/24/solid";
import {
@ -37,15 +38,16 @@ export const NotificationDropdown = memo(function NotificationDropdown({
const notifications = data?.notifications ?? [];
const hasUnread = (data?.unreadCount ?? 0) > 0;
if (!isOpen) return null;
return (
<div
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.1 }}
className={cn(
"absolute w-80 sm:w-96",
position === "right" ? "left-full top-0 ml-2" : "right-0 top-full mt-2",
"bg-popover border border-border rounded-xl shadow-lg z-50 overflow-hidden",
"animate-in fade-in-0 zoom-in-95 duration-100"
"bg-popover border border-border rounded-xl shadow-lg z-50 overflow-hidden"
)}
>
{/* Header */}
@ -105,6 +107,6 @@ export const NotificationDropdown = memo(function NotificationDropdown({
</Link>
</div>
)}
</div>
</motion.div>
);
});

View File

@ -1,8 +1,8 @@
"use client";
import { useState, type ElementType, type ReactNode } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { ChevronDown } from "lucide-react";
import { cn } from "@/shared/utils";
interface CollapsibleSectionProps {
title: string;
@ -30,21 +30,23 @@ export function CollapsibleSection({
<Icon className="w-4 h-4 text-primary" />
<span className="text-sm font-medium text-foreground">{title}</span>
</div>
<ChevronDown
className={cn(
"w-4 h-4 text-muted-foreground transition-transform duration-200",
isOpen && "rotate-180"
)}
/>
<motion.div animate={{ rotate: isOpen ? 180 : 0 }} transition={{ duration: 0.2 }}>
<ChevronDown className="w-4 h-4 text-muted-foreground" />
</motion.div>
</button>
<div
className={cn(
"overflow-hidden transition-all duration-300",
isOpen ? "max-h-[2000px]" : "max-h-0"
)}
<AnimatePresence initial={false}>
{isOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: "easeOut" }}
style={{ overflow: "hidden" }}
>
<div className="p-4 pt-0 border-t border-border/60">{children}</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@ -33,6 +33,7 @@ import {
type HighlightFeature,
} from "@/features/services/components/base/ServiceHighlights";
import { SimHowItWorksSection } from "@/features/services/components/sim/SimHowItWorksSection";
import { AnimatePresence, motion } from "framer-motion";
import { cn } from "@/shared/utils";
export type SimPlansTab = "data-voice" | "data-only" | "voice-only";
@ -339,12 +340,14 @@ function SimPlansGrid({
return (
<div id="plans" className="min-h-[280px] overflow-hidden">
<div
<AnimatePresence mode="wait">
<motion.div
key={activeTab}
className={cn(
"space-y-8",
slideDirection === "left" ? "cp-slide-fade-left" : "cp-slide-fade-right"
)}
className="space-y-8"
initial={{ opacity: 0, x: slideDirection === "left" ? 24 : -24 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: slideDirection === "left" ? -24 : 24 }}
transition={{ duration: 0.3, ease: "easeOut" as const }}
>
{regularPlans.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
@ -367,7 +370,8 @@ function SimPlansGrid({
</div>
</div>
)}
</div>
</motion.div>
</AnimatePresence>
</div>
);
}

View File

@ -37,6 +37,7 @@ import {
getTierDescription,
getTierFeatures,
} from "@/features/services/utils/internet-config";
import { AnimatePresence, motion } from "framer-motion";
import { cn } from "@/shared/utils";
// ─── Types ────────────────────────────────────────────────────────────────────
@ -302,14 +303,20 @@ function UnifiedInternetCard({
</div>
<div className="p-5 pt-3">
<div
<AnimatePresence mode="wait">
<motion.div
key={selectedOffering}
className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6 cp-slide-fade-left"
className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6"
initial={{ opacity: 0, x: 24 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -24 }}
transition={{ duration: 0.3, ease: "easeOut" as const }}
>
{displayTiers.map(tier => (
<TierCard key={tier.tier} tier={tier} />
))}
</div>
</motion.div>
</AnimatePresence>
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-4 pt-4 border-t border-border">
<p className="text-sm text-muted-foreground">
<span className="font-semibold text-foreground">

View File

@ -205,7 +205,7 @@ function SubscriptionDetailContent({
tone="primary"
actions={
<Link
href="/account/billing/invoices"
href="/account/billing"
className="text-sm font-medium text-primary hover:text-primary/80 transition-colors"
>
View Invoices

View File

@ -1,6 +1,7 @@
"use client";
import { useState, useMemo } from "react";
import { motion } from "framer-motion";
import { Button } from "@/components/atoms/button";
import { ViewToggle, type ViewMode } from "@/components/atoms/view-toggle";
import { MetricCard, MetricCardSkeleton } from "@/components/molecules/MetricCard";
@ -22,6 +23,16 @@ import {
type SubscriptionStatus,
} from "@customer-portal/domain/subscriptions";
const gridContainerVariants = {
hidden: {},
visible: { transition: { staggerChildren: 0.03 } },
};
const gridItemVariants = {
hidden: { opacity: 0, y: 12 },
visible: { opacity: 1, y: 0, transition: { duration: 0.2, ease: "easeOut" as const } },
};
const SUBSCRIPTION_STATUS_OPTIONS = Object.values(SUBSCRIPTION_STATUS) as SubscriptionStatus[];
function SubscriptionMetrics({
@ -87,13 +98,20 @@ function SubscriptionGrid({
}
return (
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3 cp-stagger-grid">
<motion.div
className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3"
initial="hidden"
animate="visible"
variants={gridContainerVariants}
>
{subscriptions.map(sub => (
<SubscriptionGridCard key={sub.serviceId} subscription={sub} />
<motion.div key={sub.serviceId} variants={gridItemVariants}>
<SubscriptionGridCard subscription={sub} />
</motion.div>
))}
{loading &&
Array.from({ length: 3 }).map((_, i) => <SubscriptionGridCardSkeleton key={`skel-${i}`} />)}
</div>
</motion.div>
);
}

View File

@ -5,4 +5,3 @@ export { useZodForm } from "./useZodForm";
export { useCurrency } from "./useCurrency";
export { useFormatCurrency, type FormatCurrencyOptions } from "./useFormatCurrency";
export { useCountUp } from "./useCountUp";
export { useAfterPaint } from "./useAfterPaint";

View File

@ -1,25 +0,0 @@
"use client";
import { useEffect } from "react";
/**
* Schedules a callback after the browser has painted, using a double-rAF.
* The first frame lets the browser commit the current DOM state,
* the second frame runs the callback after that paint is on screen.
*
* Useful for re-enabling CSS transitions after an instant DOM snap.
*/
export function useAfterPaint(callback: () => void, enabled: boolean) {
useEffect(() => {
if (!enabled) return;
let id1 = 0;
let id2 = 0;
id1 = requestAnimationFrame(() => {
id2 = requestAnimationFrame(callback);
});
return () => {
cancelAnimationFrame(id1);
cancelAnimationFrame(id2);
};
}, [enabled, callback]);
}

View File

@ -1,27 +1,16 @@
"use client";
import { useState, useEffect, useRef } from "react";
const reducedMotionQuery =
typeof window === "undefined" ? undefined : window.matchMedia("(prefers-reduced-motion: reduce)");
import { useState, useEffect } from "react";
import { animate, useReducedMotion } from "framer-motion";
interface UseCountUpOptions {
/** Starting value (default: 0) */
start?: number;
/** Target value to count to */
end: number;
/** Animation duration in ms (default: 300) */
duration?: number;
/** Delay before starting animation in ms (default: 0) */
delay?: number;
/** Whether animation is enabled (default: true) */
enabled?: boolean;
}
/**
* Animated counter hook for stats and numbers
* Uses requestAnimationFrame for smooth 60fps animation
*/
export function useCountUp({
start = 0,
end,
@ -30,8 +19,7 @@ export function useCountUp({
enabled = true,
}: UseCountUpOptions): number {
const [count, setCount] = useState(start);
const frameRef = useRef<number | undefined>(undefined);
const startTimeRef = useRef<number | undefined>(undefined);
const prefersReducedMotion = useReducedMotion();
useEffect(() => {
if (!enabled) {
@ -39,42 +27,28 @@ export function useCountUp({
return;
}
// Respect prefers-reduced-motion — show final value immediately
if (reducedMotionQuery?.matches) {
if (prefersReducedMotion) {
setCount(end);
return;
}
startTimeRef.current = undefined;
let controls: ReturnType<typeof animate> | undefined;
const timeout = setTimeout(() => {
const animate = (timestamp: number) => {
if (!startTimeRef.current) {
startTimeRef.current = timestamp;
}
const progress = Math.min((timestamp - startTimeRef.current) / duration, 1);
// Ease-out cubic for smooth deceleration
const eased = 1 - Math.pow(1 - progress, 3);
const next = Math.round(start + (end - start) * eased);
setCount(prev => (prev === next ? prev : next));
if (progress < 1) {
frameRef.current = requestAnimationFrame(animate);
}
};
frameRef.current = requestAnimationFrame(animate);
controls = animate(start, end, {
duration: duration / 1000,
ease: [0, 0, 0.2, 1],
onUpdate: value => {
setCount(Math.round(value));
},
});
}, delay);
return () => {
clearTimeout(timeout);
if (frameRef.current) {
cancelAnimationFrame(frameRef.current);
}
controls?.stop();
};
}, [start, end, duration, delay, enabled]);
}, [start, end, duration, delay, enabled, prefersReducedMotion]);
return count;
}

View File

@ -7,39 +7,6 @@
/* ===== KEYFRAMES ===== */
@keyframes cp-fade-up {
from {
opacity: 0;
transform: translateY(var(--cp-translate-lg));
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes cp-fade-scale {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes cp-slide-in-left {
from {
opacity: 0;
transform: translateX(calc(var(--cp-translate-xl) * -1));
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes cp-shimmer {
0% {
transform: translateX(-100%);
@ -49,28 +16,6 @@
}
}
@keyframes cp-toast-enter {
from {
opacity: 0;
transform: translateX(100%) scale(0.9);
}
to {
opacity: 1;
transform: translateX(0) scale(1);
}
}
@keyframes cp-toast-exit {
from {
opacity: 1;
transform: translateX(0) scale(1);
}
to {
opacity: 0;
transform: translateX(100%) scale(0.9);
}
}
@keyframes cp-shake {
0%,
100% {
@ -86,69 +31,6 @@
}
}
@keyframes cp-activity-enter {
from {
opacity: 0;
transform: translateX(-8px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes cp-slide-fade-left {
from {
opacity: 0;
transform: translateX(24px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes cp-slide-fade-right {
from {
opacity: 0;
transform: translateX(-24px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes cp-float {
0%,
100% {
transform: translateY(0px) rotate(0deg);
}
50% {
transform: translateY(-20px) rotate(2deg);
}
}
@keyframes cp-float-slow {
0%,
100% {
transform: translateY(0px) rotate(0deg);
}
50% {
transform: translateY(-12px) rotate(-1deg);
}
}
@keyframes cp-pulse-glow {
0%,
100% {
box-shadow: 0 0 0 0 var(--primary);
}
50% {
box-shadow: 0 0 20px 4px color-mix(in oklch, var(--primary) 40%, transparent);
}
}
/* Legacy shimmer animation for compatibility */
@keyframes cp-skeleton-shimmer {
0% {
@ -208,100 +90,6 @@
}
@layer utilities {
/* ===== PAGE ENTRANCE ANIMATIONS ===== */
.cp-animate-in {
animation: cp-fade-up var(--cp-duration-slow) var(--cp-ease-out) forwards;
}
.cp-animate-scale-in {
animation: cp-fade-scale var(--cp-duration-normal) var(--cp-ease-out) forwards;
}
.cp-animate-slide-left {
animation: cp-slide-in-left var(--cp-duration-slow) var(--cp-ease-out) forwards;
}
/* Staggered children animation */
.cp-stagger-children > * {
opacity: 0;
animation: cp-fade-up var(--cp-duration-slow) var(--cp-ease-out) forwards;
}
.cp-stagger-children > *:nth-child(1) {
animation-delay: var(--cp-stagger-1);
}
.cp-stagger-children > *:nth-child(2) {
animation-delay: var(--cp-stagger-2);
}
.cp-stagger-children > *:nth-child(3) {
animation-delay: var(--cp-stagger-3);
}
.cp-stagger-children > *:nth-child(4) {
animation-delay: var(--cp-stagger-4);
}
.cp-stagger-children > *:nth-child(5) {
animation-delay: var(--cp-stagger-5);
}
.cp-stagger-children > *:nth-child(n + 6) {
animation-delay: calc(var(--cp-stagger-5) + 50ms);
}
/* Card grid stagger - faster delay for dense grids */
.cp-stagger-grid > * {
opacity: 0;
animation: cp-fade-up var(--cp-duration-normal) var(--cp-ease-out) forwards;
}
.cp-stagger-grid > *:nth-child(1) {
animation-delay: 0ms;
}
.cp-stagger-grid > *:nth-child(2) {
animation-delay: 30ms;
}
.cp-stagger-grid > *:nth-child(3) {
animation-delay: 60ms;
}
.cp-stagger-grid > *:nth-child(4) {
animation-delay: 90ms;
}
.cp-stagger-grid > *:nth-child(5) {
animation-delay: 120ms;
}
.cp-stagger-grid > *:nth-child(6) {
animation-delay: 150ms;
}
.cp-stagger-grid > *:nth-child(n + 7) {
animation-delay: 180ms;
}
/* ===== TAB SLIDE TRANSITIONS ===== */
.cp-slide-fade-left {
animation: cp-slide-fade-left 300ms var(--cp-ease-out) forwards;
}
.cp-slide-fade-right {
animation: cp-slide-fade-right 300ms var(--cp-ease-out) forwards;
}
/* ===== CARD HOVER LIFT ===== */
.cp-card-hover-lift {
transition:
transform var(--cp-duration-normal) var(--cp-ease-out),
box-shadow var(--cp-duration-normal) var(--cp-ease-out);
}
.cp-card-hover-lift:hover {
transform: translateY(-2px);
box-shadow:
0 10px 40px -10px rgb(0 0 0 / 0.15),
0 4px 6px -2px rgb(0 0 0 / 0.05);
}
.cp-card-hover-lift:active {
transform: translateY(0);
transition-duration: var(--cp-duration-fast);
}
/* ===== SKELETON SHIMMER ===== */
.cp-skeleton-shimmer {
position: relative;
@ -339,50 +127,6 @@
animation: cp-shake var(--cp-duration-slow) var(--cp-ease-out);
}
/* ===== TOAST ANIMATIONS ===== */
.cp-toast-enter {
animation: cp-toast-enter var(--cp-duration-slow) var(--cp-ease-spring) forwards;
}
.cp-toast-exit {
animation: cp-toast-exit var(--cp-duration-normal) var(--cp-ease-in) forwards;
}
/* ===== ACTIVITY FEED ===== */
.cp-activity-item {
opacity: 0;
animation: cp-activity-enter var(--cp-duration-normal) var(--cp-ease-out) forwards;
}
.cp-activity-item:nth-child(1) {
animation-delay: 0ms;
}
.cp-activity-item:nth-child(2) {
animation-delay: 50ms;
}
.cp-activity-item:nth-child(3) {
animation-delay: 100ms;
}
.cp-activity-item:nth-child(4) {
animation-delay: 150ms;
}
.cp-activity-item:nth-child(5) {
animation-delay: 200ms;
}
/* ===== FLOAT ANIMATIONS ===== */
.cp-float {
animation: cp-float 6s ease-in-out infinite;
}
.cp-float-slow {
animation: cp-float-slow 8s ease-in-out infinite;
}
.cp-float-delayed {
animation: cp-float 6s ease-in-out infinite 2s;
}
/* ===== GLASS MORPHISM ===== */
.cp-glass {
background: var(--glass-bg);
@ -477,10 +221,6 @@
box-shadow: var(--shadow-primary-lg);
}
.cp-glow-pulse {
animation: cp-pulse-glow 2s ease-in-out infinite;
}
/* ===== PREMIUM CARD VARIANTS ===== */
.cp-card-glass {
background: var(--glass-bg);
@ -700,21 +440,9 @@
/* ===== ACCESSIBILITY: REDUCED MOTION ===== */
@media (prefers-reduced-motion: reduce) {
.cp-animate-in,
.cp-animate-scale-in,
.cp-animate-slide-left,
.cp-stagger-children > *,
.cp-stagger-grid > *,
.cp-card-hover-lift,
.cp-slide-fade-left,
.cp-slide-fade-right,
.cp-toast-enter,
.cp-toast-exit,
.cp-activity-item,
.cp-float,
.cp-float-slow,
.cp-float-delayed,
.cp-glow-pulse {
.cp-skeleton-shimmer::after,
.cp-skeleton::after,
.cp-input-error-shake {
animation: none !important;
transition: none !important;
opacity: 1 !important;

View File

@ -0,0 +1,458 @@
# Sidebar Navigation Consolidation
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Simplify sidebar by removing expandable sub-menus — make Support and Billing flat links, creating a combined Billing page.
**Architecture:** Remove children from navigation items, create a new combined Billing page view that composes existing PaymentMethods and InvoicesList components, update route structure to add `/account/billing` page.
**Tech Stack:** Next.js 15, React 19, Tailwind CSS, existing billing/support feature components
---
### Task 1: Make Support a flat sidebar link
**Files:**
- Modify: `apps/portal/src/components/organisms/AppShell/navigation.ts:46-49`
**Step 1: Update navigation config**
Change the Support entry from expandable (with children) to a flat link:
```ts
// Before (lines 46-49):
{
name: "Support",
icon: ChatBubbleLeftRightIcon,
children: [
{ name: "Cases", href: "/account/support" },
{ name: "New Case", href: "/account/support/new" },
],
},
// After:
{ name: "Support", href: "/account/support", icon: ChatBubbleLeftRightIcon },
```
**Step 2: Remove auto-expand logic for Support in AppShell**
File: `apps/portal/src/components/organisms/AppShell/AppShell.tsx:112`
Remove the line:
```ts
if (pathname.startsWith("/account/support")) next.add("Support");
```
**Step 3: Verify**
Run: `pnpm type-check`
Expected: PASS — no type errors
**Step 4: Commit**
```bash
git add apps/portal/src/components/organisms/AppShell/navigation.ts apps/portal/src/components/organisms/AppShell/AppShell.tsx
git commit -m "refactor: make Support a flat sidebar link"
```
---
### Task 2: Create combined Billing page view
**Files:**
- Create: `apps/portal/src/features/billing/views/BillingOverview.tsx`
**Step 1: Create the combined billing view**
This view composes existing `PaymentMethodsContainer` content and `InvoicesList` into one page. We reuse the existing components directly — payment methods section on top, invoices below.
```tsx
"use client";
import { useState } from "react";
import { CreditCardIcon } from "@heroicons/react/24/outline";
import { PageLayout } from "@/components/templates/PageLayout";
import { ErrorBoundary } from "@/components/molecules";
import { useSession } from "@/features/auth/hooks";
import { useAuthStore } from "@/features/auth/stores/auth.store";
import { isApiError } from "@/core/api";
import { openSsoLink } from "@/features/billing/utils/sso";
import { usePaymentRefresh } from "@/features/billing/hooks/usePaymentRefresh";
import {
PaymentMethodCard,
usePaymentMethods,
useCreatePaymentMethodsSsoLink,
} from "@/features/billing";
import type { PaymentMethodList } from "@customer-portal/domain/payments";
import { InlineToast } from "@/components/atoms/inline-toast";
import { Button } from "@/components/atoms/button";
import { Skeleton } from "@/components/atoms/loading-skeleton";
import { InvoicesList } from "@/features/billing/components/InvoiceList/InvoiceList";
import { logger } from "@/core/logger";
function PaymentMethodsSkeleton() {
return (
<div className="bg-card rounded-xl border border-border shadow-[var(--cp-shadow-1)] p-6">
<div className="flex items-center justify-between mb-6">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-10 w-32" />
</div>
<div className="space-y-4">
{Array.from({ length: 2 }).map((_, i) => (
<div key={i} className="bg-muted rounded-lg p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Skeleton className="h-12 w-12 rounded-lg" />
<div className="space-y-2">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-4 w-24" />
</div>
</div>
<Skeleton className="h-9 w-28" />
</div>
</div>
))}
</div>
</div>
);
}
function PaymentMethodsSection({
paymentMethodsData,
onManage,
isPending,
}: {
paymentMethodsData: PaymentMethodList;
onManage: () => void;
isPending: boolean;
}) {
const hasMethods = paymentMethodsData.paymentMethods.length > 0;
return (
<div className="bg-card rounded-xl border border-border shadow-[var(--cp-shadow-1)] overflow-hidden">
<div className="px-6 py-5 border-b border-border">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-foreground">Payment Methods</h2>
<p className="text-sm text-muted-foreground mt-0.5">
{hasMethods
? `${paymentMethodsData.paymentMethods.length} payment method${paymentMethodsData.paymentMethods.length === 1 ? "" : "s"} on file`
: "No payment methods on file"}
</p>
</div>
<Button onClick={onManage} disabled={isPending} size="default">
{isPending ? "Opening..." : "Manage Cards"}
</Button>
</div>
</div>
{hasMethods && (
<div className="p-6">
<div className="space-y-4">
{paymentMethodsData.paymentMethods.map(paymentMethod => (
<PaymentMethodCard key={paymentMethod.id} paymentMethod={paymentMethod} />
))}
</div>
</div>
)}
</div>
);
}
export function BillingOverview() {
const [error, setError] = useState<string | null>(null);
const { isAuthenticated } = useSession();
const paymentMethodsQuery = usePaymentMethods();
const {
data: paymentMethodsData,
isLoading: isLoadingPaymentMethods,
isFetching: isFetchingPaymentMethods,
error: paymentMethodsError,
} = paymentMethodsQuery;
const createPaymentMethodsSsoLink = useCreatePaymentMethodsSsoLink();
const { hasCheckedAuth } = useAuthStore();
const paymentRefresh = usePaymentRefresh({
refetch: async () => {
const result = await paymentMethodsQuery.refetch();
return { data: result.data };
},
hasMethods: data => Boolean(data && (data.totalCount > 0 || data.paymentMethods.length > 0)),
attachFocusListeners: true,
});
const openPaymentMethods = async () => {
if (!isAuthenticated) {
setError("Please log in to access payment methods.");
return;
}
setError(null);
try {
const ssoLink = await createPaymentMethodsSsoLink.mutateAsync();
openSsoLink(ssoLink.url, { newTab: true });
} catch (err: unknown) {
logger.error("Failed to open payment methods", err);
if (
isApiError(err) &&
"response" in err &&
typeof err.response === "object" &&
err.response !== null &&
"status" in err.response &&
err.response.status === 401
) {
setError("Authentication failed. Please log in again.");
} else {
setError("Unable to access payment methods. Please try again later.");
}
}
};
const isPaymentLoading = !hasCheckedAuth || isLoadingPaymentMethods || isFetchingPaymentMethods;
const combinedError = error
? new Error(error)
: paymentMethodsError instanceof Error
? paymentMethodsError
: paymentMethodsError
? new Error(String(paymentMethodsError))
: null;
return (
<PageLayout icon={<CreditCardIcon />} title="Billing" error={combinedError}>
<ErrorBoundary>
<InlineToast
visible={paymentRefresh.toast.visible}
text={paymentRefresh.toast.text}
tone={paymentRefresh.toast.tone}
/>
<div className="space-y-8">
{/* Payment Methods Section */}
{isPaymentLoading && <PaymentMethodsSkeleton />}
{!isPaymentLoading && paymentMethodsData && (
<PaymentMethodsSection
paymentMethodsData={paymentMethodsData}
onManage={() => void openPaymentMethods()}
isPending={createPaymentMethodsSsoLink.isPending}
/>
)}
{/* Invoices Section */}
<div>
<h2 className="text-lg font-semibold text-foreground mb-4">Invoices</h2>
<InvoicesList />
</div>
</div>
</ErrorBoundary>
</PageLayout>
);
}
export default BillingOverview;
```
**Step 2: Verify**
Run: `pnpm type-check`
Expected: PASS
**Step 3: Commit**
```bash
git add apps/portal/src/features/billing/views/BillingOverview.tsx
git commit -m "feat: create combined BillingOverview view"
```
---
### Task 3: Add /account/billing route and make sidebar flat
**Files:**
- Create: `apps/portal/src/app/account/billing/page.tsx`
- Modify: `apps/portal/src/components/organisms/AppShell/navigation.ts:29-36`
- Modify: `apps/portal/src/components/organisms/AppShell/AppShell.tsx:111`
**Step 1: Create the billing page**
```tsx
import { BillingOverview } from "@/features/billing/views/BillingOverview";
export default function AccountBillingPage() {
return <BillingOverview />;
}
```
**Step 2: Update navigation config**
Change Billing from expandable to flat:
```ts
// Before (lines 29-36):
{
name: "Billing",
icon: CreditCardIcon,
children: [
{ name: "Invoices", href: "/account/billing/invoices" },
{ name: "Payment Methods", href: "/account/billing/payments" },
],
},
// After:
{ name: "Billing", href: "/account/billing", icon: CreditCardIcon },
```
**Step 3: Remove auto-expand logic for Billing in AppShell**
File: `apps/portal/src/components/organisms/AppShell/AppShell.tsx`
Remove the line:
```ts
if (pathname.startsWith("/account/billing")) next.add("Billing");
```
**Step 4: Verify**
Run: `pnpm type-check`
Expected: PASS
**Step 5: Commit**
```bash
git add apps/portal/src/app/account/billing/page.tsx apps/portal/src/components/organisms/AppShell/navigation.ts apps/portal/src/components/organisms/AppShell/AppShell.tsx
git commit -m "refactor: make Billing a flat sidebar link with combined page"
```
---
### Task 4: Update Sidebar active-state matching for flat Billing and Support
**Files:**
- Modify: `apps/portal/src/components/organisms/AppShell/Sidebar.tsx:327`
The current `SimpleNavItem` uses exact match (`pathname === item.href`) which won't highlight Billing when on `/account/billing/invoices/123`. Change to `startsWith` for path-based matching:
```ts
// Before (line 327):
const isActive = item.href ? pathname === item.href : false;
// After:
const isActive = item.href
? item.href === "/account"
? pathname === item.href
: pathname.startsWith(item.href)
: false;
```
This ensures:
- Dashboard (`/account`) still uses exact match (doesn't highlight for every `/account/*` page)
- Billing (`/account/billing`) highlights on `/account/billing`, `/account/billing/invoices/123`, etc.
- Support (`/account/support`) highlights on `/account/support`, `/account/support/new`, `/account/support/123`, etc.
**Step 1: Update Sidebar active matching**
Apply the change above.
**Step 2: Verify**
Run: `pnpm type-check`
Expected: PASS
**Step 3: Commit**
```bash
git add apps/portal/src/components/organisms/AppShell/Sidebar.tsx
git commit -m "fix: use startsWith for sidebar active state on nested routes"
```
---
### Task 5: Update backLink references in InvoiceDetail
**Files:**
- Modify: `apps/portal/src/features/billing/views/InvoiceDetail.tsx:84,99`
Update the "Back to Invoices" links to point to the combined billing page:
```ts
// Before:
backLink={{ label: "Back to Invoices", href: "/account/billing/invoices" }}
// After:
backLink={{ label: "Back to Billing", href: "/account/billing" }}
```
Apply this on both lines 84 and 99.
**Step 1: Apply changes**
**Step 2: Verify**
Run: `pnpm type-check`
Expected: PASS
**Step 3: Commit**
```bash
git add apps/portal/src/features/billing/views/InvoiceDetail.tsx
git commit -m "fix: update InvoiceDetail backLink to point to combined billing page"
```
---
### Task 6: Update remaining billing route references
**Files:**
- Modify: `apps/portal/src/features/dashboard/utils/dashboard.utils.ts:43` — change `/account/billing/invoices/${activity.relatedId}` to keep as-is (invoice detail pages still live at `/account/billing/invoices/[id]`)
- Modify: `apps/portal/src/features/dashboard/components/TaskList.tsx:66` — change `href="/account/billing/invoices"` to `href="/account/billing"`
- Modify: `apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx:208` — change `href="/account/billing/invoices"` to `href="/account/billing"`
- Modify: `apps/portal/src/features/billing/components/BillingSummary/BillingSummary.tsx:160,187` — change `href="/account/billing/invoices"` to `href="/account/billing"`
Note: Keep `dashboard.utils.ts:43` unchanged — it links to a specific invoice detail page which still exists at `/account/billing/invoices/[id]`.
Note: Keep `InvoiceTable.tsx:276` unchanged — it navigates to individual invoice detail pages.
**Step 1: Apply changes to the 3 files listed above**
**Step 2: Verify**
Run: `pnpm type-check`
Expected: PASS
**Step 3: Commit**
```bash
git add apps/portal/src/features/dashboard/components/TaskList.tsx apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx apps/portal/src/features/billing/components/BillingSummary/BillingSummary.tsx
git commit -m "fix: update billing route references to use combined billing page"
```
---
### Task 7: Clean up unused imports in navigation.ts
**Files:**
- Modify: `apps/portal/src/components/organisms/AppShell/navigation.ts`
After removing children from both Billing and Support, the `NavigationChild` type and children-related interfaces may still be needed by other code (the Sidebar still supports expandable items generically). Check if `NavigationChild` is still used — if Subscriptions or any other item still has children, keep it. If no items have children anymore, remove unused types.
**Step 1: Check if any navigation item still uses children**
After our changes, review `baseNavigation` — none will have children. But `NavigationChild` and `children` field on `NavigationItem` are still referenced by `Sidebar.tsx` (the `ExpandableNavItem` component). These can stay for now since they're part of the generic nav system — removing the component is a larger cleanup.
**Step 2: Verify full build**
Run: `pnpm type-check && pnpm lint`
Expected: PASS
**Step 3: Final commit if any cleanup was needed**
```bash
git add -A
git commit -m "chore: sidebar consolidation cleanup"
```