From 7502068ea9fbaa8bcf616eb7d52cdaa2c01674d7 Mon Sep 17 00:00:00 2001 From: barsa Date: Fri, 6 Mar 2026 14:48:34 +0900 Subject: [PATCH] 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. --- .../app/account/billing/invoices/loading.tsx | 16 - .../src/app/account/billing/invoices/page.tsx | 5 - apps/portal/src/app/account/billing/page.tsx | 5 + .../app/account/billing/payments/loading.tsx | 19 - .../src/app/account/billing/payments/page.tsx | 5 - .../components/atoms/animated-container.tsx | 55 ++- .../src/components/atoms/inline-toast.tsx | 50 +- .../molecules/AnimatedCard/AnimatedCard.tsx | 27 +- .../organisms/AppShell/AppShell.tsx | 79 +-- .../components/organisms/AppShell/Sidebar.tsx | 139 +++--- .../organisms/AppShell/navigation.ts | 18 +- .../address/components/AnimatedSection.tsx | 29 +- .../BillingSummary/BillingSummary.tsx | 4 +- .../billing/views/BillingOverview.tsx | 177 +++++++ .../features/billing/views/InvoiceDetail.tsx | 4 +- .../dashboard/components/ActivityFeed.tsx | 28 +- .../dashboard/components/QuickStats.tsx | 72 ++- .../dashboard/components/TaskList.tsx | 2 +- .../dashboard/views/DashboardView.tsx | 85 ++-- .../components/ContactSection.tsx | 19 +- .../landing-page/components/HeroSection.tsx | 19 +- .../components/SupportDownloadsSection.tsx | 19 +- .../landing-page/components/TrustStrip.tsx | 18 +- .../landing-page/components/WhyUsSection.tsx | 18 +- .../features/landing-page/hooks/useInView.ts | 40 +- .../features/marketing/views/AboutUsView.tsx | 113 ++++- .../components/NotificationBell.tsx | 11 +- .../components/NotificationDropdown.tsx | 14 +- .../components/base/CollapsibleSection.tsx | 30 +- .../components/sim/SimPlansContent.tsx | 56 ++- .../services/views/PublicInternetPlans.tsx | 23 +- .../views/SubscriptionDetail.tsx | 2 +- .../subscriptions/views/SubscriptionsList.tsx | 24 +- apps/portal/src/shared/hooks/index.ts | 1 - apps/portal/src/shared/hooks/useAfterPaint.ts | 25 - apps/portal/src/shared/hooks/useCountUp.ts | 54 +-- apps/portal/src/styles/utilities.css | 278 +---------- .../plans/2026-03-06-sidebar-consolidation.md | 458 ++++++++++++++++++ 38 files changed, 1256 insertions(+), 785 deletions(-) delete mode 100644 apps/portal/src/app/account/billing/invoices/loading.tsx delete mode 100644 apps/portal/src/app/account/billing/invoices/page.tsx create mode 100644 apps/portal/src/app/account/billing/page.tsx delete mode 100644 apps/portal/src/app/account/billing/payments/loading.tsx delete mode 100644 apps/portal/src/app/account/billing/payments/page.tsx create mode 100644 apps/portal/src/features/billing/views/BillingOverview.tsx delete mode 100644 apps/portal/src/shared/hooks/useAfterPaint.ts create mode 100644 docs/plans/2026-03-06-sidebar-consolidation.md diff --git a/apps/portal/src/app/account/billing/invoices/loading.tsx b/apps/portal/src/app/account/billing/invoices/loading.tsx deleted file mode 100644 index 15bb93e0..00000000 --- a/apps/portal/src/app/account/billing/invoices/loading.tsx +++ /dev/null @@ -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 ( - } - title="Invoices" - description="Manage and view your billing invoices" - mode="content" - > - - - ); -} diff --git a/apps/portal/src/app/account/billing/invoices/page.tsx b/apps/portal/src/app/account/billing/invoices/page.tsx deleted file mode 100644 index 2d60ed00..00000000 --- a/apps/portal/src/app/account/billing/invoices/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import InvoicesListContainer from "@/features/billing/views/InvoicesList"; - -export default function AccountInvoicesPage() { - return ; -} diff --git a/apps/portal/src/app/account/billing/page.tsx b/apps/portal/src/app/account/billing/page.tsx new file mode 100644 index 00000000..57770219 --- /dev/null +++ b/apps/portal/src/app/account/billing/page.tsx @@ -0,0 +1,5 @@ +import { BillingOverview } from "@/features/billing/views/BillingOverview"; + +export default function AccountBillingPage() { + return ; +} diff --git a/apps/portal/src/app/account/billing/payments/loading.tsx b/apps/portal/src/app/account/billing/payments/loading.tsx deleted file mode 100644 index 048a66a1..00000000 --- a/apps/portal/src/app/account/billing/payments/loading.tsx +++ /dev/null @@ -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 ( - } - title="Payment Methods" - description="Manage your payment methods" - mode="content" - > -
- - -
-
- ); -} diff --git a/apps/portal/src/app/account/billing/payments/page.tsx b/apps/portal/src/app/account/billing/payments/page.tsx deleted file mode 100644 index 64d907eb..00000000 --- a/apps/portal/src/app/account/billing/payments/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import PaymentMethodsContainer from "@/features/billing/views/PaymentMethods"; - -export default function AccountPaymentMethodsPage() { - return ; -} diff --git a/apps/portal/src/components/atoms/animated-container.tsx b/apps/portal/src/components/atoms/animated-container.tsx index 8b16dd47..938d2943 100644 --- a/apps/portal/src/components/atoms/animated-container.tsx +++ b/apps/portal/src/components/atoms/animated-container.tsx @@ -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 ( -
0 ? { animationDelay: `${delay}ms` } : undefined} + {children} -
+ ); } diff --git a/apps/portal/src/components/atoms/inline-toast.tsx b/apps/portal/src/components/atoms/inline-toast.tsx index 871dace8..676f7eee 100644 --- a/apps/portal/src/components/atoms/inline-toast.tsx +++ b/apps/portal/src/components/atoms/inline-toast.tsx @@ -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 { +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,22 +21,25 @@ export function InlineToast({ }[tone]; return ( -
+ {visible && ( + +
+ {text} +
+
)} - {...rest} - > -
- {text} -
-
+ ); } diff --git a/apps/portal/src/components/molecules/AnimatedCard/AnimatedCard.tsx b/apps/portal/src/components/molecules/AnimatedCard/AnimatedCard.tsx index ad6c8fe8..78603431 100644 --- a/apps/portal/src/components/molecules/AnimatedCard/AnimatedCard.tsx +++ b/apps/portal/src/components/molecules/AnimatedCard/AnimatedCard.tsx @@ -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 ( -
{children} -
+ ); } diff --git a/apps/portal/src/components/organisms/AppShell/AppShell.tsx b/apps/portal/src/components/organisms/AppShell/AppShell.tsx index 68f4d6fc..f8d3c6df 100644 --- a/apps/portal/src/components/organisms/AppShell/AppShell.tsx +++ b/apps/portal/src/components/organisms/AppShell/AppShell.tsx @@ -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,34 +166,46 @@ export function AppShell({ children }: AppShellProps) { <>
{/* Mobile sidebar overlay */} - {sidebarOpen && ( -
-
setSidebarOpen(false)} - /> -
-
- + {sidebarOpen && ( +
+ setSidebarOpen(false)} /> + +
+ +
-
- )} + )} + {/* Desktop sidebar */}
@@ -219,19 +231,22 @@ export function AppShell({ children }: AppShellProps) { {/* Main content */}
- {/* Mobile-only hamburger bar */} -
+ {/* Header bar */} +
+ {/* Mobile hamburger + logo */} -
+
+
+
{/* Main content area */} diff --git a/apps/portal/src/components/organisms/AppShell/Sidebar.tsx b/apps/portal/src/components/organisms/AppShell/Sidebar.tsx index 42b3614e..f81d70b1 100644 --- a/apps/portal/src/components/organisms/AppShell/Sidebar.tsx +++ b/apps/portal/src/components/organisms/AppShell/Sidebar.tsx @@ -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,25 +97,19 @@ function SidebarProfile({ const initials = getSidebarInitials(user, profileReady, displayName); return ( -
-
- -
- {initials} -
- - {displayName} - - - -
+
+ +
+ {initials} +
+ + {displayName} + +
); } @@ -142,12 +136,8 @@ export const Sidebar = memo(function Sidebar({
-
- -
-
-
))} + {navigation .filter(item => item.isLogout) .map(item => ( @@ -205,50 +196,58 @@ function ExpandableNavItem({ {isActive && } {item.name} - - - + + + + + -
-
- {item.children?.map((child: NavigationChild) => { - const isChildActive = pathname === (child.href || "").split(/[?#]/)[0]; - return ( - 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 ${ - isChildActive - ? "text-white bg-white/15 font-medium" - : "text-white/70 hover:text-white hover:bg-white/10 font-normal" - }`} - title={child.tooltip || child.name} - aria-current={isChildActive ? "page" : undefined} - > - {isChildActive && } - {child.name} - - ); - })} -
-
+ + {isExpanded && ( + +
+ {item.children?.map((child: NavigationChild) => { + const isChildActive = pathname === (child.href || "").split(/[?#]/)[0]; + return ( + 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 ${ + isChildActive + ? "text-white bg-white/15 font-medium" + : "text-white/70 hover:text-white hover:bg-white/10 font-normal" + }`} + title={child.tooltip || child.name} + aria-current={isChildActive ? "page" : undefined} + > + {isChildActive && } + {child.name} + + ); + })} +
+
+ )} +
); } @@ -324,6 +323,10 @@ const NavigationItem = memo(function NavigationItem({ return ; } - 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 ; }); diff --git a/apps/portal/src/components/organisms/AppShell/navigation.ts b/apps/portal/src/components/organisms/AppShell/navigation.ts index 7617474b..148c5a6d 100644 --- a/apps/portal/src/components/organisms/AppShell/navigation.ts +++ b/apps/portal/src/components/organisms/AppShell/navigation.ts @@ -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 }, ]; diff --git a/apps/portal/src/features/address/components/AnimatedSection.tsx b/apps/portal/src/features/address/components/AnimatedSection.tsx index 59a2f5d7..cb31ea37 100644 --- a/apps/portal/src/features/address/components/AnimatedSection.tsx +++ b/apps/portal/src/features/address/components/AnimatedSection.tsx @@ -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 ( -
+ {show && ( + + {children} + )} - style={{ transitionDelay: show ? `${delay}ms` : "0ms" }} - > -
{children}
-
+ ); } diff --git a/apps/portal/src/features/billing/components/BillingSummary/BillingSummary.tsx b/apps/portal/src/features/billing/components/BillingSummary/BillingSummary.tsx index 88c91049..c427934e 100644 --- a/apps/portal/src/features/billing/components/BillingSummary/BillingSummary.tsx +++ b/apps/portal/src/features/billing/components/BillingSummary/BillingSummary.tsx @@ -157,7 +157,7 @@ const BillingSummary = forwardRef(
{!compact && ( View All @@ -184,7 +184,7 @@ const BillingSummary = forwardRef( {compact && (
View All Invoices diff --git a/apps/portal/src/features/billing/views/BillingOverview.tsx b/apps/portal/src/features/billing/views/BillingOverview.tsx new file mode 100644 index 00000000..7faf78c8 --- /dev/null +++ b/apps/portal/src/features/billing/views/BillingOverview.tsx @@ -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 ( +
+
+ + +
+
+ {Array.from({ length: 2 }).map((_, i) => ( +
+
+
+ +
+ + +
+
+ +
+
+ ))} +
+
+ ); +} + +function PaymentMethodsSection({ + paymentMethodsData, + onManage, + isPending, +}: { + paymentMethodsData: PaymentMethodList; + onManage: () => void; + isPending: boolean; +}) { + const hasMethods = paymentMethodsData.paymentMethods.length > 0; + + return ( +
+
+
+
+

Payment Methods

+

+ {hasMethods + ? `${paymentMethodsData.paymentMethods.length} payment method${paymentMethodsData.paymentMethods.length === 1 ? "" : "s"} on file` + : "No payment methods on file"} +

+
+ +
+
+ {hasMethods && ( +
+
+ {paymentMethodsData.paymentMethods.map(paymentMethod => ( + + ))} +
+
+ )} +
+ ); +} + +export function BillingOverview() { + const [error, setError] = useState(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 ( + } title="Billing" error={combinedError}> + + + +
+ {isPaymentLoading && } + {!isPaymentLoading && paymentMethodsData && ( + void openPaymentMethods()} + isPending={createPaymentMethodsSsoLink.isPending} + /> + )} + +
+

Invoices

+ +
+
+
+
+ ); +} + +export default BillingOverview; diff --git a/apps/portal/src/features/billing/views/InvoiceDetail.tsx b/apps/portal/src/features/billing/views/InvoiceDetail.tsx index 85b86c69..9b73aa45 100644 --- a/apps/portal/src/features/billing/views/InvoiceDetail.tsx +++ b/apps/portal/src/features/billing/views/InvoiceDetail.tsx @@ -81,7 +81,7 @@ export function InvoiceDetailContainer() { } title="Invoice" - backLink={{ label: "Back to Invoices", href: "/account/billing/invoices" }} + backLink={{ label: "Back to Billing", href: "/account/billing" }} > } title={`Invoice #${invoice.id}`} - backLink={{ label: "Back to Invoices", href: "/account/billing/invoices" }} + backLink={{ label: "Back to Billing", href: "/account/billing" }} >
diff --git a/apps/portal/src/features/dashboard/components/ActivityFeed.tsx b/apps/portal/src/features/dashboard/components/ActivityFeed.tsx index f6ac0a5e..512236d7 100644 --- a/apps/portal/src/features/dashboard/components/ActivityFeed.tsx +++ b/apps/portal/src/features/dashboard/components/ActivityFeed.tsx @@ -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 ? ( ) : ( -
+ {visibleActivities.map((activity, index) => ( - + + + ))} -
+ )}
); diff --git a/apps/portal/src/features/dashboard/components/QuickStats.tsx b/apps/portal/src/features/dashboard/components/QuickStats.tsx index f7f185f1..be022529 100644 --- a/apps/portal/src/features/dashboard/components/QuickStats.tsx +++ b/apps/portal/src/features/dashboard/components/QuickStats.tsx @@ -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,34 +143,45 @@ export function QuickStats({

Account Overview

-
- - 0 ? "warning" : "info"} - emptyText="No open cases" - /> - {recentOrders !== undefined && ( + + + + + 0 ? "warning" : "info"} + emptyText="No open cases" + /> + + {recentOrders !== undefined && ( + + + )} -
+
); } diff --git a/apps/portal/src/features/dashboard/components/TaskList.tsx b/apps/portal/src/features/dashboard/components/TaskList.tsx index 52a94e17..709769f1 100644 --- a/apps/portal/src/features/dashboard/components/TaskList.tsx +++ b/apps/portal/src/features/dashboard/components/TaskList.tsx @@ -63,7 +63,7 @@ function AllCaughtUp() {
diff --git a/apps/portal/src/features/dashboard/views/DashboardView.tsx b/apps/portal/src/features/dashboard/views/DashboardView.tsx index 8a58da4a..abffda6f 100644 --- a/apps/portal/src/features/dashboard/views/DashboardView.tsx +++ b/apps/portal/src/features/dashboard/views/DashboardView.tsx @@ -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 ( @@ -54,13 +65,20 @@ function DashboardGreeting({ }) { return (
-

+ Welcome back, {displayName} -

+ {taskCount > 0 ? ( -
} {taskCount === 1 ? "1 task needs attention" : `${taskCount} tasks need attention`} -
+ ) : ( -

Everything is up to date -

+ )}
); @@ -178,32 +198,41 @@ function DashboardContent({ taskCount={taskCount} hasUrgentTask={tasks.some(t => t.tone === "critical")} /> -

Your Tasks

-
-
+ - - -
+ + + + + + +
); } diff --git a/apps/portal/src/features/landing-page/components/ContactSection.tsx b/apps/portal/src/features/landing-page/components/ContactSection.tsx index 54fbfa9a..603671fe 100644 --- a/apps/portal/src/features/landing-page/components/ContactSection.tsx +++ b/apps/portal/src/features/landing-page/components/ContactSection.tsx @@ -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(); + const ref = useRef(null); + const isInView = useInView(ref, { once: true, amount: 0.1 }); return ( -

@@ -117,6 +118,6 @@ export function ContactSection() {

- + ); } diff --git a/apps/portal/src/features/landing-page/components/HeroSection.tsx b/apps/portal/src/features/landing-page/components/HeroSection.tsx index f944a7a8..6eb69eda 100644 --- a/apps/portal/src/features/landing-page/components/HeroSection.tsx +++ b/apps/portal/src/features/landing-page/components/HeroSection.tsx @@ -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; } export function HeroSection({ heroCTARef }: HeroSectionProps) { - const [heroRef, heroInView] = useInView(); + const heroRef = useRef(null); + const heroInView = useInView(heroRef, { once: true, amount: 0.1 }); return ( -
{/* Gradient Background */}
@@ -70,6 +71,6 @@ export function HeroSection({ heroCTARef }: HeroSectionProps) {
-
+ ); } diff --git a/apps/portal/src/features/landing-page/components/SupportDownloadsSection.tsx b/apps/portal/src/features/landing-page/components/SupportDownloadsSection.tsx index 646f3cea..2683ee3d 100644 --- a/apps/portal/src/features/landing-page/components/SupportDownloadsSection.tsx +++ b/apps/portal/src/features/landing-page/components/SupportDownloadsSection.tsx @@ -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(null); + const isInView = useInView(ref, { once: true, amount: 0.1 }); return ( -

@@ -60,6 +61,6 @@ export function SupportDownloadsSection() { ))}

- + ); } diff --git a/apps/portal/src/features/landing-page/components/TrustStrip.tsx b/apps/portal/src/features/landing-page/components/TrustStrip.tsx index 7c1dbe4b..e9c3b7c7 100644 --- a/apps/portal/src/features/landing-page/components/TrustStrip.tsx +++ b/apps/portal/src/features/landing-page/components/TrustStrip.tsx @@ -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(); + const ref = useRef(null); + const inView = useInView(ref, { once: true, amount: 0.1 }); return ( -
{/* Gradient background */}
@@ -114,6 +116,6 @@ export function TrustStrip() { ))}
-
+ ); } diff --git a/apps/portal/src/features/landing-page/components/WhyUsSection.tsx b/apps/portal/src/features/landing-page/components/WhyUsSection.tsx index eedd7322..50df0762 100644 --- a/apps/portal/src/features/landing-page/components/WhyUsSection.tsx +++ b/apps/portal/src/features/landing-page/components/WhyUsSection.tsx @@ -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(); + const ref = useRef(null); + const isInView = useInView(ref, { once: true, amount: 0.1 }); return ( -
@@ -63,6 +63,6 @@ export function WhyUsSection() {
-
+ ); } diff --git a/apps/portal/src/features/landing-page/hooks/useInView.ts b/apps/portal/src/features/landing-page/hooks/useInView.ts index c9cdadde..f574f3d4 100644 --- a/apps/portal/src/features/landing-page/hooks/useInView.ts +++ b/apps/portal/src/features/landing-page/hooks/useInView.ts @@ -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( - options: IntersectionObserverInit = DEFAULT_OPTIONS + options: UseInViewOptions = DEFAULT_OPTIONS ) { const ref = useRef(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; } diff --git a/apps/portal/src/features/marketing/views/AboutUsView.tsx b/apps/portal/src/features/marketing/views/AboutUsView.tsx index b1e928c6..86fcd32b 100644 --- a/apps/portal/src/features/marketing/views/AboutUsView.tsx +++ b/apps/portal/src/features/marketing/views/AboutUsView.tsx @@ -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() { />
-
- + + Since 2002 - -

+ + Your Trusted IT Partner in Japan -

-
+ +

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.

-
-
+ +
-
-

What We Do

-

+ + + What We Do + + End-to-end IT services designed for the international community in Japan — all in English. -

-
+ +
{services.map(service => ( @@ -201,21 +241,36 @@ function ValuesSection() { return (
-
-

Our Values

-

+ + + Our Values + + These principles guide how we serve customers, support our community, and advance our craft every day. -

-
+ +
{values.map(value => { const Icon = value.icon; return ( -

{value.text}

-
+ ); })}
@@ -239,9 +294,19 @@ function CorporateSection() { return (
-
-

Corporate Data

-
+ + + Corporate Data + +
diff --git a/apps/portal/src/features/notifications/components/NotificationBell.tsx b/apps/portal/src/features/notifications/components/NotificationBell.tsx index cdf703cb..cac3a255 100644 --- a/apps/portal/src/features/notifications/components/NotificationBell.tsx +++ b/apps/portal/src/features/notifications/components/NotificationBell.tsx @@ -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({ )} - + + {isOpen && ( + + )} +
); }); diff --git a/apps/portal/src/features/notifications/components/NotificationDropdown.tsx b/apps/portal/src/features/notifications/components/NotificationDropdown.tsx index 76b96989..4f778419 100644 --- a/apps/portal/src/features/notifications/components/NotificationDropdown.tsx +++ b/apps/portal/src/features/notifications/components/NotificationDropdown.tsx @@ -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 ( -
{/* Header */} @@ -105,6 +107,6 @@ export const NotificationDropdown = memo(function NotificationDropdown({
)} -
+ ); }); diff --git a/apps/portal/src/features/services/components/base/CollapsibleSection.tsx b/apps/portal/src/features/services/components/base/CollapsibleSection.tsx index 81205042..a7c3bc07 100644 --- a/apps/portal/src/features/services/components/base/CollapsibleSection.tsx +++ b/apps/portal/src/features/services/components/base/CollapsibleSection.tsx @@ -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({ {title}
- + + + -
+ {isOpen && ( + +
{children}
+
)} - > -
{children}
-
+
); } diff --git a/apps/portal/src/features/services/components/sim/SimPlansContent.tsx b/apps/portal/src/features/services/components/sim/SimPlansContent.tsx index 61eaefbd..8dd085d7 100644 --- a/apps/portal/src/features/services/components/sim/SimPlansContent.tsx +++ b/apps/portal/src/features/services/components/sim/SimPlansContent.tsx @@ -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,35 +340,38 @@ function SimPlansGrid({ return (
-
- {regularPlans.length > 0 && ( -
- {regularPlans.map(plan => ( - - ))} -
- )} - - {variant === "account" && hasExistingSim && familyPlans.length > 0 && ( -
-
- -

Family Discount Plans

-
+ + + {regularPlans.length > 0 && (
- {familyPlans.map(plan => ( - + {regularPlans.map(plan => ( + ))}
-
- )} -
+ )} + + {variant === "account" && hasExistingSim && familyPlans.length > 0 && ( +
+
+ +

Family Discount Plans

+
+
+ {familyPlans.map(plan => ( + + ))} +
+
+ )} + +
); } diff --git a/apps/portal/src/features/services/views/PublicInternetPlans.tsx b/apps/portal/src/features/services/views/PublicInternetPlans.tsx index 1090c42f..de8b898a 100644 --- a/apps/portal/src/features/services/views/PublicInternetPlans.tsx +++ b/apps/portal/src/features/services/views/PublicInternetPlans.tsx @@ -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({
-
- {displayTiers.map(tier => ( - - ))} -
+ + + {displayTiers.map(tier => ( + + ))} + +

diff --git a/apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx b/apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx index a21c01df..94e2c5b6 100644 --- a/apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx +++ b/apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx @@ -205,7 +205,7 @@ function SubscriptionDetailContent({ tone="primary" actions={ View Invoices diff --git a/apps/portal/src/features/subscriptions/views/SubscriptionsList.tsx b/apps/portal/src/features/subscriptions/views/SubscriptionsList.tsx index e7f285cf..e1458796 100644 --- a/apps/portal/src/features/subscriptions/views/SubscriptionsList.tsx +++ b/apps/portal/src/features/subscriptions/views/SubscriptionsList.tsx @@ -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 ( -

+ {subscriptions.map(sub => ( - + + + ))} {loading && Array.from({ length: 3 }).map((_, i) => )} -
+ ); } diff --git a/apps/portal/src/shared/hooks/index.ts b/apps/portal/src/shared/hooks/index.ts index d52b2d66..8de54e79 100644 --- a/apps/portal/src/shared/hooks/index.ts +++ b/apps/portal/src/shared/hooks/index.ts @@ -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"; diff --git a/apps/portal/src/shared/hooks/useAfterPaint.ts b/apps/portal/src/shared/hooks/useAfterPaint.ts deleted file mode 100644 index bee1a651..00000000 --- a/apps/portal/src/shared/hooks/useAfterPaint.ts +++ /dev/null @@ -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]); -} diff --git a/apps/portal/src/shared/hooks/useCountUp.ts b/apps/portal/src/shared/hooks/useCountUp.ts index 78db1f57..ead496dd 100644 --- a/apps/portal/src/shared/hooks/useCountUp.ts +++ b/apps/portal/src/shared/hooks/useCountUp.ts @@ -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(undefined); - const startTimeRef = useRef(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 | 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; } diff --git a/apps/portal/src/styles/utilities.css b/apps/portal/src/styles/utilities.css index 698f1796..92f2cd6b 100644 --- a/apps/portal/src/styles/utilities.css +++ b/apps/portal/src/styles/utilities.css @@ -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; diff --git a/docs/plans/2026-03-06-sidebar-consolidation.md b/docs/plans/2026-03-06-sidebar-consolidation.md new file mode 100644 index 00000000..3ea374d3 --- /dev/null +++ b/docs/plans/2026-03-06-sidebar-consolidation.md @@ -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 ( +
+
+ + +
+
+ {Array.from({ length: 2 }).map((_, i) => ( +
+
+
+ +
+ + +
+
+ +
+
+ ))} +
+
+ ); +} + +function PaymentMethodsSection({ + paymentMethodsData, + onManage, + isPending, +}: { + paymentMethodsData: PaymentMethodList; + onManage: () => void; + isPending: boolean; +}) { + const hasMethods = paymentMethodsData.paymentMethods.length > 0; + + return ( +
+
+
+
+

Payment Methods

+

+ {hasMethods + ? `${paymentMethodsData.paymentMethods.length} payment method${paymentMethodsData.paymentMethods.length === 1 ? "" : "s"} on file` + : "No payment methods on file"} +

+
+ +
+
+ {hasMethods && ( +
+
+ {paymentMethodsData.paymentMethods.map(paymentMethod => ( + + ))} +
+
+ )} +
+ ); +} + +export function BillingOverview() { + const [error, setError] = useState(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 ( + } title="Billing" error={combinedError}> + + + +
+ {/* Payment Methods Section */} + {isPaymentLoading && } + {!isPaymentLoading && paymentMethodsData && ( + void openPaymentMethods()} + isPending={createPaymentMethodsSsoLink.isPending} + /> + )} + + {/* Invoices Section */} +
+

Invoices

+ +
+
+
+
+ ); +} + +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 ; +} +``` + +**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" +```