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:
barsa 2026-03-06 10:45:18 +09:00
parent cab58d1c5b
commit b3cb1064d8
17 changed files with 557 additions and 223 deletions

View File

@ -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",

View File

@ -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);

View File

@ -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 (
<>

View File

@ -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]">&#8984;</span>K
</kbd>
</button>
<div className="flex-1" />
{/* Right side actions */}

View File

@ -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>

View File

@ -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" },

View File

@ -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>

View File

@ -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 />

View File

@ -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>;
}

View File

@ -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>
);
}

View File

@ -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";

View File

@ -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;

View File

@ -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>

View File

@ -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),

View File

@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 506 KiB

After

Width:  |  Height:  |  Size: 440 KiB

47
pnpm-lock.yaml generated
View File

@ -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: {}