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", "@xstate/react": "^6.0.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^12.35.0",
"geist": "^1.5.1", "geist": "^1.5.1",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"next": "^16.1.6", "next": "^16.1.6",

View File

@ -80,10 +80,10 @@
--input: oklch(0.955 0.005 70); --input: oklch(0.955 0.005 70);
--ring: oklch(0.6884 0.1342 234.4 / 0.5); --ring: oklch(0.6884 0.1342 234.4 / 0.5);
/* Sidebar - Dark Navy */ /* Sidebar - Deep purple/indigo */
--sidebar: oklch(0.18 0.03 250); --sidebar: oklch(0.2754 0.1199 272.34);
--sidebar-foreground: oklch(1 0 0); --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-active: oklch(0.99 0 0 / 0.12);
--sidebar-accent: var(--primary); --sidebar-accent: var(--primary);
@ -194,9 +194,9 @@
--input: oklch(0.33 0.01 70); --input: oklch(0.33 0.01 70);
--ring: oklch(0.75 0.12 234.4 / 0.5); --ring: oklch(0.75 0.12 234.4 / 0.5);
/* Sidebar - Dark Navy for dark mode */ /* Sidebar - Purple/indigo theme for dark mode */
--sidebar: oklch(0.13 0.025 250); --sidebar: oklch(0.2 0.08 272.34);
--sidebar-border: oklch(0.22 0.03 250); --sidebar-border: oklch(0.28 0.08 272.34);
--header: oklch(0.15 0.015 234.4 / 0.95); --header: oklch(0.15 0.015 234.4 / 0.95);
--header-foreground: var(--foreground); --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 { accountService } from "@/features/account/api/account.api";
import { Sidebar } from "./Sidebar"; import { Sidebar } from "./Sidebar";
import { Header } from "./Header"; import { Header } from "./Header";
import { baseNavigation, type NavigationItem } from "./navigation"; import { baseNavigation } from "./navigation";
interface AppShellProps { interface AppShellProps {
children: React.ReactNode; children: React.ReactNode;
} }
function collectPrefetchUrls(navigation: NavigationItem[]): string[] { const prefetchUrls: string[] = (() => {
const hrefs = new Set<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.href && item.href !== "#") hrefs.add(item.href);
if (!item.children || item.children.length === 0) continue; 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)) { for (const child of item.children.slice(0, 5)) {
if (child.href && child.href !== "#") hrefs.add(child.href); if (child.href && child.href !== "#") hrefs.add(child.href);
} }
} }
return [...hrefs]; return [...hrefs];
} })();
// Sidebar and navigation are modularized in ./Sidebar and ./navigation // Sidebar and navigation are modularized in ./Sidebar and ./navigation
@ -153,19 +152,15 @@ export function AppShell({ children }: AppShellProps) {
const navigation = baseNavigation; const navigation = baseNavigation;
useEffect(() => { useEffect(() => {
try { for (const href of prefetchUrls) {
const urls = collectPrefetchUrls(navigation); try {
for (const href of urls) { router.prefetch(href);
try { } catch {
router.prefetch(href); /* best-effort */
} 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 ( return (
<> <>

View File

@ -2,11 +2,7 @@
import Link from "next/link"; import Link from "next/link";
import { memo } from "react"; import { memo } from "react";
import { import { Bars3Icon, QuestionMarkCircleIcon } from "@heroicons/react/24/outline";
Bars3Icon,
MagnifyingGlassIcon,
QuestionMarkCircleIcon,
} from "@heroicons/react/24/outline";
import { NotificationBell } from "@/features/notifications"; import { NotificationBell } from "@/features/notifications";
interface UserInfo { interface UserInfo {
@ -55,19 +51,6 @@ export const Header = memo(function Header({ onMenuClick, user, profileReady }:
<Bars3Icon className="h-5 w-5" /> <Bars3Icon className="h-5 w-5" />
</button> </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" /> <div className="flex-1" />
{/* Right side actions */} {/* Right side actions */}

View File

@ -10,15 +10,14 @@ import type { ComponentType, SVGProps } from "react";
// Shared navigation item styling // Shared navigation item styling
const navItemBaseClass = 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"; "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/[0.08] shadow-sm"; const activeClass = "text-white bg-white/20 shadow-sm";
const inactiveClass = "text-white/60 hover:text-white/90 hover:bg-white/[0.06]"; const inactiveClass = "text-white/90 hover:text-white hover:bg-white/10";
function ActiveIndicator({ small = false }: { small?: boolean }) { function ActiveIndicator({ small = false }: { small?: boolean }) {
const size = small ? "w-0.5 h-3.5" : "w-[3px] h-5"; const size = small ? "w-0.5 h-4" : "w-1 h-6";
return ( const rounded = small ? "rounded-full" : "rounded-r-full";
<div className={`absolute left-0 top-1/2 -translate-y-1/2 ${size} bg-primary rounded-full`} /> return <div className={`absolute left-0 top-1/2 -translate-y-1/2 ${size} bg-white ${rounded}`} />;
);
} }
function NavIcon({ function NavIcon({
@ -32,19 +31,19 @@ function NavIcon({
}) { }) {
if (variant === "logout") { if (variant === "logout") {
return ( return (
<div className="p-1 mr-2.5 text-red-400/70 group-hover:text-red-300 transition-colors duration-200"> <div className="p-1.5 rounded-md mr-3 text-red-300 group-hover:text-red-100 transition-colors duration-200">
<Icon className="h-[18px] w-[18px]" /> <Icon className="h-5 w-5" />
</div> </div>
); );
} }
return ( return (
<div <div
className={`p-1 mr-2.5 transition-colors duration-200 ${ className={`p-1.5 rounded-md mr-3 transition-colors duration-200 ${
isActive ? "text-primary" : "text-white/40 group-hover:text-white/70" 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> </div>
); );
} }
@ -65,36 +64,44 @@ export const Sidebar = memo(function Sidebar({
}: SidebarProps) { }: SidebarProps) {
return ( return (
<div className="flex flex-col h-0 flex-1 bg-sidebar"> <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"> <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> <div>
<span className="text-sm font-bold text-white tracking-tight">Assist Solutions</span> <span className="text-base font-bold text-white">Assist Solutions</span>
<p className="text-[11px] text-white/50 font-medium">Customer Portal</p> <p className="text-xs text-white/70 font-medium">Customer Portal</p>
</div> </div>
</div> </div>
</div> </div>
<div className="flex-1 flex flex-col pt-6 pb-4 overflow-y-auto"> <div className="flex-1 flex flex-col pt-6 pb-4 overflow-y-auto">
<nav className="flex-1 px-3 space-y-0.5"> <nav className="flex-1 px-3 space-y-1">
{navigation.map((item, index) => ( {navigation
<div key={item.name}> .filter(item => !item.isLogout)
{item.section && ( .map(item => (
<div className={`px-3 ${index === 0 ? "pt-0" : "pt-5"} pb-2`}> <div key={item.name}>
<span className="text-[10px] font-semibold uppercase tracking-[0.1em] text-white/30"> <NavigationItem
{item.section} item={item}
</span> pathname={pathname}
</div> isExpanded={expandedItems.includes(item.name)}
)} toggleExpanded={toggleExpanded}
<NavigationItem />
item={item} </div>
pathname={pathname} ))}
isExpanded={expandedItems.includes(item.name)}
toggleExpanded={toggleExpanded}
/>
</div>
))}
</nav> </nav>
{navigation
.filter(item => item.isLogout)
.map(item => (
<NavigationItem
key={item.name}
item={item}
pathname={pathname}
isExpanded={false}
toggleExpanded={toggleExpanded}
/>
))}
</div> </div>
</div> </div>
); );
@ -130,7 +137,7 @@ function ExpandableNavItem({
<span className="flex-1">{item.name}</span> <span className="flex-1">{item.name}</span>
<svg <svg
className={`h-4 w-4 transition-transform duration-200 ease-out ${isExpanded ? "rotate-90" : ""} ${ 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" viewBox="0 0 20 20"
fill="currentColor" fill="currentColor"
@ -148,7 +155,7 @@ function ExpandableNavItem({
isExpanded ? "max-h-96 opacity-100" : "max-h-0 opacity-0" 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) => { {item.children?.map((child: NavigationChild) => {
const isChildActive = pathname === (child.href || "").split(/[?#]/)[0]; const isChildActive = pathname === (child.href || "").split(/[?#]/)[0];
return ( return (
@ -157,10 +164,10 @@ function ExpandableNavItem({
href={child.href} href={child.href}
prefetch prefetch
onMouseEnter={() => child.href && void router.prefetch(child.href)} 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 isChildActive
? "text-white bg-white/[0.08] font-medium" ? "text-white bg-white/15 font-medium"
: "text-white/50 hover:text-white/80 hover:bg-white/[0.04] font-normal" : "text-white/70 hover:text-white hover:bg-white/10 font-normal"
}`} }`}
title={child.tooltip || child.name} title={child.tooltip || child.name}
aria-current={isChildActive ? "page" : undefined} aria-current={isChildActive ? "page" : undefined}
@ -178,10 +185,10 @@ function ExpandableNavItem({
function LogoutNavItem({ item, onLogout }: { item: NavigationItem; onLogout: () => void }) { function LogoutNavItem({ item, onLogout }: { item: NavigationItem; onLogout: () => void }) {
return ( 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 <button
onClick={onLogout} 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" /> <NavIcon icon={item.icon} isActive={false} variant="logout" />
<span>{item.name}</span> <span>{item.name}</span>

View File

@ -22,16 +22,14 @@ export interface NavigationItem {
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>; icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
children?: NavigationChild[] | undefined; children?: NavigationChild[] | undefined;
isLogout?: boolean | undefined; isLogout?: boolean | undefined;
section?: string | undefined;
} }
export const baseNavigation: NavigationItem[] = [ 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: "Orders", href: "/account/orders", icon: ClipboardDocumentListIcon },
{ {
name: "Billing", name: "Billing",
icon: CreditCardIcon, icon: CreditCardIcon,
section: "Account",
children: [ children: [
{ name: "Invoices", href: "/account/billing/invoices" }, { name: "Invoices", href: "/account/billing/invoices" },
{ name: "Payment Methods", href: "/account/billing/payments" }, { name: "Payment Methods", href: "/account/billing/payments" },

View File

@ -33,12 +33,11 @@ export function PageLayout({
}: PageLayoutProps) { }: PageLayoutProps) {
return ( return (
<div> <div>
{/* Header band with subtle background */} {/* Page header */}
<div className="bg-muted/40 border-b border-border/40"> <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-md)] sm:py-[var(--cp-space-lg)]"> <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)]">
{/* Back link */}
{backLink && ( {backLink && (
<div className="mb-3"> <div className="mb-[var(--cp-space-sm)] sm:mb-[var(--cp-space-md)]">
<Link <Link
href={backLink.href} href={backLink.href}
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors duration-200" 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> </Link>
</div> </div>
)} )}
<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 justify-between gap-4 min-w-0">
<div className="flex items-start min-w-0 flex-1"> <div className="flex items-start min-w-0 flex-1">
{icon && ( {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"> <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} {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>
)} )}
<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> </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> </div>
</div> </div>
{/* Content */} {/* 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 py-[var(--cp-space-lg)] sm:py-[var(--cp-space-xl)] md:py-[var(--cp-space-2xl)]">
<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)]">
<div className="space-y-[var(--cp-space-xl)] sm:space-y-[var(--cp-space-2xl)]"> {renderPageContent({
{renderPageContent({ loading,
loading, error: error ?? undefined,
error: error ?? undefined, children,
children, onRetry,
onRetry, loadingFallback,
loadingFallback, })}
})}
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -450,9 +450,7 @@ export function PublicShell({ children }: PublicShellProps) {
)} )}
<main id="main-content" className="flex-1"> <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}
{children}
</div>
</main> </main>
<SiteFooter /> <SiteFooter />

View File

@ -2,31 +2,9 @@ import { cn } from "@/shared/utils";
interface ChapterProps { interface ChapterProps {
children: React.ReactNode; children: React.ReactNode;
zIndex: number;
className?: string; className?: string;
overlay?: boolean;
sticky?: boolean;
} }
const CHAPTER_SHADOW = "shadow-[0_-8px_30px_-10px_rgba(0,0,0,0.08)]"; export function Chapter({ children, className }: ChapterProps) {
return <section className={cn("relative", className)}>{children}</section>;
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>
);
} }

View File

@ -1,10 +1,11 @@
"use client"; "use client";
import { memo, useEffect, useState } from "react"; import { memo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { AnimatePresence, motion } from "framer-motion";
import { ArrowRight, ChevronLeft, ChevronRight } from "lucide-react"; import { ArrowRight, ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/shared/utils"; import { cn } from "@/shared/utils";
import { useSnapCarousel, useInView } from "@/features/landing-page/hooks"; import { useCarousel, useInView } from "@/features/landing-page/hooks";
import { import {
personalConversionCards, personalConversionCards,
businessConversionCards, 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 ─── */ /* ─── 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]; const a = ACCENTS[card.accent];
return ( return (
<Link <Link
href={card.href} href={card.href}
draggable={false}
onClick={e => {
if (wasDragging()) e.preventDefault();
}}
className={cn( className={cn(
"block rounded-3xl border overflow-hidden", "block rounded-3xl border overflow-hidden",
"shadow-lg hover:shadow-xl transition-shadow duration-300", "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"> <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-1 flex flex-col justify-center min-w-0">
<div className="flex items-center gap-3 mb-5"> <div className="flex items-center gap-3 mb-5">
<div <div
@ -139,7 +163,7 @@ const ServiceCard = memo(function ServiceCard({ card }: { card: ConversionServic
</div> </div>
<p className="text-sm font-medium text-muted-foreground mb-1">{card.problemHook}</p> <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} {card.title}
</h3> </h3>
<p className="text-[15px] text-muted-foreground leading-relaxed mb-6 max-w-lg"> <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> </span>
</div> </div>
{/* Right: Key benefit highlight */}
<div <div
className={cn( className={cn(
"hidden sm:flex items-center justify-center w-56 shrink-0", "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 ─── */ /* ─── 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({ function CarouselHeader({
activeTab, activeTab,
onTabChange, onTabChange,
@ -193,31 +227,51 @@ function CarouselHeader({
activeTab: Tab; activeTab: Tab;
onTabChange: (tab: Tab) => void; onTabChange: (tab: Tab) => void;
}) { }) {
const copy = TAB_COPY[activeTab];
return ( return (
<div className="mx-auto max-w-3xl px-6 sm:px-10 mb-10"> <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 className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div> <div className="min-h-[4.5rem]">
<h2 className="text-3xl sm:text-4xl font-extrabold text-foreground font-heading"> <AnimatePresence mode="wait">
Our Services <motion.div
</h2> key={activeTab}
<p className="mt-2 text-lg text-muted-foreground"> variants={headingVariants}
Everything you need to stay connected in Japan initial="enter"
</p> 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>
<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 => ( {(["personal", "business"] as const).map(tab => (
<button <button
key={tab} key={tab}
type="button" type="button"
onClick={() => onTabChange(tab)} onClick={() => onTabChange(tab)}
className={cn( 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 activeTab === tab
? "bg-foreground text-background shadow-sm" ? "text-background"
: "text-muted-foreground hover:text-foreground" : "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> </button>
))} ))}
</div> </div>
@ -284,51 +338,81 @@ function CarouselNav({
); );
} }
/* ─── Main Carousel ─── */ /* ─── Slide styles ─── */
export function ServicesCarousel() { function slideStyles(offset: number, isDragging: boolean) {
const [activeTab, setActiveTab] = useState<Tab>("personal"); const absOffset = Math.abs(offset);
const [sectionRef, isInView] = useInView<HTMLDivElement>(); const isVisible = absOffset < 2.5;
const cards = activeTab === "personal" ? personalConversionCards : businessConversionCards; const t = Math.min(absOffset, 2);
const c = useSnapCarousel({ total: cards.length, autoPlayMs: 10000 });
useEffect(() => { const translateX = offset * 100;
c.reset(); const scale = 1 - t * 0.15;
}, [activeTab, c.reset]); 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 ( 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 <div
ref={c.scrollRef} className="relative overflow-hidden select-none cursor-grab active:cursor-grabbing"
className="flex overflow-x-auto snap-x snap-mandatory scrollbar-hide" onTouchStart={c.onTouchStart}
onPointerDown={c.onPointerDown} onTouchMove={c.onTouchMove}
onTouchEnd={c.onTouchEnd}
onMouseDown={c.onMouseDown}
onMouseMove={c.onMouseMove}
onMouseUp={c.onMouseUp}
onMouseLeave={c.onMouseLeave}
onKeyDown={c.onKeyDown} onKeyDown={c.onKeyDown}
tabIndex={0} tabIndex={0}
role="region" role="region"
aria-label="Services carousel" aria-label="Services carousel"
aria-roledescription="carousel" aria-roledescription="carousel"
> >
{cards.map((card, i) => ( <div className="mx-auto max-w-3xl px-6 sm:px-10">
<div <div className="relative">
key={`${card.title}-${i}`} {cards.map((card, i) => {
className="min-w-full snap-center px-6 sm:px-10" const offset = c.getSlideOffset(i);
role="group" const absOffset = Math.abs(offset);
aria-roledescription="slide" const isActive = absOffset < 0.5;
aria-label={`${i + 1} of ${cards.length}: ${card.title}`} const styles = slideStyles(offset, c.isDragging);
>
<div className="mx-auto max-w-3xl"> return (
<ServiceCard card={card} /> <div
</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>
</div> </div>
<CarouselNav <CarouselNav
@ -338,6 +422,46 @@ export function ServicesCarousel() {
goPrev={c.goPrev} goPrev={c.goPrev}
goNext={c.goNext} 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> </div>
); );
} }

View File

@ -1,3 +1,4 @@
export { useInView } from "./useInView";
export { useSnapCarousel } from "./useSnapCarousel"; export { useSnapCarousel } from "./useSnapCarousel";
export { useCarousel, useInfiniteCarousel } from "./useInfiniteCarousel";
export { useInView } from "./useInView";
export { useStickyCta } from "./useStickyCta"; 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 ( return (
<div className="pb-8"> <div className="pb-8">
{/* Chapter 1: Who we are */} {/* Chapter 1: Who we are */}
<Chapter <Chapter className="min-h-dvh flex flex-col bg-gradient-to-br from-surface-sunken via-background to-info-bg/80">
zIndex={1}
className="min-h-dvh flex flex-col bg-gradient-to-br from-surface-sunken via-background to-info-bg/80"
>
<HeroSection heroCTARef={heroCTARef} /> <HeroSection heroCTARef={heroCTARef} />
<TrustStrip /> <TrustStrip />
</Chapter> </Chapter>
{/* Chapter 2: What we offer */} {/* Chapter 2: What we offer */}
<Chapter zIndex={2} overlay className="bg-surface-sunken/30"> <Chapter className="bg-surface-sunken/30">
<ServicesCarousel /> <ServicesCarousel />
</Chapter> </Chapter>
{/* Chapter 3: Why choose us */} {/* Chapter 3: Why choose us */}
<Chapter zIndex={3} overlay className="bg-background"> <Chapter className="bg-background">
<WhyUsSection /> <WhyUsSection />
<CTABanner /> <CTABanner />
</Chapter> </Chapter>
{/* Chapter 4: Get in touch */} {/* Chapter 4: Get in touch */}
<Chapter zIndex={4} overlay sticky={false} className="bg-background"> <Chapter className="bg-background">
<SupportDownloadsSection /> <SupportDownloadsSection />
<ContactSection /> <ContactSection />
</Chapter> </Chapter>

View File

@ -41,16 +41,6 @@ import type { OrderDetails, OrderUpdateEventPayload } from "@customer-portal/dom
import { Formatting } from "@customer-portal/domain/toolkit"; import { Formatting } from "@customer-portal/domain/toolkit";
import { cn, formatIsoDate } from "@/shared/utils"; 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< const CATEGORY_CONFIG: Record<
OrderDisplayItemCategory, OrderDisplayItemCategory,
{ {
@ -416,9 +406,7 @@ function useDerivedOrderData(data: OrderDetails | null) {
scheduledAt: data.activationScheduledAt ?? "", scheduledAt: data.activationScheduledAt ?? "",
}) })
: null; : null;
const statusPillVariant = statusDescriptor const statusPillVariant = statusDescriptor?.tone ?? "neutral";
? STATUS_PILL_VARIANT[statusDescriptor.tone]
: STATUS_PILL_VARIANT.neutral;
const serviceCategory = getServiceCategory(data?.orderType); const serviceCategory = getServiceCategory(data?.orderType);
const displayItems = useMemo<OrderDisplayItem[]>( const displayItems = useMemo<OrderDisplayItem[]>(
() => buildOrderDisplayItems(data?.itemsSummary), () => buildOrderDisplayItems(data?.itemsSummary),

View File

@ -34,7 +34,13 @@ export function SubscriptionGridCard({ subscription, className }: SubscriptionGr
const { formatCurrency } = useFormatCurrency(); const { formatCurrency } = useFormatCurrency();
const statusIndicator = mapSubscriptionStatus(subscription.status); const statusIndicator = mapSubscriptionStatus(subscription.status);
const cycleLabel = getBillingCycleLabel(subscription.cycle); 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 ( return (
<Link <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: clsx:
specifier: ^2.1.1 specifier: ^2.1.1
version: 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: geist:
specifier: ^1.5.1 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)) 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" } 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: fresh@2.0.0:
resolution: resolution:
{ {
@ -6345,6 +6365,18 @@ packages:
} }
hasBin: true 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: mrmime@2.0.1:
resolution: resolution:
{ {
@ -11509,6 +11541,15 @@ snapshots:
forwarded@0.2.0: {} 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: {} fresh@2.0.0: {}
fs-extra@10.1.0: fs-extra@10.1.0:
@ -12127,6 +12168,12 @@ snapshots:
dependencies: dependencies:
minimist: 1.2.8 minimist: 1.2.8
motion-dom@12.35.0:
dependencies:
motion-utils: 12.29.2
motion-utils@12.29.2: {}
mrmime@2.0.1: {} mrmime@2.0.1: {}
ms@2.1.3: {} ms@2.1.3: {}