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:
barsa 2026-03-05 11:18:34 +09:00
parent 7125f79baa
commit cde418b81e
8 changed files with 797 additions and 800 deletions

View File

@ -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,
};
}

View File

@ -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 => (

View File

@ -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",
],
};
// ============================================================================

View File

@ -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";

View 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]);
}

View File

@ -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,

View 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"
```