Enhance UI components and improve accessibility across catalog features

- Updated AddonGroup to include transition effects for better user experience.
- Refactored AddressConfirmation to streamline button layout and improve accessibility.
- Enhanced FeatureCard and ServiceHeroCard with updated styling and layout for consistency.
- Improved InstallationOptions to support better pricing display and accessibility features.
- Refactored InternetConfigureView to utilize props for cleaner state management.
- Updated various components to improve responsiveness and visual clarity.
- Enhanced error handling and logging in checkout processes for better user feedback.
This commit is contained in:
barsa 2025-10-22 16:10:42 +09:00
parent 67bad7819a
commit b3086a5593
23 changed files with 618 additions and 457 deletions

View File

@ -189,7 +189,7 @@ export function AddonGroup({
})}
{selectedAddonSkus.length === 0 && (
<div className="text-center py-4 text-gray-500">
<div className="text-center py-4 text-gray-500 transition-all duration-300 animate-in fade-in">
<p>Select add-ons to enhance your service</p>
</div>
)}

View File

@ -407,30 +407,28 @@ export function AddressConfirmation({
)}
{/* Action buttons */}
<div className="flex items-center justify-between pt-4 border-t border-gray-100">
<div className="flex items-center gap-3">
{/* Primary action when pending for Internet orders */}
{isInternetOrder && !addressConfirmed && !editing && (
<Button
type="button"
onClick={handleConfirmAddress}
onKeyDown={e => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
}
}}
>
Confirm Address
</Button>
)}
</div>
<div className="flex items-center gap-3 pt-4 border-t border-gray-100">
{/* Primary action when pending for Internet orders */}
{isInternetOrder && !addressConfirmed && !editing && (
<Button
type="button"
onClick={handleConfirmAddress}
onKeyDown={e => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
}
}}
>
Confirm Address
</Button>
)}
{/* Edit button - always on the right */}
{/* Edit button */}
{billingInfo.isComplete && !editing && (
<Button type="button" variant="outline" size="sm" onClick={handleEdit}>
<PencilIcon className="h-4 w-4 mr-1" />
Edit Address
<PencilIcon className="h-4 w-4" />
<span className="ml-1.5">Edit Address</span>
</Button>
)}
</div>

View File

@ -0,0 +1,46 @@
"use client";
export type BadgeVariant =
| "gold"
| "platinum"
| "silver"
| "recommended"
| "family"
| "new"
| "default";
interface CardBadgeProps {
text: string;
variant?: BadgeVariant;
size?: "sm" | "md";
}
export function CardBadge({ text, variant = "default", size = "md" }: CardBadgeProps) {
const getVariantClasses = () => {
switch (variant) {
case "gold":
return "bg-yellow-50 text-yellow-700 border-yellow-200";
case "platinum":
return "bg-indigo-50 text-indigo-700 border-indigo-200";
case "silver":
return "bg-gray-50 text-gray-700 border-gray-200";
case "recommended":
return "bg-green-50 text-green-700 border-green-200";
case "family":
return "bg-blue-50 text-blue-700 border-blue-200";
case "new":
return "bg-purple-50 text-purple-700 border-purple-200";
default:
return "bg-gray-50 text-gray-700 border-gray-200";
}
};
const sizeClasses = size === "sm" ? "text-xs px-2 py-0.5" : "text-xs px-2.5 py-1";
return (
<span className={`${sizeClasses} rounded-full font-medium border ${getVariantClasses()}`}>
{text}
</span>
);
}

View File

