diff --git a/apps/portal/src/features/landing-page/hooks/useInfiniteCarousel.ts b/apps/portal/src/features/landing-page/hooks/useInfiniteCarousel.ts index 226b6186..fdfb9d48 100644 --- a/apps/portal/src/features/landing-page/hooks/useInfiniteCarousel.ts +++ b/apps/portal/src/features/landing-page/hooks/useInfiniteCarousel.ts @@ -1,19 +1,23 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useAfterPaint } from "@/shared/hooks"; const AUTO_INTERVAL = 5000; function useResponsiveCardWidth() { const [cardWidth, setCardWidth] = useState(520); const rafRef = useRef(0); + const prevWidthRef = useRef(520); useEffect(() => { const update = () => { const vw = window.innerWidth; - if (vw < 640) setCardWidth(vw - 48); - else if (vw < 1024) setCardWidth(440); - else setCardWidth(520); + const next = vw < 640 ? vw - 48 : vw < 1024 ? 440 : 520; + if (next !== prevWidthRef.current) { + prevWidthRef.current = next; + setCardWidth(next); + } }; const onResize = () => { cancelAnimationFrame(rafRef.current); @@ -30,88 +34,8 @@ function useResponsiveCardWidth() { return cardWidth; } -export function useInfiniteCarousel({ items }: { items: T[] }) { - const total = items.length; - const autoRef = useRef | null>(null); +function useCarouselInput(goPrev: () => void, goNext: () => void) { const touchXRef = useRef(0); - const cardWidth = useResponsiveCardWidth(); - const [trackIndex, setTrackIndex] = useState(1); - const [isTransitioning, setIsTransitioning] = useState(true); - - const extendedItems = useMemo(() => { - if (total === 0) return []; - return [items[total - 1]!, ...items, items[0]!]; - }, [items, total]); - - const activeIndex = (((trackIndex - 1) % total) + total) % total; - - const startAuto = useCallback(() => { - if (autoRef.current) clearInterval(autoRef.current); - autoRef.current = setInterval(() => { - setTrackIndex(prev => { - if (prev <= 0 || prev >= total + 1) return prev; - return prev + 1; - }); - setIsTransitioning(true); - }, AUTO_INTERVAL); - }, [total]); - - const stopAuto = useCallback(() => { - if (autoRef.current) { - clearInterval(autoRef.current); - autoRef.current = null; - } - }, []); - - useEffect(() => { - startAuto(); - return stopAuto; - }, [startAuto, stopAuto]); - - const handleTransitionEnd = useCallback( - (e: React.TransitionEvent) => { - // Only respond to the track's own transform transition, - // not bubbled events from child slide transitions (scale/opacity/filter) - if (e.target !== e.currentTarget || e.propertyName !== "transform") return; - - if (trackIndex >= total + 1) { - setIsTransitioning(false); - setTrackIndex(1); - } else if (trackIndex <= 0) { - setIsTransitioning(false); - setTrackIndex(total); - } - }, - [trackIndex, total] - ); - - useEffect(() => { - if (isTransitioning) return; - const id = requestAnimationFrame(() => setIsTransitioning(true)); - return () => cancelAnimationFrame(id); - }, [isTransitioning]); - - const navigate = useCallback( - (updater: number | ((prev: number) => number)) => { - setTrackIndex(prev => { - // Block navigation while at a clone position (snap-back pending) - if (prev <= 0 || prev >= total + 1) return prev; - return typeof updater === "function" ? updater(prev) : updater; - }); - setIsTransitioning(true); - startAuto(); - }, - [startAuto, total] - ); - - const goTo = useCallback((i: number) => navigate(i + 1), [navigate]); - const goPrev = useCallback(() => navigate(p => p - 1), [navigate]); - const goNext = useCallback(() => navigate(p => p + 1), [navigate]); - - const reset = useCallback(() => { - setTrackIndex(1); - setIsTransitioning(false); - }, []); const onTouchStart = useCallback((e: React.TouchEvent) => { const touch = e.touches[0]; @@ -139,6 +63,97 @@ export function useInfiniteCarousel({ items }: { items: T[] }) { [goPrev, goNext] ); + return { onTouchStart, onTouchEnd, onKeyDown }; +} + +export function useInfiniteCarousel({ items }: { items: T[] }) { + const total = items.length; + const totalRef = useRef(total); + totalRef.current = total; + + const autoRef = useRef | null>(null); + const cardWidth = useResponsiveCardWidth(); + const [trackIndex, setTrackIndex] = useState(1); + const [isTransitioning, setIsTransitioning] = useState(true); + + const extendedItems = useMemo(() => { + if (total === 0) return []; + return [items[total - 1]!, ...items, items[0]!]; + }, [items, total]); + + const activeIndex = (((trackIndex - 1) % total) + total) % total; + + const startAuto = useCallback(() => { + if (autoRef.current) clearInterval(autoRef.current); + autoRef.current = setInterval(() => { + setTrackIndex(prev => { + const t = totalRef.current; + if (prev <= 0 || prev >= t + 1) return prev; + return prev + 1; + }); + setIsTransitioning(true); + }, AUTO_INTERVAL); + }, []); + + const stopAuto = useCallback(() => { + if (autoRef.current) { + clearInterval(autoRef.current); + autoRef.current = null; + } + }, []); + + useEffect(() => { + startAuto(); + return stopAuto; + }, [startAuto, stopAuto]); + + const handleTransitionEnd = useCallback((e: React.TransitionEvent) => { + // Only respond to the track's own transform transition, + // not bubbled events from child slide transitions (scale/opacity/filter) + if (e.target !== e.currentTarget || e.propertyName !== "transform") return; + + setTrackIndex(prev => { + const t = totalRef.current; + if (prev >= t + 1) { + setIsTransitioning(false); + return 1; + } + if (prev <= 0) { + setIsTransitioning(false); + return t; + } + return prev; + }); + }, []); + + const enableTransition = useCallback(() => setIsTransitioning(true), []); + useAfterPaint(enableTransition, !isTransitioning); + + const navigate = useCallback( + (updater: number | ((prev: number) => number)) => { + setTrackIndex(prev => { + const t = totalRef.current; + // Block navigation while at a clone position (snap-back pending) + if (prev <= 0 || prev >= t + 1) return prev; + return typeof updater === "function" ? updater(prev) : updater; + }); + setIsTransitioning(true); + startAuto(); + }, + [startAuto] + ); + + const goTo = useCallback((i: number) => navigate(i + 1), [navigate]); + const goPrev = useCallback(() => navigate(p => p - 1), [navigate]); + const goNext = useCallback(() => navigate(p => p + 1), [navigate]); + + const reset = useCallback(() => { + setTrackIndex(1); + setIsTransitioning(false); + }, []); + + const inputHandlers = useCarouselInput(goPrev, goNext); + return { extendedItems, total, @@ -153,8 +168,6 @@ export function useInfiniteCarousel({ items }: { items: T[] }) { reset, startAuto, stopAuto, - onTouchStart, - onTouchEnd, - onKeyDown, + ...inputHandlers, }; } diff --git a/apps/portal/src/features/services/components/sim/SimPlansContent.tsx b/apps/portal/src/features/services/components/sim/SimPlansContent.tsx index 3ad25d98..a3f58f69 100644 --- a/apps/portal/src/features/services/components/sim/SimPlansContent.tsx +++ b/apps/portal/src/features/services/components/sim/SimPlansContent.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo } from "react"; +import { useMemo, useRef } from "react"; import { Smartphone, Check, @@ -25,7 +25,6 @@ import type { SimCatalogProduct } from "@customer-portal/domain/services"; import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink"; import { ServicesHero } from "@/features/services/components/base/ServicesHero"; import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath"; -import { CardPricing } from "@/features/services/components/base/CardPricing"; import { CollapsibleSection } from "@/features/services/components/base/CollapsibleSection"; import { ServiceFAQ, type FAQItem } from "@/features/services/components/base/ServiceFAQ"; import { DeviceCompatibility } from "./DeviceCompatibility"; @@ -163,60 +162,58 @@ function SimPlanCardCompact({ return (
- {/* Top accent stripe */} -
- -
+
{isFamily && (
- + Family Discount
)} - {/* Data size — the hero of each card */} -
+ {/* Data size - hero element */} +
- +
- {plan.simDataSize} + + {plan.simDataSize} +
- {/* Pricing */} -
- + {/* Price - prominent */} +
+
+ + ¥{displayPrice.toLocaleString()} + + /mo +
{isFamily && (
Discounted price
)}
{/* Plan name */} -

{plan.name}

+

{plan.name}

- {/* CTA */} + {/* CTA - filled button */}
{/* Plans Grid */} -
+
{regularPlans.length > 0 || familyPlans.length > 0 ? ( -
+
{regularPlans.length > 0 && (
{regularPlans.map(plan => ( diff --git a/apps/portal/src/features/services/utils/internet-config.ts b/apps/portal/src/features/services/utils/internet-config.ts index 4262d2a5..f1574428 100644 --- a/apps/portal/src/features/services/utils/internet-config.ts +++ b/apps/portal/src/features/services/utils/internet-config.ts @@ -100,9 +100,21 @@ export const PUBLIC_TIER_DESCRIPTIONS: Record = { * Public-facing tier features (shorter list for marketing) */ export const PUBLIC_TIER_FEATURES: Record = { - Silver: [FEATURE_NTT_MODEM, "Use your own router", "Email/ticket support"], - Gold: [FEATURE_NTT_MODEM, "WiFi router included", "Priority phone support"], - Platinum: [FEATURE_NTT_MODEM, "Mesh WiFi system included", "Dedicated support line"], + Silver: [ + FEATURE_NTT_MODEM, + "Two ISP connection protocols: IPoE (recommended) or PPPoE", + "Self-configuration of router (you provide your own)", + ], + Gold: [ + "NTT modem + wireless router (rental)", + "ISP (IPoE) configured automatically within 24 hours", + "Basic wireless router included", + ], + Platinum: [ + "NTT modem + Netgear INSIGHT Wi-Fi routers", + "Cloud management support for remote router management", + "Automatic updates and quicker support", + ], }; // ============================================================================ diff --git a/apps/portal/src/features/services/views/PublicInternetPlans.tsx b/apps/portal/src/features/services/views/PublicInternetPlans.tsx index d2ba81a5..1c04e2aa 100644 --- a/apps/portal/src/features/services/views/PublicInternetPlans.tsx +++ b/apps/portal/src/features/services/views/PublicInternetPlans.tsx @@ -3,6 +3,7 @@ import { useMemo, useState } from "react"; import { ArrowRight, + Check, Sparkles, Wifi, Zap, @@ -11,7 +12,6 @@ import { Wrench, Globe, ChevronDown, - ChevronUp, Home, Building, } from "lucide-react"; @@ -26,91 +26,235 @@ import { ServicesBackLink } from "@/features/services/components/base/ServicesBa import { ServicesHero } from "@/features/services/components/base/ServicesHero"; import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath"; import { Button } from "@/components/atoms/button"; -import type { TierInfo } from "@/features/services/components/internet/PublicOfferingCard"; import { ServiceHighlights, HighlightFeature, } from "@/features/services/components/base/ServiceHighlights"; import { HowItWorksSection } from "@/features/services/components/internet/HowItWorksSection"; +import { + type InternetTier, + TIER_ORDER_MAP, + getTierDescription, + getTierFeatures, +} from "@/features/services/utils/internet-config"; import { cn } from "@/shared/utils"; -// Tier styling - matching the design with left border accents +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface TierInfo { + tier: InternetTier; + monthlyPrice: number; + maxMonthlyPrice?: number; + description: string; + features: string[]; + pricingNote?: string; +} + +type OfferingKey = "all" | "home10g" | "home1g" | "apartment"; + +interface OfferingOption { + key: OfferingKey; + label: string; + shortLabel: string; + icon: React.ReactNode; +} + +// ─── Constants ──────────────────────────────────────────────────────────────── + const tierStyles = { Silver: { card: "border-border bg-card border-l-4 border-l-muted-foreground/60", accent: "text-muted-foreground", - header: "Silver", }, Gold: { card: "border-border bg-card border-l-4 border-l-amber-500", accent: "text-amber-600", - header: "Gold", }, Platinum: { card: "border-border bg-card border-l-4 border-l-primary", accent: "text-primary", - header: "Platinum", }, } as const; -/** - * Consolidated Internet Card - Single card showing all tiers with price ranges - */ -function ConsolidatedInternetCard({ - minPrice, - maxPrice, +const OFFERING_OPTIONS: OfferingOption[] = [ + { + key: "all", + label: "All Plans", + shortLabel: "All", + icon: , + }, + { + key: "home10g", + label: "Home 10Gbps", + shortLabel: "10G", + icon: , + }, + { + key: "home1g", + label: "Home 1Gbps", + shortLabel: "1G", + icon: , + }, + { + key: "apartment", + label: "Apartment", + shortLabel: "Apt", + icon: , + }, +]; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function getOfferingTypeId(offeringType: string | undefined): string { + if (!offeringType) return "home1g"; + const type = offeringType.toLowerCase(); + if (type.includes("10g") || type.includes("10gbps")) return "home10g"; + if (type.includes("apartment") || type.includes("mansion")) return "apartment"; + if (type.includes("1g") || type.includes("1gbps") || type.includes("home")) return "home1g"; + return "home1g"; +} + +// ─── Unified Internet Card ──────────────────────────────────────────────────── + +function UnifiedInternetCard({ + consolidatedTiers, + consolidatedMinPrice, + consolidatedMaxPrice, + plansByOffering, setupFee, - tiers, ctaPath, ctaLabel, onCtaClick, }: { - minPrice: number; - maxPrice: number; + consolidatedTiers: TierInfo[]; + consolidatedMinPrice: number; + consolidatedMaxPrice: number; + plansByOffering: Record; setupFee: number; - tiers: TierInfo[]; ctaPath: string; ctaLabel: string; onCtaClick?: (e: React.MouseEvent) => void; }) { + const [selectedOffering, setSelectedOffering] = useState("all"); + + // Determine which tiers + price range to show based on selection + const { displayTiers, displayMinPrice, displayMaxPrice } = useMemo(() => { + if (selectedOffering === "all") { + return { + displayTiers: consolidatedTiers, + displayMinPrice: consolidatedMinPrice, + displayMaxPrice: consolidatedMaxPrice, + }; + } + + const offeringData = plansByOffering[selectedOffering]; + if (!offeringData) { + return { + displayTiers: consolidatedTiers, + displayMinPrice: consolidatedMinPrice, + displayMaxPrice: consolidatedMaxPrice, + }; + } + + const prices = offeringData.tiers.map(t => t.monthlyPrice); + return { + displayTiers: offeringData.tiers, + displayMinPrice: Math.min(...prices), + displayMaxPrice: Math.max(...prices), + }; + }, [ + selectedOffering, + consolidatedTiers, + consolidatedMinPrice, + consolidatedMaxPrice, + plansByOffering, + ]); + + const showRange = displayMinPrice !== displayMaxPrice; + + // Only show offerings that actually have data + const availableOptions = OFFERING_OPTIONS.filter( + opt => opt.key === "all" || plansByOffering[opt.key] + ); + return (
{/* Header */}
-
-
-
- -
-
-

NTT Fiber Internet

-

Home & Apartment plans available

-
+
+
+ +
+
+

NTT Fiber Internet

+

Home & Apartment plans available

-
+
- ¥{minPrice.toLocaleString()}~{maxPrice.toLocaleString()} + ¥{displayMinPrice.toLocaleString()} + {showRange && `~${displayMaxPrice.toLocaleString()}`} /mo
-

Price varies by location & tier

+

+ {selectedOffering === "all" + ? "Price varies by location & tier" + : "Price varies by tier"} +

+ {/* Offering Selector */} +
+

Choose your connection type:

+
+
+ {availableOptions.map(option => ( + + ))} +
+
+ {selectedOffering !== "all" && ( +

+ {selectedOffering === "home10g" && "Ultra-fast fiber — available in select areas."} + {selectedOffering === "home1g" && + "High-speed fiber — the most popular choice for homes."} + {selectedOffering === "apartment" && + "For mansions and apartment buildings. Speed depends on building infrastructure."} +

+ )} + {selectedOffering === "all" && ( +

+ Select a connection type above to see exact pricing, or browse all tiers below. +

+ )} +
+ {/* Tier Cards */} -
-

Choose your service tier:

-

- Price varies based on your connection type: Home 10Gbps (select areas), Home 1Gbps, or - Apartment (up to 1Gbps depending on building infrastructure). We'll confirm - availability at your address. -

-
- {tiers.map(tier => ( +
+
+ {displayTiers.map(tier => (
- {/* Popular Badge for Gold */} {tier.tier === "Gold" && (
@@ -128,7 +271,6 @@ function ConsolidatedInternetCard({
)} - {/* Tier Name */}

- {/* Price Range */}
@@ -159,24 +300,12 @@ function ConsolidatedInternetCard({ )}
- {/* Description */}

{tier.description}

- {/* Features */}
    {tier.features.map((feature, index) => (
  • - - - + {feature}
  • ))} @@ -208,527 +337,7 @@ function ConsolidatedInternetCard({ ); } -/** - * Offering type configuration for expandable plan cards - */ -interface OfferingTypeConfig { - id: string; - title: string; - badge: string; - badgeColor: string; - description: string; - icon: React.ReactNode; -} - -const offeringTypeConfigs: OfferingTypeConfig[] = [ - { - id: "home10g", - title: "Home 10Gbps", - badge: "10Gbps", - badgeColor: "bg-primary text-white", - description: "Ultra-fast fiber with the highest speeds available in Japan.", - icon: , - }, - { - id: "home1g", - title: "Home 1Gbps", - badge: "1Gbps", - badgeColor: "bg-muted text-foreground", - description: "High-speed fiber. The most popular choice for home internet.", - icon: , - }, - { - id: "apartment", - title: "Apartment", - badge: "Up to 1Gbps", - badgeColor: "bg-muted text-foreground", - description: - "For mansions and apartment buildings. Speed depends on your building (up to 1Gbps).", - icon: , - }, -]; - -/** - * Get offering type ID from plan's internetOfferingType field - */ -function getOfferingTypeId(offeringType: string | undefined): string { - if (!offeringType) return "home1g"; - const type = offeringType.toLowerCase(); - if (type.includes("10g") || type.includes("10gbps")) return "home10g"; - if (type.includes("apartment") || type.includes("mansion")) return "apartment"; - if (type.includes("1g") || type.includes("1gbps") || type.includes("home")) return "home1g"; - return "home1g"; -} - -/** - * Plan Card Header - Compact horizontal card for offering type - */ -function PlanCardHeader({ - config, - minPrice, - isExpanded, - onToggle, -}: { - config: OfferingTypeConfig; - minPrice: number; - isExpanded: boolean; - onToggle: () => void; -}) { - return ( -
    - {/* Header - Clickable to expand/collapse */} - -
    - ); -} - -/** - * Expanded Tier Details - Shows tier cards below the header cards - */ -function ExpandedTierDetails({ - config, - tiers, - setupFee, -}: { - config: OfferingTypeConfig; - tiers: TierInfo[]; - setupFee: number; -}) { - return ( -
    - {/* Header */} -
    -
    -
    - {config.icon} -
    -
    -
    -

    {config.title}

    - - {config.badge} - - {config.id === "home10g" && ( - (select areas) - )} -
    -
    -
    -
    - - {/* Tier Cards */} -
    -
    - {tiers.map(tier => ( -
    - {/* Popular Badge for Gold */} - {tier.tier === "Gold" && ( -
    - - - Popular - -
    - )} - - {/* Tier Name */} -

    - {tier.tier} -

    - - {/* Price */} -
    -
    - - ¥{tier.monthlyPrice.toLocaleString()} - - /mo -
    - {tier.pricingNote && ( - - {tier.pricingNote} - - )} -
    - - {/* Description */} -

    {tier.description}

    - - {/* Features */} -
      - {tier.features.map((feature, index) => ( -
    • - - - - {feature} -
    • - ))} -
    -
    - ))} -
    - - {/* Footer with setup fee */} -
    -

    - - + ¥{setupFee.toLocaleString()} one-time setup - {" "} - (or 12/24-month installment) -

    -
    -
    -
    - ); -} - -/** - * Mobile Plan Card - Combined header + inline expandable details for mobile view - */ -function MobilePlanCard({ - config, - minPrice, - tiers, - setupFee, - isExpanded, - onToggle, -}: { - config: OfferingTypeConfig; - minPrice: number; - tiers: TierInfo[]; - setupFee: number; - isExpanded: boolean; - onToggle: () => void; -}) { - return ( -
    - {/* Header - Clickable to expand/collapse */} - - - {/* Inline Expanded Content - slides down below header */} -
    -
    -
    - {/* Tier Cards - Stacked vertically on mobile */} -
    - {tiers.map(tier => ( -
    - {/* Popular Badge for Gold */} - {tier.tier === "Gold" && ( -
    - - - Popular - -
    - )} - -
    -
    - {/* Tier Name */} -

    - {tier.tier} -

    - - {/* Description */} -

    {tier.description}

    -
    - - {/* Price - Right aligned */} -
    -
    - - ¥{tier.monthlyPrice.toLocaleString()} - - /mo -
    - {tier.pricingNote && ( - - {tier.pricingNote} - - )} -
    -
    - - {/* Features - Compact horizontal layout */} -
      - {tier.features.slice(0, 2).map((feature, index) => ( -
    • - - - - {feature} -
    • - ))} -
    -
    - ))} -
    - - {/* Footer with setup fee */} -
    -

    - - + ¥{setupFee.toLocaleString()} one-time setup - {" "} - (or 12/24-month installment) -

    -
    -
    -
    -
    -
    - ); -} - -/** - * Available Plans Section - Horizontal cards for each offering type with expandable details - */ -function AvailablePlansSection({ - plansByOffering, - setupFee, -}: { - plansByOffering: Record; - setupFee: number; -}) { - const [expandedCard, setExpandedCard] = useState(null); - - const toggleCard = (id: string) => { - setExpandedCard(prev => (prev === id ? null : id)); - }; - - // Get configs that have data - const availableConfigs = offeringTypeConfigs.filter(config => plansByOffering[config.id]); - - return ( -
    -

    Available Plans

    - - {/* Mobile view - Cards with inline expandable content */} -
    - {availableConfigs.map(config => { - const offeringData = plansByOffering[config.id]; - if (!offeringData) return null; - - return ( - toggleCard(config.id)} - /> - ); - })} -
    - - {/* Desktop view - Horizontal card headers with separate expanded section */} -
    -
    - {availableConfigs.map(config => { - const offeringData = plansByOffering[config.id]; - if (!offeringData) return null; - - return ( - toggleCard(config.id)} - /> - ); - })} -
    - - {/* Expanded tier details - shown below the horizontal cards (desktop only) */} -
    - {availableConfigs.map(config => { - const offeringData = plansByOffering[config.id]; - if (!offeringData || expandedCard !== config.id) return null; - - return ( - - ); - })} -
    -
    -
    - ); -} +// ─── Public Internet Plans Content ──────────────────────────────────────────── export interface PublicInternetPlansContentProps { onCtaClick?: (e: React.MouseEvent) => void; @@ -738,9 +347,6 @@ export interface PublicInternetPlansContentProps { heroDescription?: string; } -/** - * Public Internet Plans Content - Reusable component - */ export function PublicInternetPlansContent({ onCtaClick, ctaPath: propCtaPath, @@ -749,7 +355,6 @@ export function PublicInternetPlansContent({ heroDescription = "Fast, reliable fiber internet with full English support. No Japanese required", }: PublicInternetPlansContentProps) { const { data: servicesCatalog, error } = usePublicInternetCatalog(); - // Simple loading check: show skeleton until we have data or an error const isLoading = !servicesCatalog && !error; const servicesBasePath = useServicesBasePath(); const defaultCtaPath = `${servicesBasePath}/internet/check-availability`; @@ -794,23 +399,19 @@ export function PublicInternetPlansContent({ }, ]; - // Consolidated internet plans data - one card with all tiers + // Consolidated internet plans data - tiers with price ranges across all offerings const consolidatedPlanData = useMemo(() => { if (!servicesCatalog?.plans) return null; - // Get installation item for setup fee const installationItem = servicesCatalog.installations?.[0] as | InternetInstallationCatalogItem | undefined; const setupFee = installationItem?.oneTimePrice ?? 22800; - // Get all prices across all plan types to show full range const allPrices = servicesCatalog.plans.map(p => p.monthlyPrice ?? 0).filter(p => p > 0); const minPrice = Math.min(...allPrices); const maxPrice = Math.max(...allPrices); - // Get unique tiers with their price ranges across all offering types - const tierOrder: Record = { Silver: 0, Gold: 1, Platinum: 2 }; const tierData: Record< string, { minPrice: number; maxPrice: number; plans: InternetPlanCatalogItem[] } @@ -827,29 +428,22 @@ export function PublicInternetPlansContent({ tierData[tier].plans.push(plan); } - // Build consolidated tier info with price ranges const tiers: TierInfo[] = Object.entries(tierData) - .sort(([a], [b]) => (tierOrder[a] ?? 99) - (tierOrder[b] ?? 99)) + .sort(([a], [b]) => (TIER_ORDER_MAP[a] ?? 99) - (TIER_ORDER_MAP[b] ?? 99)) .map(([tier, data]) => ({ tier: tier as TierInfo["tier"], monthlyPrice: data.minPrice, maxMonthlyPrice: data.maxPrice, - description: getTierDescription(tier), - features: getTierFeatures(tier), + description: getTierDescription(tier, true), + features: getTierFeatures(tier, true), ...(tier === "Platinum" && { pricingNote: "+ equipment fees" }), })); - return { - minPrice, - maxPrice, - setupFee, - tiers, - }; + return { minPrice, maxPrice, setupFee, tiers }; }, [servicesCatalog]); // Plans grouped by offering type (Home 10G, Home 1G, Apartment) const plansByOffering = useMemo(() => { - // Default pricing for each offering type (used when catalog doesn't have specific data) const defaultOfferingPrices: Record< string, { silver: number; gold: number; platinum: number } @@ -859,7 +453,6 @@ export function PublicInternetPlansContent({ apartment: { silver: 4800, gold: 5300, platinum: 5300 }, }; - const tierOrder: Record = { Silver: 0, Gold: 1, Platinum: 2 }; const offeringData: Record< string, { @@ -868,7 +461,6 @@ export function PublicInternetPlansContent({ } > = {}; - // Process catalog plans if available if (servicesCatalog?.plans) { for (const plan of servicesCatalog.plans) { const offeringType = getOfferingTypeId(plan.internetOfferingType); @@ -881,7 +473,6 @@ export function PublicInternetPlansContent({ offeringData[offeringType].minPrice = Math.min(offeringData[offeringType].minPrice, price); - // Keep the price for this tier (use lowest if multiple plans for same tier) if ( !offeringData[offeringType].tierData[tier] || price < offeringData[offeringType].tierData[tier].price @@ -891,14 +482,13 @@ export function PublicInternetPlansContent({ } } - // Ensure all three offering types exist with default data - for (const config of offeringTypeConfigs) { - const defaults = defaultOfferingPrices[config.id]; + const offeringKeys = ["home10g", "home1g", "apartment"]; + for (const configId of offeringKeys) { + const defaults = defaultOfferingPrices[configId]; if (!defaults) continue; - const existingData = offeringData[config.id]; + const existingData = offeringData[configId]; if (existingData) { - // Fill in missing tiers with defaults if (!existingData.tierData["Silver"]) { existingData.tierData["Silver"] = { price: defaults.silver }; } @@ -908,11 +498,10 @@ export function PublicInternetPlansContent({ if (!existingData.tierData["Platinum"]) { existingData.tierData["Platinum"] = { price: defaults.platinum }; } - // Recalculate min price const allPrices = Object.values(existingData.tierData).map(t => t.price); existingData.minPrice = Math.min(...allPrices); } else { - offeringData[config.id] = { + offeringData[configId] = { minPrice: defaults.silver, tierData: { Silver: { price: defaults.silver }, @@ -923,30 +512,25 @@ export function PublicInternetPlansContent({ } } - // Convert to the format expected by AvailablePlansSection const result: Record = {}; for (const [offeringType, data] of Object.entries(offeringData)) { const tiers: TierInfo[] = Object.entries(data.tierData) - .sort(([a], [b]) => (tierOrder[a] ?? 99) - (tierOrder[b] ?? 99)) + .sort(([a], [b]) => (TIER_ORDER_MAP[a] ?? 99) - (TIER_ORDER_MAP[b] ?? 99)) .map(([tier, tierInfo]) => ({ tier: tier as TierInfo["tier"], monthlyPrice: tierInfo.price, - description: getTierDescription(tier), - features: getTierFeatures(tier), + description: getTierDescription(tier, true), + features: getTierFeatures(tier, true), ...(tier === "Platinum" && { pricingNote: "+ equipment fees" }), })); - result[offeringType] = { - minPrice: data.minPrice, - tiers, - }; + result[offeringType] = { minPrice: data.minPrice, tiers }; } return result; }, [servicesCatalog]); - // Error state if (error) { return (
    @@ -960,25 +544,23 @@ export function PublicInternetPlansContent({ return (
    - {/* Back link */} - {/* Hero - Clean and impactful */} - {/* Service Highlights */} - {/* Consolidated Internet Plans Card */} -
    + {/* Unified Internet Plans Card */} +
    {isLoading ? ( ) : consolidatedPlanData ? ( - - {/* Available Plans - Expandable cards by offering type */} - {!isLoading && plansByOffering && consolidatedPlanData && ( - - )} - - {/* How It Works Section */} - {/* FAQ Section */} - {/* Final CTA - Polished */} + {/* Final CTA */}
    @@ -1025,45 +597,14 @@ export function PublicInternetPlansContent({ } /** - * Public Internet Plans page - Marketing/Conversion focused - * Clean, polished design optimized for conversion + * Public Internet Plans page */ export function PublicInternetPlansView() { return ; } -// Helper functions -function getTierDescription(tier: string): string { - const descriptions: Record = { - Silver: "Use your own router. Best for tech-savvy users.", - Gold: "Includes WiFi router rental. Our most popular choice.", - Platinum: "Premium equipment with mesh WiFi for larger homes.", - }; - return descriptions[tier] ?? ""; -} +// ─── FAQ ────────────────────────────────────────────────────────────────────── -function getTierFeatures(tier: string): string[] { - const features: Record = { - Silver: [ - "NTT modem + ISP connection", - "Two ISP connection protocols: IPoE (recommended) or PPPoE", - "Self-configuration of router (you provide your own)", - ], - Gold: [ - "NTT modem + wireless router (rental)", - "ISP (IPoE) configured automatically within 24 hours", - "Basic wireless router included", - ], - Platinum: [ - "NTT modem + Netgear INSIGHT Wi-Fi routers", - "Cloud management support for remote router management", - "Automatic updates and quicker support", - ], - }; - return features[tier] ?? []; -} - -// Internet FAQ data const INTERNET_FAQ_ITEMS = [ { question: "How can I check if the 10Gbps service is available in my apartment or home?", @@ -1099,9 +640,6 @@ const INTERNET_FAQ_ITEMS = [ }, ]; -/** - * Internet FAQ Section - */ function InternetFaqSection() { return (
    @@ -1117,9 +655,6 @@ function InternetFaqSection() { ); } -/** - * FAQ Item component with expand/collapse functionality - */ function FaqItem({ question, answer }: { question: string; answer: string }) { const [isOpen, setIsOpen] = useState(false); diff --git a/apps/portal/src/shared/hooks/index.ts b/apps/portal/src/shared/hooks/index.ts index 8de54e79..d52b2d66 100644 --- a/apps/portal/src/shared/hooks/index.ts +++ b/apps/portal/src/shared/hooks/index.ts @@ -5,3 +5,4 @@ export { useZodForm } from "./useZodForm"; export { useCurrency } from "./useCurrency"; export { useFormatCurrency, type FormatCurrencyOptions } from "./useFormatCurrency"; export { useCountUp } from "./useCountUp"; +export { useAfterPaint } from "./useAfterPaint"; diff --git a/apps/portal/src/shared/hooks/useAfterPaint.ts b/apps/portal/src/shared/hooks/useAfterPaint.ts new file mode 100644 index 00000000..bee1a651 --- /dev/null +++ b/apps/portal/src/shared/hooks/useAfterPaint.ts @@ -0,0 +1,25 @@ +"use client"; + +import { useEffect } from "react"; + +/** + * Schedules a callback after the browser has painted, using a double-rAF. + * The first frame lets the browser commit the current DOM state, + * the second frame runs the callback after that paint is on screen. + * + * Useful for re-enabling CSS transitions after an instant DOM snap. + */ +export function useAfterPaint(callback: () => void, enabled: boolean) { + useEffect(() => { + if (!enabled) return; + let id1 = 0; + let id2 = 0; + id1 = requestAnimationFrame(() => { + id2 = requestAnimationFrame(callback); + }); + return () => { + cancelAnimationFrame(id1); + cancelAnimationFrame(id2); + }; + }, [enabled, callback]); +} diff --git a/apps/portal/src/styles/utilities.css b/apps/portal/src/styles/utilities.css index 8c261a96..c0502a23 100644 --- a/apps/portal/src/styles/utilities.css +++ b/apps/portal/src/styles/utilities.css @@ -97,6 +97,28 @@ } } +@keyframes cp-slide-fade-left { + from { + opacity: 0; + transform: translateX(24px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes cp-slide-fade-right { + from { + opacity: 0; + transform: translateX(-24px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + @keyframes cp-float { 0%, 100% { @@ -220,6 +242,15 @@ animation-delay: calc(var(--cp-stagger-5) + 50ms); } + /* ===== TAB SLIDE TRANSITIONS ===== */ + .cp-slide-fade-left { + animation: cp-slide-fade-left 300ms var(--cp-ease-out) forwards; + } + + .cp-slide-fade-right { + animation: cp-slide-fade-right 300ms var(--cp-ease-out) forwards; + } + /* ===== CARD HOVER LIFT ===== */ .cp-card-hover-lift { transition: @@ -642,6 +673,8 @@ .cp-animate-slide-left, .cp-stagger-children > *, .cp-card-hover-lift, + .cp-slide-fade-left, + .cp-slide-fade-right, .cp-toast-enter, .cp-toast-exit, .cp-activity-item, diff --git a/docs/plans/2026-03-05-internet-sim-page-redesign.md b/docs/plans/2026-03-05-internet-sim-page-redesign.md new file mode 100644 index 00000000..900f33e8 --- /dev/null +++ b/docs/plans/2026-03-05-internet-sim-page-redesign.md @@ -0,0 +1,363 @@ +# Internet & SIM Service Page Redesign - Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Redesign the public internet page to use a unified card with offering selector, and improve SIM page tab transitions + plan card design. + +**Architecture:** Two independent UI refactors. Internet page merges ConsolidatedInternetCard + AvailablePlansSection into one component with a segmented control for offering type. SIM page adds direction-aware slide transitions and richer plan cards. + +**Tech Stack:** React 19, Tailwind CSS, shadcn/ui, lucide-react icons + +--- + +## Task 1: Internet - Build Unified Internet Card with Offering Selector + +**Files:** + +- Modify: `apps/portal/src/features/services/views/PublicInternetPlans.tsx` + +**Context:** This file currently has two separate sections: + +1. `ConsolidatedInternetCard` (lines 59-209) - shows price range + 3 tier cards +2. `AvailablePlansSection` (lines 653-731) - 3 expandable offering headers that open tier details below +3. Helper components: `PlanCardHeader` (266-340), `ExpandedTierDetails` (345-465), `MobilePlanCard` (470-648) + +The goal is to merge these into ONE card with a segmented offering type selector. + +**Step 1: Replace ConsolidatedInternetCard with UnifiedInternetCard** + +Delete the `ConsolidatedInternetCard` component (lines 59-209) and replace with a new `UnifiedInternetCard` component that: + +1. Accepts both `consolidatedPlanData` (for "All Plans" price ranges) AND `plansByOffering` (for per-offering exact prices) +2. Has internal state: `selectedOffering: "all" | "home10g" | "home1g" | "apartment"` +3. Header section: + - Same Wifi icon + "NTT Fiber Internet" title + - Price display that updates based on `selectedOffering`: + - "all": shows full min~max range + - specific offering: shows that offering's min~max (will often be exact price) +4. Segmented control below header: + - Pills: "All Plans" | "Home 10G" | "Home 1G" | "Apartment" + - Active pill: `bg-card text-foreground shadow-sm` + - Inactive: `text-muted-foreground hover:text-foreground` + - Container: `bg-muted/60 p-0.5 rounded-lg border border-border/60` (matches SIM tab style) + - On mobile: horizontally scrollable with `overflow-x-auto` + - Each pill shows the offering icon (Home/Building) and optional speed badge +5. Tier cards grid (same 3 columns: Silver, Gold, Platinum): + - When "All Plans": show price ranges per tier (current behavior from consolidated card) + - When specific offering selected: show exact prices for that offering's tiers + - Wrap the grid in a container with `transition-opacity duration-200` for smooth price updates +6. Footer: same setup fee + CTA button + +**Step 2: Remove obsolete components** + +Delete from `PublicInternetPlans.tsx`: + +- `PlanCardHeader` component (lines 266-340) +- `ExpandedTierDetails` component (lines 345-465) +- `MobilePlanCard` component (lines 470-648) +- `AvailablePlansSection` component (lines 653-731) + +**Step 3: Update PublicInternetPlansContent render** + +In the `PublicInternetPlansContent` component's return JSX (starts line 961): + +- Replace the `ConsolidatedInternetCard` usage (lines 973-987) with `UnifiedInternetCard` +- Pass it both `consolidatedPlanData` and `plansByOffering` +- Remove the `AvailablePlansSection` usage (lines 989-995) entirely + +**Step 4: Verify and commit** + +```bash +cd /home/barsa/projects/customer_portal/customer-portal +pnpm type-check +pnpm lint +``` + +Expected: No type errors or lint issues. + +```bash +git add apps/portal/src/features/services/views/PublicInternetPlans.tsx +git commit -m "feat: unified internet card with offering type selector" +``` + +--- + +## Task 2: Internet - Clean Up Unused PublicOfferingCard + +**Files:** + +- Check: `apps/portal/src/features/services/components/internet/PublicOfferingCard.tsx` + +**Context:** After Task 1, the `TierInfo` type is still imported from `PublicOfferingCard.tsx` in `PublicInternetPlans.tsx`. The `PublicOfferingCard` component itself is no longer used. + +**Step 1: Move TierInfo type inline** + +In `PublicInternetPlans.tsx`, the import `type { TierInfo } from "@/features/services/components/internet/PublicOfferingCard"` needs to change. Define `TierInfo` directly in `PublicInternetPlans.tsx`: + +```typescript +interface TierInfo { + tier: "Silver" | "Gold" | "Platinum"; + monthlyPrice: number; + maxMonthlyPrice?: number; + description: string; + features: string[]; + pricingNote?: string; +} +``` + +Remove the import of `TierInfo` from `PublicOfferingCard`. + +**Step 2: Check if PublicOfferingCard is imported anywhere else** + +Search for all imports of `PublicOfferingCard` across the codebase. If no other files import it, it can be deleted. If other files import only `TierInfo`, move the type to a shared location or inline it. + +**Step 3: Verify and commit** + +```bash +pnpm type-check +pnpm lint +git add -A +git commit -m "refactor: inline TierInfo type, remove unused PublicOfferingCard" +``` + +--- + +## Task 3: SIM - Direction-Aware Tab Transitions + +**Files:** + +- Modify: `apps/portal/src/features/services/components/sim/SimPlansContent.tsx` + +**Context:** The tab switcher is at lines 348-372. The plans grid is at lines 374-410. Currently uses `animate-in fade-in duration-300` on the grid wrapper (line 377). The `SIM_TABS` array (lines 86-108) defines tab order: data-voice (0), data-only (1), voice-only (2). + +**Step 1: Add transition state tracking** + +In `SimPlansContent` component, add state to track slide direction: + +```typescript +import { useMemo, useRef } from "react"; + +// Inside SimPlansContent: +const prevTabRef = useRef(activeTab); +const slideDirection = useRef<"left" | "right">("left"); + +// Update direction when tab changes +if (prevTabRef.current !== activeTab) { + const tabKeys = SIM_TABS.map(t => t.key); + const prevIndex = tabKeys.indexOf(prevTabRef.current); + const nextIndex = tabKeys.indexOf(activeTab); + slideDirection.current = nextIndex > prevIndex ? "left" : "right"; + prevTabRef.current = activeTab; +} +``` + +**Step 2: Replace the grid animation** + +Replace the current `animate-in fade-in duration-300` on line 377 with a keyed wrapper that triggers CSS animation: + +```tsx +
    +``` + +**Step 3: Add the CSS animations** + +Check if `tailwind.config.ts` is in `apps/portal/` and add custom keyframes: + +```javascript +// In tailwind.config.ts extend.keyframes: +"slide-fade-left": { + "0%": { opacity: "0", transform: "translateX(24px)" }, + "100%": { opacity: "1", transform: "translateX(0)" }, +}, +"slide-fade-right": { + "0%": { opacity: "0", transform: "translateX(-24px)" }, + "100%": { opacity: "1", transform: "translateX(0)" }, +}, + +// In extend.animation: +"slide-fade-left": "slide-fade-left 300ms ease-out", +"slide-fade-right": "slide-fade-right 300ms ease-out", +``` + +**Step 4: Add overflow-hidden to the container** + +On the plans grid container (`
    ` at line 375), add `overflow-hidden` to prevent horizontal scrollbar during slide: + +```tsx +
    +``` + +**Step 5: Verify and commit** + +```bash +pnpm type-check +pnpm lint +git add -A +git commit -m "feat: direction-aware slide transitions for SIM tab switching" +``` + +--- + +## Task 4: SIM - Redesign Plan Cards + +**Files:** + +- Modify: `apps/portal/src/features/services/components/sim/SimPlansContent.tsx` + +**Context:** `SimPlanCardCompact` is defined at lines 152-229. Currently has: + +- Thin 0.5px top accent stripe (line 173-180) +- Small signal icon (w-4 h-4) in a 9x9 box + data size in text-lg (line 193-203) +- Small pricing via CardPricing component (line 207) +- Plan name in text-xs (line 214) +- Outline "Select Plan" button (line 217-225) + +**Step 1: Redesign the card** + +Replace the entire `SimPlanCardCompact` function (lines 152-229) with an enhanced version: + +Key changes: + +1. **Remove thin accent stripe** - replace with subtle gradient overlay at top +2. **Data size hero**: Bump from `text-lg` to `text-2xl font-bold`, make it the visual centerpiece +3. **Signal icon**: Keep but make slightly larger (w-5 h-5) in a bigger container +4. **Price**: Show directly as `text-xl font-bold` with yen symbol, more prominent than current CardPricing +5. **Plan name**: Keep as subtitle in `text-xs text-muted-foreground` +6. **Hover effect**: Add `hover:-translate-y-0.5 hover:shadow-lg` for lift effect +7. **Button**: Change from `variant="outline"` to filled `variant="default"` for regular, `variant="success"` for family +8. **Background**: Add subtle gradient - `bg-gradient-to-br from-sky-50/50 to-transparent` for regular (dark mode: `dark:from-sky-950/20`), emerald variant for family +9. **Border**: Slightly thicker on hover with primary color + +```tsx +function SimPlanCardCompact({ + plan, + isFamily, + onSelect, +}: { + plan: SimCatalogProduct; + isFamily?: boolean; + onSelect: (sku: string) => void; +}) { + const displayPrice = plan.monthlyPrice ?? plan.unitPrice ?? plan.oneTimePrice ?? 0; + + return ( +
    +
    + {isFamily && ( +
    + + + Family Discount + +
    + )} + + {/* Data size - hero element */} +
    +
    + +
    + + {plan.simDataSize} + +
    + + {/* Price - prominent */} +
    +
    + + ¥{displayPrice.toLocaleString()} + + /mo +
    + {isFamily && ( +
    Discounted price
    + )} +
    + + {/* Plan name */} +

    {plan.name}

    + + {/* CTA - filled button */} + +
    +
    + ); +} +``` + +**Step 2: Verify the Button `success` variant exists** + +Check `apps/portal/src/components/atoms/button.tsx` for available variants. If `success` doesn't exist, use `variant="default"` with a custom className for family cards: `className="w-full bg-success hover:bg-success/90 text-success-foreground"`. + +**Step 3: Verify and commit** + +```bash +pnpm type-check +pnpm lint +git add apps/portal/src/features/services/components/sim/SimPlansContent.tsx +git commit -m "feat: redesign SIM plan cards with improved visual hierarchy" +``` + +--- + +## Task 5: Visual QA and Polish + +**Step 1: Run dev server and check both pages** + +```bash +pnpm --filter @customer-portal/portal dev +``` + +Check in browser: + +- `/services/internet` - Verify unified card, offering selector, price updates +- `/services/sim` - Verify tab transitions, card design +- Test mobile responsiveness (Chrome DevTools responsive mode) +- Test dark mode if supported + +**Step 2: Fix any visual issues found during QA** + +Common things to check: + +- Segmented control alignment on mobile +- Price animation smoothness +- Slide transition not causing layout shift +- Card hover effects working +- Dark mode color contrast + +**Step 3: Final commit** + +```bash +git add -A +git commit -m "style: polish internet and SIM service page redesign" +```