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",
|
"@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",
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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 (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@ -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]">⌘</span>K
|
|
||||||
</kbd>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
{/* Right side actions */}
|
{/* Right side actions */}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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" },
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 />
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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 (
|
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>
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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
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:
|
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: {}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user