chore: update pnpm-lock.yaml and add framer-motion dependency
- Updated lockfileVersion in pnpm-lock.yaml for consistency. - Added framer-motion dependency to the portal for enhanced animation capabilities. - Updated image assets and made minor adjustments to global styles for improved UI consistency.
This commit is contained in:
parent
cab58d1c5b
commit
b3cb1064d8
@ -24,6 +24,7 @@
|
||||
"@xstate/react": "^6.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.35.0",
|
||||
"geist": "^1.5.1",
|
||||
"lucide-react": "^0.563.0",
|
||||
"next": "^16.1.6",
|
||||
|
||||
@ -80,10 +80,10 @@
|
||||
--input: oklch(0.955 0.005 70);
|
||||
--ring: oklch(0.6884 0.1342 234.4 / 0.5);
|
||||
|
||||
/* Sidebar - Dark Navy */
|
||||
--sidebar: oklch(0.18 0.03 250);
|
||||
/* Sidebar - Deep purple/indigo */
|
||||
--sidebar: oklch(0.2754 0.1199 272.34);
|
||||
--sidebar-foreground: oklch(1 0 0);
|
||||
--sidebar-border: oklch(0.25 0.04 250);
|
||||
--sidebar-border: oklch(0.36 0.1 272.34);
|
||||
--sidebar-active: oklch(0.99 0 0 / 0.12);
|
||||
--sidebar-accent: var(--primary);
|
||||
|
||||
@ -194,9 +194,9 @@
|
||||
--input: oklch(0.33 0.01 70);
|
||||
--ring: oklch(0.75 0.12 234.4 / 0.5);
|
||||
|
||||
/* Sidebar - Dark Navy for dark mode */
|
||||
--sidebar: oklch(0.13 0.025 250);
|
||||
--sidebar-border: oklch(0.22 0.03 250);
|
||||
/* Sidebar - Purple/indigo theme for dark mode */
|
||||
--sidebar: oklch(0.2 0.08 272.34);
|
||||
--sidebar-border: oklch(0.28 0.08 272.34);
|
||||
|
||||
--header: oklch(0.15 0.015 234.4 / 0.95);
|
||||
--header-foreground: var(--foreground);
|
||||
|
||||
@ -6,24 +6,23 @@ import { useAuthStore, useAuthSession } from "@/features/auth/stores/auth.store"
|
||||
import { accountService } from "@/features/account/api/account.api";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { Header } from "./Header";
|
||||
import { baseNavigation, type NavigationItem } from "./navigation";
|
||||
import { baseNavigation } from "./navigation";
|
||||
|
||||
interface AppShellProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function collectPrefetchUrls(navigation: NavigationItem[]): string[] {
|
||||
const prefetchUrls: string[] = (() => {
|
||||
const hrefs = new Set<string>();
|
||||
for (const item of navigation) {
|
||||
for (const item of baseNavigation) {
|
||||
if (item.href && item.href !== "#") hrefs.add(item.href);
|
||||
if (!item.children || item.children.length === 0) continue;
|
||||
// Prefetch only the first few children to avoid heavy prefetch
|
||||
for (const child of item.children.slice(0, 5)) {
|
||||
if (child.href && child.href !== "#") hrefs.add(child.href);
|
||||
}
|
||||
}
|
||||
return [...hrefs];
|
||||
}
|
||||
})();
|
||||
|
||||
// Sidebar and navigation are modularized in ./Sidebar and ./navigation
|
||||
|
||||
@ -153,19 +152,15 @@ export function AppShell({ children }: AppShellProps) {
|
||||
const navigation = baseNavigation;
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const urls = collectPrefetchUrls(navigation);
|
||||
for (const href of urls) {
|
||||
try {
|
||||
router.prefetch(href);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
for (const href of prefetchUrls) {
|
||||
try {
|
||||
router.prefetch(href);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [navigation, router]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- prefetchUrls is static; router is unstable but functionally stable
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@ -2,11 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { memo } from "react";
|
||||
import {
|
||||
Bars3Icon,
|
||||
MagnifyingGlassIcon,
|
||||
QuestionMarkCircleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { Bars3Icon, QuestionMarkCircleIcon } from "@heroicons/react/24/outline";
|
||||
import { NotificationBell } from "@/features/notifications";
|
||||
|
||||
interface UserInfo {
|
||||
@ -55,19 +51,6 @@ export const Header = memo(function Header({ onMenuClick, user, profileReady }:
|
||||
<Bars3Icon className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{/* Search trigger */}
|
||||
<button
|
||||
type="button"
|
||||
className="hidden sm:flex items-center gap-2.5 h-9 px-3 w-full max-w-xs rounded-lg bg-muted/50 border border-border/50 text-muted-foreground text-sm hover:bg-muted/80 hover:border-border transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||
aria-label="Search"
|
||||
>
|
||||
<MagnifyingGlassIcon className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span className="flex-1 text-left text-xs">Search...</span>
|
||||
<kbd className="hidden lg:inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded border border-border/60 bg-background/80 text-[10px] font-mono text-muted-foreground/60">
|
||||
<span className="text-[11px]">⌘</span>K
|
||||
</kbd>
|
||||
</button>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Right side actions */}
|
||||
|
||||
@ -10,15 +10,14 @@ import type { ComponentType, SVGProps } from "react";
|
||||
|
||||
// Shared navigation item styling
|
||||
const navItemBaseClass =
|
||||
"group w-full flex items-center px-3 py-2 text-[13px] font-medium rounded-lg transition-all duration-200 relative focus:outline-none focus:ring-2 focus:ring-white/20";
|
||||
const activeClass = "text-white bg-white/[0.08] shadow-sm";
|
||||
const inactiveClass = "text-white/60 hover:text-white/90 hover:bg-white/[0.06]";
|
||||
"group w-full flex items-center px-3 py-2.5 text-sm font-semibold rounded-lg transition-all duration-200 relative focus:outline-none focus:ring-2 focus:ring-white/30";
|
||||
const activeClass = "text-white bg-white/20 shadow-sm";
|
||||
const inactiveClass = "text-white/90 hover:text-white hover:bg-white/10";
|
||||
|
||||
function ActiveIndicator({ small = false }: { small?: boolean }) {
|
||||
const size = small ? "w-0.5 h-3.5" : "w-[3px] h-5";
|
||||
return (
|
||||
<div className={`absolute left-0 top-1/2 -translate-y-1/2 ${size} bg-primary rounded-full`} />
|
||||
);
|
||||
const size = small ? "w-0.5 h-4" : "w-1 h-6";
|
||||
const rounded = small ? "rounded-full" : "rounded-r-full";
|
||||
return <div className={`absolute left-0 top-1/2 -translate-y-1/2 ${size} bg-white ${rounded}`} />;
|
||||
}
|
||||
|
||||
function NavIcon({
|
||||
@ -32,19 +31,19 @@ function NavIcon({
|
||||
}) {
|
||||
if (variant === "logout") {
|
||||
return (
|
||||
<div className="p-1 mr-2.5 text-red-400/70 group-hover:text-red-300 transition-colors duration-200">
|
||||
<Icon className="h-[18px] w-[18px]" />
|
||||
<div className="p-1.5 rounded-md mr-3 text-red-300 group-hover:text-red-100 transition-colors duration-200">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`p-1 mr-2.5 transition-colors duration-200 ${
|
||||
isActive ? "text-primary" : "text-white/40 group-hover:text-white/70"
|
||||
className={`p-1.5 rounded-md mr-3 transition-colors duration-200 ${
|
||||
isActive ? "bg-white/20 text-white" : "text-white/80 group-hover:text-white"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-[18px] w-[18px]" />
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -65,36 +64,44 @@ export const Sidebar = memo(function Sidebar({
|
||||
}: SidebarProps) {
|
||||
return (
|
||||
<div className="flex flex-col h-0 flex-1 bg-sidebar">
|
||||
<div className="flex items-center flex-shrink-0 h-16 px-5 border-b border-sidebar-border/50">
|
||||
<div className="flex items-center flex-shrink-0 h-16 px-5 border-b border-sidebar-border">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Logo size={28} />
|
||||
<div className="h-10 w-10 bg-white rounded-xl shadow-lg shadow-black/10 flex items-center justify-center">
|
||||
<Logo size={26} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm font-bold text-white tracking-tight">Assist Solutions</span>
|
||||
<p className="text-[11px] text-white/50 font-medium">Customer Portal</p>
|
||||
<span className="text-base font-bold text-white">Assist Solutions</span>
|
||||
<p className="text-xs text-white/70 font-medium">Customer Portal</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col pt-6 pb-4 overflow-y-auto">
|
||||
<nav className="flex-1 px-3 space-y-0.5">
|
||||
{navigation.map((item, index) => (
|
||||
<div key={item.name}>
|
||||
{item.section && (
|
||||
<div className={`px-3 ${index === 0 ? "pt-0" : "pt-5"} pb-2`}>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.1em] text-white/30">
|
||||
{item.section}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<NavigationItem
|
||||
item={item}
|
||||
pathname={pathname}
|
||||
isExpanded={expandedItems.includes(item.name)}
|
||||
toggleExpanded={toggleExpanded}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<nav className="flex-1 px-3 space-y-1">
|
||||
{navigation
|
||||
.filter(item => !item.isLogout)
|
||||
.map(item => (
|
||||
<div key={item.name}>
|
||||
<NavigationItem
|
||||
item={item}
|
||||
pathname={pathname}
|
||||
isExpanded={expandedItems.includes(item.name)}
|
||||
toggleExpanded={toggleExpanded}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
{navigation
|
||||
.filter(item => item.isLogout)
|
||||
.map(item => (
|
||||
<NavigationItem
|
||||
key={item.name}
|
||||
item={item}
|
||||
pathname={pathname}
|
||||
isExpanded={false}
|
||||
toggleExpanded={toggleExpanded}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -130,7 +137,7 @@ function ExpandableNavItem({
|
||||
<span className="flex-1">{item.name}</span>
|
||||
<svg
|
||||
className={`h-4 w-4 transition-transform duration-200 ease-out ${isExpanded ? "rotate-90" : ""} ${
|
||||
isActive ? "text-white" : "text-white/60 group-hover:text-white/80"
|
||||
isActive ? "text-white" : "text-white/70 group-hover:text-white"
|
||||
}`}
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
@ -148,7 +155,7 @@ function ExpandableNavItem({
|
||||
isExpanded ? "max-h-96 opacity-100" : "max-h-0 opacity-0"
|
||||
}`}
|
||||
>
|
||||
<div className="mt-0.5 ml-[30px] space-y-0.5 border-l border-white/[0.08] pl-3">
|
||||
<div className="mt-0.5 ml-[30px] space-y-0.5 border-l border-white/15 pl-3">
|
||||
{item.children?.map((child: NavigationChild) => {
|
||||
const isChildActive = pathname === (child.href || "").split(/[?#]/)[0];
|
||||
return (
|
||||
@ -157,10 +164,10 @@ function ExpandableNavItem({
|
||||
href={child.href}
|
||||
prefetch
|
||||
onMouseEnter={() => child.href && void router.prefetch(child.href)}
|
||||
className={`group flex items-center px-2.5 py-1.5 text-[13px] rounded-md transition-all duration-200 relative ${
|
||||
className={`group flex items-center px-2.5 py-1.5 text-sm rounded-md transition-all duration-200 relative ${
|
||||
isChildActive
|
||||
? "text-white bg-white/[0.08] font-medium"
|
||||
: "text-white/50 hover:text-white/80 hover:bg-white/[0.04] font-normal"
|
||||
? "text-white bg-white/15 font-medium"
|
||||
: "text-white/70 hover:text-white hover:bg-white/10 font-normal"
|
||||
}`}
|
||||
title={child.tooltip || child.name}
|
||||
aria-current={isChildActive ? "page" : undefined}
|
||||
@ -178,10 +185,10 @@ function ExpandableNavItem({
|
||||
|
||||
function LogoutNavItem({ item, onLogout }: { item: NavigationItem; onLogout: () => void }) {
|
||||
return (
|
||||
<div className="px-3 pt-4 mt-2 border-t border-white/[0.06]">
|
||||
<div className="px-3 pt-4 mt-2 border-t border-white/10">
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="group w-full flex items-center px-3 py-2 text-[13px] font-medium text-red-400/70 hover:text-red-300 hover:bg-red-500/10 rounded-lg transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-red-400/20"
|
||||
className="group w-full flex items-center px-3 py-2.5 text-sm font-medium text-red-300 hover:text-red-100 hover:bg-red-500/15 rounded-lg transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-red-400/20"
|
||||
>
|
||||
<NavIcon icon={item.icon} isActive={false} variant="logout" />
|
||||
<span>{item.name}</span>
|
||||
|
||||
@ -22,16 +22,14 @@ export interface NavigationItem {
|
||||
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
||||
children?: NavigationChild[] | undefined;
|
||||
isLogout?: boolean | undefined;
|
||||
section?: string | undefined;
|
||||
}
|
||||
|
||||
export const baseNavigation: NavigationItem[] = [
|
||||
{ name: "Dashboard", href: "/account", icon: HomeIcon, section: "Overview" },
|
||||
{ name: "Dashboard", href: "/account", icon: HomeIcon },
|
||||
{ name: "Orders", href: "/account/orders", icon: ClipboardDocumentListIcon },
|
||||
{
|
||||
name: "Billing",
|
||||
icon: CreditCardIcon,
|
||||
section: "Account",
|
||||
children: [
|
||||
{ name: "Invoices", href: "/account/billing/invoices" },
|
||||
{ name: "Payment Methods", href: "/account/billing/payments" },
|
||||
|
||||
@ -33,12 +33,11 @@ export function PageLayout({
|
||||
}: PageLayoutProps) {
|
||||
return (
|
||||
<div>
|
||||
{/* Header band with subtle background */}
|
||||
<div className="bg-muted/40 border-b border-border/40">
|
||||
<div className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-space-md)] sm:px-[var(--cp-space-lg)] md:px-8 py-[var(--cp-space-md)] sm:py-[var(--cp-space-lg)]">
|
||||
{/* Back link */}
|
||||
{/* Page header */}
|
||||
<div className="bg-muted/40 border-b border-border">
|
||||
<div className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-space-md)] sm:px-[var(--cp-space-lg)] md:px-8 py-[var(--cp-space-lg)] sm:py-[var(--cp-space-xl)]">
|
||||
{backLink && (
|
||||
<div className="mb-3">
|
||||
<div className="mb-[var(--cp-space-sm)] sm:mb-[var(--cp-space-md)]">
|
||||
<Link
|
||||
href={backLink.href}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors duration-200"
|
||||
@ -48,49 +47,48 @@ export function PageLayout({
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-start justify-between gap-4 min-w-0">
|
||||
<div className="flex items-start min-w-0 flex-1">
|
||||
{icon && (
|
||||
<div className="h-7 w-7 sm:h-8 sm:w-8 text-primary mr-[var(--cp-space-md)] sm:mr-[var(--cp-space-lg)] flex-shrink-0 mt-0.5">
|
||||
{icon}
|
||||
<div className="flex flex-col gap-[var(--cp-space-md)] sm:gap-[var(--cp-space-lg)]">
|
||||
<div className="flex items-start justify-between gap-4 min-w-0">
|
||||
<div className="flex items-start min-w-0 flex-1">
|
||||
{icon && (
|
||||
<div className="h-7 w-7 sm:h-8 sm:w-8 text-primary mr-[var(--cp-space-md)] sm:mr-[var(--cp-space-lg)] flex-shrink-0 mt-0.5">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-xl sm:text-2xl md:text-3xl font-bold text-foreground leading-tight">
|
||||
{title}
|
||||
</h1>
|
||||
{statusPill}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground mt-1 leading-relaxed line-clamp-2 sm:line-clamp-none">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{actions && (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3 flex-shrink-0">
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-xl sm:text-2xl md:text-3xl font-bold text-foreground leading-tight">
|
||||
{title}
|
||||
</h1>
|
||||
{statusPill}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground mt-1 leading-relaxed line-clamp-2 sm:line-clamp-none">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{actions && (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3 flex-shrink-0">
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="py-[var(--cp-space-lg)] sm:py-[var(--cp-space-xl)]">
|
||||
<div className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-space-md)] sm:px-[var(--cp-space-lg)] md:px-8">
|
||||
<div className="space-y-[var(--cp-space-xl)] sm:space-y-[var(--cp-space-2xl)]">
|
||||
{renderPageContent({
|
||||
loading,
|
||||
error: error ?? undefined,
|
||||
children,
|
||||
onRetry,
|
||||
loadingFallback,
|
||||
})}
|
||||
</div>
|
||||
<div className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-space-md)] sm:px-[var(--cp-space-lg)] md:px-8 py-[var(--cp-space-lg)] sm:py-[var(--cp-space-xl)] md:py-[var(--cp-space-2xl)]">
|
||||
<div className="space-y-[var(--cp-space-xl)] sm:space-y-[var(--cp-space-2xl)]">
|
||||
{renderPageContent({
|
||||
loading,
|
||||
error: error ?? undefined,
|
||||
children,
|
||||
onRetry,
|
||||
loadingFallback,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -450,9 +450,7 @@ export function PublicShell({ children }: PublicShellProps) {
|
||||
)}
|
||||
|
||||
<main id="main-content" className="flex-1">
|
||||
<div className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-space-md)] sm:px-[var(--cp-page-padding)] pt-0 pb-0">
|
||||
{children}
|
||||
</div>
|
||||
{children}
|
||||
</main>
|
||||
|
||||
<SiteFooter />
|
||||
|
||||
@ -2,31 +2,9 @@ import { cn } from "@/shared/utils";
|
||||
|
||||
interface ChapterProps {
|
||||
children: React.ReactNode;
|
||||
zIndex: number;
|
||||
className?: string;
|
||||
overlay?: boolean;
|
||||
sticky?: boolean;
|
||||
}
|
||||
|
||||
const CHAPTER_SHADOW = "shadow-[0_-8px_30px_-10px_rgba(0,0,0,0.08)]";
|
||||
|
||||
export function Chapter({
|
||||
children,
|
||||
zIndex,
|
||||
className,
|
||||
overlay = false,
|
||||
sticky = true,
|
||||
}: ChapterProps) {
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
sticky ? "sticky top-0 motion-reduce:relative" : "relative",
|
||||
overlay && cn(CHAPTER_SHADOW, "motion-reduce:!shadow-none"),
|
||||
className
|
||||
)}
|
||||
style={{ zIndex }}
|
||||
>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
export function Chapter({ children, className }: ChapterProps) {
|
||||
return <section className={cn("relative", className)}>{children}</section>;
|
||||
}
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useEffect, useState } from "react";
|
||||
import { memo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { ArrowRight, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/shared/utils";
|
||||
import { useSnapCarousel, useInView } from "@/features/landing-page/hooks";
|
||||
import { useCarousel, useInView } from "@/features/landing-page/hooks";
|
||||
import {
|
||||
personalConversionCards,
|
||||
businessConversionCards,
|
||||
@ -101,14 +102,38 @@ const ACCENTS: Record<CarouselAccent, AccentStyles> = {
|
||||
},
|
||||
};
|
||||
|
||||
/* ─── Framer Motion variants ─── */
|
||||
|
||||
const tabContentVariants = {
|
||||
enter: { opacity: 0, y: 20, scale: 0.98 },
|
||||
center: { opacity: 1, y: 0, scale: 1 },
|
||||
exit: { opacity: 0, y: -20, scale: 0.98 },
|
||||
};
|
||||
|
||||
const headingVariants = {
|
||||
enter: { opacity: 0, y: 12 },
|
||||
center: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, y: -12 },
|
||||
};
|
||||
|
||||
/* ─── Service Card ─── */
|
||||
|
||||
const ServiceCard = memo(function ServiceCard({ card }: { card: ConversionServiceCard }) {
|
||||
const ServiceCard = memo(function ServiceCard({
|
||||
card,
|
||||
wasDragging,
|
||||
}: {
|
||||
card: ConversionServiceCard;
|
||||
wasDragging: () => boolean;
|
||||
}) {
|
||||
const a = ACCENTS[card.accent];
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={card.href}
|
||||
draggable={false}
|
||||
onClick={e => {
|
||||
if (wasDragging()) e.preventDefault();
|
||||
}}
|
||||
className={cn(
|
||||
"block rounded-3xl border overflow-hidden",
|
||||
"shadow-lg hover:shadow-xl transition-shadow duration-300",
|
||||
@ -119,7 +144,6 @@ const ServiceCard = memo(function ServiceCard({ card }: { card: ConversionServic
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row gap-6 sm:gap-10 p-7 sm:p-10">
|
||||
{/* Left: Content */}
|
||||
<div className="flex-1 flex flex-col justify-center min-w-0">
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<div
|
||||
@ -139,7 +163,7 @@ const ServiceCard = memo(function ServiceCard({ card }: { card: ConversionServic
|
||||
</div>
|
||||
|
||||
<p className="text-sm font-medium text-muted-foreground mb-1">{card.problemHook}</p>
|
||||
<h3 className="text-2xl sm:text-3xl font-extrabold text-foreground mb-3 leading-tight font-heading">
|
||||
<h3 className="text-2xl sm:text-3xl font-extrabold text-foreground mb-3 leading-tight">
|
||||
{card.title}
|
||||
</h3>
|
||||
<p className="text-[15px] text-muted-foreground leading-relaxed mb-6 max-w-lg">
|
||||
@ -158,7 +182,6 @@ const ServiceCard = memo(function ServiceCard({ card }: { card: ConversionServic
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Right: Key benefit highlight */}
|
||||
<div
|
||||
className={cn(
|
||||
"hidden sm:flex items-center justify-center w-56 shrink-0",
|
||||
@ -186,6 +209,17 @@ const ServiceCard = memo(function ServiceCard({ card }: { card: ConversionServic
|
||||
|
||||
/* ─── Header + Tab Toggle ─── */
|
||||
|
||||
const TAB_COPY: Record<Tab, { heading: string; subheading: string }> = {
|
||||
personal: {
|
||||
heading: "Personal Services",
|
||||
subheading: "Everything you need to stay connected in Japan",
|
||||
},
|
||||
business: {
|
||||
heading: "Business Services",
|
||||
subheading: "Enterprise connectivity solutions for your team",
|
||||
},
|
||||
};
|
||||
|
||||
function CarouselHeader({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
@ -193,31 +227,51 @@ function CarouselHeader({
|
||||
activeTab: Tab;
|
||||
onTabChange: (tab: Tab) => void;
|
||||
}) {
|
||||
const copy = TAB_COPY[activeTab];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-6 sm:px-10 mb-10">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-3xl sm:text-4xl font-extrabold text-foreground font-heading">
|
||||
Our Services
|
||||
</h2>
|
||||
<p className="mt-2 text-lg text-muted-foreground">
|
||||
Everything you need to stay connected in Japan
|
||||
</p>
|
||||
<div className="min-h-[4.5rem]">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={activeTab}
|
||||
variants={headingVariants}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
>
|
||||
<h2 className="text-3xl sm:text-4xl font-extrabold text-foreground font-heading">
|
||||
{copy.heading}
|
||||
</h2>
|
||||
<p className="mt-2 text-lg text-muted-foreground">{copy.subheading}</p>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<div className="flex bg-muted rounded-full p-1 self-start">
|
||||
<div className="flex bg-muted rounded-full p-1 self-start relative">
|
||||
{(["personal", "business"] as const).map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
onClick={() => onTabChange(tab)}
|
||||
className={cn(
|
||||
"px-5 py-2.5 text-sm font-semibold rounded-full transition-all",
|
||||
"relative z-10 px-5 py-2.5 text-sm font-semibold rounded-full transition-colors duration-300",
|
||||
activeTab === tab
|
||||
? "bg-foreground text-background shadow-sm"
|
||||
? "text-background"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{tab === "personal" ? "For You" : "For Business"}
|
||||
{activeTab === tab && (
|
||||
<motion.span
|
||||
layoutId="tab-indicator"
|
||||
className="absolute inset-0 rounded-full bg-foreground shadow-sm"
|
||||
transition={{ type: "spring", stiffness: 400, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
<span className="relative z-10">
|
||||
{tab === "personal" ? "For You" : "For Business"}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@ -284,51 +338,81 @@ function CarouselNav({
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Main Carousel ─── */
|
||||
/* ─── Slide styles ─── */
|
||||
|
||||
export function ServicesCarousel() {
|
||||
const [activeTab, setActiveTab] = useState<Tab>("personal");
|
||||
const [sectionRef, isInView] = useInView<HTMLDivElement>();
|
||||
const cards = activeTab === "personal" ? personalConversionCards : businessConversionCards;
|
||||
const c = useSnapCarousel({ total: cards.length, autoPlayMs: 10000 });
|
||||
function slideStyles(offset: number, isDragging: boolean) {
|
||||
const absOffset = Math.abs(offset);
|
||||
const isVisible = absOffset < 2.5;
|
||||
const t = Math.min(absOffset, 2);
|
||||
|
||||
useEffect(() => {
|
||||
c.reset();
|
||||
}, [activeTab, c.reset]);
|
||||
const translateX = offset * 100;
|
||||
const scale = 1 - t * 0.15;
|
||||
const opacity = isVisible ? 1 - t * 0.3 : 0;
|
||||
const blur = t * 2;
|
||||
|
||||
return {
|
||||
opacity,
|
||||
transform: `translateX(${translateX}%) scale(${scale})`,
|
||||
filter: blur > 0.1 ? `blur(${blur}px)` : "none",
|
||||
transition: isDragging ? "none" : "all 500ms cubic-bezier(0.25, 1, 0.5, 1)",
|
||||
zIndex: isVisible ? Math.round((1 - absOffset) * 10) : 0,
|
||||
pointerEvents: (absOffset < 0.5 ? "auto" : "none") as "auto" | "none",
|
||||
visibility: (isVisible ? "visible" : "hidden") as "visible" | "hidden",
|
||||
};
|
||||
}
|
||||
|
||||
/* ─── Carousel track (extracted so AnimatePresence can swap it) ─── */
|
||||
|
||||
function CarouselTrack({
|
||||
cards,
|
||||
carousel,
|
||||
}: {
|
||||
cards: ConversionServiceCard[];
|
||||
carousel: ReturnType<typeof useCarousel<ConversionServiceCard>>;
|
||||
}) {
|
||||
const c = carousel;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={sectionRef}
|
||||
className={cn(
|
||||
"py-16 sm:py-20 transition-all duration-700",
|
||||
isInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
|
||||
)}
|
||||
>
|
||||
<CarouselHeader activeTab={activeTab} onTabChange={setActiveTab} />
|
||||
|
||||
<>
|
||||
<div
|
||||
ref={c.scrollRef}
|
||||
className="flex overflow-x-auto snap-x snap-mandatory scrollbar-hide"
|
||||
onPointerDown={c.onPointerDown}
|
||||
className="relative overflow-hidden select-none cursor-grab active:cursor-grabbing"
|
||||
onTouchStart={c.onTouchStart}
|
||||
onTouchMove={c.onTouchMove}
|
||||
onTouchEnd={c.onTouchEnd}
|
||||
onMouseDown={c.onMouseDown}
|
||||
onMouseMove={c.onMouseMove}
|
||||
onMouseUp={c.onMouseUp}
|
||||
onMouseLeave={c.onMouseLeave}
|
||||
onKeyDown={c.onKeyDown}
|
||||
tabIndex={0}
|
||||
role="region"
|
||||
aria-label="Services carousel"
|
||||
aria-roledescription="carousel"
|
||||
>
|
||||
{cards.map((card, i) => (
|
||||
<div
|
||||
key={`${card.title}-${i}`}
|
||||
className="min-w-full snap-center px-6 sm:px-10"
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
aria-label={`${i + 1} of ${cards.length}: ${card.title}`}
|
||||
>
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<ServiceCard card={card} />
|
||||
</div>
|
||||
<div className="mx-auto max-w-3xl px-6 sm:px-10">
|
||||
<div className="relative">
|
||||
{cards.map((card, i) => {
|
||||
const offset = c.getSlideOffset(i);
|
||||
const absOffset = Math.abs(offset);
|
||||
const isActive = absOffset < 0.5;
|
||||
const styles = slideStyles(offset, c.isDragging);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${card.title}-${i}`}
|
||||
className={cn(isActive ? "relative shadow-2xl rounded-3xl" : "absolute inset-0")}
|
||||
style={styles}
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
aria-label={`${i + 1} of ${c.total}: ${card.title}`}
|
||||
aria-hidden={!isActive}
|
||||
>
|
||||
<ServiceCard card={card} wasDragging={c.wasDragging} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CarouselNav
|
||||
@ -338,6 +422,46 @@ export function ServicesCarousel() {
|
||||
goPrev={c.goPrev}
|
||||
goNext={c.goNext}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Main Carousel ─── */
|
||||
|
||||
export function ServicesCarousel() {
|
||||
const [activeTab, setActiveTab] = useState<Tab>("personal");
|
||||
const [sectionRef, isInView] = useInView<HTMLDivElement>();
|
||||
const cards = activeTab === "personal" ? personalConversionCards : businessConversionCards;
|
||||
const c = useCarousel({ items: cards, autoPlayMs: 10000 });
|
||||
|
||||
const handleTabChange = (tab: Tab) => {
|
||||
if (tab === activeTab) return;
|
||||
setActiveTab(tab);
|
||||
c.reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={sectionRef}
|
||||
className={cn(
|
||||
"py-16 sm:py-20 transition-all duration-700",
|
||||
isInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
|
||||
)}
|
||||
>
|
||||
<CarouselHeader activeTab={activeTab} onTabChange={handleTabChange} />
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={activeTab}
|
||||
variants={tabContentVariants}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={{ duration: 0.3, ease: [0.25, 1, 0.5, 1] }}
|
||||
>
|
||||
<CarouselTrack cards={cards} carousel={c} />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
export { useInView } from "./useInView";
|
||||
export { useSnapCarousel } from "./useSnapCarousel";
|
||||
export { useCarousel, useInfiniteCarousel } from "./useInfiniteCarousel";
|
||||
export { useInView } from "./useInView";
|
||||
export { useStickyCta } from "./useStickyCta";
|
||||
|
||||
@ -0,0 +1,213 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* Carousel hook using a continuous track position model.
|
||||
*
|
||||
* `getSlideOffset(i)` returns a fractional position for each slide:
|
||||
* 0 = centered (active), -1 = previous, +1 = next
|
||||
*
|
||||
* During drag the offset moves continuously. On release it snaps to the
|
||||
* nearest integer index. Wraps infinitely.
|
||||
*/
|
||||
export function useCarousel<T>({ items, autoPlayMs = 5000 }: { items: T[]; autoPlayMs?: number }) {
|
||||
const total = items.length;
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [dragOffset, setDragOffset] = useState(0);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
// Ref mirror so callbacks don't re-create on every index change
|
||||
// Track whether last interaction was a drag to prevent link clicks
|
||||
const wasDraggingRef = useRef(false);
|
||||
const stateRef = useRef({ activeIndex, dragOffset, total });
|
||||
stateRef.current = { activeIndex, dragOffset, total };
|
||||
|
||||
// --- Auto-play pause ---
|
||||
const pausedRef = useRef(false);
|
||||
const pauseTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
|
||||
const pauseAutoPlay = useCallback(() => {
|
||||
pausedRef.current = true;
|
||||
clearTimeout(pauseTimerRef.current);
|
||||
pauseTimerRef.current = setTimeout(() => {
|
||||
pausedRef.current = false;
|
||||
}, autoPlayMs * 2);
|
||||
}, [autoPlayMs]);
|
||||
|
||||
useEffect(() => () => clearTimeout(pauseTimerRef.current), []);
|
||||
|
||||
// --- Navigation ---
|
||||
const goTo = useCallback(
|
||||
(i: number) => {
|
||||
pauseAutoPlay();
|
||||
setActiveIndex(i);
|
||||
setDragOffset(0);
|
||||
},
|
||||
[pauseAutoPlay]
|
||||
);
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
pauseAutoPlay();
|
||||
setActiveIndex(prev => (prev + 1) % stateRef.current.total);
|
||||
setDragOffset(0);
|
||||
}, [pauseAutoPlay]);
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
pauseAutoPlay();
|
||||
setActiveIndex(prev => (prev - 1 + stateRef.current.total) % stateRef.current.total);
|
||||
setDragOffset(0);
|
||||
}, [pauseAutoPlay]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setActiveIndex(0);
|
||||
setDragOffset(0);
|
||||
}, []);
|
||||
|
||||
// --- Drag logic ---
|
||||
const startXRef = useRef(0);
|
||||
const containerWidthRef = useRef(0);
|
||||
const draggingRef = useRef(false);
|
||||
|
||||
const startDrag = useCallback(
|
||||
(clientX: number, container: HTMLElement) => {
|
||||
startXRef.current = clientX;
|
||||
containerWidthRef.current = container.getBoundingClientRect().width;
|
||||
draggingRef.current = true;
|
||||
wasDraggingRef.current = false;
|
||||
setIsDragging(true);
|
||||
setDragOffset(0);
|
||||
pauseAutoPlay();
|
||||
},
|
||||
[pauseAutoPlay]
|
||||
);
|
||||
|
||||
const moveDrag = useCallback((clientX: number) => {
|
||||
if (!draggingRef.current) return;
|
||||
const width = containerWidthRef.current || 1;
|
||||
const delta = clientX - startXRef.current;
|
||||
// Mark as a real drag once moved more than 5px (not just a click)
|
||||
if (Math.abs(delta) > 5) wasDraggingRef.current = true;
|
||||
// Positive offset = dragged right = reveal previous
|
||||
// Negative offset = dragged left = reveal next
|
||||
setDragOffset(delta / width);
|
||||
}, []);
|
||||
|
||||
const endDrag = useCallback(() => {
|
||||
if (!draggingRef.current) return;
|
||||
draggingRef.current = false;
|
||||
setIsDragging(false);
|
||||
|
||||
const { dragOffset: currentOffset, activeIndex: currentIndex, total: n } = stateRef.current;
|
||||
|
||||
if (currentOffset < -0.15) {
|
||||
// Dragged left → go next
|
||||
setActiveIndex((currentIndex + 1) % n);
|
||||
} else if (currentOffset > 0.15) {
|
||||
// Dragged right → go prev
|
||||
setActiveIndex((currentIndex - 1 + n) % n);
|
||||
}
|
||||
setDragOffset(0);
|
||||
}, []);
|
||||
|
||||
// --- Touch handlers ---
|
||||
const onTouchStart = useCallback(
|
||||
(e: React.TouchEvent) => {
|
||||
const t = e.touches[0];
|
||||
if (t) startDrag(t.clientX, e.currentTarget as HTMLElement);
|
||||
},
|
||||
[startDrag]
|
||||
);
|
||||
|
||||
const onTouchMove = useCallback(
|
||||
(e: React.TouchEvent) => {
|
||||
const t = e.touches[0];
|
||||
if (t) moveDrag(t.clientX);
|
||||
},
|
||||
[moveDrag]
|
||||
);
|
||||
|
||||
const onTouchEnd = useCallback(() => endDrag(), [endDrag]);
|
||||
|
||||
// --- Mouse handlers ---
|
||||
const onMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
startDrag(e.clientX, e.currentTarget as HTMLElement);
|
||||
},
|
||||
[startDrag]
|
||||
);
|
||||
|
||||
const onMouseMove = useCallback((e: React.MouseEvent) => moveDrag(e.clientX), [moveDrag]);
|
||||
|
||||
const onMouseUp = useCallback(() => endDrag(), [endDrag]);
|
||||
const onMouseLeave = useCallback(() => endDrag(), [endDrag]);
|
||||
|
||||
// --- Keyboard ---
|
||||
const onKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "ArrowLeft") goPrev();
|
||||
else if (e.key === "ArrowRight") goNext();
|
||||
},
|
||||
[goPrev, goNext]
|
||||
);
|
||||
|
||||
// --- Auto-play ---
|
||||
useEffect(() => {
|
||||
if (total <= 1) return;
|
||||
const id = setInterval(() => {
|
||||
if (!pausedRef.current) {
|
||||
setActiveIndex(prev => (prev + 1) % total);
|
||||
setDragOffset(0);
|
||||
}
|
||||
}, autoPlayMs);
|
||||
return () => clearInterval(id);
|
||||
}, [total, autoPlayMs]);
|
||||
|
||||
/**
|
||||
* Get the visual offset for slide `i`.
|
||||
* Returns: 0 = centered, -1 = left neighbor, +1 = right neighbor.
|
||||
* Incorporates drag offset for real-time movement.
|
||||
*/
|
||||
const getSlideOffset = useCallback(
|
||||
(i: number) => {
|
||||
let diff = i - activeIndex;
|
||||
// Shortest path wrapping — use half = floor(total/2) so both
|
||||
// directions get equal neighbor count
|
||||
const half = Math.floor(total / 2);
|
||||
if (diff > half) diff -= total;
|
||||
if (diff < -half) diff += total;
|
||||
// dragOffset is positive when dragging right (revealing prev)
|
||||
// so slide positions shift right: diff + dragOffset
|
||||
return diff + dragOffset;
|
||||
},
|
||||
[activeIndex, dragOffset, total]
|
||||
);
|
||||
|
||||
/** True if the last pointer interaction was a drag (not a tap/click) */
|
||||
const wasDragging = useCallback(() => wasDraggingRef.current, []);
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
activeIndex,
|
||||
isDragging,
|
||||
wasDragging,
|
||||
getSlideOffset,
|
||||
goTo,
|
||||
goNext,
|
||||
goPrev,
|
||||
reset,
|
||||
onTouchStart,
|
||||
onTouchMove,
|
||||
onTouchEnd,
|
||||
onMouseDown,
|
||||
onMouseMove,
|
||||
onMouseUp,
|
||||
onMouseLeave,
|
||||
onKeyDown,
|
||||
};
|
||||
}
|
||||
|
||||
/** @deprecated Use `useCarousel` instead */
|
||||
export const useInfiniteCarousel = useCarousel;
|
||||
@ -20,27 +20,24 @@ export function PublicLandingView() {
|
||||
return (
|
||||
<div className="pb-8">
|
||||
{/* Chapter 1: Who we are */}
|
||||
<Chapter
|
||||
zIndex={1}
|
||||
className="min-h-dvh flex flex-col bg-gradient-to-br from-surface-sunken via-background to-info-bg/80"
|
||||
>
|
||||
<Chapter className="min-h-dvh flex flex-col bg-gradient-to-br from-surface-sunken via-background to-info-bg/80">
|
||||
<HeroSection heroCTARef={heroCTARef} />
|
||||
<TrustStrip />
|
||||
</Chapter>
|
||||
|
||||
{/* Chapter 2: What we offer */}
|
||||
<Chapter zIndex={2} overlay className="bg-surface-sunken/30">
|
||||
<Chapter className="bg-surface-sunken/30">
|
||||
<ServicesCarousel />
|
||||
</Chapter>
|
||||
|
||||
{/* Chapter 3: Why choose us */}
|
||||
<Chapter zIndex={3} overlay className="bg-background">
|
||||
<Chapter className="bg-background">
|
||||
<WhyUsSection />
|
||||
<CTABanner />
|
||||
</Chapter>
|
||||
|
||||
{/* Chapter 4: Get in touch */}
|
||||
<Chapter zIndex={4} overlay sticky={false} className="bg-background">
|
||||
<Chapter className="bg-background">
|
||||
<SupportDownloadsSection />
|
||||
<ContactSection />
|
||||
</Chapter>
|
||||
|
||||
@ -41,16 +41,6 @@ import type { OrderDetails, OrderUpdateEventPayload } from "@customer-portal/dom
|
||||
import { Formatting } from "@customer-portal/domain/toolkit";
|
||||
import { cn, formatIsoDate } from "@/shared/utils";
|
||||
|
||||
const STATUS_PILL_VARIANT: Record<
|
||||
"success" | "info" | "warning" | "neutral",
|
||||
"success" | "info" | "warning" | "neutral"
|
||||
> = {
|
||||
success: "success",
|
||||
info: "info",
|
||||
warning: "warning",
|
||||
neutral: "neutral",
|
||||
};
|
||||
|
||||
const CATEGORY_CONFIG: Record<
|
||||
OrderDisplayItemCategory,
|
||||
{
|
||||
@ -416,9 +406,7 @@ function useDerivedOrderData(data: OrderDetails | null) {
|
||||
scheduledAt: data.activationScheduledAt ?? "",
|
||||
})
|
||||
: null;
|
||||
const statusPillVariant = statusDescriptor
|
||||
? STATUS_PILL_VARIANT[statusDescriptor.tone]
|
||||
: STATUS_PILL_VARIANT.neutral;
|
||||
const statusPillVariant = statusDescriptor?.tone ?? "neutral";
|
||||
const serviceCategory = getServiceCategory(data?.orderType);
|
||||
const displayItems = useMemo<OrderDisplayItem[]>(
|
||||
() => buildOrderDisplayItems(data?.itemsSummary),
|
||||
|
||||
@ -34,7 +34,13 @@ export function SubscriptionGridCard({ subscription, className }: SubscriptionGr
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
const statusIndicator = mapSubscriptionStatus(subscription.status);
|
||||
const cycleLabel = getBillingCycleLabel(subscription.cycle);
|
||||
const isInactive = ["Completed", "Cancelled", "Terminated"].includes(subscription.status);
|
||||
const isInactive = (
|
||||
[
|
||||
SUBSCRIPTION_STATUS.COMPLETED,
|
||||
SUBSCRIPTION_STATUS.CANCELLED,
|
||||
SUBSCRIPTION_STATUS.TERMINATED,
|
||||
] as string[]
|
||||
).includes(subscription.status);
|
||||
|
||||
return (
|
||||
<Link
|
||||
|
||||
BIN
image.png
BIN
image.png
Binary file not shown.
|
Before Width: | Height: | Size: 506 KiB After Width: | Height: | Size: 440 KiB |
47
pnpm-lock.yaml
generated
47
pnpm-lock.yaml
generated
@ -227,6 +227,9 @@ importers:
|
||||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
framer-motion:
|
||||
specifier: ^12.35.0
|
||||
version: 12.35.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
geist:
|
||||
specifier: ^1.5.1
|
||||
version: 1.5.1(next@16.1.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))
|
||||
@ -5155,6 +5158,23 @@ packages:
|
||||
}
|
||||
engines: { node: ">= 0.6" }
|
||||
|
||||
framer-motion@12.35.0:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-w8hghCMQ4oq10j6aZh3U2yeEQv5K69O/seDI/41PK4HtgkLrcBovUNc0ayBC3UyyU7V1mrY2yLzvYdWJX9pGZQ==,
|
||||
}
|
||||
peerDependencies:
|
||||
"@emotion/is-prop-valid": "*"
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
"@emotion/is-prop-valid":
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
|
||||
fresh@2.0.0:
|
||||
resolution:
|
||||
{
|
||||
@ -6345,6 +6365,18 @@ packages:
|
||||
}
|
||||
hasBin: true
|
||||
|
||||
motion-dom@12.35.0:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-FFMLEnIejK/zDABn+vqGVAUN4T0+3fw+cVAY8MMT65yR+j5uMuvWdd4npACWhh94OVWQs79CrBBuwOwGRZAQiA==,
|
||||
}
|
||||
|
||||
motion-utils@12.29.2:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==,
|
||||
}
|
||||
|
||||
mrmime@2.0.1:
|
||||
resolution:
|
||||
{
|
||||
@ -11509,6 +11541,15 @@ snapshots:
|
||||
|
||||
forwarded@0.2.0: {}
|
||||
|
||||
framer-motion@12.35.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
motion-dom: 12.35.0
|
||||
motion-utils: 12.29.2
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
|
||||
fresh@2.0.0: {}
|
||||
|
||||
fs-extra@10.1.0:
|
||||
@ -12127,6 +12168,12 @@ snapshots:
|
||||
dependencies:
|
||||
minimist: 1.2.8
|
||||
|
||||
motion-dom@12.35.0:
|
||||
dependencies:
|
||||
motion-utils: 12.29.2
|
||||
|
||||
motion-utils@12.29.2: {}
|
||||
|
||||
mrmime@2.0.1: {}
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user