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"; "use client";
import { motion, type Variants } from "framer-motion";
import { cn } from "@/shared/utils"; import { cn } from "@/shared/utils";
interface AnimatedContainerProps { interface AnimatedContainerProps {
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;
/** Animation type */
animation?: "fade-up" | "fade-scale" | "slide-left" | "none"; animation?: "fade-up" | "fade-scale" | "slide-left" | "none";
/** Whether to stagger children animations */
stagger?: boolean; stagger?: boolean;
/** Delay before animation starts in ms */
delay?: number; delay?: number;
} }
/** const fadeUp: Variants = {
* Reusable animation wrapper component hidden: { opacity: 0, y: 16 },
* Provides consistent entrance animations for page content 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({ export function AnimatedContainer({
children, children,
className, className,
@ -24,19 +45,19 @@ export function AnimatedContainer({
stagger = false, stagger = false,
delay = 0, delay = 0,
}: AnimatedContainerProps) { }: AnimatedContainerProps) {
const animationClass = { const variants = variantMap[animation];
"fade-up": "cp-animate-in",
"fade-scale": "cp-animate-scale-in",
"slide-left": "cp-animate-slide-left",
none: "",
}[animation];
return ( return (
<div <motion.div
className={cn(animationClass, stagger && "cp-stagger-children", className)} className={cn(className)}
style={delay > 0 ? { animationDelay: `${delay}ms` } : undefined} initial="hidden"
animate="visible"
variants={variants}
transition={
stagger ? { staggerChildren: 0.1, delayChildren: delay / 1000 } : { delay: delay / 1000 }
}
> >
{children} {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"; import { cn } from "@/shared/utils";
type Tone = "info" | "success" | "warning" | "error"; type Tone = "info" | "success" | "warning" | "error";
interface InlineToastProps extends HTMLAttributes<HTMLDivElement> { interface InlineToastProps {
visible: boolean; visible: boolean;
text: string; text: string;
tone?: Tone; tone?: Tone;
className?: string;
} }
export function InlineToast({ export function InlineToast({ visible, text, tone = "info", className = "" }: InlineToastProps) {
visible,
text,
tone = "info",
className = "",
...rest
}: InlineToastProps) {
const toneClasses = { const toneClasses = {
success: "bg-success-bg border-success-border text-success", success: "bg-success-bg border-success-border text-success",
warning: "bg-warning-bg border-warning-border text-warning", warning: "bg-warning-bg border-warning-border text-warning",
@ -24,22 +21,25 @@ export function InlineToast({
}[tone]; }[tone];
return ( return (
<div <AnimatePresence>
className={cn( {visible && (
"fixed bottom-6 right-6 z-50", <motion.div
visible ? "cp-toast-enter" : "cp-toast-exit pointer-events-none", className={cn("fixed bottom-6 right-6 z-50", className)}
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(
"flex items-center gap-2 rounded-lg border px-4 py-3 shadow-lg min-w-[240px] text-sm font-medium",
toneClasses
)}
>
<span>{text}</span>
</div>
</motion.div>
)} )}
{...rest} </AnimatePresence>
>
<div
className={cn(
"flex items-center gap-2 rounded-lg border px-4 py-3 shadow-lg min-w-[240px] text-sm font-medium",
toneClasses
)}
>
<span>{text}</span>
</div>
</div>
); );
} }

View File

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

View File

@ -2,12 +2,14 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { motion, AnimatePresence } from "framer-motion";
import { useAuthStore, useAuthSession } from "@/features/auth/stores/auth.store"; import { useAuthStore, useAuthSession } from "@/features/auth/stores/auth.store";
import { accountService } from "@/features/account/api/account.api"; import { accountService } from "@/features/account/api/account.api";
import { Bars3Icon } from "@heroicons/react/24/outline"; import { Bars3Icon } from "@heroicons/react/24/outline";
import { Sidebar } from "./Sidebar"; import { Sidebar } from "./Sidebar";
import { baseNavigation } from "./navigation"; import { baseNavigation } from "./navigation";
import { Logo } from "@/components/atoms/logo"; import { Logo } from "@/components/atoms/logo";
import { NotificationBell } from "@/features/notifications";
interface AppShellProps { interface AppShellProps {
children: React.ReactNode; children: React.ReactNode;
@ -108,8 +110,6 @@ function useSidebarExpansion(pathname: string) {
setExpandedItems(prev => { setExpandedItems(prev => {
const next = new Set(prev); const next = new Set(prev);
if (pathname.startsWith("/account/subscriptions")) next.add("Subscriptions"); 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]; const result = [...next];
if (result.length === prev.length && result.every(v => prev.includes(v))) return prev; if (result.length === prev.length && result.every(v => prev.includes(v))) return prev;
return result; return result;
@ -166,34 +166,46 @@ export function AppShell({ children }: AppShellProps) {
<> <>
<div className="h-screen flex overflow-hidden bg-background"> <div className="h-screen flex overflow-hidden bg-background">
{/* Mobile sidebar overlay */} {/* Mobile sidebar overlay */}
{sidebarOpen && ( <AnimatePresence>
<div className="fixed inset-0 flex z-50 md:hidden"> {sidebarOpen && (
<div <div className="fixed inset-0 flex z-50 md:hidden">
className="fixed inset-0 bg-black/50 animate-in fade-in duration-300" <motion.div
onClick={() => setSidebarOpen(false)} className="fixed inset-0 bg-black/50"
/> initial={{ opacity: 0 }}
<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"> animate={{ opacity: 1 }}
<div className="absolute top-0 right-0 -mr-12 pt-2" /> exit={{ opacity: 0 }}
<Sidebar transition={{ duration: 0.3 }}
navigation={navigation} onClick={() => setSidebarOpen(false)}
pathname={pathname}
expandedItems={expandedItems}
toggleExpanded={toggleExpanded}
isMobile
user={
user
? {
firstName: user.firstname ?? null,
lastName: user.lastname ?? null,
email: user.email,
}
: null
}
profileReady={!!(user?.firstname || user?.lastname)}
/> />
<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}
pathname={pathname}
expandedItems={expandedItems}
toggleExpanded={toggleExpanded}
isMobile
user={
user
? {
firstName: user.firstname ?? null,
lastName: user.lastname ?? null,
email: user.email,
}
: null
}
profileReady={!!(user?.firstname || user?.lastname)}
/>
</motion.div>
</div> </div>
</div> )}
)} </AnimatePresence>
{/* Desktop sidebar */} {/* Desktop sidebar */}
<div className="hidden md:flex md:flex-shrink-0"> <div className="hidden md:flex md:flex-shrink-0">
@ -219,19 +231,22 @@ export function AppShell({ children }: AppShellProps) {
{/* Main content */} {/* Main content */}
<div className="flex flex-col w-0 flex-1 overflow-hidden bg-background"> <div className="flex flex-col w-0 flex-1 overflow-hidden bg-background">
{/* Mobile-only hamburger bar */} {/* Header bar */}
<div className="md:hidden flex items-center h-16 px-3 border-b border-border/40 bg-background"> <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 <button
type="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)} onClick={() => setSidebarOpen(true)}
aria-label="Open navigation" aria-label="Open navigation"
> >
<Bars3Icon className="h-5 w-5" /> <Bars3Icon className="h-5 w-5" />
</button> </button>
<div className="ml-2"> <div className="md:hidden ml-2">
<Logo size={20} /> <Logo size={20} />
</div> </div>
<div className="flex-1" />
<NotificationBell />
</div> </div>
{/* Main content area */} {/* Main content area */}

View File

@ -3,9 +3,9 @@
import Link from "next/link"; import Link from "next/link";
import { memo } from "react"; import { memo } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { motion, AnimatePresence } from "framer-motion";
import { useAuthStore } from "@/features/auth/stores/auth.store"; import { useAuthStore } from "@/features/auth/stores/auth.store";
import { Logo } from "@/components/atoms/logo"; import { Logo } from "@/components/atoms/logo";
import { NotificationBell } from "@/features/notifications";
import type { NavigationChild, NavigationItem } from "./navigation"; import type { NavigationChild, NavigationItem } from "./navigation";
import type { ComponentType, SVGProps } from "react"; import type { ComponentType, SVGProps } from "react";
@ -97,25 +97,19 @@ function SidebarProfile({
const initials = getSidebarInitials(user, profileReady, displayName); const initials = getSidebarInitials(user, profileReady, displayName);
return ( return (
<div className="px-3 pb-4"> <div className="px-3 py-3">
<div className="flex items-center gap-2 px-2 py-2 rounded-lg bg-white/10"> <Link
<Link href="/account/settings"
href="/account/settings" prefetch
prefetch className="flex items-center gap-2.5 px-2 py-2 rounded-lg bg-white/10 hover:bg-white/15 transition-colors group"
className="flex items-center gap-2 flex-1 min-w-0 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">
<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}
{initials} </div>
</div> <span className="text-sm font-medium text-white/90 truncate group-hover:text-white transition-colors">
<span className="text-sm font-medium text-white/90 truncate group-hover:text-white transition-colors"> {displayName}
{displayName} </span>
</span> </Link>
</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> </div>
); );
} }
@ -142,12 +136,8 @@ export const Sidebar = memo(function Sidebar({
</div> </div>
</div> </div>
<div className="pt-4">
<SidebarProfile user={user} profileReady={profileReady} />
</div>
<div className="flex-1 flex flex-col pb-4 overflow-y-auto"> <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 {navigation
.filter(item => !item.isLogout) .filter(item => !item.isLogout)
.map(item => ( .map(item => (
@ -161,6 +151,7 @@ export const Sidebar = memo(function Sidebar({
</div> </div>
))} ))}
</nav> </nav>
<SidebarProfile user={user} profileReady={profileReady} />
{navigation {navigation
.filter(item => item.isLogout) .filter(item => item.isLogout)
.map(item => ( .map(item => (
@ -205,50 +196,58 @@ function ExpandableNavItem({
{isActive && <ActiveIndicator />} {isActive && <ActiveIndicator />}
<NavIcon icon={item.icon} isActive={isActive} /> <NavIcon icon={item.icon} isActive={isActive} />
<span className="flex-1">{item.name}</span> <span className="flex-1">{item.name}</span>
<svg <motion.div animate={{ rotate: isExpanded ? 90 : 0 }} transition={{ duration: 0.2 }}>
className={`h-4 w-4 transition-transform duration-200 ease-out ${isExpanded ? "rotate-90" : ""} ${ <svg
isActive ? "text-white" : "text-white/70 group-hover:text-white" className={`h-4 w-4 ${
}`} isActive ? "text-white" : "text-white/70 group-hover:text-white"
viewBox="0 0 20 20" }`}
fill="currentColor" viewBox="0 0 20 20"
> fill="currentColor"
<path >
fillRule="evenodd" <path
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" fillRule="evenodd"
clipRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
/> clipRule="evenodd"
</svg> />
</svg>
</motion.div>
</button> </button>
<div <AnimatePresence initial={false}>
className={`overflow-hidden transition-all duration-300 ease-out ${ {isExpanded && (
isExpanded ? "max-h-96 opacity-100" : "max-h-0 opacity-0" <motion.div
}`} initial={{ height: 0, opacity: 0 }}
> animate={{ height: "auto", opacity: 1 }}
<div className="mt-0.5 ml-[30px] space-y-0.5 border-l border-white/15 pl-3"> exit={{ height: 0, opacity: 0 }}
{item.children?.map((child: NavigationChild) => { transition={{ duration: 0.3, ease: "easeOut" }}
const isChildActive = pathname === (child.href || "").split(/[?#]/)[0]; style={{ overflow: "hidden" }}
return ( >
<Link <div className="mt-0.5 ml-[30px] space-y-0.5 border-l border-white/15 pl-3">
key={child.href || child.name} {item.children?.map((child: NavigationChild) => {
href={child.href} const isChildActive = pathname === (child.href || "").split(/[?#]/)[0];
prefetch return (
onMouseEnter={() => child.href && void router.prefetch(child.href)} <Link
className={`group flex items-center px-2.5 py-1.5 text-sm rounded-md transition-all duration-200 relative ${ key={child.href || child.name}
isChildActive href={child.href}
? "text-white bg-white/15 font-medium" prefetch
: "text-white/70 hover:text-white hover:bg-white/10 font-normal" onMouseEnter={() => child.href && void router.prefetch(child.href)}
}`} className={`group flex items-center px-2.5 py-1.5 text-sm rounded-md transition-all duration-200 relative ${
title={child.tooltip || child.name} isChildActive
aria-current={isChildActive ? "page" : undefined} ? "text-white bg-white/15 font-medium"
> : "text-white/70 hover:text-white hover:bg-white/10 font-normal"
{isChildActive && <ActiveIndicator small />} }`}
<span className="truncate">{child.name}</span> title={child.tooltip || child.name}
</Link> aria-current={isChildActive ? "page" : undefined}
); >
})} {isChildActive && <ActiveIndicator small />}
</div> <span className="truncate">{child.name}</span>
</div> </Link>
);
})}
</div>
</motion.div>
)}
</AnimatePresence>
</div> </div>
); );
} }
@ -324,6 +323,10 @@ const NavigationItem = memo(function NavigationItem({
return <LogoutNavItem item={item} onLogout={handleLogout} />; 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} />; return <SimpleNavItem item={item} isActive={isActive} router={router} />;
}); });

View File

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

View File

@ -1,30 +1,27 @@
"use client"; "use client";
import { cn } from "@/shared/utils"; import { AnimatePresence, motion } from "framer-motion";
interface AnimatedSectionProps { interface AnimatedSectionProps {
/** Whether to show the section */
show: boolean; show: boolean;
/** Content to animate */
children: React.ReactNode; children: React.ReactNode;
/** Delay in ms before animation starts (default: 0) */
delay?: number; 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) { export function AnimatedSection({ show, children, delay = 0 }: AnimatedSectionProps) {
return ( return (
<div <AnimatePresence initial={false}>
className={cn( {show && (
"grid transition-all duration-500 ease-out", <motion.div
show ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0" 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" }}
>
{children}
</motion.div>
)} )}
style={{ transitionDelay: show ? `${delay}ms` : "0ms" }} </AnimatePresence>
>
<div className="overflow-hidden">{children}</div>
</div>
); );
} }

View File

@ -157,7 +157,7 @@ const BillingSummary = forwardRef<HTMLDivElement, BillingSummaryProps>(
</div> </div>
{!compact && ( {!compact && (
<Link <Link
href="/account/billing/invoices" href="/account/billing"
className="inline-flex items-center text-sm text-primary hover:text-primary-hover font-medium" className="inline-flex items-center text-sm text-primary hover:text-primary-hover font-medium"
> >
View All View All
@ -184,7 +184,7 @@ const BillingSummary = forwardRef<HTMLDivElement, BillingSummaryProps>(
{compact && ( {compact && (
<div className="mt-4 pt-4 border-t border-border"> <div className="mt-4 pt-4 border-t border-border">
<Link <Link
href="/account/billing/invoices" href="/account/billing"
className="inline-flex items-center text-sm text-primary hover:text-primary-hover font-medium" className="inline-flex items-center text-sm text-primary hover:text-primary-hover font-medium"
> >
View All Invoices 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 <PageLayout
icon={<DocumentTextIcon />} icon={<DocumentTextIcon />}
title="Invoice" title="Invoice"
backLink={{ label: "Back to Invoices", href: "/account/billing/invoices" }} backLink={{ label: "Back to Billing", href: "/account/billing" }}
> >
<ErrorState <ErrorState
title="Error loading invoice" title="Error loading invoice"
@ -96,7 +96,7 @@ export function InvoiceDetailContainer() {
<PageLayout <PageLayout
icon={<DocumentTextIcon />} icon={<DocumentTextIcon />}
title={`Invoice #${invoice.id}`} title={`Invoice #${invoice.id}`}
backLink={{ label: "Back to Invoices", href: "/account/billing/invoices" }} backLink={{ label: "Back to Billing", href: "/account/billing" }}
> >
<div> <div>
<div className="bg-card text-card-foreground rounded-xl shadow-[var(--cp-shadow-1)] border border-border overflow-hidden"> <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"; "use client";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { motion } from "framer-motion";
import { import {
DocumentTextIcon, DocumentTextIcon,
CheckCircleIcon, CheckCircleIcon,
@ -13,6 +14,16 @@ import type { Activity } from "@customer-portal/domain/dashboard";
import { formatActivityDate, getActivityNavigationPath } from "../utils/dashboard.utils"; import { formatActivityDate, getActivityNavigationPath } from "../utils/dashboard.utils";
import { cn } from "@/shared/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 { interface ActivityFeedProps {
activities: Activity[]; activities: Activity[];
maxItems?: number; maxItems?: number;
@ -182,15 +193,18 @@ export function ActivityFeed({
{visibleActivities.length === 0 ? ( {visibleActivities.length === 0 ? (
<EmptyActivity /> <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) => ( {visibleActivities.map((activity, index) => (
<ActivityItem <motion.div key={activity.id} variants={itemVariants}>
key={activity.id} <ActivityItem activity={activity} isLast={index === visibleActivities.length - 1} />
activity={activity} </motion.div>
isLast={index === visibleActivities.length - 1}
/>
))} ))}
</div> </motion.div>
)} )}
</div> </div>
); );

View File

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

View File

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

View File

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

View File

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

View File

@ -1,21 +1,22 @@
"use client"; "use client";
import { useRef } from "react";
import Image from "next/image"; import Image from "next/image";
import { motion, useInView } from "framer-motion";
import { Download } from "lucide-react"; import { Download } from "lucide-react";
import { cn } from "@/shared/utils";
import { useInView } from "@/features/landing-page/hooks";
import { supportDownloads } from "@/features/landing-page/data"; import { supportDownloads } from "@/features/landing-page/data";
export function SupportDownloadsSection() { export function SupportDownloadsSection() {
const [ref, isInView] = useInView(); const ref = useRef<HTMLElement>(null);
const isInView = useInView(ref, { once: true, amount: 0.1 });
return ( return (
<section <motion.section
ref={ref} ref={ref}
className={cn( initial={{ opacity: 0, y: 32 }}
"py-14 sm:py-16 transition-all duration-700", animate={isInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 32 }}
isInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8" 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"> <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"> <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>
</div> </div>
</section> </motion.section>
); );
} }

View File

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

View File

@ -1,10 +1,10 @@
"use client"; "use client";
import { useRef } from "react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { motion, useInView } from "framer-motion";
import { ArrowRight, BadgeCheck } from "lucide-react"; import { ArrowRight, BadgeCheck } from "lucide-react";
import { cn } from "@/shared/utils";
import { useInView } from "@/features/landing-page/hooks";
const trustPoints = [ const trustPoints = [
"Full English support, no Japanese needed", "Full English support, no Japanese needed",
@ -13,15 +13,15 @@ const trustPoints = [
]; ];
export function WhyUsSection() { export function WhyUsSection() {
const [ref, isInView] = useInView<HTMLDivElement>(); const ref = useRef<HTMLDivElement>(null);
const isInView = useInView(ref, { once: true, amount: 0.1 });
return ( return (
<div <motion.div
ref={ref} ref={ref}
className={cn( initial={{ opacity: 0, y: 32 }}
"transition-all duration-700", animate={isInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 32 }}
isInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8" 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="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"> <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>
</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 * Scroll-triggered visibility hook (trigger once).
* Returns a ref and boolean indicating if element is in viewport. * Wraps framer-motion's useInView.
* Once the element becomes visible, it stays marked as "in view" (trigger once).
*/ */
export function useInView<T extends HTMLElement = HTMLElement>( export function useInView<T extends HTMLElement = HTMLElement>(
options: IntersectionObserverInit = DEFAULT_OPTIONS options: UseInViewOptions = DEFAULT_OPTIONS
) { ) {
const ref = useRef<T>(null!); const ref = useRef<T>(null!);
const [isInView, setIsInView] = useState(false);
useEffect(() => { const isInView = useFramerInView(ref, {
const element = ref.current; once: true,
if (!element) return; amount: typeof options.threshold === "number" ? options.threshold : 0.1,
...(options.root ? { root: { current: options.root } } : undefined),
const observer = new IntersectionObserver( });
([entry]) => {
if (entry?.isIntersecting) {
setIsInView(true);
observer.disconnect(); // triggerOnce
}
},
{ threshold: 0.1, ...options }
);
observer.observe(element);
return () => observer.disconnect();
}, [options]);
return [ref, isInView] as const; return [ref, isInView] as const;
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -33,6 +33,7 @@ import {
type HighlightFeature, type HighlightFeature,
} from "@/features/services/components/base/ServiceHighlights"; } from "@/features/services/components/base/ServiceHighlights";
import { SimHowItWorksSection } from "@/features/services/components/sim/SimHowItWorksSection"; import { SimHowItWorksSection } from "@/features/services/components/sim/SimHowItWorksSection";
import { AnimatePresence, motion } from "framer-motion";
import { cn } from "@/shared/utils"; import { cn } from "@/shared/utils";
export type SimPlansTab = "data-voice" | "data-only" | "voice-only"; export type SimPlansTab = "data-voice" | "data-only" | "voice-only";
@ -339,35 +340,38 @@ function SimPlansGrid({
return ( return (
<div id="plans" className="min-h-[280px] overflow-hidden"> <div id="plans" className="min-h-[280px] overflow-hidden">
<div <AnimatePresence mode="wait">
key={activeTab} <motion.div
className={cn( key={activeTab}
"space-y-8", className="space-y-8"
slideDirection === "left" ? "cp-slide-fade-left" : "cp-slide-fade-right" initial={{ opacity: 0, x: slideDirection === "left" ? 24 : -24 }}
)} animate={{ opacity: 1, x: 0 }}
> exit={{ opacity: 0, x: slideDirection === "left" ? -24 : 24 }}
{regularPlans.length > 0 && ( transition={{ duration: 0.3, ease: "easeOut" as const }}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"> >
{regularPlans.map(plan => ( {regularPlans.length > 0 && (
<SimPlanCardCompact key={plan.id} plan={plan} onSelect={onSelectPlan} />
))}
</div>
)}
{variant === "account" && hasExistingSim && familyPlans.length > 0 && (
<div>
<div className="flex items-center gap-2 mb-3">
<Users className="h-4 w-4 text-success" />
<h3 className="text-sm font-semibold text-foreground">Family Discount Plans</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{familyPlans.map(plan => ( {regularPlans.map(plan => (
<SimPlanCardCompact key={plan.id} plan={plan} isFamily onSelect={onSelectPlan} /> <SimPlanCardCompact key={plan.id} plan={plan} onSelect={onSelectPlan} />
))} ))}
</div> </div>
</div> )}
)}
</div> {variant === "account" && hasExistingSim && familyPlans.length > 0 && (
<div>
<div className="flex items-center gap-2 mb-3">
<Users className="h-4 w-4 text-success" />
<h3 className="text-sm font-semibold text-foreground">Family Discount Plans</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{familyPlans.map(plan => (
<SimPlanCardCompact key={plan.id} plan={plan} isFamily onSelect={onSelectPlan} />
))}
</div>
</div>
)}
</motion.div>
</AnimatePresence>
</div> </div>
); );
} }

View File

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

View File

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

View File

@ -1,6 +1,7 @@
"use client"; "use client";
import { useState, useMemo } from "react"; import { useState, useMemo } from "react";
import { motion } from "framer-motion";
import { Button } from "@/components/atoms/button"; import { Button } from "@/components/atoms/button";
import { ViewToggle, type ViewMode } from "@/components/atoms/view-toggle"; import { ViewToggle, type ViewMode } from "@/components/atoms/view-toggle";
import { MetricCard, MetricCardSkeleton } from "@/components/molecules/MetricCard"; import { MetricCard, MetricCardSkeleton } from "@/components/molecules/MetricCard";
@ -22,6 +23,16 @@ import {
type SubscriptionStatus, type SubscriptionStatus,
} from "@customer-portal/domain/subscriptions"; } 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[]; const SUBSCRIPTION_STATUS_OPTIONS = Object.values(SUBSCRIPTION_STATUS) as SubscriptionStatus[];
function SubscriptionMetrics({ function SubscriptionMetrics({
@ -87,13 +98,20 @@ function SubscriptionGrid({
} }
return ( 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 => ( {subscriptions.map(sub => (
<SubscriptionGridCard key={sub.serviceId} subscription={sub} /> <motion.div key={sub.serviceId} variants={gridItemVariants}>
<SubscriptionGridCard subscription={sub} />
</motion.div>
))} ))}
{loading && {loading &&
Array.from({ length: 3 }).map((_, i) => <SubscriptionGridCardSkeleton key={`skel-${i}`} />)} 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 { useCurrency } from "./useCurrency";
export { useFormatCurrency, type FormatCurrencyOptions } from "./useFormatCurrency"; export { useFormatCurrency, type FormatCurrencyOptions } from "./useFormatCurrency";
export { useCountUp } from "./useCountUp"; 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"; "use client";
import { useState, useEffect, useRef } from "react"; import { useState, useEffect } from "react";
import { animate, useReducedMotion } from "framer-motion";
const reducedMotionQuery =
typeof window === "undefined" ? undefined : window.matchMedia("(prefers-reduced-motion: reduce)");
interface UseCountUpOptions { interface UseCountUpOptions {
/** Starting value (default: 0) */
start?: number; start?: number;
/** Target value to count to */
end: number; end: number;
/** Animation duration in ms (default: 300) */
duration?: number; duration?: number;
/** Delay before starting animation in ms (default: 0) */
delay?: number; delay?: number;
/** Whether animation is enabled (default: true) */
enabled?: boolean; enabled?: boolean;
} }
/**
* Animated counter hook for stats and numbers
* Uses requestAnimationFrame for smooth 60fps animation
*/
export function useCountUp({ export function useCountUp({
start = 0, start = 0,
end, end,
@ -30,8 +19,7 @@ export function useCountUp({
enabled = true, enabled = true,
}: UseCountUpOptions): number { }: UseCountUpOptions): number {
const [count, setCount] = useState(start); const [count, setCount] = useState(start);
const frameRef = useRef<number | undefined>(undefined); const prefersReducedMotion = useReducedMotion();
const startTimeRef = useRef<number | undefined>(undefined);
useEffect(() => { useEffect(() => {
if (!enabled) { if (!enabled) {
@ -39,42 +27,28 @@ export function useCountUp({
return; return;
} }
// Respect prefers-reduced-motion — show final value immediately if (prefersReducedMotion) {
if (reducedMotionQuery?.matches) {
setCount(end); setCount(end);
return; return;
} }
startTimeRef.current = undefined; let controls: ReturnType<typeof animate> | undefined;
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
const animate = (timestamp: number) => { controls = animate(start, end, {
if (!startTimeRef.current) { duration: duration / 1000,
startTimeRef.current = timestamp; ease: [0, 0, 0.2, 1],
} onUpdate: value => {
setCount(Math.round(value));
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);
}, delay); }, delay);
return () => { return () => {
clearTimeout(timeout); clearTimeout(timeout);
if (frameRef.current) { controls?.stop();
cancelAnimationFrame(frameRef.current);
}
}; };
}, [start, end, duration, delay, enabled]); }, [start, end, duration, delay, enabled, prefersReducedMotion]);
return count; return count;
} }

View File

@ -7,39 +7,6 @@
/* ===== KEYFRAMES ===== */ /* ===== 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 { @keyframes cp-shimmer {
0% { 0% {
transform: translateX(-100%); 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 { @keyframes cp-shake {
0%, 0%,
100% { 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 */ /* Legacy shimmer animation for compatibility */
@keyframes cp-skeleton-shimmer { @keyframes cp-skeleton-shimmer {
0% { 0% {
@ -208,100 +90,6 @@
} }
@layer utilities { @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 ===== */ /* ===== SKELETON SHIMMER ===== */
.cp-skeleton-shimmer { .cp-skeleton-shimmer {
position: relative; position: relative;
@ -339,50 +127,6 @@
animation: cp-shake var(--cp-duration-slow) var(--cp-ease-out); 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 ===== */ /* ===== GLASS MORPHISM ===== */
.cp-glass { .cp-glass {
background: var(--glass-bg); background: var(--glass-bg);
@ -477,10 +221,6 @@
box-shadow: var(--shadow-primary-lg); box-shadow: var(--shadow-primary-lg);
} }
.cp-glow-pulse {
animation: cp-pulse-glow 2s ease-in-out infinite;
}
/* ===== PREMIUM CARD VARIANTS ===== */ /* ===== PREMIUM CARD VARIANTS ===== */
.cp-card-glass { .cp-card-glass {
background: var(--glass-bg); background: var(--glass-bg);
@ -700,21 +440,9 @@
/* ===== ACCESSIBILITY: REDUCED MOTION ===== */ /* ===== ACCESSIBILITY: REDUCED MOTION ===== */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.cp-animate-in, .cp-skeleton-shimmer::after,
.cp-animate-scale-in, .cp-skeleton::after,
.cp-animate-slide-left, .cp-input-error-shake {
.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 {
animation: none !important; animation: none !important;
transition: none !important; transition: none !important;
opacity: 1 !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"
```