refactor: enhance infinite carousel functionality and styling
- Improved the useResponsiveCardWidth hook to prevent unnecessary state updates by tracking previous width. - Introduced a new useCarouselInput hook to handle touch and keyboard navigation, streamlining event management. - Updated the useInfiniteCarousel hook to utilize a ref for total items, enhancing performance and reliability. - Enhanced styling for the SimPlansContent component, including improved layout and hover effects for better user experience. - Added slide transition animations for tab changes in the PublicInternetPlans view, improving visual feedback during interactions. - Updated internet tier descriptions and features for clarity and consistency across the application.
This commit is contained in:
parent
7125f79baa
commit
cde418b81e
@ -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<T>({ items }: { items: T[] }) {
|
||||
const total = items.length;
|
||||
const autoRef = useRef<ReturnType<typeof setInterval> | 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<T>({ items }: { items: T[] }) {
|
||||
[goPrev, goNext]
|
||||
);
|
||||
|
||||
return { onTouchStart, onTouchEnd, onKeyDown };
|
||||
}
|
||||
|
||||
export function useInfiniteCarousel<T>({ items }: { items: T[] }) {
|
||||
const total = items.length;
|
||||
const totalRef = useRef(total);
|
||||
totalRef.current = total;
|
||||
|
||||
const autoRef = useRef<ReturnType<typeof setInterval> | 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<T>({ items }: { items: T[] }) {
|
||||
reset,
|
||||
startAuto,
|
||||
stopAuto,
|
||||
onTouchStart,
|
||||
onTouchEnd,
|
||||
onKeyDown,
|
||||
...inputHandlers,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative bg-card rounded-xl overflow-hidden transition-all duration-200",
|
||||
"group relative rounded-xl overflow-hidden transition-all duration-200",
|
||||
"hover:-translate-y-0.5 hover:shadow-lg",
|
||||
isFamily
|
||||
? "border border-success/40 hover:border-success hover:shadow-md"
|
||||
: "border border-border hover:border-primary/40 hover:shadow-md"
|
||||
? "border border-success/30 bg-gradient-to-br from-emerald-50/60 to-card dark:from-emerald-950/20 dark:to-card hover:border-success"
|
||||
: "border border-border bg-gradient-to-br from-sky-50/40 to-card dark:from-sky-950/15 dark:to-card hover:border-primary/50"
|
||||
)}
|
||||
>
|
||||
{/* Top accent stripe */}
|
||||
<div
|
||||
className={cn(
|
||||
"h-0.5 w-full",
|
||||
isFamily
|
||||
? "bg-gradient-to-r from-emerald-500 to-teal-500"
|
||||
: "bg-gradient-to-r from-sky-500 to-blue-500"
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="p-4">
|
||||
<div className="p-5">
|
||||
{isFamily && (
|
||||
<div className="flex items-center gap-1.5 mb-3">
|
||||
<Users className="w-3.5 h-3.5 text-success" />
|
||||
<span className="text-[10px] font-semibold text-success uppercase tracking-wide">
|
||||
<span className="text-[10px] font-semibold text-success uppercase tracking-wider">
|
||||
Family Discount
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Data size — the hero of each card */}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
{/* Data size - hero element */}
|
||||
<div className="flex items-center gap-2.5 mb-4">
|
||||
<div
|
||||
className={cn(
|
||||
"w-9 h-9 rounded-lg flex items-center justify-center",
|
||||
"w-10 h-10 rounded-xl flex items-center justify-center",
|
||||
isFamily ? "bg-success/10" : "bg-primary/8"
|
||||
)}
|
||||
>
|
||||
<Signal className={cn("w-4 h-4", isFamily ? "text-success" : "text-primary")} />
|
||||
<Signal className={cn("w-5 h-5", isFamily ? "text-success" : "text-primary")} />
|
||||
</div>
|
||||
<span className="text-lg font-bold text-foreground">{plan.simDataSize}</span>
|
||||
<span className="text-2xl font-bold text-foreground tracking-tight">
|
||||
{plan.simDataSize}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Pricing */}
|
||||
<div className="mb-3">
|
||||
<CardPricing monthlyPrice={displayPrice} size="sm" alignment="left" />
|
||||
{/* Price - prominent */}
|
||||
<div className="mb-1">
|
||||
<div className="flex items-baseline gap-0.5">
|
||||
<span className="text-xl font-bold text-foreground">
|
||||
¥{displayPrice.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">/mo</span>
|
||||
</div>
|
||||
{isFamily && (
|
||||
<div className="text-[10px] text-success font-medium mt-0.5">Discounted price</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Plan name */}
|
||||
<p className="text-xs text-muted-foreground mb-4 line-clamp-2">{plan.name}</p>
|
||||
<p className="text-xs text-muted-foreground mb-4 line-clamp-2 min-h-[2rem]">{plan.name}</p>
|
||||
|
||||
{/* CTA */}
|
||||
{/* CTA - filled button */}
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
className={cn("w-full", isFamily && "bg-success hover:bg-success/90 text-white")}
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => onSelect(plan.sku)}
|
||||
rightIcon={<ArrowRight className="w-3.5 h-3.5" />}
|
||||
@ -247,6 +244,18 @@ export function SimPlansContent({
|
||||
}) {
|
||||
const servicesBasePath = useServicesBasePath();
|
||||
|
||||
// Track slide direction for tab transitions
|
||||
const prevTabRef = useRef(activeTab);
|
||||
const slideDirection = useRef<"left" | "right">("left");
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const simPlans: SimCatalogProduct[] = useMemo(() => plans ?? [], [plans]);
|
||||
const hasExistingSim = useMemo(() => simPlans.some(p => p.simHasFamilyDiscount), [simPlans]);
|
||||
|
||||
@ -304,12 +313,12 @@ export function SimPlansContent({
|
||||
);
|
||||
|
||||
const getCurrentPlans = () => {
|
||||
const tabPlans =
|
||||
activeTab === "data-voice"
|
||||
? plansByType.DataSmsVoice
|
||||
: activeTab === "data-only"
|
||||
? plansByType.DataOnly
|
||||
: plansByType.VoiceOnly;
|
||||
const tabPlanMap: Record<SimPlansTab, SimCatalogProduct[]> = {
|
||||
"data-voice": plansByType.DataSmsVoice,
|
||||
"data-only": plansByType.DataOnly,
|
||||
"voice-only": plansByType.VoiceOnly,
|
||||
};
|
||||
const tabPlans = tabPlanMap[activeTab];
|
||||
|
||||
const regularPlans = tabPlans.filter(p => !p.simHasFamilyDiscount);
|
||||
const familyPlans = tabPlans.filter(p => p.simHasFamilyDiscount);
|
||||
@ -372,9 +381,15 @@ export function SimPlansContent({
|
||||
</div>
|
||||
|
||||
{/* Plans Grid */}
|
||||
<div id="plans" className="min-h-[280px]">
|
||||
<div id="plans" className="min-h-[280px] overflow-hidden">
|
||||
{regularPlans.length > 0 || familyPlans.length > 0 ? (
|
||||
<div className="space-y-8 animate-in fade-in duration-300">
|
||||
<div
|
||||
key={activeTab}
|
||||
className={cn(
|
||||
"space-y-8",
|
||||
slideDirection.current === "left" ? "cp-slide-fade-left" : "cp-slide-fade-right"
|
||||
)}
|
||||
>
|
||||
{regularPlans.length > 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{regularPlans.map(plan => (
|
||||
|
||||
@ -100,9 +100,21 @@ export const PUBLIC_TIER_DESCRIPTIONS: Record<InternetTier, string> = {
|
||||
* Public-facing tier features (shorter list for marketing)
|
||||
*/
|
||||
export const PUBLIC_TIER_FEATURES: Record<InternetTier, string[]> = {
|
||||
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",
|
||||
],
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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";
|
||||
|
||||
25
apps/portal/src/shared/hooks/useAfterPaint.ts
Normal file
25
apps/portal/src/shared/hooks/useAfterPaint.ts
Normal file
@ -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]);
|
||||
}
|
||||
@ -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,
|
||||
|
||||
363
docs/plans/2026-03-05-internet-sim-page-redesign.md
Normal file
363
docs/plans/2026-03-05-internet-sim-page-redesign.md
Normal file
@ -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
|
||||
<div
|
||||
key={activeTab}
|
||||
className={cn(
|
||||
"space-y-8",
|
||||
slideDirection.current === "left"
|
||||
? "animate-slide-fade-left"
|
||||
: "animate-slide-fade-right"
|
||||
)}
|
||||
>
|
||||
```
|
||||
|
||||
**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 (`<div id="plans" className="min-h-[280px]">` at line 375), add `overflow-hidden` to prevent horizontal scrollbar during slide:
|
||||
|
||||
```tsx
|
||||
<div id="plans" className="min-h-[280px] overflow-hidden">
|
||||
```
|
||||
|
||||
**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 (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative rounded-xl overflow-hidden transition-all duration-200",
|
||||
"hover:-translate-y-0.5 hover:shadow-lg",
|
||||
isFamily
|
||||
? "border border-success/30 bg-gradient-to-br from-emerald-50/60 to-card dark:from-emerald-950/20 dark:to-card hover:border-success"
|
||||
: "border border-border bg-gradient-to-br from-sky-50/40 to-card dark:from-sky-950/15 dark:to-card hover:border-primary/50"
|
||||
)}
|
||||
>
|
||||
<div className="p-5">
|
||||
{isFamily && (
|
||||
<div className="flex items-center gap-1.5 mb-3">
|
||||
<Users className="w-3.5 h-3.5 text-success" />
|
||||
<span className="text-[10px] font-semibold text-success uppercase tracking-wider">
|
||||
Family Discount
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Data size - hero element */}
|
||||
<div className="flex items-center gap-2.5 mb-4">
|
||||
<div
|
||||
className={cn(
|
||||
"w-10 h-10 rounded-xl flex items-center justify-center",
|
||||
isFamily ? "bg-success/10" : "bg-primary/8"
|
||||
)}
|
||||
>
|
||||
<Signal className={cn("w-5 h-5", isFamily ? "text-success" : "text-primary")} />
|
||||
</div>
|
||||
<span className="text-2xl font-bold text-foreground tracking-tight">
|
||||
{plan.simDataSize}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Price - prominent */}
|
||||
<div className="mb-1">
|
||||
<div className="flex items-baseline gap-0.5">
|
||||
<span className="text-xl font-bold text-foreground">
|
||||
¥{displayPrice.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">/mo</span>
|
||||
</div>
|
||||
{isFamily && (
|
||||
<div className="text-[10px] text-success font-medium mt-0.5">Discounted price</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Plan name */}
|
||||
<p className="text-xs text-muted-foreground mb-4 line-clamp-2 min-h-[2rem]">{plan.name}</p>
|
||||
|
||||
{/* CTA - filled button */}
|
||||
<Button
|
||||
className="w-full"
|
||||
variant={isFamily ? "success" : "default"}
|
||||
size="sm"
|
||||
onClick={() => onSelect(plan.sku)}
|
||||
rightIcon={<ArrowRight className="w-3.5 h-3.5" />}
|
||||
>
|
||||
Select Plan
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**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"
|
||||
```
|
||||
Loading…
x
Reference in New Issue
Block a user