@ -0,0 +1,78 @@
"use client";
import { CurrencyYenIcon } from "@heroicons/react/24/outline";
interface CardPricingProps {
monthlyPrice?: number | null;
oneTimePrice?: number | null;
size?: "sm" | "md" | "lg";
alignment?: "left" | "right";
}
export function CardPricing({
monthlyPrice,
oneTimePrice,
size = "md",
alignment = "right"
}: CardPricingProps) {
const sizeClasses = {
sm: {
monthlyPrice: "text-xl",
monthlyLabel: "text-xs",
icon: "h-5 w-5",
oneTimePrice: "text-sm",
oneTimeLabel: "text-xs",
},
md: {
monthlyPrice: "text-2xl",
monthlyLabel: "text-sm",
icon: "h-6 w-6",
oneTimePrice: "text-base",
oneTimeLabel: "text-xs",
},
lg: {
monthlyPrice: "text-3xl",
monthlyLabel: "text-base",
icon: "h-7 w-7",
oneTimePrice: "text-lg",
oneTimeLabel: "text-sm",
},
};
const alignClass = alignment === "right" ? "text-right" : "text-left";
const justifyClass = alignment === "right" ? "justify-end" : "justify-start";
if (!monthlyPrice && !oneTimePrice) {
return null;
}
const classes = sizeClasses[size];
return (
<div className={`flex-shrink-0 ${alignClass}`}>
{monthlyPrice && monthlyPrice > 0 && (
<div className={`flex items-baseline gap-1 ${justifyClass}`}>
<CurrencyYenIcon className={`${classes.icon} text-gray-600`} />
<span className={`${classes.monthlyPrice} font-bold text-gray-900`}>
{monthlyPrice.toLocaleString()}
</span>
<span className={`${classes.monthlyLabel} text-gray-500 font-normal`}>
/month
</span>
</div>
)}
{oneTimePrice && oneTimePrice > 0 && (
<div className={`flex items-baseline gap-1 ${justifyClass} ${monthlyPrice ? "mt-1" : ""}`}>
<CurrencyYenIcon className={`h-4 w-4 text-orange-600`} />
<span className={`${classes.oneTimePrice} font-semibold text-orange-600`}>
{oneTimePrice.toLocaleString()}
</span>
<span className={`${classes.oneTimeLabel} text-orange-500`}>
one-time
</span>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,12 @@
export { CardPricing } from "./CardPricing";
export { CardBadge } from "./CardBadge";
export type { BadgeVariant } from "./CardBadge";
export { ProductCard } from "./ProductCard";
export type { ProductCardProps } from "./ProductCard";
export { CatalogHero } from "./CatalogHero";
export type { CatalogHeroProps } from "./CatalogHero";
export { CatalogBackLink } from "./CatalogBackLink";
export { OrderSummary } from "./OrderSummary";
export { PricingDisplay } from "./PricingDisplay";
export type { PricingDisplayProps } from "./PricingDisplay";

View File

@ -1,7 +1,6 @@
"use client";
import React from "react";
import { AnimatedCard } from "@/components/molecules/AnimatedCard/AnimatedCard";
export function FeatureCard({
icon,
@ -13,12 +12,14 @@ export function FeatureCard({
description: string;
}) {
return (
<AnimatedCard className="text-center p-6 rounded-2xl">
<div className="flex justify-center mb-6">
<div className="p-3 bg-gray-50 rounded-xl">{icon}</div>
<div className="flex items-start gap-4 p-6 bg-gray-50 rounded-xl border border-gray-100 transition-all duration-300 hover:shadow-md hover:border-gray-200">
<div className="flex-shrink-0">
{icon}
</div>
<h3 className="text-xl font-bold text-gray-900 mb-3">{title}</h3>
<p className="text-gray-600 leading-relaxed">{description}</p>
</AnimatedCard>
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">{title}</h3>
<p className="text-sm text-gray-600 leading-relaxed">{description}</p>
</div>
</div>
);
}

View File

@ -22,38 +22,35 @@ export function ServiceHeroCard({
}) {
const colorClasses = {
blue: {
bg: "bg-blue-50",
border: "border-blue-200",
iconBg: "bg-blue-100",
iconText: "text-blue-600",
button: "bg-blue-600 hover:bg-blue-700",
hoverBorder: "hover:border-blue-300",
border: "border-blue-100",
hoverBorder: "hover:border-blue-200",
},
green: {
bg: "bg-green-50",
border: "border-green-200",
iconBg: "bg-green-100",
iconText: "text-green-600",
button: "bg-green-600 hover:bg-green-700",
hoverBorder: "hover:border-green-300",
border: "border-green-100",
hoverBorder: "hover:border-green-200",
},
purple: {
bg: "bg-purple-50",
border: "border-purple-200",
iconBg: "bg-purple-100",
iconText: "text-purple-600",
button: "bg-purple-600 hover:bg-purple-700",
hoverBorder: "hover:border-purple-300",
border: "border-purple-100",
hoverBorder: "hover:border-purple-200",
},
} as const;
const colors = colorClasses[color];
return (
<AnimatedCard className="relative group rounded-3xl overflow-hidden h-full p-0">
<div className="p-8 h-full flex flex-col">
<div className="flex items-center gap-4 mb-6">
<div className={`p-4 rounded-xl ${colors.iconBg}`}>
<AnimatedCard
className={`relative group rounded-2xl overflow-hidden h-full border-2 ${colors.border} ${colors.hoverBorder} transition-all duration-300 hover:shadow-lg hover:-translate-y-1`}
>
<div className="p-8 h-full flex flex-col bg-white">
{/* Icon and Title */}
<div className="flex items-start gap-4 mb-4">
<div className={`p-3 rounded-xl ${colors.iconBg} flex-shrink-0`}>
<div className={colors.iconText}>{icon}</div>
</div>
<div>
@ -61,32 +58,31 @@ export function ServiceHeroCard({
</div>
</div>
<p className="text-gray-600 mb-6 leading-relaxed">{description}</p>
{/* Description */}
<p className="text-sm text-gray-600 mb-6 leading-relaxed">{description}</p>
<ul className="space-y-3 mb-8 flex-grow">
{/* Features List */}
<ul className="space-y-2.5 mb-8 flex-grow">
{features.map((feature, index) => (
<li key={index} className="flex items-center gap-3">
<div className={`w-2 h-2 rounded-full ${colors.button.split(" ")[0]}`} />
<span className="text-sm text-gray-700">{feature}</span>
<li key={index} className="flex items-start gap-2.5 text-sm text-gray-700">
<span className="text-green-600 mt-0.5 flex-shrink-0"></span>
<span>{feature}</span>
</li>
))}
</ul>
<div className="mt-auto relative z-10">
{/* Action Button */}
<div className="mt-auto">
<Button
as="a"
href={href}
className="w-full font-semibold rounded-2xl relative z-10 group"
size="lg"
rightIcon={<ArrowRightIcon className="w-5 h-5" />}
className="w-full font-semibold rounded-xl"
rightIcon={<ArrowRightIcon className="w-4 h-4" />}
>
Explore Plans
</Button>
</div>
</div>
<div
className={`absolute inset-0 ${colors.bg} opacity-0 group-hover:opacity-10 transition-opacity duration-300 pointer-events-none`}
/>
</AnimatedCard>
);
}

View File

@ -1,7 +1,7 @@
"use client";
import type { InternetInstallationCatalogItem } from "@customer-portal/domain/catalog";
import { getDisplayPrice } from "../../utils/pricing";
import { CardPricing } from "@/features/catalog/components/base/CardPricing";
type InstallationTerm = NonNullable<
NonNullable<InternetInstallationCatalogItem["catalogMetadata"]>["installationTerm"]
@ -14,24 +14,6 @@ interface InstallationOptionsProps {
showSkus?: boolean;
}
function getPriceLabel(installation: InternetInstallationCatalogItem): string {
const priceInfo = getDisplayPrice(installation);
if (!priceInfo) {
return "Price not available";
}
const billingCycle = installation.billingCycle?.toLowerCase();
if (billingCycle === "monthly" && priceInfo.monthly !== null) {
return `¥${priceInfo.monthly.toLocaleString()}/month`;
}
if (priceInfo.oneTime !== null) {
return `¥${priceInfo.oneTime.toLocaleString()} one-time`;
}
if (priceInfo.monthly !== null) {
return `¥${priceInfo.monthly.toLocaleString()}`;
}
return priceInfo.display || "Price not available";
}
export function InstallationOptions({
installations,
selectedInstallationSku,
@ -51,18 +33,17 @@ export function InstallationOptions({
}
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{sortedInstallations.map(installation => {
const isSelected = selectedInstallationSku === installation.sku;
const priceInfo = getDisplayPrice(installation);
const installationTerm = installation.catalogMetadata?.installationTerm ?? null;
const description =
installation.description ||
(installationTerm === "12-Month"
? "Spread the installation fee across 12 payments."
? "Spread the installation fee across 12 monthly payments."
: installationTerm === "24-Month"
? "Spread the installation fee across 24 payments."
: "Pay the full installation fee once.");
? "Spread the installation fee across 24 monthly payments."
: "Pay the full installation fee in one payment.");
return (
<button
@ -70,46 +51,52 @@ export function InstallationOptions({
type="button"
onClick={() => onInstallationSelect(installation)}
aria-pressed={isSelected}
className={`p-6 rounded-xl border-2 text-left transition-all duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 ${
className={`p-6 rounded-xl border-2 text-left transition-all duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 flex flex-col gap-4 ${
isSelected
? "border-blue-500 bg-blue-50"
: "border-gray-200 hover:border-blue-400 hover:bg-blue-50"
? "border-blue-500 bg-blue-50 shadow-md"
: "border-gray-200 hover:border-blue-400 hover:bg-blue-50/50 shadow-sm hover:shadow-md"
}`}
>
<div className="flex items-center justify-between mb-3">
<h4 className="font-semibold text-gray-900">{installation.name}</h4>
{/* Header with title and radio button */}
<div className="flex items-start justify-between gap-3">
<h4 className="text-lg font-semibold text-gray-900 flex-1">{installation.name}</h4>
<div
className={`w-4 h-4 aspect-square rounded-full border-2 flex items-center justify-center ${
isSelected ? "bg-blue-500 border-blue-500" : "border-gray-300"
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center flex-shrink-0 mt-0.5 ${
isSelected ? "bg-blue-500 border-blue-500" : "border-gray-300 bg-white"
}`}
aria-hidden="true"
>
{isSelected && (
<div className="w-2 h-2 bg-white rounded-full"></div>
)}
</div>
</div>
<p className="text-sm text-gray-600 mb-3">{description}</p>
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500">
{installation.billingCycle === "Monthly" ? "Monthly payment" : "One-time payment"}
</span>
</div>
<div className="flex justify-end">
{priceInfo ? (
<span
className={`text-lg font-bold ${
installation.billingCycle === "Monthly" ? "text-blue-600" : "text-green-600"
}`}
>
{getPriceLabel(installation)}
</span>
) : (
<span className="text-sm text-gray-400">Price not available</span>
)}
</div>
{/* Description */}
<p className="text-sm text-gray-600 leading-relaxed">{description}</p>
{/* Payment type badge */}
<div className="flex items-center gap-2">
<span className={`text-xs px-2.5 py-1 rounded-full font-medium ${
installation.billingCycle === "Monthly"
? "bg-blue-100 text-blue-700 border border-blue-200"
: "bg-green-100 text-green-700 border border-green-200"
}`}>
{installation.billingCycle === "Monthly" ? "Monthly Payment" : "One-time Payment"}
</span>
</div>
{showSkus && <div className="text-xs text-gray-400 mt-2">SKU: {installation.sku}</div>}
{/* Pricing */}
<div className="pt-3 border-t border-gray-200">
<CardPricing
monthlyPrice={installation.billingCycle === "Monthly" ? installation.monthlyPrice : null}
oneTimePrice={installation.billingCycle !== "Monthly" ? installation.oneTimePrice : null}
size="md"
alignment="left"
/>
</div>
{showSkus && <div className="text-xs text-gray-400 pt-2 border-t border-gray-100">SKU: {installation.sku}</div>}
</button>
);
})}

View File

@ -6,23 +6,28 @@ import type {
InternetInstallationCatalogItem,
InternetAddonCatalogItem,
} from "@customer-portal/domain/catalog";
import type { UseInternetConfigureResult } from "@/features/catalog/hooks/useInternetConfigure";
interface Props {
plan: InternetPlanCatalogItem | null;
loading: boolean;
addons: InternetAddonCatalogItem[];
installations: InternetInstallationCatalogItem[];
interface Props extends UseInternetConfigureResult {
onConfirm: () => void;
}
export function InternetConfigureView({ plan, loading, addons, installations, onConfirm }: Props) {
export function InternetConfigureView(props: Props) {
return (
<InternetConfigureContainer
plan={plan}
loading={loading}
addons={addons}
installations={installations}
onConfirm={onConfirm}
plan={props.plan}
loading={props.loading}
addons={props.addons}
installations={props.installations}
onConfirm={props.onConfirm}
mode={props.mode}
setMode={props.setMode}
selectedInstallation={props.selectedInstallation}
setSelectedInstallationSku={props.setSelectedInstallationSku}
selectedAddonSkus={props.selectedAddonSkus}
setSelectedAddonSkus={props.setSelectedAddonSkus}
monthlyTotal={props.monthlyTotal}
oneTimeTotal={props.oneTimeTotal}
/>
);
}

View File

@ -8,6 +8,9 @@ import type {
InternetInstallationCatalogItem,
} from "@customer-portal/domain/catalog";
import { useRouter } from "next/navigation";
import { CardPricing } from "@/features/catalog/components/base/CardPricing";
import { CardBadge } from "@/features/catalog/components/base/CardBadge";
import type { BadgeVariant } from "@/features/catalog/components/base/CardBadge";
interface InternetPlanCardProps {
plan: InternetPlanCatalogItem;
@ -54,91 +57,92 @@ export function InternetPlanCard({
return "border border-gray-200 bg-white shadow hover:shadow-lg";
};
const getTierBadgeVariant = (): BadgeVariant => {
if (isGold) return "gold";
if (isPlatinum) return "platinum";
if (isSilver) return "silver";
return "default";
};
return (
<AnimatedCard
variant="static"
className={`overflow-hidden flex flex-col h-full transition-all duration-300 ease-out hover:-translate-y-1 ${getBorderClass()}`}
>
<div className="p-6 flex flex-col flex-grow space-y-5">
<div className="p-6 flex flex-col flex-grow space-y-4">
{/* Header with badges and pricing */}
<div className="flex items-start justify-between gap-4">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<span
className={`px-2.5 py-1 rounded-full text-xs font-medium border ${
isGold
? "bg-yellow-50 text-yellow-700 border-yellow-200"
: isPlatinum
? "bg-indigo-50 text-indigo-700 border-indigo-200"
: "bg-gray-50 text-gray-700 border-gray-200"
}`}
>
{tier || "Plan"}
</span>
<div className="flex flex-col gap-2 flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<CardBadge text={tier || "Plan"} variant={getTierBadgeVariant()} />
{isGold && (
<span className="px-2 py-0.5 text-xs font-medium text-green-700 bg-green-50 border border-green-200 rounded-full">
Recommended
</span>
<CardBadge text="Recommended" variant="recommended" size="sm" />
)}
</div>
<h3 className="text-lg font-semibold text-gray-900 leading-snug max-w-xs">{plan.name}</h3>
<h3 className="text-xl font-semibold text-gray-900 leading-tight">{plan.name}</h3>
</div>
{plan.monthlyPrice && plan.monthlyPrice > 0 && (
<div className="text-right shrink-0">
<div className="text-xs uppercase tracking-wide text-gray-500 mb-1">Monthly</div>
<div className="text-2xl font-bold text-gray-900 leading-none">
¥{plan.monthlyPrice.toLocaleString()}
</div>
{plan.oneTimePrice && plan.oneTimePrice > 0 && (
<div className="text-xs text-gray-500 mt-1">One-time ¥{plan.oneTimePrice.toLocaleString()}</div>
)}
</div>
)}
<div className="flex-shrink-0">
<CardPricing
monthlyPrice={plan.monthlyPrice}
oneTimePrice={plan.oneTimePrice}
size="md"
alignment="right"
/>
</div>
</div>
<p className="text-gray-600 text-sm leading-relaxed">
{/* Description */}
<p className="text-sm text-gray-600 leading-relaxed">
{plan.catalogMetadata?.tierDescription || plan.description}
</p>
{/* Features */}
<div className="flex-grow">
<h4 className="font-medium text-gray-900 mb-3">Your Plan Includes</h4>
<h4 className="font-medium text-gray-900 mb-3 text-sm">Plan Includes:</h4>
<ul className="space-y-2 text-sm text-gray-700">
{plan.catalogMetadata?.features && plan.catalogMetadata.features.length > 0 ? (
plan.catalogMetadata.features.map((feature, index) => (
<li key={index} className="flex items-start">
<span className="text-green-600 mr-2"></span>
{feature}
<li key={index} className="flex items-start gap-2">
<span className="text-green-600 mt-0.5 flex-shrink-0"></span>
<span>{feature}</span>
</li>
))
) : (
<>
<li className="flex items-start">
<span className="text-green-600 mr-2"></span>1 NTT Optical Fiber (Flet&apos;s
Hikari Next -{" "}
{plan.internetOfferingType?.includes("Apartment") ? "Mansion" : "Home"}{" "}
{plan.internetOfferingType?.includes("10G")
? "10Gbps"
: plan.internetOfferingType?.includes("100M")
? "100Mbps"
: "1Gbps"}
) Installation + Monthly
<li className="flex items-start gap-2">
<span className="text-green-600 mt-0.5 flex-shrink-0"></span>
<span>NTT Optical Fiber (Flet&apos;s Hikari Next)</span>
</li>
<li className="flex items-start">
<span className="text-green-600 mr-2"></span>
Monthly: ¥{(plan.monthlyPrice ?? 0).toLocaleString()}
{installations.length > 0 && minInstallationPrice > 0 && (
<span className="text-gray-600 text-sm ml-2">
(+ installation from ¥{minInstallationPrice.toLocaleString()})
</span>
)}
<li className="flex items-start gap-2">
<span className="text-green-600 mt-0.5 flex-shrink-0"></span>
<span>
{plan.internetOfferingType?.includes("Apartment") ? "Mansion" : "Home"}{" "}
{plan.internetOfferingType?.includes("10G")
? "10Gbps"
: plan.internetOfferingType?.includes("100M")
? "100Mbps"
: "1Gbps"} connection
</span>
</li>
<li className="flex items-start gap-2">
<span className="text-green-600 mt-0.5 flex-shrink-0"></span>
<span>ISP connection protocols: IPoE and PPPoE</span>
</li>
{installations.length > 0 && minInstallationPrice > 0 && (
<li className="flex items-start gap-2">
<span className="text-green-600 mt-0.5 flex-shrink-0"></span>
<span>Installation from ¥{minInstallationPrice.toLocaleString()}</span>
</li>
)}
</>
)}
</ul>
</div>
{/* Action Button */}
<Button
className="w-full group"
className="w-full"
disabled={isDisabled}
rightIcon={!isDisabled ? <ArrowRightIcon className="w-4 h-4" /> : undefined}
onClick={() => {

View File

@ -9,6 +9,7 @@ import type {
InternetInstallationCatalogItem,
InternetAddonCatalogItem,
} from "@customer-portal/domain/catalog";
import type { AccessMode } from "../../../hooks/useConfigureParams";
import { ConfigureLoadingSkeleton } from "./components/ConfigureLoadingSkeleton";
import { ServiceConfigurationStep } from "./steps/ServiceConfigurationStep";
import { InstallationStep } from "./steps/InstallationStep";
@ -22,6 +23,15 @@ interface Props {
addons: InternetAddonCatalogItem[];
installations: InternetInstallationCatalogItem[];
onConfirm: () => void;
// State from parent hook
mode: AccessMode | null;
setMode: (mode: AccessMode) => void;
selectedInstallation: InternetInstallationCatalogItem | null;
setSelectedInstallationSku: (sku: string | null) => void;
selectedAddonSkus: string[];
setSelectedAddonSkus: (skus: string[]) => void;
monthlyTotal: number;
oneTimeTotal: number;
}
const STEPS = [
@ -37,21 +47,22 @@ export function InternetConfigureContainer({
addons,
installations,
onConfirm,
mode,
setMode,
selectedInstallation,
setSelectedInstallationSku,
selectedAddonSkus,
setSelectedAddonSkus,
monthlyTotal,
oneTimeTotal,
}: Props) {
// Use local state ONLY for step navigation, not for configuration data
const {
currentStep,
isTransitioning,
mode,
selectedInstallation,
selectedAddonSkus,
monthlyTotal,
oneTimeTotal,
setMode,
setSelectedInstallationSku,
setSelectedAddonSkus,
transitionToStep,
canProceedFromStep,
} = useConfigureState(plan, installations, addons);
} = useConfigureState(plan, installations, addons, mode, selectedInstallation);
const handleAddonSelection = (newSelectedSkus: string[]) => setSelectedAddonSkus(newSelectedSkus);

View File

@ -1,171 +1,112 @@
"use client";
import { useState, useCallback } from "react";
import { useState, useCallback, useEffect } from "react";
import { useRouter } from "next/navigation";
import type {
InternetPlanCatalogItem,
InternetInstallationCatalogItem,
InternetAddonCatalogItem,
} from "@customer-portal/domain/catalog";
import type { AccessMode } from "../../../../hooks/useConfigureParams";
type InstallationTerm = NonNullable<
NonNullable<InternetInstallationCatalogItem["catalogMetadata"]>["installationTerm"]
>;
interface ConfigureState {
currentStep: number;
isTransitioning: boolean;
mode: AccessMode | null;
selectedInstallation: InternetInstallationCatalogItem | null;
selectedInstallationType: InstallationTerm | null;
selectedAddonSkus: string[];
}
interface ConfigureTotals {
monthlyTotal: number;
oneTimeTotal: number;
}
/**
* Hook for managing configuration wizard UI state (step navigation and transitions)
* Follows domain/BFF architecture: pure UI state management, no business logic
*
* Uses internal state for step navigation - no URL manipulation needed.
* Steps are determined by:
* 1. Router state (when navigating back from checkout)
* 2. Presence of configuration data (auto-advance to review if complete)
* 3. Default to step 1 for new configurations
*
* @param plan - Selected internet plan
* @param installations - Available installation options
* @param addons - Available addon options
* @param mode - Currently selected access mode
* @param selectedInstallation - Currently selected installation
* @returns Step navigation state and helpers
*/
export function useConfigureState(
plan: InternetPlanCatalogItem | null,
installations: InternetInstallationCatalogItem[],
addons: InternetAddonCatalogItem[]
addons: InternetAddonCatalogItem[],
mode: AccessMode | null,
selectedInstallation: InternetInstallationCatalogItem | null
) {
const [state, setState] = useState<ConfigureState>({
currentStep: 1,
isTransitioning: false,
mode: null,
selectedInstallation: null,
selectedInstallationType: null,
selectedAddonSkus: [],
});
// Check if we should return to a specific step (from checkout navigation)
const getInitialStep = (): number => {
// Check for router state (passed when navigating back from checkout)
if (typeof window !== "undefined") {
const state = (window.history.state as any)?.state;
if (state?.returnToStep) {
return state.returnToStep;
}
}
// If returning with full config, go to review step
if (mode && selectedInstallation) {
return 4;
}
// Default to step 1 for new configurations
return 1;
};
const [currentStep, setCurrentStep] = useState<number>(getInitialStep);
const [isTransitioning, setIsTransitioning] = useState(false);
// Only update step when configuration data changes, not on every render
useEffect(() => {
// Auto-advance to review if all required config is present and we're on an earlier step
if (mode && selectedInstallation && currentStep < 4) {
const shouldAutoAdvance = getInitialStep();
if (shouldAutoAdvance === 4 && currentStep !== 4) {
setCurrentStep(4);
}
}
}, [mode, selectedInstallation]);
// Step navigation
// Step navigation with transition effect
const transitionToStep = useCallback((nextStep: number) => {
setState(prev => ({ ...prev, isTransitioning: true }));
setIsTransitioning(true);
setTimeout(() => {
setState(prev => ({ ...prev, currentStep: nextStep, isTransitioning: false }));
setCurrentStep(nextStep);
setTimeout(() => setIsTransitioning(false), 50);
}, 150);
}, []);
// Mode selection
const setMode = useCallback((mode: AccessMode) => {
setState(prev => ({ ...prev, mode }));
}, []);
// Installation selection
const setSelectedInstallationSku = useCallback(
(sku: string | null) => {
const installation = sku ? installations.find(inst => inst.sku === sku) || null : null;
const installationType = installation?.catalogMetadata?.installationTerm ?? null;
setState(prev => ({
...prev,
selectedInstallation: installation,
selectedInstallationType: installationType,
}));
},
[installations]
);
// Addon selection
const setSelectedAddonSkus = useCallback((skus: string[]) => {
setState(prev => ({ ...prev, selectedAddonSkus: skus }));
}, []);
// Calculate totals
const totals: ConfigureTotals = {
monthlyTotal: calculateMonthlyTotal(
plan,
state.selectedInstallation,
state.selectedAddonSkus,
addons
),
oneTimeTotal: calculateOneTimeTotal(
plan,
state.selectedInstallation,
state.selectedAddonSkus,
addons
),
};
// Validation
// UI validation - determines if user can proceed from current step
// Note: Real validation should happen on BFF during order submission
const canProceedFromStep = useCallback(
(step: number): boolean => {
switch (step) {
case 1:
return plan?.internetPlanTier !== "Silver" || state.mode !== null;
// Silver plans require explicit mode selection
// Gold/Platinum have default mode
if (plan?.internetPlanTier === "Silver") {
return mode !== null;
}
return true;
case 2:
return state.selectedInstallation !== null;
// Installation selection is required
return selectedInstallation !== null;
case 3:
return true; // Add-ons are optional
// Add-ons are optional
return true;
case 4:
return true; // Review step
// Review step - always can proceed
return true;
default:
return false;
}
},
[plan, state.mode, state.selectedInstallation]
[plan, mode, selectedInstallation]
);
return {
...state,
...totals,
setMode,
setSelectedInstallationSku,
setSelectedAddonSkus,
currentStep,
isTransitioning,
transitionToStep,
canProceedFromStep,
};
}
function calculateMonthlyTotal(
plan: InternetPlanCatalogItem | null,
selectedInstallation: InternetInstallationCatalogItem | null,
selectedAddonSkus: string[],
addons: InternetAddonCatalogItem[]
): number {
let total = 0;
if (typeof plan?.monthlyPrice === "number") {
total += plan.monthlyPrice;
}
if (typeof selectedInstallation?.monthlyPrice === "number") {
total += selectedInstallation.monthlyPrice;
}
selectedAddonSkus.forEach(sku => {
const addon = addons.find(a => a.sku === sku);
if (typeof addon?.monthlyPrice === "number") {
total += addon.monthlyPrice;
}
});
return total;
}
function calculateOneTimeTotal(
plan: InternetPlanCatalogItem | null,
selectedInstallation: InternetInstallationCatalogItem | null,
selectedAddonSkus: string[],
addons: InternetAddonCatalogItem[]
): number {
let total = 0;
if (typeof plan?.oneTimePrice === "number") {
total += plan.oneTimePrice;
}
if (typeof selectedInstallation?.oneTimePrice === "number") {
total += selectedInstallation.oneTimePrice;
}
selectedAddonSkus.forEach(sku => {
const addon = addons.find(a => a.sku === sku);
if (typeof addon?.oneTimePrice === "number") {
total += addon.oneTimePrice;
}
});
return total;
}

View File

@ -64,21 +64,17 @@ export function ReviewOrderStep({
/>
</div>
<div className="flex justify-between items-center pt-6 border-t">
<div className="flex justify-between pt-6 border-t">
<Button
onClick={onBack}
variant="outline"
size="lg"
className="px-8 py-4 text-lg"
leftIcon={<ArrowLeftIcon className="w-5 h-5" />}
leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
>
Back to Add-ons
</Button>
<Button
onClick={onConfirm}
size="lg"
className="px-12 py-4 text-lg font-semibold"
rightIcon={<ArrowRightIcon className="w-5 h-5" />}
rightIcon={<ArrowRightIcon className="w-4 h-4" />}
>
Proceed to Checkout
</Button>

View File

@ -1,46 +1,59 @@
"use client";
import { DevicePhoneMobileIcon, UsersIcon, CurrencyYenIcon } from "@heroicons/react/24/outline";
import { DevicePhoneMobileIcon } from "@heroicons/react/24/outline";
import { AnimatedCard } from "@/components/molecules/AnimatedCard/AnimatedCard";
import { Button } from "@/components/atoms/button";
import { ArrowRightIcon } from "@heroicons/react/24/outline";
import type { SimCatalogProduct } from "@customer-portal/domain/catalog";
import { CardPricing } from "@/features/catalog/components/base/CardPricing";
import { CardBadge } from "@/features/catalog/components/base/CardBadge";
export function SimPlanCard({ plan, isFamily }: { plan: SimCatalogProduct; isFamily?: boolean }) {
const displayPrice = plan.monthlyPrice ?? plan.unitPrice ?? plan.oneTimePrice ?? 0;
const isFamilyPlan = isFamily ?? Boolean(plan.simHasFamilyDiscount);
return (
<AnimatedCard variant={isFamilyPlan ? "success" : "default"} className="p-6 w-full max-w-sm">
<div className="flex items-start justify-between mb-3">
<div>
<div className="flex items-center gap-2 mb-1">
<DevicePhoneMobileIcon className="h-4 w-4 text-blue-600" />
<span className="font-bold text-sm text-gray-900">{plan.simDataSize}</span>
<AnimatedCard
variant={isFamilyPlan ? "success" : "default"}
className="p-6 w-full max-w-sm flex flex-col h-full"
>
{/* Header with data size and pricing */}
<div className="flex items-start justify-between gap-4 mb-3">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<DevicePhoneMobileIcon className="h-5 w-5 text-blue-600" />
<span className="font-bold text-base text-gray-900">{plan.simDataSize}</span>
</div>
{isFamilyPlan && (
<div className="flex items-center gap-1 mb-1">
<UsersIcon className="h-4 w-4 text-green-600" />
<span className="bg-green-100 text-green-800 text-xs px-2 py-1 rounded-full font-medium">
Family
</span>
</div>
<CardBadge text="Family Discount" variant="family" size="sm" />
)}
</div>
</div>
<div className="mb-3">
<div className="flex items-baseline gap-1">
<CurrencyYenIcon className="h-4 w-4 text-gray-600" />
<span className="text-xl font-bold text-gray-900">{displayPrice.toLocaleString()}</span>
<span className="text-gray-600 text-sm">/month</span>
</div>
{/* Pricing */}
<div className="mb-4">
<CardPricing
monthlyPrice={displayPrice}
size="sm"
alignment="left"
/>
{isFamilyPlan && (
<div className="text-xs text-green-600 font-medium mt-1">Discounted price</div>
<div className="text-xs text-green-600 font-medium mt-1">Discounted pricing applied</div>
)}
</div>
<div className="mb-4">
<p className="text-xs text-gray-600 line-clamp-2">{plan.name}</p>
{/* Description */}
<div className="mb-4 flex-grow">
<p className="text-sm text-gray-600 leading-relaxed line-clamp-2">{plan.name}</p>
</div>
<Button as="a" href={`/catalog/sim/configure?plan=${plan.sku}`} className="w-full" size="sm">
{/* Action Button */}
<Button
as="a"
href={`/catalog/sim/configure?plan=${plan.sku}`}
className="w-full"
rightIcon={<ArrowRightIcon className="w-4 h-4" />}
>
Configure
</Button>
</AnimatedCard>

View File

@ -2,8 +2,9 @@
import { AnimatedCard } from "@/components/molecules";
import { Button } from "@/components/atoms/button";
import { CurrencyYenIcon } from "@heroicons/react/24/outline";
import { ArrowRightIcon, ShieldCheckIcon } from "@heroicons/react/24/outline";
import type { VpnCatalogProduct } from "@customer-portal/domain/catalog";
import { CardPricing } from "@/features/catalog/components/base/CardPricing";
interface VpnPlanCardProps {
plan: VpnCatalogProduct;
@ -11,24 +12,40 @@ interface VpnPlanCardProps {
export function VpnPlanCard({ plan }: VpnPlanCardProps) {
return (
<AnimatedCard className="p-6 border-2 border-blue-200 hover:border-blue-300 transition-colors">
<div className="text-center mb-4">
<h3 className="text-xl font-bold text-gray-900">{plan.name}</h3>
</div>
<div className="mb-4 text-center">
<div className="flex items-baseline justify-center gap-1">
<CurrencyYenIcon className="h-5 w-5 text-gray-600" />
<span className="text-3xl font-bold text-gray-900">
{plan.monthlyPrice?.toLocaleString()}
</span>
<span className="text-gray-600">/month</span>
<AnimatedCard className="p-6 border border-blue-200 hover:border-blue-300 transition-all duration-300 hover:shadow-lg flex flex-col h-full">
{/* Header with icon and name */}
<div className="flex items-start gap-3 mb-4">
<div className="p-2 bg-blue-50 rounded-lg">
<ShieldCheckIcon className="h-6 w-6 text-blue-600" />
</div>
<div className="flex-1">
<h3 className="text-xl font-semibold text-gray-900">{plan.name}</h3>
</div>
</div>
<Button as="a" href={`/catalog/vpn/configure?plan=${plan.sku}`} className="w-full">
Configure Plan
</Button>
{/* Pricing */}
<div className="mb-6">
<CardPricing
monthlyPrice={plan.monthlyPrice}
size="lg"
alignment="left"
/>
</div>
{/* Action Button */}
<div className="mt-auto">
<Button
as="a"
href={`/catalog/vpn/configure?plan=${plan.sku}`}
className="w-full"
rightIcon={<ArrowRightIcon className="w-4 h-4" />}
>
Configure Plan
</Button>
</div>
</AnimatedCard>
);
}
export type { VpnPlanCardProps };

View File

@ -41,7 +41,16 @@ export function useInternetConfigureParams() {
const accessMode: AccessMode | null =
accessModeParam === "IPoE-BYOR" || accessModeParam === "PPPoE" ? accessModeParam : null;
const installationSku = params.get("installationSku");
const addonSkus = params.getAll("addonSku");
// Support both formats: comma-separated 'addons' or multiple 'addonSku' params
const addonsParam = params.get("addons");
const addonSkuParams = params.getAll("addonSku");
const addonSkus = addonsParam
? addonsParam.split(",").map(s => s.trim()).filter(Boolean)
: addonSkuParams.length > 0
? addonSkuParams
: [];
return {
accessMode,

View File

@ -29,40 +29,38 @@ export type UseInternetConfigureResult = {
selectedAddonSkus: string[];
setSelectedAddonSkus: (skus: string[]) => void;
currentStep: number;
isTransitioning: boolean;
transitionToStep: (nextStep: number) => void;
monthlyTotal: number;
oneTimeTotal: number;
buildCheckoutSearchParams: () => URLSearchParams | null;
};
/**
* Hook for managing internet service configuration state
* Follows domain/BFF architecture: minimal client logic, state management only
*/
export function useInternetConfigure(): UseInternetConfigureResult {
const router = useRouter();
const searchParams = useSearchParams();
const planSku = searchParams.get("plan");
// Fetch catalog data from BFF
const { data: internetData, isLoading: internetLoading } = useInternetCatalog();
const { plan: selectedPlan } = useInternetPlan(planSku || undefined);
const { accessMode, installationSku, addonSkus } = useInternetConfigureParams();
// Local UI state
const [plan, setPlan] = useState<InternetPlanCatalogItem | null>(null);
const [loading, setLoading] = useState(true);
const [addons, setAddons] = useState<InternetAddonCatalogItem[]>([]);
const [installations, setInstallations] = useState<InternetInstallationCatalogItem[]>([]);
// Configuration selections
const [mode, setMode] = useState<InternetAccessMode | null>(null);
const [selectedInstallationSku, setSelectedInstallationSku] = useState<string | null>(null);
const [selectedAddonSkus, setSelectedAddonSkus] = useState<string[]>([]);
const [currentStep, setCurrentStep] = useState<number>(() => {
const stepParam = searchParams.get("step");
return stepParam ? parseInt(stepParam, 10) : 1;
});
const [isTransitioning, setIsTransitioning] = useState(false);
// Initialize state from BFF data and URL params
useEffect(() => {
let mounted = true;
if (!planSku) {
@ -78,9 +76,24 @@ export function useInternetConfigure(): UseInternetConfigureResult {
setAddons(addonsData);
setInstallations(installationsData);
if (accessMode) setMode(accessMode as InternetAccessMode);
if (installationSku) setSelectedInstallationSku(installationSku);
if (addonSkus.length > 0) setSelectedAddonSkus(addonSkus);
// Always restore state from URL if present (important for back navigation)
if (accessMode) {
setMode(accessMode as InternetAccessMode);
} else if (selectedPlan.internetPlanTier === "Gold" || selectedPlan.internetPlanTier === "Platinum") {
// Auto-set default mode for Gold/Platinum plans (IPoE-BYOR is standard for these tiers)
setMode("IPoE-BYOR");
}
// Restore installation and addons from URL params
if (installationSku) {
setSelectedInstallationSku(installationSku);
}
if (addonSkus.length > 0) {
setSelectedAddonSkus(addonSkus);
} else {
// Clear addons if none in URL (user might have removed them)
setSelectedAddonSkus([]);
}
} else {
router.push("/catalog/internet");
}
@ -98,17 +111,10 @@ export function useInternetConfigure(): UseInternetConfigureResult {
selectedPlan,
accessMode,
installationSku,
addonSkus,
JSON.stringify(addonSkus), // Use JSON.stringify for array comparison
]);
const transitionToStep = (nextStep: number) => {
setIsTransitioning(true);
setTimeout(() => {
setCurrentStep(nextStep);
setTimeout(() => setIsTransitioning(false), 50);
}, 200);
};
// Derive selected installation from SKU
const selectedInstallation = useMemo(() => {
if (!selectedInstallationSku) return null;
return installations.find(installation => installation.sku === selectedInstallationSku) || null;
@ -119,6 +125,7 @@ export function useInternetConfigure(): UseInternetConfigureResult {
return selectedInstallation.catalogMetadata?.installationTerm ?? null;
}, [selectedInstallation]);
// Calculate totals (simple summation - real pricing logic should be in BFF)
const { monthlyTotal, oneTimeTotal } = useMemo(() => {
const baseMonthly = plan?.monthlyPrice ?? 0;
const baseOneTime = plan?.oneTimePrice ?? 0;
@ -154,12 +161,15 @@ export function useInternetConfigure(): UseInternetConfigureResult {
} as const;
}, [plan, selectedAddonSkus, addons, selectedInstallation]);
// Build checkout URL params (simple data marshalling, not business logic)
const buildCheckoutSearchParams = () => {
if (!plan || !mode || !selectedInstallationSku) return null;
const params = new URLSearchParams({ type: "internet", plan: plan.sku, accessMode: mode });
params.append("installationSku", selectedInstallationSku);
if (selectedAddonSkus.length > 0)
selectedAddonSkus.forEach(sku => params.append("addonSku", sku));
if (selectedAddonSkus.length > 0) {
// Send addons as comma-separated string to match BFF expectations
params.append("addons", selectedAddonSkus.join(","));
}
return params;
};
@ -175,9 +185,6 @@ export function useInternetConfigure(): UseInternetConfigureResult {
selectedInstallationType,
selectedAddonSkus,
setSelectedAddonSkus,
currentStep,
isTransitioning,
transitionToStep,
monthlyTotal,
oneTimeTotal,
buildCheckoutSearchParams,

View File

@ -15,10 +15,10 @@ import { FeatureCard } from "@/features/catalog/components/common/FeatureCard";
export function CatalogHomeView() {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50">
<div className="min-h-screen bg-slate-50">
<PageLayout icon={<></>} title="" description="">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-16">
<div className="text-center mb-12">
<div className="inline-flex items-center gap-2 bg-blue-50 text-blue-700 px-4 py-2 rounded-full text-sm font-medium mb-6">
<Squares2X2Icon className="h-4 w-4" />
Services Catalog
@ -26,20 +26,20 @@ export function CatalogHomeView() {
<h1 className="text-5xl font-bold text-gray-900 mb-6 leading-tight">
Choose Your Perfect
<br />
<span className="bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
<span className="text-blue-600">
Connectivity Solution
</span>
</h1>
<p className="text-xl text-gray-600 max-w-4xl mx-auto leading-relaxed">
<p className="text-lg text-gray-600 max-w-3xl mx-auto leading-relaxed">
Discover high-speed internet, mobile data/voice options, and secure VPN services.
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-16">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-12">
<ServiceHeroCard
title="Internet Service"
description="Ultra-high-speed fiber internet with speeds up to 10Gbps."
icon={<ServerIcon className="h-12 w-12" />}
icon={<ServerIcon className="h-10 w-10" />}
features={[
"Up to 10Gbps speeds",
"Fiber optic technology",
@ -52,7 +52,7 @@ export function CatalogHomeView() {
<ServiceHeroCard
title="SIM & eSIM"
description="Data, SMS, and voice plans with both physical SIM and eSIM options."
icon={<DevicePhoneMobileIcon className="h-12 w-12" />}
icon={<DevicePhoneMobileIcon className="h-10 w-10" />}
features={[
"Physical SIM & eSIM",
"Data + SMS + Voice plans",
@ -65,7 +65,7 @@ export function CatalogHomeView() {
<ServiceHeroCard
title="VPN Service"
description="Secure remote access solutions for business and personal use."
icon={<ShieldCheckIcon className="h-12 w-12" />}
icon={<ShieldCheckIcon className="h-10 w-10" />}
features={[
"Secure encryption",
"Multiple locations",
@ -77,21 +77,21 @@ export function CatalogHomeView() {
/>
</div>
<div className="bg-gradient-to-br from-gray-50 to-blue-50 rounded-3xl p-10 border border-gray-100">
<div className="text-center mb-10">
<h2 className="text-3xl font-bold text-gray-900 mb-4">Why Choose Our Services?</h2>
<p className="text-lg text-gray-600 max-w-3xl mx-auto leading-relaxed">
<div className="bg-white rounded-2xl p-10 border border-gray-200 shadow-sm">
<div className="text-center mb-8">
<h2 className="text-3xl font-bold text-gray-900 mb-3">Why Choose Our Services?</h2>
<p className="text-base text-gray-600 max-w-2xl mx-auto leading-relaxed">
Personalized recommendations based on your location and account eligibility.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<FeatureCard
icon={<WifiIcon className="h-10 w-10 text-blue-600" />}
icon={<WifiIcon className="h-8 w-8 text-blue-600" />}
title="Location-Based Plans"
description="Internet plans tailored to your house type and infrastructure"
/>
<FeatureCard
icon={<GlobeAltIcon className="h-10 w-10 text-purple-600" />}
icon={<GlobeAltIcon className="h-8 w-8 text-purple-600" />}
title="Seamless Integration"
description="Manage all services from a single account"
/>

View File

@ -8,9 +8,41 @@ export function InternetConfigureContainer() {
const router = useRouter();
const vm = useInternetConfigure();
// Debug: log current state
console.log("InternetConfigure state:", {
plan: vm.plan?.sku,
mode: vm.mode,
installation: vm.selectedInstallation?.sku,
addons: vm.selectedAddonSkus,
});
const handleConfirm = () => {
console.log("handleConfirm called, current state:", {
plan: vm.plan?.sku,
mode: vm.mode,
installation: vm.selectedInstallation?.sku,
selectedInstallationSku: vm.selectedInstallation?.sku,
});
const params = vm.buildCheckoutSearchParams();
if (!params) return;
if (!params) {
console.error("Cannot proceed to checkout: missing required configuration", {
plan: vm.plan?.sku,
mode: vm.mode,
installation: vm.selectedInstallation?.sku,
});
// Determine what's missing
let missingItems = [];
if (!vm.plan) missingItems.push("plan selection");
if (!vm.mode) missingItems.push("access mode");
if (!vm.selectedInstallation) missingItems.push("installation option");
alert(`Please complete the following before proceeding:\n- ${missingItems.join("\n- ")}`);
return;
}
console.log("Navigating to checkout with params:", params.toString());
router.push(`/checkout?${params.toString()}`);
};

View File

@ -236,52 +236,52 @@ export function SimPlansContainer() {
</div>
</div>
<div className="min-h-[400px] relative">
<div
className={`transition-all duration-500 ease-in-out ${activeTab === "data-voice" ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4 absolute inset-0 pointer-events-none"}`}
>
<SimPlanTypeSection
title="Data + SMS + Voice Plans"
description={
hasExistingSim
? "Family discount shown where eligible"
: "Comprehensive plans with high-speed data, messaging, and calling"
}
icon={<DevicePhoneMobileIcon className="h-6 w-6 text-blue-600" />}
plans={plansByType.DataSmsVoice}
showFamilyDiscount={hasExistingSim}
/>
</div>
<div
className={`transition-all duration-500 ease-in-out ${activeTab === "data-only" ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4 absolute inset-0 pointer-events-none"}`}
>
<SimPlanTypeSection
title="Data Only Plans"
description={
hasExistingSim
? "Family discount shown where eligible"
: "Flexible data-only plans for internet usage"
}
icon={<GlobeAltIcon className="h-6 w-6 text-purple-600" />}
plans={plansByType.DataOnly}
showFamilyDiscount={hasExistingSim}
/>
</div>
<div
className={`transition-all duration-500 ease-in-out ${activeTab === "voice-only" ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4 absolute inset-0 pointer-events-none"}`}
>
<SimPlanTypeSection
title="Voice + SMS Only Plans"
description={
hasExistingSim
? "Family discount shown where eligible"
: "Plans focused on voice calling and messaging without data bundles"
}
icon={<PhoneIcon className="h-6 w-6 text-orange-600" />}
plans={plansByType.VoiceOnly}
showFamilyDiscount={hasExistingSim}
/>
</div>
<div className="min-h-[500px] relative">
{activeTab === "data-voice" && (
<div className="animate-in fade-in duration-300">
<SimPlanTypeSection
title="Data + SMS + Voice Plans"
description={
hasExistingSim
? "Family discount shown where eligible"
: "Comprehensive plans with high-speed data, messaging, and calling"
}
icon={<DevicePhoneMobileIcon className="h-6 w-6 text-blue-600" />}
plans={plansByType.DataSmsVoice}
showFamilyDiscount={hasExistingSim}
/>
</div>
)}
{activeTab === "data-only" && (
<div className="animate-in fade-in duration-300">
<SimPlanTypeSection
title="Data Only Plans"
description={
hasExistingSim
? "Family discount shown where eligible"
: "Flexible data-only plans for internet usage"
}
icon={<GlobeAltIcon className="h-6 w-6 text-purple-600" />}
plans={plansByType.DataOnly}
showFamilyDiscount={hasExistingSim}
/>
</div>
)}
{activeTab === "voice-only" && (
<div className="animate-in fade-in duration-300">
<SimPlanTypeSection
title="Voice + SMS Only Plans"
description={
hasExistingSim
? "Family discount shown where eligible"
: "Plans focused on voice calling and messaging without data bundles"
}
icon={<PhoneIcon className="h-6 w-6 text-orange-600" />}
plans={plansByType.VoiceOnly}
showFamilyDiscount={hasExistingSim}
/>
</div>
)}
</div>
<div className="mt-8 bg-gray-50 rounded-2xl p-8 max-w-4xl mx-auto">

View File

@ -215,13 +215,16 @@ export function useCheckout() {
const navigateBackToConfigure = useCallback(() => {
const urlParams = new URLSearchParams(params.toString());
const reviewStep = orderType === "Internet" ? "4" : "5";
urlParams.set("step", reviewStep);
// Remove the 'type' param as it's not needed in configure URLs
urlParams.delete('type');
const configureUrl =
orderType === "Internet"
? `/catalog/internet/configure?${urlParams.toString()}`
: `/catalog/sim/configure?${urlParams.toString()}`;
router.push(configureUrl);
// Use Next.js router state to pass the step internally (not in URL)
router.push(configureUrl, { state: { returnToStep: 4 } } as any);
}, [orderType, params, router]);
return {

View File

@ -1,5 +1,6 @@
import { apiClient, getDataOrThrow } from "@/lib/api";
import type { CheckoutCart, OrderConfigurations } from "@customer-portal/domain/orders";
import type { ApiSuccessResponse } from "@customer-portal/domain/common";
export const checkoutService = {
/**
@ -10,7 +11,7 @@ export const checkoutService = {
selections: Record<string, string>,
configuration?: OrderConfigurations
): Promise<CheckoutCart> {
const response = await apiClient.POST<CheckoutCart>("/checkout/cart", {
const response = await apiClient.POST<ApiSuccessResponse<CheckoutCart>>("/api/checkout/cart", {
body: {
orderType,
selections,
@ -18,13 +19,17 @@ export const checkoutService = {
},
});
return getDataOrThrow(response, "Failed to build checkout cart");
const wrappedResponse = getDataOrThrow(response, "Failed to build checkout cart");
if (!wrappedResponse.success) {
throw new Error("Failed to build checkout cart");
}
return wrappedResponse.data;
},
/**
* Validate checkout cart
*/
async validateCart(cart: CheckoutCart): Promise<void> {
await apiClient.POST("/checkout/validate", { body: cart });
await apiClient.POST("/api/checkout/validate", { body: cart });
},
};

View File

@ -221,7 +221,7 @@ export function CheckoutContainer() {
onClick={navigateBackToConfigure}
className="flex-1 px-6 py-4 border border-gray-300 rounded-lg text-center hover:bg-gray-50 transition-colors font-medium"
>
Back to Review
Back to Configuration
</button>
<button
type="button"