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:
parent
be3388cf58
commit
7502068ea9
@ -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>
|
||||
);
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
import InvoicesListContainer from "@/features/billing/views/InvoicesList";
|
||||
|
||||
export default function AccountInvoicesPage() {
|
||||
return <InvoicesListContainer />;
|
||||
}
|
||||
5
apps/portal/src/app/account/billing/page.tsx
Normal file
5
apps/portal/src/app/account/billing/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { BillingOverview } from "@/features/billing/views/BillingOverview";
|
||||
|
||||
export default function AccountBillingPage() {
|
||||
return <BillingOverview />;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
import PaymentMethodsContainer from "@/features/billing/views/PaymentMethods";
|
||||
|
||||
export default function AccountPaymentMethodsPage() {
|
||||
return <PaymentMethodsContainer />;
|
||||
}
|
||||
@ -1,22 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { motion, type Variants } from "framer-motion";
|
||||
import { cn } from "@/shared/utils";
|
||||
|
||||
interface AnimatedContainerProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
/** Animation type */
|
||||
animation?: "fade-up" | "fade-scale" | "slide-left" | "none";
|
||||
/** Whether to stagger children animations */
|
||||
stagger?: boolean;
|
||||
/** Delay before animation starts in ms */
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable animation wrapper component
|
||||
* Provides consistent entrance animations for page content
|
||||
*/
|
||||
const fadeUp: Variants = {
|
||||
hidden: { opacity: 0, y: 16 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.3, ease: "easeOut" } },
|
||||
};
|
||||
|
||||
const fadeScale: Variants = {
|
||||
hidden: { opacity: 0, scale: 0.95 },
|
||||
visible: { opacity: 1, scale: 1, transition: { duration: 0.2, ease: "easeOut" } },
|
||||
};
|
||||
|
||||
const slideLeft: Variants = {
|
||||
hidden: { opacity: 0, x: -24 },
|
||||
visible: { opacity: 1, x: 0, transition: { duration: 0.3, ease: "easeOut" } },
|
||||
};
|
||||
|
||||
const noneVariant: Variants = {
|
||||
hidden: {},
|
||||
visible: {},
|
||||
};
|
||||
|
||||
const variantMap = {
|
||||
"fade-up": fadeUp,
|
||||
"fade-scale": fadeScale,
|
||||
"slide-left": slideLeft,
|
||||
none: noneVariant,
|
||||
} as const;
|
||||
|
||||
export function AnimatedContainer({
|
||||
children,
|
||||
className,
|
||||
@ -24,19 +45,19 @@ export function AnimatedContainer({
|
||||
stagger = false,
|
||||
delay = 0,
|
||||
}: AnimatedContainerProps) {
|
||||
const animationClass = {
|
||||
"fade-up": "cp-animate-in",
|
||||
"fade-scale": "cp-animate-scale-in",
|
||||
"slide-left": "cp-animate-slide-left",
|
||||
none: "",
|
||||
}[animation];
|
||||
const variants = variantMap[animation];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(animationClass, stagger && "cp-stagger-children", className)}
|
||||
style={delay > 0 ? { animationDelay: `${delay}ms` } : undefined}
|
||||
<motion.div
|
||||
className={cn(className)}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={variants}
|
||||
transition={
|
||||
stagger ? { staggerChildren: 0.1, delayChildren: delay / 1000 } : { delay: delay / 1000 }
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,21 +1,18 @@
|
||||
import type { HTMLAttributes } from "react";
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { cn } from "@/shared/utils";
|
||||
|
||||
type Tone = "info" | "success" | "warning" | "error";
|
||||
|
||||
interface InlineToastProps extends HTMLAttributes<HTMLDivElement> {
|
||||
interface InlineToastProps {
|
||||
visible: boolean;
|
||||
text: string;
|
||||
tone?: Tone;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function InlineToast({
|
||||
visible,
|
||||
text,
|
||||
tone = "info",
|
||||
className = "",
|
||||
...rest
|
||||
}: InlineToastProps) {
|
||||
export function InlineToast({ visible, text, tone = "info", className = "" }: InlineToastProps) {
|
||||
const toneClasses = {
|
||||
success: "bg-success-bg border-success-border text-success",
|
||||
warning: "bg-warning-bg border-warning-border text-warning",
|
||||
@ -24,13 +21,14 @@ export function InlineToast({
|
||||
}[tone];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed bottom-6 right-6 z-50",
|
||||
visible ? "cp-toast-enter" : "cp-toast-exit pointer-events-none",
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
<AnimatePresence>
|
||||
{visible && (
|
||||
<motion.div
|
||||
className={cn("fixed bottom-6 right-6 z-50", className)}
|
||||
initial={{ opacity: 0, x: "100%", scale: 0.9 }}
|
||||
animate={{ opacity: 1, x: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, x: "100%", scale: 0.9 }}
|
||||
transition={{ duration: 0.3, ease: [0.175, 0.885, 0.32, 1.275] }}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
@ -40,6 +38,8 @@ export function InlineToast({
|
||||
>
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface AnimatedCardProps {
|
||||
@ -8,6 +11,9 @@ interface AnimatedCardProps {
|
||||
disabled?: boolean | undefined;
|
||||
}
|
||||
|
||||
const SHADOW_BASE = "0 1px 3px 0 rgb(0 0 0 / 0.06), 0 1px 2px -1px rgb(0 0 0 / 0.06)";
|
||||
const SHADOW_ELEVATED = "0 4px 6px -1px rgb(0 0 0 / 0.07), 0 2px 4px -2px rgb(0 0 0 / 0.07)";
|
||||
|
||||
export function AnimatedCard({
|
||||
children,
|
||||
className = "",
|
||||
@ -15,27 +21,30 @@ export function AnimatedCard({
|
||||
onClick,
|
||||
disabled = false,
|
||||
}: AnimatedCardProps) {
|
||||
const baseClasses =
|
||||
"bg-card text-card-foreground rounded-xl border shadow-[var(--cp-shadow-1)] transition-shadow duration-[var(--cp-duration-normal)]";
|
||||
const baseClasses = "bg-card text-card-foreground rounded-xl border";
|
||||
|
||||
const variantClasses: Record<"default" | "highlighted" | "success" | "static", string> = {
|
||||
default: "border-border hover:shadow-[var(--cp-shadow-2)]",
|
||||
highlighted: "border-primary/35 ring-1 ring-primary/15 hover:shadow-[var(--cp-shadow-2)]",
|
||||
success: "border-success/25 ring-1 ring-success/15 hover:shadow-[var(--cp-shadow-2)]",
|
||||
static: "border-border shadow-[var(--cp-shadow-1)]", // No hover animations for static containers
|
||||
default: "border-border",
|
||||
highlighted: "border-primary/35 ring-1 ring-primary/15",
|
||||
success: "border-success/25 ring-1 ring-success/15",
|
||||
static: "border-border",
|
||||
};
|
||||
|
||||
const interactiveClasses = onClick && !disabled ? "cursor-pointer" : "";
|
||||
|
||||
const disabledClasses = disabled ? "opacity-50 cursor-not-allowed" : "";
|
||||
|
||||
const isStatic = variant === "static" || disabled;
|
||||
|
||||
return (
|
||||
<div
|
||||
<motion.div
|
||||
className={`${baseClasses} ${variantClasses[variant]} ${interactiveClasses} ${disabledClasses} ${className}`}
|
||||
initial={{ boxShadow: SHADOW_BASE }}
|
||||
whileHover={isStatic ? {} : { boxShadow: SHADOW_ELEVATED }}
|
||||
transition={{ duration: 0.2 }}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -2,12 +2,14 @@
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useAuthStore, useAuthSession } from "@/features/auth/stores/auth.store";
|
||||
import { accountService } from "@/features/account/api/account.api";
|
||||
import { Bars3Icon } from "@heroicons/react/24/outline";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { baseNavigation } from "./navigation";
|
||||
import { Logo } from "@/components/atoms/logo";
|
||||
import { NotificationBell } from "@/features/notifications";
|
||||
|
||||
interface AppShellProps {
|
||||
children: React.ReactNode;
|
||||
@ -108,8 +110,6 @@ function useSidebarExpansion(pathname: string) {
|
||||
setExpandedItems(prev => {
|
||||
const next = new Set(prev);
|
||||
if (pathname.startsWith("/account/subscriptions")) next.add("Subscriptions");
|
||||
if (pathname.startsWith("/account/billing")) next.add("Billing");
|
||||
if (pathname.startsWith("/account/support")) next.add("Support");
|
||||
const result = [...next];
|
||||
if (result.length === prev.length && result.every(v => prev.includes(v))) return prev;
|
||||
return result;
|
||||
@ -166,13 +166,24 @@ export function AppShell({ children }: AppShellProps) {
|
||||
<>
|
||||
<div className="h-screen flex overflow-hidden bg-background">
|
||||
{/* Mobile sidebar overlay */}
|
||||
<AnimatePresence>
|
||||
{sidebarOpen && (
|
||||
<div className="fixed inset-0 flex z-50 md:hidden">
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 animate-in fade-in duration-300"
|
||||
<motion.div
|
||||
className="fixed inset-0 bg-black/50"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
<div className="relative flex-1 flex flex-col max-w-xs w-full bg-sidebar border-r border-sidebar-border animate-in slide-in-from-left duration-300 shadow-2xl">
|
||||
<motion.div
|
||||
className="relative flex-1 flex flex-col max-w-xs w-full bg-sidebar border-r border-sidebar-border shadow-2xl"
|
||||
initial={{ x: "-100%" }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: "-100%" }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
>
|
||||
<div className="absolute top-0 right-0 -mr-12 pt-2" />
|
||||
<Sidebar
|
||||
navigation={navigation}
|
||||
@ -191,9 +202,10 @@ export function AppShell({ children }: AppShellProps) {
|
||||
}
|
||||
profileReady={!!(user?.firstname || user?.lastname)}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Desktop sidebar */}
|
||||
<div className="hidden md:flex md:flex-shrink-0">
|
||||
@ -219,19 +231,22 @@ export function AppShell({ children }: AppShellProps) {
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex flex-col w-0 flex-1 overflow-hidden bg-background">
|
||||
{/* Mobile-only hamburger bar */}
|
||||
<div className="md:hidden flex items-center h-16 px-3 border-b border-border/40 bg-background">
|
||||
{/* Header bar */}
|
||||
<div className="flex items-center h-16 px-3 md:px-6 border-b border-border/40 bg-background flex-shrink-0">
|
||||
{/* Mobile hamburger + logo */}
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center w-10 h-10 -ml-1 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/60 active:bg-muted transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||
className="md:hidden flex items-center justify-center w-10 h-10 -ml-1 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/60 active:bg-muted transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
aria-label="Open navigation"
|
||||
>
|
||||
<Bars3Icon className="h-5 w-5" />
|
||||
</button>
|
||||
<div className="ml-2">
|
||||
<div className="md:hidden ml-2">
|
||||
<Logo size={20} />
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<NotificationBell />
|
||||
</div>
|
||||
|
||||
{/* Main content area */}
|
||||
|
||||
@ -3,9 +3,9 @@
|
||||
import Link from "next/link";
|
||||
import { memo } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useAuthStore } from "@/features/auth/stores/auth.store";
|
||||
import { Logo } from "@/components/atoms/logo";
|
||||
import { NotificationBell } from "@/features/notifications";
|
||||
import type { NavigationChild, NavigationItem } from "./navigation";
|
||||
import type { ComponentType, SVGProps } from "react";
|
||||
|
||||
@ -97,12 +97,11 @@ function SidebarProfile({
|
||||
const initials = getSidebarInitials(user, profileReady, displayName);
|
||||
|
||||
return (
|
||||
<div className="px-3 pb-4">
|
||||
<div className="flex items-center gap-2 px-2 py-2 rounded-lg bg-white/10">
|
||||
<div className="px-3 py-3">
|
||||
<Link
|
||||
href="/account/settings"
|
||||
prefetch
|
||||
className="flex items-center gap-2 flex-1 min-w-0 group"
|
||||
className="flex items-center gap-2.5 px-2 py-2 rounded-lg bg-white/10 hover:bg-white/15 transition-colors group"
|
||||
>
|
||||
<div className="h-7 w-7 rounded-lg bg-white/20 flex items-center justify-center text-[11px] font-bold text-white flex-shrink-0">
|
||||
{initials}
|
||||
@ -111,11 +110,6 @@ function SidebarProfile({
|
||||
{displayName}
|
||||
</span>
|
||||
</Link>
|
||||
<NotificationBell
|
||||
dropdownPosition="right"
|
||||
className="flex-shrink-0 [&_button]:text-white/70 [&_button]:hover:text-white [&_button]:hover:bg-white/10 [&_button]:p-1.5 [&_button]:rounded-md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -142,12 +136,8 @@ export const Sidebar = memo(function Sidebar({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<SidebarProfile user={user} profileReady={profileReady} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col pb-4 overflow-y-auto">
|
||||
<nav className="flex-1 px-3 space-y-1">
|
||||
<nav className="flex-1 px-3 pt-4 space-y-1">
|
||||
{navigation
|
||||
.filter(item => !item.isLogout)
|
||||
.map(item => (
|
||||
@ -161,6 +151,7 @@ export const Sidebar = memo(function Sidebar({
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
<SidebarProfile user={user} profileReady={profileReady} />
|
||||
{navigation
|
||||
.filter(item => item.isLogout)
|
||||
.map(item => (
|
||||
@ -205,8 +196,9 @@ function ExpandableNavItem({
|
||||
{isActive && <ActiveIndicator />}
|
||||
<NavIcon icon={item.icon} isActive={isActive} />
|
||||
<span className="flex-1">{item.name}</span>
|
||||
<motion.div animate={{ rotate: isExpanded ? 90 : 0 }} transition={{ duration: 0.2 }}>
|
||||
<svg
|
||||
className={`h-4 w-4 transition-transform duration-200 ease-out ${isExpanded ? "rotate-90" : ""} ${
|
||||
className={`h-4 w-4 ${
|
||||
isActive ? "text-white" : "text-white/70 group-hover:text-white"
|
||||
}`}
|
||||
viewBox="0 0 20 20"
|
||||
@ -218,12 +210,17 @@ function ExpandableNavItem({
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</motion.div>
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={`overflow-hidden transition-all duration-300 ease-out ${
|
||||
isExpanded ? "max-h-96 opacity-100" : "max-h-0 opacity-0"
|
||||
}`}
|
||||
<AnimatePresence initial={false}>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
<div className="mt-0.5 ml-[30px] space-y-0.5 border-l border-white/15 pl-3">
|
||||
{item.children?.map((child: NavigationChild) => {
|
||||
@ -248,7 +245,9 @@ function ExpandableNavItem({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -324,6 +323,10 @@ const NavigationItem = memo(function NavigationItem({
|
||||
return <LogoutNavItem item={item} onLogout={handleLogout} />;
|
||||
}
|
||||
|
||||
const isActive = item.href ? pathname === item.href : false;
|
||||
const isActive = (() => {
|
||||
if (!item.href) return false;
|
||||
if (item.href === "/account") return pathname === item.href;
|
||||
return pathname.startsWith(item.href);
|
||||
})();
|
||||
return <SimpleNavItem item={item} isActive={isActive} router={router} />;
|
||||
});
|
||||
|
||||
@ -26,27 +26,13 @@ export interface NavigationItem {
|
||||
export const baseNavigation: NavigationItem[] = [
|
||||
{ name: "Dashboard", href: "/account", icon: HomeIcon },
|
||||
{ name: "Orders", href: "/account/orders", icon: ClipboardDocumentListIcon },
|
||||
{
|
||||
name: "Billing",
|
||||
icon: CreditCardIcon,
|
||||
children: [
|
||||
{ name: "Invoices", href: "/account/billing/invoices" },
|
||||
{ name: "Payment Methods", href: "/account/billing/payments" },
|
||||
],
|
||||
},
|
||||
{ name: "Billing", href: "/account/billing", icon: CreditCardIcon },
|
||||
{
|
||||
name: "Subscriptions",
|
||||
href: "/account/subscriptions",
|
||||
icon: ServerIcon,
|
||||
},
|
||||
{ name: "Services", href: "/account/services", icon: Squares2X2Icon },
|
||||
{
|
||||
name: "Support",
|
||||
icon: ChatBubbleLeftRightIcon,
|
||||
children: [
|
||||
{ name: "Cases", href: "/account/support" },
|
||||
{ name: "New Case", href: "/account/support/new" },
|
||||
],
|
||||
},
|
||||
{ name: "Support", href: "/account/support", icon: ChatBubbleLeftRightIcon },
|
||||
{ name: "Log out", href: "#", icon: ArrowRightStartOnRectangleIcon, isLogout: true },
|
||||
];
|
||||
|
||||
@ -1,30 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/shared/utils";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
interface AnimatedSectionProps {
|
||||
/** Whether to show the section */
|
||||
show: boolean;
|
||||
/** Content to animate */
|
||||
children: React.ReactNode;
|
||||
/** Delay in ms before animation starts (default: 0) */
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper component that provides smooth height and opacity transitions.
|
||||
* Uses CSS grid for smooth height animation.
|
||||
*/
|
||||
export function AnimatedSection({ show, children, delay = 0 }: AnimatedSectionProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid transition-all duration-500 ease-out",
|
||||
show ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
|
||||
)}
|
||||
style={{ transitionDelay: show ? `${delay}ms` : "0ms" }}
|
||||
<AnimatePresence initial={false}>
|
||||
{show && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.5, ease: "easeOut", delay: delay / 1000 }}
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
<div className="overflow-hidden">{children}</div>
|
||||
</div>
|
||||
{children}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
@ -157,7 +157,7 @@ const BillingSummary = forwardRef<HTMLDivElement, BillingSummaryProps>(
|
||||
</div>
|
||||
{!compact && (
|
||||
<Link
|
||||
href="/account/billing/invoices"
|
||||
href="/account/billing"
|
||||
className="inline-flex items-center text-sm text-primary hover:text-primary-hover font-medium"
|
||||
>
|
||||
View All
|
||||
@ -184,7 +184,7 @@ const BillingSummary = forwardRef<HTMLDivElement, BillingSummaryProps>(
|
||||
{compact && (
|
||||
<div className="mt-4 pt-4 border-t border-border">
|
||||
<Link
|
||||
href="/account/billing/invoices"
|
||||
href="/account/billing"
|
||||
className="inline-flex items-center text-sm text-primary hover:text-primary-hover font-medium"
|
||||
>
|
||||
View All Invoices
|
||||
|
||||
177
apps/portal/src/features/billing/views/BillingOverview.tsx
Normal file
177
apps/portal/src/features/billing/views/BillingOverview.tsx
Normal 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;
|
||||
@ -81,7 +81,7 @@ export function InvoiceDetailContainer() {
|
||||
<PageLayout
|
||||
icon={<DocumentTextIcon />}
|
||||
title="Invoice"
|
||||
backLink={{ label: "Back to Invoices", href: "/account/billing/invoices" }}
|
||||
backLink={{ label: "Back to Billing", href: "/account/billing" }}
|
||||
>
|
||||
<ErrorState
|
||||
title="Error loading invoice"
|
||||
@ -96,7 +96,7 @@ export function InvoiceDetailContainer() {
|
||||
<PageLayout
|
||||
icon={<DocumentTextIcon />}
|
||||
title={`Invoice #${invoice.id}`}
|
||||
backLink={{ label: "Back to Invoices", href: "/account/billing/invoices" }}
|
||||
backLink={{ label: "Back to Billing", href: "/account/billing" }}
|
||||
>
|
||||
<div>
|
||||
<div className="bg-card text-card-foreground rounded-xl shadow-[var(--cp-shadow-1)] border border-border overflow-hidden">
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
DocumentTextIcon,
|
||||
CheckCircleIcon,
|
||||
@ -13,6 +14,16 @@ import type { Activity } from "@customer-portal/domain/dashboard";
|
||||
import { formatActivityDate, getActivityNavigationPath } from "../utils/dashboard.utils";
|
||||
import { cn } from "@/shared/utils";
|
||||
|
||||
const containerVariants = {
|
||||
hidden: {},
|
||||
visible: { transition: { staggerChildren: 0.05 } },
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, x: -8 },
|
||||
visible: { opacity: 1, x: 0, transition: { duration: 0.2, ease: "easeOut" as const } },
|
||||
};
|
||||
|
||||
interface ActivityFeedProps {
|
||||
activities: Activity[];
|
||||
maxItems?: number;
|
||||
@ -182,15 +193,18 @@ export function ActivityFeed({
|
||||
{visibleActivities.length === 0 ? (
|
||||
<EmptyActivity />
|
||||
) : (
|
||||
<div className="bg-surface border border-border/60 rounded-xl p-4 cp-stagger-children">
|
||||
<motion.div
|
||||
className="bg-surface border border-border/60 rounded-xl p-4"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={containerVariants}
|
||||
>
|
||||
{visibleActivities.map((activity, index) => (
|
||||
<ActivityItem
|
||||
key={activity.id}
|
||||
activity={activity}
|
||||
isLast={index === visibleActivities.length - 1}
|
||||
/>
|
||||
<motion.div key={activity.id} variants={itemVariants}>
|
||||
<ActivityItem activity={activity} isLast={index === visibleActivities.length - 1} />
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
ServerIcon,
|
||||
ChatBubbleLeftRightIcon,
|
||||
@ -9,6 +10,16 @@ import {
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { cn } from "@/shared/utils";
|
||||
|
||||
const containerVariants = {
|
||||
hidden: {},
|
||||
visible: { transition: { staggerChildren: 0.05 } },
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 16 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.2, ease: "easeOut" as const } },
|
||||
};
|
||||
|
||||
interface QuickStatsProps {
|
||||
activeSubscriptions: number;
|
||||
openCases: number;
|
||||
@ -132,7 +143,13 @@ export function QuickStats({
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold text-foreground">Account Overview</h3>
|
||||
</div>
|
||||
<div className="space-y-3 cp-stagger-children">
|
||||
<motion.div
|
||||
className="space-y-3"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={containerVariants}
|
||||
>
|
||||
<motion.div variants={itemVariants}>
|
||||
<StatItem
|
||||
icon={ServerIcon}
|
||||
label="Active Services"
|
||||
@ -141,6 +158,8 @@ export function QuickStats({
|
||||
tone="primary"
|
||||
emptyText="No active services"
|
||||
/>
|
||||
</motion.div>
|
||||
<motion.div variants={itemVariants}>
|
||||
<StatItem
|
||||
icon={ChatBubbleLeftRightIcon}
|
||||
label="Open Support Cases"
|
||||
@ -149,7 +168,9 @@ export function QuickStats({
|
||||
tone={openCases > 0 ? "warning" : "info"}
|
||||
emptyText="No open cases"
|
||||
/>
|
||||
</motion.div>
|
||||
{recentOrders !== undefined && (
|
||||
<motion.div variants={itemVariants}>
|
||||
<StatItem
|
||||
icon={ClipboardDocumentListIcon}
|
||||
label="Recent Orders"
|
||||
@ -158,8 +179,9 @@ export function QuickStats({
|
||||
tone="success"
|
||||
emptyText="No recent orders"
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -63,7 +63,7 @@ function AllCaughtUp() {
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/account/billing/invoices"
|
||||
href="/account/billing"
|
||||
className="group flex flex-col items-center gap-2 p-4 rounded-xl bg-surface/80 backdrop-blur-sm border border-border/60 hover:border-info/40 hover:shadow-lg transition-all"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-lg bg-info/10 flex items-center justify-center group-hover:bg-info/20 transition-colors">
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
import { useAuthStore } from "@/features/auth/stores/auth.store";
|
||||
@ -12,6 +13,16 @@ import { cn } from "@/shared/utils";
|
||||
import { InlineToast } from "@/components/atoms/inline-toast";
|
||||
import { useInternetEligibility } from "@/features/services/hooks";
|
||||
|
||||
const gridContainerVariants = {
|
||||
hidden: {},
|
||||
visible: { transition: { staggerChildren: 0.05 } },
|
||||
};
|
||||
|
||||
const gridItemVariants = {
|
||||
hidden: { opacity: 0, y: 16 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.3, ease: "easeOut" as const } },
|
||||
};
|
||||
|
||||
function DashboardSkeleton() {
|
||||
return (
|
||||
<PageLayout title="Dashboard" loading>
|
||||
@ -54,13 +65,20 @@ function DashboardGreeting({
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-foreground font-heading tracking-tight animate-in fade-in slide-in-from-bottom-2 duration-500">
|
||||
<motion.h2
|
||||
className="text-2xl sm:text-3xl font-bold text-foreground font-heading tracking-tight"
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
Welcome back, {displayName}
|
||||
</h2>
|
||||
</motion.h2>
|
||||
{taskCount > 0 ? (
|
||||
<div
|
||||
className="flex items-center gap-2 mt-2 animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||
style={{ animationDelay: "50ms" }}
|
||||
<motion.div
|
||||
className="flex items-center gap-2 mt-2"
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.05 }}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
@ -71,14 +89,16 @@ function DashboardGreeting({
|
||||
{hasUrgentTask && <ExclamationTriangleIcon className="h-3.5 w-3.5" />}
|
||||
{taskCount === 1 ? "1 task needs attention" : `${taskCount} tasks need attention`}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<p
|
||||
className="text-sm text-muted-foreground mt-1.5 animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||
style={{ animationDelay: "50ms" }}
|
||||
<motion.p
|
||||
className="text-sm text-muted-foreground mt-1.5"
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.05 }}
|
||||
>
|
||||
Everything is up to date
|
||||
</p>
|
||||
</motion.p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@ -178,32 +198,41 @@ function DashboardContent({
|
||||
taskCount={taskCount}
|
||||
hasUrgentTask={tasks.some(t => t.tone === "critical")}
|
||||
/>
|
||||
<section
|
||||
className="mb-10 animate-in fade-in slide-in-from-bottom-6 duration-600"
|
||||
style={{ animationDelay: "150ms" }}
|
||||
<motion.section
|
||||
className="mb-10"
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.15 }}
|
||||
aria-labelledby="tasks-heading"
|
||||
>
|
||||
<h3 id="tasks-heading" className="sr-only">
|
||||
Your Tasks
|
||||
</h3>
|
||||
<TaskList tasks={tasks} isLoading={tasksLoading} maxTasks={4} />
|
||||
</section>
|
||||
<section
|
||||
className="grid grid-cols-1 lg:grid-cols-2 gap-6 cp-stagger-children"
|
||||
</motion.section>
|
||||
<motion.section
|
||||
className="grid grid-cols-1 lg:grid-cols-2 gap-6"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={gridContainerVariants}
|
||||
aria-label="Account overview"
|
||||
>
|
||||
<motion.div variants={gridItemVariants}>
|
||||
<QuickStats
|
||||
activeSubscriptions={summary?.stats?.activeSubscriptions ?? 0}
|
||||
openCases={summary?.stats?.openCases ?? 0}
|
||||
recentOrders={summary?.stats?.recentOrders}
|
||||
isLoading={summaryLoading}
|
||||
/>
|
||||
</motion.div>
|
||||
<motion.div variants={gridItemVariants}>
|
||||
<ActivityFeed
|
||||
activities={summary?.recentActivity || []}
|
||||
maxItems={5}
|
||||
isLoading={summaryLoading}
|
||||
/>
|
||||
</section>
|
||||
</motion.div>
|
||||
</motion.section>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { motion, useInView } from "framer-motion";
|
||||
import { Mail, MapPin, MessageSquare, PhoneCall, Train } from "lucide-react";
|
||||
import { cn } from "@/shared/utils";
|
||||
import { useInView } from "@/features/landing-page/hooks";
|
||||
import { ContactForm } from "@/features/support/components";
|
||||
|
||||
function ContactFormSection() {
|
||||
@ -95,16 +95,17 @@ function MapAndAddress() {
|
||||
}
|
||||
|
||||
export function ContactSection() {
|
||||
const [ref, isInView] = useInView<HTMLElement>();
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const isInView = useInView(ref, { once: true, amount: 0.1 });
|
||||
|
||||
return (
|
||||
<section
|
||||
<motion.section
|
||||
id="contact"
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-surface-sunken/30 py-14 sm:py-16 transition-all duration-700",
|
||||
isInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
|
||||
)}
|
||||
initial={{ opacity: 0, y: 32 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 32 }}
|
||||
transition={{ duration: 0.7, ease: "easeOut" as const }}
|
||||
className="bg-surface-sunken/30 py-14 sm:py-16"
|
||||
>
|
||||
<div className="max-w-6xl mx-auto px-6 sm:px-10 lg:px-14 space-y-6">
|
||||
<h2 className="text-2xl sm:text-3xl font-extrabold text-foreground font-heading">
|
||||
@ -117,6 +118,6 @@ export function ContactSection() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</motion.section>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,24 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { motion, useInView } from "framer-motion";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import { cn } from "@/shared/utils";
|
||||
import { useInView } from "@/features/landing-page/hooks";
|
||||
|
||||
interface HeroSectionProps {
|
||||
heroCTARef: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export function HeroSection({ heroCTARef }: HeroSectionProps) {
|
||||
const [heroRef, heroInView] = useInView<HTMLDivElement>();
|
||||
const heroRef = useRef<HTMLDivElement>(null);
|
||||
const heroInView = useInView(heroRef, { once: true, amount: 0.1 });
|
||||
|
||||
return (
|
||||
<div
|
||||
<motion.div
|
||||
ref={heroRef}
|
||||
className={cn(
|
||||
"relative flex-1 flex items-center py-16 sm:py-20 lg:py-24 overflow-hidden transition-all duration-700",
|
||||
heroInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
|
||||
)}
|
||||
initial={{ opacity: 0, y: 32 }}
|
||||
animate={heroInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 32 }}
|
||||
transition={{ duration: 0.7, ease: "easeOut" as const }}
|
||||
className="relative flex-1 flex items-center py-16 sm:py-20 lg:py-24 overflow-hidden"
|
||||
>
|
||||
{/* Gradient Background */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-surface-sunken via-background to-info-bg/80" />
|
||||
@ -70,6 +71,6 @@ export function HeroSection({ heroCTARef }: HeroSectionProps) {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,21 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import Image from "next/image";
|
||||
import { motion, useInView } from "framer-motion";
|
||||
import { Download } from "lucide-react";
|
||||
import { cn } from "@/shared/utils";
|
||||
import { useInView } from "@/features/landing-page/hooks";
|
||||
import { supportDownloads } from "@/features/landing-page/data";
|
||||
|
||||
export function SupportDownloadsSection() {
|
||||
const [ref, isInView] = useInView();
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const isInView = useInView(ref, { once: true, amount: 0.1 });
|
||||
|
||||
return (
|
||||
<section
|
||||
<motion.section
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"py-14 sm:py-16 transition-all duration-700",
|
||||
isInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
|
||||
)}
|
||||
initial={{ opacity: 0, y: 32 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 32 }}
|
||||
transition={{ duration: 0.7, ease: "easeOut" as const }}
|
||||
className="py-14 sm:py-16"
|
||||
>
|
||||
<div className="mx-auto max-w-5xl px-6 sm:px-10 lg:px-14">
|
||||
<h2 className="text-center text-2xl sm:text-3xl font-extrabold text-foreground tracking-tight mb-2 font-heading">
|
||||
@ -60,6 +61,6 @@ export function SupportDownloadsSection() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</motion.section>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { motion, useInView } from "framer-motion";
|
||||
import { Clock, CreditCard, Globe, Users } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { cn } from "@/shared/utils";
|
||||
import { useCountUp } from "@/shared/hooks";
|
||||
import { useInView } from "@/features/landing-page/hooks";
|
||||
|
||||
const numberFormatter = new Intl.NumberFormat();
|
||||
|
||||
@ -66,16 +67,17 @@ function AnimatedValue({
|
||||
}
|
||||
|
||||
export function TrustStrip() {
|
||||
const [ref, inView] = useInView<HTMLDivElement>();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const inView = useInView(ref, { once: true, amount: 0.1 });
|
||||
|
||||
return (
|
||||
<div
|
||||
<motion.div
|
||||
ref={ref}
|
||||
aria-label="Company statistics"
|
||||
className={cn(
|
||||
"relative py-10 sm:py-12 overflow-hidden transition-all duration-700",
|
||||
inView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
|
||||
)}
|
||||
initial={{ opacity: 0, y: 32 }}
|
||||
animate={inView ? { opacity: 1, y: 0 } : { opacity: 0, y: 32 }}
|
||||
transition={{ duration: 0.7, ease: "easeOut" as const }}
|
||||
className="relative py-10 sm:py-12 overflow-hidden"
|
||||
>
|
||||
{/* Gradient background */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-surface-sunken via-background to-info-bg/30" />
|
||||
@ -114,6 +116,6 @@ export function TrustStrip() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { motion, useInView } from "framer-motion";
|
||||
import { ArrowRight, BadgeCheck } from "lucide-react";
|
||||
import { cn } from "@/shared/utils";
|
||||
import { useInView } from "@/features/landing-page/hooks";
|
||||
|
||||
const trustPoints = [
|
||||
"Full English support, no Japanese needed",
|
||||
@ -13,15 +13,15 @@ const trustPoints = [
|
||||
];
|
||||
|
||||
export function WhyUsSection() {
|
||||
const [ref, isInView] = useInView<HTMLDivElement>();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const isInView = useInView(ref, { once: true, amount: 0.1 });
|
||||
|
||||
return (
|
||||
<div
|
||||
<motion.div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"transition-all duration-700",
|
||||
isInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
|
||||
)}
|
||||
initial={{ opacity: 0, y: 32 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 32 }}
|
||||
transition={{ duration: 0.7, ease: "easeOut" as const }}
|
||||
>
|
||||
<div className="max-w-6xl mx-auto px-6 sm:px-10 lg:px-14 py-14 sm:py-16">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-10 lg:gap-14 items-center">
|
||||
@ -63,6 +63,6 @@ export function WhyUsSection() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,35 +1,27 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRef } from "react";
|
||||
import { useInView as useFramerInView } from "framer-motion";
|
||||
|
||||
const DEFAULT_OPTIONS: IntersectionObserverInit = {};
|
||||
interface UseInViewOptions {
|
||||
threshold?: number | number[];
|
||||
root?: Element | null;
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS: UseInViewOptions = {};
|
||||
|
||||
/**
|
||||
* useInView - Intersection Observer hook for scroll-triggered animations
|
||||
* Returns a ref and boolean indicating if element is in viewport.
|
||||
* Once the element becomes visible, it stays marked as "in view" (trigger once).
|
||||
* Scroll-triggered visibility hook (trigger once).
|
||||
* Wraps framer-motion's useInView.
|
||||
*/
|
||||
export function useInView<T extends HTMLElement = HTMLElement>(
|
||||
options: IntersectionObserverInit = DEFAULT_OPTIONS
|
||||
options: UseInViewOptions = DEFAULT_OPTIONS
|
||||
) {
|
||||
const ref = useRef<T>(null!);
|
||||
const [isInView, setIsInView] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current;
|
||||
if (!element) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry?.isIntersecting) {
|
||||
setIsInView(true);
|
||||
observer.disconnect(); // triggerOnce
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1, ...options }
|
||||
);
|
||||
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [options]);
|
||||
const isInView = useFramerInView(ref, {
|
||||
once: true,
|
||||
amount: typeof options.threshold === "number" ? options.threshold : 0.1,
|
||||
...(options.root ? { root: { current: options.root } } : undefined),
|
||||
});
|
||||
|
||||
return [ref, isInView] as const;
|
||||
}
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { motion } from "framer-motion";
|
||||
import { ServiceCard } from "@/components/molecules";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
@ -20,6 +23,16 @@ import {
|
||||
BriefcaseBusiness,
|
||||
} from "lucide-react";
|
||||
|
||||
const staggerContainerVariants = {
|
||||
hidden: {},
|
||||
visible: { transition: { staggerChildren: 0.05 } },
|
||||
};
|
||||
|
||||
const fadeUpItemVariants = {
|
||||
hidden: { opacity: 0, y: 16 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.3, ease: "easeOut" as const } },
|
||||
};
|
||||
|
||||
/* ─── Data ─── */
|
||||
|
||||
const services = [
|
||||
@ -132,14 +145,28 @@ function HeroSection() {
|
||||
/>
|
||||
<div className="relative mx-auto max-w-6xl px-6 py-16 sm:px-8 sm:py-20 lg:py-24">
|
||||
<div className="grid grid-cols-1 items-center gap-12 lg:grid-cols-2">
|
||||
<div className="cp-stagger-children space-y-6">
|
||||
<span className="inline-block rounded-full bg-primary/10 px-4 py-1.5 text-sm font-semibold tracking-wide text-primary">
|
||||
<motion.div
|
||||
className="space-y-6"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={staggerContainerVariants}
|
||||
>
|
||||
<motion.span
|
||||
className="inline-block rounded-full bg-primary/10 px-4 py-1.5 text-sm font-semibold tracking-wide text-primary"
|
||||
variants={fadeUpItemVariants}
|
||||
>
|
||||
Since 2002
|
||||
</span>
|
||||
<h1 className="text-display-lg font-extrabold leading-[1.1] tracking-tight font-heading text-foreground">
|
||||
</motion.span>
|
||||
<motion.h1
|
||||
className="text-display-lg font-extrabold leading-[1.1] tracking-tight font-heading text-foreground"
|
||||
variants={fadeUpItemVariants}
|
||||
>
|
||||
Your Trusted IT Partner <span className="cp-gradient-text">in Japan</span>
|
||||
</h1>
|
||||
<div className="max-w-lg space-y-4 text-base leading-relaxed text-muted-foreground sm:text-lg">
|
||||
</motion.h1>
|
||||
<motion.div
|
||||
className="max-w-lg space-y-4 text-base leading-relaxed text-muted-foreground sm:text-lg"
|
||||
variants={fadeUpItemVariants}
|
||||
>
|
||||
<p>
|
||||
Assist Solutions has been the go-to IT partner for expats and international
|
||||
businesses in Japan for over two decades. We understand the unique challenges of
|
||||
@ -150,8 +177,8 @@ function HeroSection() {
|
||||
English service. No Japanese required — we handle everything from contracts to
|
||||
installation.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
<div className="relative mx-auto h-[380px] w-full max-w-md lg:h-[440px] lg:max-w-none">
|
||||
<Image
|
||||
src="/assets/images/about-us.png"
|
||||
@ -172,13 +199,26 @@ function ServicesSection() {
|
||||
return (
|
||||
<section className="full-bleed bg-background py-16 sm:py-20">
|
||||
<div className="mx-auto max-w-6xl px-6 sm:px-8">
|
||||
<div className="cp-stagger-children mb-10 max-w-xl">
|
||||
<h2 className="text-display-sm font-bold font-heading text-foreground">What We Do</h2>
|
||||
<p className="mt-3 leading-relaxed text-muted-foreground">
|
||||
<motion.div
|
||||
className="mb-10 max-w-xl"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={staggerContainerVariants}
|
||||
>
|
||||
<motion.h2
|
||||
className="text-display-sm font-bold font-heading text-foreground"
|
||||
variants={fadeUpItemVariants}
|
||||
>
|
||||
What We Do
|
||||
</motion.h2>
|
||||
<motion.p
|
||||
className="mt-3 leading-relaxed text-muted-foreground"
|
||||
variants={fadeUpItemVariants}
|
||||
>
|
||||
End-to-end IT services designed for the international community in Japan — all in
|
||||
English.
|
||||
</p>
|
||||
</div>
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{services.map(service => (
|
||||
@ -201,21 +241,36 @@ function ValuesSection() {
|
||||
return (
|
||||
<section className="full-bleed bg-surface-sunken py-16 sm:py-20">
|
||||
<div className="mx-auto max-w-6xl px-6 sm:px-8">
|
||||
<div className="cp-stagger-children mb-10 text-center">
|
||||
<h2 className="text-display-sm font-bold font-heading text-foreground">Our Values</h2>
|
||||
<p className="mx-auto mt-3 max-w-lg leading-relaxed text-muted-foreground">
|
||||
<motion.div
|
||||
className="mb-10 text-center"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={staggerContainerVariants}
|
||||
>
|
||||
<motion.h2
|
||||
className="text-display-sm font-bold font-heading text-foreground"
|
||||
variants={fadeUpItemVariants}
|
||||
>
|
||||
Our Values
|
||||
</motion.h2>
|
||||
<motion.p
|
||||
className="mx-auto mt-3 max-w-lg leading-relaxed text-muted-foreground"
|
||||
variants={fadeUpItemVariants}
|
||||
>
|
||||
These principles guide how we serve customers, support our community, and advance our
|
||||
craft every day.
|
||||
</p>
|
||||
</div>
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
{values.map(value => {
|
||||
const Icon = value.icon;
|
||||
return (
|
||||
<div
|
||||
<motion.div
|
||||
key={value.title}
|
||||
className="group cp-card-hover-lift rounded-2xl border border-border/40 bg-card p-5"
|
||||
className="group rounded-2xl border border-border/40 bg-card p-5"
|
||||
whileHover={{ y: -2 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" as const }}
|
||||
>
|
||||
<div
|
||||
className={`mb-3 inline-flex h-10 w-10 items-center justify-center rounded-lg ${value.accent}`}
|
||||
@ -226,7 +281,7 @@ function ValuesSection() {
|
||||
{value.title}
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">{value.text}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@ -239,9 +294,19 @@ function CorporateSection() {
|
||||
return (
|
||||
<section className="full-bleed bg-background py-16 sm:py-20">
|
||||
<div className="mx-auto max-w-6xl px-6 sm:px-8">
|
||||
<div className="cp-stagger-children mb-10">
|
||||
<h2 className="text-display-sm font-bold font-heading text-foreground">Corporate Data</h2>
|
||||
</div>
|
||||
<motion.div
|
||||
className="mb-10"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={staggerContainerVariants}
|
||||
>
|
||||
<motion.h2
|
||||
className="text-display-sm font-bold font-heading text-foreground"
|
||||
variants={fadeUpItemVariants}
|
||||
>
|
||||
Corporate Data
|
||||
</motion.h2>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-10 lg:grid-cols-5">
|
||||
<CorporateDetails />
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useState, useRef, useCallback, useEffect } from "react";
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import { BellIcon } from "@heroicons/react/24/outline";
|
||||
import { useUnreadNotificationCount } from "../hooks/useNotifications";
|
||||
import { NotificationDropdown } from "./NotificationDropdown";
|
||||
@ -84,7 +85,15 @@ export const NotificationBell = memo(function NotificationBell({
|
||||
)}
|
||||
</button>
|
||||
|
||||
<NotificationDropdown isOpen={isOpen} onClose={closeDropdown} position={dropdownPosition} />
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<NotificationDropdown
|
||||
isOpen={isOpen}
|
||||
onClose={closeDropdown}
|
||||
position={dropdownPosition}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import { memo } from "react";
|
||||
import Link from "next/link";
|
||||
import { motion } from "framer-motion";
|
||||
import { CheckIcon } from "@heroicons/react/24/outline";
|
||||
import { BellSlashIcon } from "@heroicons/react/24/solid";
|
||||
import {
|
||||
@ -37,15 +38,16 @@ export const NotificationDropdown = memo(function NotificationDropdown({
|
||||
const notifications = data?.notifications ?? [];
|
||||
const hasUnread = (data?.unreadCount ?? 0) > 0;
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
className={cn(
|
||||
"absolute w-80 sm:w-96",
|
||||
position === "right" ? "left-full top-0 ml-2" : "right-0 top-full mt-2",
|
||||
"bg-popover border border-border rounded-xl shadow-lg z-50 overflow-hidden",
|
||||
"animate-in fade-in-0 zoom-in-95 duration-100"
|
||||
"bg-popover border border-border rounded-xl shadow-lg z-50 overflow-hidden"
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
@ -105,6 +107,6 @@ export const NotificationDropdown = memo(function NotificationDropdown({
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type ElementType, type ReactNode } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { cn } from "@/shared/utils";
|
||||
|
||||
interface CollapsibleSectionProps {
|
||||
title: string;
|
||||
@ -30,21 +30,23 @@ export function CollapsibleSection({
|
||||
<Icon className="w-4 h-4 text-primary" />
|
||||
<span className="text-sm font-medium text-foreground">{title}</span>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"w-4 h-4 text-muted-foreground transition-transform duration-200",
|
||||
isOpen && "rotate-180"
|
||||
)}
|
||||
/>
|
||||
<motion.div animate={{ rotate: isOpen ? 180 : 0 }} transition={{ duration: 0.2 }}>
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
||||
</motion.div>
|
||||
</button>
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden transition-all duration-300",
|
||||
isOpen ? "max-h-[2000px]" : "max-h-0"
|
||||
)}
|
||||
<AnimatePresence initial={false}>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
<div className="p-4 pt-0 border-t border-border/60">{children}</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -33,6 +33,7 @@ import {
|
||||
type HighlightFeature,
|
||||
} from "@/features/services/components/base/ServiceHighlights";
|
||||
import { SimHowItWorksSection } from "@/features/services/components/sim/SimHowItWorksSection";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { cn } from "@/shared/utils";
|
||||
|
||||
export type SimPlansTab = "data-voice" | "data-only" | "voice-only";
|
||||
@ -339,12 +340,14 @@ function SimPlansGrid({
|
||||
|
||||
return (
|
||||
<div id="plans" className="min-h-[280px] overflow-hidden">
|
||||
<div
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={activeTab}
|
||||
className={cn(
|
||||
"space-y-8",
|
||||
slideDirection === "left" ? "cp-slide-fade-left" : "cp-slide-fade-right"
|
||||
)}
|
||||
className="space-y-8"
|
||||
initial={{ opacity: 0, x: slideDirection === "left" ? 24 : -24 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: slideDirection === "left" ? -24 : 24 }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" as const }}
|
||||
>
|
||||
{regularPlans.length > 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
@ -367,7 +370,8 @@ function SimPlansGrid({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -37,6 +37,7 @@ import {
|
||||
getTierDescription,
|
||||
getTierFeatures,
|
||||
} from "@/features/services/utils/internet-config";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { cn } from "@/shared/utils";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
@ -302,14 +303,20 @@ function UnifiedInternetCard({
|
||||
</div>
|
||||
|
||||
<div className="p-5 pt-3">
|
||||
<div
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={selectedOffering}
|
||||
className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6 cp-slide-fade-left"
|
||||
className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6"
|
||||
initial={{ opacity: 0, x: 24 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -24 }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" as const }}
|
||||
>
|
||||
{displayTiers.map(tier => (
|
||||
<TierCard key={tier.tier} tier={tier} />
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-4 pt-4 border-t border-border">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<span className="font-semibold text-foreground">
|
||||
|
||||
@ -205,7 +205,7 @@ function SubscriptionDetailContent({
|
||||
tone="primary"
|
||||
actions={
|
||||
<Link
|
||||
href="/account/billing/invoices"
|
||||
href="/account/billing"
|
||||
className="text-sm font-medium text-primary hover:text-primary/80 transition-colors"
|
||||
>
|
||||
View Invoices
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import { ViewToggle, type ViewMode } from "@/components/atoms/view-toggle";
|
||||
import { MetricCard, MetricCardSkeleton } from "@/components/molecules/MetricCard";
|
||||
@ -22,6 +23,16 @@ import {
|
||||
type SubscriptionStatus,
|
||||
} from "@customer-portal/domain/subscriptions";
|
||||
|
||||
const gridContainerVariants = {
|
||||
hidden: {},
|
||||
visible: { transition: { staggerChildren: 0.03 } },
|
||||
};
|
||||
|
||||
const gridItemVariants = {
|
||||
hidden: { opacity: 0, y: 12 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.2, ease: "easeOut" as const } },
|
||||
};
|
||||
|
||||
const SUBSCRIPTION_STATUS_OPTIONS = Object.values(SUBSCRIPTION_STATUS) as SubscriptionStatus[];
|
||||
|
||||
function SubscriptionMetrics({
|
||||
@ -87,13 +98,20 @@ function SubscriptionGrid({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3 cp-stagger-grid">
|
||||
<motion.div
|
||||
className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={gridContainerVariants}
|
||||
>
|
||||
{subscriptions.map(sub => (
|
||||
<SubscriptionGridCard key={sub.serviceId} subscription={sub} />
|
||||
<motion.div key={sub.serviceId} variants={gridItemVariants}>
|
||||
<SubscriptionGridCard subscription={sub} />
|
||||
</motion.div>
|
||||
))}
|
||||
{loading &&
|
||||
Array.from({ length: 3 }).map((_, i) => <SubscriptionGridCardSkeleton key={`skel-${i}`} />)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -5,4 +5,3 @@ export { useZodForm } from "./useZodForm";
|
||||
export { useCurrency } from "./useCurrency";
|
||||
export { useFormatCurrency, type FormatCurrencyOptions } from "./useFormatCurrency";
|
||||
export { useCountUp } from "./useCountUp";
|
||||
export { useAfterPaint } from "./useAfterPaint";
|
||||
|
||||
@ -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]);
|
||||
}
|
||||
@ -1,27 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
|
||||
const reducedMotionQuery =
|
||||
typeof window === "undefined" ? undefined : window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
import { useState, useEffect } from "react";
|
||||
import { animate, useReducedMotion } from "framer-motion";
|
||||
|
||||
interface UseCountUpOptions {
|
||||
/** Starting value (default: 0) */
|
||||
start?: number;
|
||||
/** Target value to count to */
|
||||
end: number;
|
||||
/** Animation duration in ms (default: 300) */
|
||||
duration?: number;
|
||||
/** Delay before starting animation in ms (default: 0) */
|
||||
delay?: number;
|
||||
/** Whether animation is enabled (default: true) */
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Animated counter hook for stats and numbers
|
||||
* Uses requestAnimationFrame for smooth 60fps animation
|
||||
*/
|
||||
export function useCountUp({
|
||||
start = 0,
|
||||
end,
|
||||
@ -30,8 +19,7 @@ export function useCountUp({
|
||||
enabled = true,
|
||||
}: UseCountUpOptions): number {
|
||||
const [count, setCount] = useState(start);
|
||||
const frameRef = useRef<number | undefined>(undefined);
|
||||
const startTimeRef = useRef<number | undefined>(undefined);
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
@ -39,42 +27,28 @@ export function useCountUp({
|
||||
return;
|
||||
}
|
||||
|
||||
// Respect prefers-reduced-motion — show final value immediately
|
||||
if (reducedMotionQuery?.matches) {
|
||||
if (prefersReducedMotion) {
|
||||
setCount(end);
|
||||
return;
|
||||
}
|
||||
|
||||
startTimeRef.current = undefined;
|
||||
let controls: ReturnType<typeof animate> | undefined;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
const animate = (timestamp: number) => {
|
||||
if (!startTimeRef.current) {
|
||||
startTimeRef.current = timestamp;
|
||||
}
|
||||
|
||||
const progress = Math.min((timestamp - startTimeRef.current) / duration, 1);
|
||||
// Ease-out cubic for smooth deceleration
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
const next = Math.round(start + (end - start) * eased);
|
||||
|
||||
setCount(prev => (prev === next ? prev : next));
|
||||
|
||||
if (progress < 1) {
|
||||
frameRef.current = requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
|
||||
frameRef.current = requestAnimationFrame(animate);
|
||||
controls = animate(start, end, {
|
||||
duration: duration / 1000,
|
||||
ease: [0, 0, 0.2, 1],
|
||||
onUpdate: value => {
|
||||
setCount(Math.round(value));
|
||||
},
|
||||
});
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
if (frameRef.current) {
|
||||
cancelAnimationFrame(frameRef.current);
|
||||
}
|
||||
controls?.stop();
|
||||
};
|
||||
}, [start, end, duration, delay, enabled]);
|
||||
}, [start, end, duration, delay, enabled, prefersReducedMotion]);
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
@ -7,39 +7,6 @@
|
||||
|
||||
/* ===== KEYFRAMES ===== */
|
||||
|
||||
@keyframes cp-fade-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(var(--cp-translate-lg));
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cp-fade-scale {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cp-slide-in-left {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(calc(var(--cp-translate-xl) * -1));
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cp-shimmer {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
@ -49,28 +16,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cp-toast-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(100%) scale(0.9);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cp-toast-exit {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(100%) scale(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cp-shake {
|
||||
0%,
|
||||
100% {
|
||||
@ -86,69 +31,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cp-activity-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cp-slide-fade-left {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(24px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cp-slide-fade-right {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-24px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cp-float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0px) rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-20px) rotate(2deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cp-float-slow {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0px) rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-12px) rotate(-1deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cp-pulse-glow {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 var(--primary);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 20px 4px color-mix(in oklch, var(--primary) 40%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
/* Legacy shimmer animation for compatibility */
|
||||
@keyframes cp-skeleton-shimmer {
|
||||
0% {
|
||||
@ -208,100 +90,6 @@
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* ===== PAGE ENTRANCE ANIMATIONS ===== */
|
||||
.cp-animate-in {
|
||||
animation: cp-fade-up var(--cp-duration-slow) var(--cp-ease-out) forwards;
|
||||
}
|
||||
|
||||
.cp-animate-scale-in {
|
||||
animation: cp-fade-scale var(--cp-duration-normal) var(--cp-ease-out) forwards;
|
||||
}
|
||||
|
||||
.cp-animate-slide-left {
|
||||
animation: cp-slide-in-left var(--cp-duration-slow) var(--cp-ease-out) forwards;
|
||||
}
|
||||
|
||||
/* Staggered children animation */
|
||||
.cp-stagger-children > * {
|
||||
opacity: 0;
|
||||
animation: cp-fade-up var(--cp-duration-slow) var(--cp-ease-out) forwards;
|
||||
}
|
||||
|
||||
.cp-stagger-children > *:nth-child(1) {
|
||||
animation-delay: var(--cp-stagger-1);
|
||||
}
|
||||
.cp-stagger-children > *:nth-child(2) {
|
||||
animation-delay: var(--cp-stagger-2);
|
||||
}
|
||||
.cp-stagger-children > *:nth-child(3) {
|
||||
animation-delay: var(--cp-stagger-3);
|
||||
}
|
||||
.cp-stagger-children > *:nth-child(4) {
|
||||
animation-delay: var(--cp-stagger-4);
|
||||
}
|
||||
.cp-stagger-children > *:nth-child(5) {
|
||||
animation-delay: var(--cp-stagger-5);
|
||||
}
|
||||
.cp-stagger-children > *:nth-child(n + 6) {
|
||||
animation-delay: calc(var(--cp-stagger-5) + 50ms);
|
||||
}
|
||||
|
||||
/* Card grid stagger - faster delay for dense grids */
|
||||
.cp-stagger-grid > * {
|
||||
opacity: 0;
|
||||
animation: cp-fade-up var(--cp-duration-normal) var(--cp-ease-out) forwards;
|
||||
}
|
||||
|
||||
.cp-stagger-grid > *:nth-child(1) {
|
||||
animation-delay: 0ms;
|
||||
}
|
||||
.cp-stagger-grid > *:nth-child(2) {
|
||||
animation-delay: 30ms;
|
||||
}
|
||||
.cp-stagger-grid > *:nth-child(3) {
|
||||
animation-delay: 60ms;
|
||||
}
|
||||
.cp-stagger-grid > *:nth-child(4) {
|
||||
animation-delay: 90ms;
|
||||
}
|
||||
.cp-stagger-grid > *:nth-child(5) {
|
||||
animation-delay: 120ms;
|
||||
}
|
||||
.cp-stagger-grid > *:nth-child(6) {
|
||||
animation-delay: 150ms;
|
||||
}
|
||||
.cp-stagger-grid > *:nth-child(n + 7) {
|
||||
animation-delay: 180ms;
|
||||
}
|
||||
|
||||
/* ===== TAB SLIDE TRANSITIONS ===== */
|
||||
.cp-slide-fade-left {
|
||||
animation: cp-slide-fade-left 300ms var(--cp-ease-out) forwards;
|
||||
}
|
||||
|
||||
.cp-slide-fade-right {
|
||||
animation: cp-slide-fade-right 300ms var(--cp-ease-out) forwards;
|
||||
}
|
||||
|
||||
/* ===== CARD HOVER LIFT ===== */
|
||||
.cp-card-hover-lift {
|
||||
transition:
|
||||
transform var(--cp-duration-normal) var(--cp-ease-out),
|
||||
box-shadow var(--cp-duration-normal) var(--cp-ease-out);
|
||||
}
|
||||
|
||||
.cp-card-hover-lift:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
0 10px 40px -10px rgb(0 0 0 / 0.15),
|
||||
0 4px 6px -2px rgb(0 0 0 / 0.05);
|
||||
}
|
||||
|
||||
.cp-card-hover-lift:active {
|
||||
transform: translateY(0);
|
||||
transition-duration: var(--cp-duration-fast);
|
||||
}
|
||||
|
||||
/* ===== SKELETON SHIMMER ===== */
|
||||
.cp-skeleton-shimmer {
|
||||
position: relative;
|
||||
@ -339,50 +127,6 @@
|
||||
animation: cp-shake var(--cp-duration-slow) var(--cp-ease-out);
|
||||
}
|
||||
|
||||
/* ===== TOAST ANIMATIONS ===== */
|
||||
.cp-toast-enter {
|
||||
animation: cp-toast-enter var(--cp-duration-slow) var(--cp-ease-spring) forwards;
|
||||
}
|
||||
|
||||
.cp-toast-exit {
|
||||
animation: cp-toast-exit var(--cp-duration-normal) var(--cp-ease-in) forwards;
|
||||
}
|
||||
|
||||
/* ===== ACTIVITY FEED ===== */
|
||||
.cp-activity-item {
|
||||
opacity: 0;
|
||||
animation: cp-activity-enter var(--cp-duration-normal) var(--cp-ease-out) forwards;
|
||||
}
|
||||
|
||||
.cp-activity-item:nth-child(1) {
|
||||
animation-delay: 0ms;
|
||||
}
|
||||
.cp-activity-item:nth-child(2) {
|
||||
animation-delay: 50ms;
|
||||
}
|
||||
.cp-activity-item:nth-child(3) {
|
||||
animation-delay: 100ms;
|
||||
}
|
||||
.cp-activity-item:nth-child(4) {
|
||||
animation-delay: 150ms;
|
||||
}
|
||||
.cp-activity-item:nth-child(5) {
|
||||
animation-delay: 200ms;
|
||||
}
|
||||
|
||||
/* ===== FLOAT ANIMATIONS ===== */
|
||||
.cp-float {
|
||||
animation: cp-float 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.cp-float-slow {
|
||||
animation: cp-float-slow 8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.cp-float-delayed {
|
||||
animation: cp-float 6s ease-in-out infinite 2s;
|
||||
}
|
||||
|
||||
/* ===== GLASS MORPHISM ===== */
|
||||
.cp-glass {
|
||||
background: var(--glass-bg);
|
||||
@ -477,10 +221,6 @@
|
||||
box-shadow: var(--shadow-primary-lg);
|
||||
}
|
||||
|
||||
.cp-glow-pulse {
|
||||
animation: cp-pulse-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ===== PREMIUM CARD VARIANTS ===== */
|
||||
.cp-card-glass {
|
||||
background: var(--glass-bg);
|
||||
@ -700,21 +440,9 @@
|
||||
|
||||
/* ===== ACCESSIBILITY: REDUCED MOTION ===== */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cp-animate-in,
|
||||
.cp-animate-scale-in,
|
||||
.cp-animate-slide-left,
|
||||
.cp-stagger-children > *,
|
||||
.cp-stagger-grid > *,
|
||||
.cp-card-hover-lift,
|
||||
.cp-slide-fade-left,
|
||||
.cp-slide-fade-right,
|
||||
.cp-toast-enter,
|
||||
.cp-toast-exit,
|
||||
.cp-activity-item,
|
||||
.cp-float,
|
||||
.cp-float-slow,
|
||||
.cp-float-delayed,
|
||||
.cp-glow-pulse {
|
||||
.cp-skeleton-shimmer::after,
|
||||
.cp-skeleton::after,
|
||||
.cp-input-error-shake {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
opacity: 1 !important;
|
||||
|
||||
458
docs/plans/2026-03-06-sidebar-consolidation.md
Normal file
458
docs/plans/2026-03-06-sidebar-consolidation.md
Normal 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"
|
||||
```
|
||||
Loading…
x
Reference in New Issue
Block a user