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