diff --git a/apps/portal/package.json b/apps/portal/package.json index d3985191..c93b8038 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -24,6 +24,7 @@ "@xstate/react": "^6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "framer-motion": "^12.35.0", "geist": "^1.5.1", "lucide-react": "^0.563.0", "next": "^16.1.6", diff --git a/apps/portal/src/app/globals.css b/apps/portal/src/app/globals.css index 8aeafa59..f34d88e6 100644 --- a/apps/portal/src/app/globals.css +++ b/apps/portal/src/app/globals.css @@ -80,10 +80,10 @@ --input: oklch(0.955 0.005 70); --ring: oklch(0.6884 0.1342 234.4 / 0.5); - /* Sidebar - Dark Navy */ - --sidebar: oklch(0.18 0.03 250); + /* Sidebar - Deep purple/indigo */ + --sidebar: oklch(0.2754 0.1199 272.34); --sidebar-foreground: oklch(1 0 0); - --sidebar-border: oklch(0.25 0.04 250); + --sidebar-border: oklch(0.36 0.1 272.34); --sidebar-active: oklch(0.99 0 0 / 0.12); --sidebar-accent: var(--primary); @@ -194,9 +194,9 @@ --input: oklch(0.33 0.01 70); --ring: oklch(0.75 0.12 234.4 / 0.5); - /* Sidebar - Dark Navy for dark mode */ - --sidebar: oklch(0.13 0.025 250); - --sidebar-border: oklch(0.22 0.03 250); + /* Sidebar - Purple/indigo theme for dark mode */ + --sidebar: oklch(0.2 0.08 272.34); + --sidebar-border: oklch(0.28 0.08 272.34); --header: oklch(0.15 0.015 234.4 / 0.95); --header-foreground: var(--foreground); diff --git a/apps/portal/src/components/organisms/AppShell/AppShell.tsx b/apps/portal/src/components/organisms/AppShell/AppShell.tsx index 542b4f94..0bd036ab 100644 --- a/apps/portal/src/components/organisms/AppShell/AppShell.tsx +++ b/apps/portal/src/components/organisms/AppShell/AppShell.tsx @@ -6,24 +6,23 @@ import { useAuthStore, useAuthSession } from "@/features/auth/stores/auth.store" import { accountService } from "@/features/account/api/account.api"; import { Sidebar } from "./Sidebar"; import { Header } from "./Header"; -import { baseNavigation, type NavigationItem } from "./navigation"; +import { baseNavigation } from "./navigation"; interface AppShellProps { children: React.ReactNode; } -function collectPrefetchUrls(navigation: NavigationItem[]): string[] { +const prefetchUrls: string[] = (() => { const hrefs = new Set(); - for (const item of navigation) { + for (const item of baseNavigation) { if (item.href && item.href !== "#") hrefs.add(item.href); if (!item.children || item.children.length === 0) continue; - // Prefetch only the first few children to avoid heavy prefetch for (const child of item.children.slice(0, 5)) { if (child.href && child.href !== "#") hrefs.add(child.href); } } return [...hrefs]; -} +})(); // Sidebar and navigation are modularized in ./Sidebar and ./navigation @@ -153,19 +152,15 @@ export function AppShell({ children }: AppShellProps) { const navigation = baseNavigation; useEffect(() => { - try { - const urls = collectPrefetchUrls(navigation); - for (const href of urls) { - try { - router.prefetch(href); - } catch { - /* best-effort */ - } + for (const href of prefetchUrls) { + try { + router.prefetch(href); + } catch { + /* best-effort */ } - } catch { - /* ignore */ } - }, [navigation, router]); + // eslint-disable-next-line react-hooks/exhaustive-deps -- prefetchUrls is static; router is unstable but functionally stable + }, []); return ( <> diff --git a/apps/portal/src/components/organisms/AppShell/Header.tsx b/apps/portal/src/components/organisms/AppShell/Header.tsx index debc2d5f..c253afb9 100644 --- a/apps/portal/src/components/organisms/AppShell/Header.tsx +++ b/apps/portal/src/components/organisms/AppShell/Header.tsx @@ -2,11 +2,7 @@ import Link from "next/link"; import { memo } from "react"; -import { - Bars3Icon, - MagnifyingGlassIcon, - QuestionMarkCircleIcon, -} from "@heroicons/react/24/outline"; +import { Bars3Icon, QuestionMarkCircleIcon } from "@heroicons/react/24/outline"; import { NotificationBell } from "@/features/notifications"; interface UserInfo { @@ -55,19 +51,6 @@ export const Header = memo(function Header({ onMenuClick, user, profileReady }: - {/* Search trigger */} - -
{/* Right side actions */} diff --git a/apps/portal/src/components/organisms/AppShell/Sidebar.tsx b/apps/portal/src/components/organisms/AppShell/Sidebar.tsx index 24581ad6..1836eb4b 100644 --- a/apps/portal/src/components/organisms/AppShell/Sidebar.tsx +++ b/apps/portal/src/components/organisms/AppShell/Sidebar.tsx @@ -10,15 +10,14 @@ import type { ComponentType, SVGProps } from "react"; // Shared navigation item styling const navItemBaseClass = - "group w-full flex items-center px-3 py-2 text-[13px] font-medium rounded-lg transition-all duration-200 relative focus:outline-none focus:ring-2 focus:ring-white/20"; -const activeClass = "text-white bg-white/[0.08] shadow-sm"; -const inactiveClass = "text-white/60 hover:text-white/90 hover:bg-white/[0.06]"; + "group w-full flex items-center px-3 py-2.5 text-sm font-semibold rounded-lg transition-all duration-200 relative focus:outline-none focus:ring-2 focus:ring-white/30"; +const activeClass = "text-white bg-white/20 shadow-sm"; +const inactiveClass = "text-white/90 hover:text-white hover:bg-white/10"; function ActiveIndicator({ small = false }: { small?: boolean }) { - const size = small ? "w-0.5 h-3.5" : "w-[3px] h-5"; - return ( -
- ); + const size = small ? "w-0.5 h-4" : "w-1 h-6"; + const rounded = small ? "rounded-full" : "rounded-r-full"; + return
; } function NavIcon({ @@ -32,19 +31,19 @@ function NavIcon({ }) { if (variant === "logout") { return ( -
- +
+
); } return (
- +
); } @@ -65,36 +64,44 @@ export const Sidebar = memo(function Sidebar({ }: SidebarProps) { return (
-
+
- +
+ +
- Assist Solutions -

Customer Portal

+ Assist Solutions +

Customer Portal

-
); @@ -130,7 +137,7 @@ function ExpandableNavItem({ {item.name} -
+
{item.children?.map((child: NavigationChild) => { const isChildActive = pathname === (child.href || "").split(/[?#]/)[0]; return ( @@ -157,10 +164,10 @@ function ExpandableNavItem({ href={child.href} prefetch onMouseEnter={() => child.href && void router.prefetch(child.href)} - className={`group flex items-center px-2.5 py-1.5 text-[13px] rounded-md transition-all duration-200 relative ${ + className={`group flex items-center px-2.5 py-1.5 text-sm rounded-md transition-all duration-200 relative ${ isChildActive - ? "text-white bg-white/[0.08] font-medium" - : "text-white/50 hover:text-white/80 hover:bg-white/[0.04] font-normal" + ? "text-white bg-white/15 font-medium" + : "text-white/70 hover:text-white hover:bg-white/10 font-normal" }`} title={child.tooltip || child.name} aria-current={isChildActive ? "page" : undefined} @@ -178,10 +185,10 @@ function ExpandableNavItem({ function LogoutNavItem({ item, onLogout }: { item: NavigationItem; onLogout: () => void }) { return ( -
+
))}
@@ -284,51 +338,81 @@ function CarouselNav({ ); } -/* ─── Main Carousel ─── */ +/* ─── Slide styles ─── */ -export function ServicesCarousel() { - const [activeTab, setActiveTab] = useState("personal"); - const [sectionRef, isInView] = useInView(); - const cards = activeTab === "personal" ? personalConversionCards : businessConversionCards; - const c = useSnapCarousel({ total: cards.length, autoPlayMs: 10000 }); +function slideStyles(offset: number, isDragging: boolean) { + const absOffset = Math.abs(offset); + const isVisible = absOffset < 2.5; + const t = Math.min(absOffset, 2); - useEffect(() => { - c.reset(); - }, [activeTab, c.reset]); + const translateX = offset * 100; + const scale = 1 - t * 0.15; + const opacity = isVisible ? 1 - t * 0.3 : 0; + const blur = t * 2; + + return { + opacity, + transform: `translateX(${translateX}%) scale(${scale})`, + filter: blur > 0.1 ? `blur(${blur}px)` : "none", + transition: isDragging ? "none" : "all 500ms cubic-bezier(0.25, 1, 0.5, 1)", + zIndex: isVisible ? Math.round((1 - absOffset) * 10) : 0, + pointerEvents: (absOffset < 0.5 ? "auto" : "none") as "auto" | "none", + visibility: (isVisible ? "visible" : "hidden") as "visible" | "hidden", + }; +} + +/* ─── Carousel track (extracted so AnimatePresence can swap it) ─── */ + +function CarouselTrack({ + cards, + carousel, +}: { + cards: ConversionServiceCard[]; + carousel: ReturnType>; +}) { + const c = carousel; return ( -
- - + <>
- {cards.map((card, i) => ( -
-
- -
+
+
+ {cards.map((card, i) => { + const offset = c.getSlideOffset(i); + const absOffset = Math.abs(offset); + const isActive = absOffset < 0.5; + const styles = slideStyles(offset, c.isDragging); + + return ( +
+ +
+ ); + })}
- ))} +
+ + ); +} + +/* ─── Main Carousel ─── */ + +export function ServicesCarousel() { + const [activeTab, setActiveTab] = useState("personal"); + const [sectionRef, isInView] = useInView(); + 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 ( +
+ + + + + + +
); } diff --git a/apps/portal/src/features/landing-page/hooks/index.ts b/apps/portal/src/features/landing-page/hooks/index.ts index 538c37fd..433c2ef3 100644 --- a/apps/portal/src/features/landing-page/hooks/index.ts +++ b/apps/portal/src/features/landing-page/hooks/index.ts @@ -1,3 +1,4 @@ -export { useInView } from "./useInView"; export { useSnapCarousel } from "./useSnapCarousel"; +export { useCarousel, useInfiniteCarousel } from "./useInfiniteCarousel"; +export { useInView } from "./useInView"; export { useStickyCta } from "./useStickyCta"; diff --git a/apps/portal/src/features/landing-page/hooks/useInfiniteCarousel.ts b/apps/portal/src/features/landing-page/hooks/useInfiniteCarousel.ts new file mode 100644 index 00000000..b9bdeb0b --- /dev/null +++ b/apps/portal/src/features/landing-page/hooks/useInfiniteCarousel.ts @@ -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({ 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 | 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; diff --git a/apps/portal/src/features/landing-page/views/PublicLandingView.tsx b/apps/portal/src/features/landing-page/views/PublicLandingView.tsx index 5d866ca4..dc76e2a8 100644 --- a/apps/portal/src/features/landing-page/views/PublicLandingView.tsx +++ b/apps/portal/src/features/landing-page/views/PublicLandingView.tsx @@ -20,27 +20,24 @@ export function PublicLandingView() { return (
{/* Chapter 1: Who we are */} - + {/* Chapter 2: What we offer */} - + {/* Chapter 3: Why choose us */} - + {/* Chapter 4: Get in touch */} - + diff --git a/apps/portal/src/features/orders/views/OrderDetail.tsx b/apps/portal/src/features/orders/views/OrderDetail.tsx index 3d7e85e5..3ad36692 100644 --- a/apps/portal/src/features/orders/views/OrderDetail.tsx +++ b/apps/portal/src/features/orders/views/OrderDetail.tsx @@ -41,16 +41,6 @@ import type { OrderDetails, OrderUpdateEventPayload } from "@customer-portal/dom import { Formatting } from "@customer-portal/domain/toolkit"; import { cn, formatIsoDate } from "@/shared/utils"; -const STATUS_PILL_VARIANT: Record< - "success" | "info" | "warning" | "neutral", - "success" | "info" | "warning" | "neutral" -> = { - success: "success", - info: "info", - warning: "warning", - neutral: "neutral", -}; - const CATEGORY_CONFIG: Record< OrderDisplayItemCategory, { @@ -416,9 +406,7 @@ function useDerivedOrderData(data: OrderDetails | null) { scheduledAt: data.activationScheduledAt ?? "", }) : null; - const statusPillVariant = statusDescriptor - ? STATUS_PILL_VARIANT[statusDescriptor.tone] - : STATUS_PILL_VARIANT.neutral; + const statusPillVariant = statusDescriptor?.tone ?? "neutral"; const serviceCategory = getServiceCategory(data?.orderType); const displayItems = useMemo( () => buildOrderDisplayItems(data?.itemsSummary), diff --git a/apps/portal/src/features/subscriptions/components/SubscriptionGridCard.tsx b/apps/portal/src/features/subscriptions/components/SubscriptionGridCard.tsx index 8e6c429c..f7bb14be 100644 --- a/apps/portal/src/features/subscriptions/components/SubscriptionGridCard.tsx +++ b/apps/portal/src/features/subscriptions/components/SubscriptionGridCard.tsx @@ -34,7 +34,13 @@ export function SubscriptionGridCard({ subscription, className }: SubscriptionGr const { formatCurrency } = useFormatCurrency(); const statusIndicator = mapSubscriptionStatus(subscription.status); const cycleLabel = getBillingCycleLabel(subscription.cycle); - const isInactive = ["Completed", "Cancelled", "Terminated"].includes(subscription.status); + const isInactive = ( + [ + SUBSCRIPTION_STATUS.COMPLETED, + SUBSCRIPTION_STATUS.CANCELLED, + SUBSCRIPTION_STATUS.TERMINATED, + ] as string[] + ).includes(subscription.status); return ( = 0.6" } + framer-motion@12.35.0: + resolution: + { + integrity: sha512-w8hghCMQ4oq10j6aZh3U2yeEQv5K69O/seDI/41PK4HtgkLrcBovUNc0ayBC3UyyU7V1mrY2yLzvYdWJX9pGZQ==, + } + peerDependencies: + "@emotion/is-prop-valid": "*" + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@emotion/is-prop-valid": + optional: true + react: + optional: true + react-dom: + optional: true + fresh@2.0.0: resolution: { @@ -6345,6 +6365,18 @@ packages: } hasBin: true + motion-dom@12.35.0: + resolution: + { + integrity: sha512-FFMLEnIejK/zDABn+vqGVAUN4T0+3fw+cVAY8MMT65yR+j5uMuvWdd4npACWhh94OVWQs79CrBBuwOwGRZAQiA==, + } + + motion-utils@12.29.2: + resolution: + { + integrity: sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==, + } + mrmime@2.0.1: resolution: { @@ -11509,6 +11541,15 @@ snapshots: forwarded@0.2.0: {} + framer-motion@12.35.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + motion-dom: 12.35.0 + motion-utils: 12.29.2 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + fresh@2.0.0: {} fs-extra@10.1.0: @@ -12127,6 +12168,12 @@ snapshots: dependencies: minimist: 1.2.8 + motion-dom@12.35.0: + dependencies: + motion-utils: 12.29.2 + + motion-utils@12.29.2: {} + mrmime@2.0.1: {} ms@2.1.3: {}