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 && ( {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> <p>Select add-ons to enhance your service</p>
</div> </div>
)} )}

View File

@ -407,30 +407,28 @@ export function AddressConfirmation({
)} )}
{/* Action buttons */} {/* Action buttons */}
<div className="flex items-center justify-between pt-4 border-t border-gray-100"> <div className="flex items-center gap-3 pt-4 border-t border-gray-100">
<div className="flex items-center gap-3"> {/* Primary action when pending for Internet orders */}
{/* Primary action when pending for Internet orders */} {isInternetOrder && !addressConfirmed && !editing && (
{isInternetOrder && !addressConfirmed && !editing && ( <Button
<Button type="button"
type="button" onClick={handleConfirmAddress}
onClick={handleConfirmAddress} onKeyDown={e => {
onKeyDown={e => { if (e.key === "Enter" || e.key === " ") {
if (e.key === "Enter" || e.key === " ") { e.preventDefault();
e.preventDefault(); e.stopPropagation();
e.stopPropagation(); }
} }}
}} >
> Confirm Address
Confirm Address </Button>
</Button> )}
)}
</div>
{/* Edit button - always on the right */} {/* Edit button */}
{billingInfo.isComplete && !editing && ( {billingInfo.isComplete && !editing && (
<Button type="button" variant="outline" size="sm" onClick={handleEdit}> <Button type="button" variant="outline" size="sm" onClick={handleEdit}>
<PencilIcon className="h-4 w-4 mr-1" /> <PencilIcon className="h-4 w-4" />
Edit Address <span className="ml-1.5">Edit Address</span>
</Button> </Button>
)} )}
</div> </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"; "use client";
import React from "react"; import React from "react";
import { AnimatedCard } from "@/components/molecules/AnimatedCard/AnimatedCard";
export function FeatureCard({ export function FeatureCard({
icon, icon,
@ -13,12 +12,14 @@ export function FeatureCard({
description: string; description: string;
}) { }) {
return ( return (
<AnimatedCard className="text-center p-6 rounded-2xl"> <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 justify-center mb-6"> <div className="flex-shrink-0">
<div className="p-3 bg-gray-50 rounded-xl">{icon}</div> {icon}
</div> </div>
<h3 className="text-xl font-bold text-gray-900 mb-3">{title}</h3> <div>
<p className="text-gray-600 leading-relaxed">{description}</p> <h3 className="text-lg font-semibold text-gray-900 mb-2">{title}</h3>
</AnimatedCard> <p className="text-sm text-gray-600 leading-relaxed">{description}</p>
</div>
</div>
); );
} }

View File

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

View File

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

View File

@ -6,23 +6,28 @@ import type {
InternetInstallationCatalogItem, InternetInstallationCatalogItem,
InternetAddonCatalogItem, InternetAddonCatalogItem,
} from "@customer-portal/domain/catalog"; } from "@customer-portal/domain/catalog";
import type { UseInternetConfigureResult } from "@/features/catalog/hooks/useInternetConfigure";
interface Props { interface Props extends UseInternetConfigureResult {
plan: InternetPlanCatalogItem | null;
loading: boolean;
addons: InternetAddonCatalogItem[];
installations: InternetInstallationCatalogItem[];
onConfirm: () => void; onConfirm: () => void;
} }
export function InternetConfigureView({ plan, loading, addons, installations, onConfirm }: Props) { export function InternetConfigureView(props: Props) {
return ( return (
<InternetConfigureContainer <InternetConfigureContainer
plan={plan} plan={props.plan}
loading={loading} loading={props.loading}
addons={addons} addons={props.addons}
installations={installations} installations={props.installations}
onConfirm={onConfirm} 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, InternetInstallationCatalogItem,
} from "@customer-portal/domain/catalog"; } from "@customer-portal/domain/catalog";
import { useRouter } from "next/navigation"; 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 { interface InternetPlanCardProps {
plan: InternetPlanCatalogItem; plan: InternetPlanCatalogItem;
@ -54,91 +57,92 @@ export function InternetPlanCard({
return "border border-gray-200 bg-white shadow hover:shadow-lg"; 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 ( return (
<AnimatedCard <AnimatedCard
variant="static" variant="static"
className={`overflow-hidden flex flex-col h-full transition-all duration-300 ease-out hover:-translate-y-1 ${getBorderClass()}`} 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 items-start justify-between gap-4">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2 flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 flex-wrap">
<span <CardBadge text={tier || "Plan"} variant={getTierBadgeVariant()} />
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>
{isGold && ( {isGold && (
<span className="px-2 py-0.5 text-xs font-medium text-green-700 bg-green-50 border border-green-200 rounded-full"> <CardBadge text="Recommended" variant="recommended" size="sm" />
Recommended
</span>
)} )}
</div> </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> </div>
{plan.monthlyPrice && plan.monthlyPrice > 0 && ( <div className="flex-shrink-0">
<div className="text-right shrink-0"> <CardPricing
<div className="text-xs uppercase tracking-wide text-gray-500 mb-1">Monthly</div> monthlyPrice={plan.monthlyPrice}
<div className="text-2xl font-bold text-gray-900 leading-none"> oneTimePrice={plan.oneTimePrice}
¥{plan.monthlyPrice.toLocaleString()} size="md"
</div> alignment="right"
{plan.oneTimePrice && plan.oneTimePrice > 0 && ( />
<div className="text-xs text-gray-500 mt-1">One-time ¥{plan.oneTimePrice.toLocaleString()}</div> </div>
)}
</div>
)}
</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} {plan.catalogMetadata?.tierDescription || plan.description}
</p> </p>
{/* Features */}
<div className="flex-grow"> <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"> <ul className="space-y-2 text-sm text-gray-700">
{plan.catalogMetadata?.features && plan.catalogMetadata.features.length > 0 ? ( {plan.catalogMetadata?.features && plan.catalogMetadata.features.length > 0 ? (
plan.catalogMetadata.features.map((feature, index) => ( plan.catalogMetadata.features.map((feature, index) => (
<li key={index} className="flex items-start"> <li key={index} className="flex items-start gap-2">
<span className="text-green-600 mr-2"></span> <span className="text-green-600 mt-0.5 flex-shrink-0"></span>
{feature} <span>{feature}</span>
</li> </li>
)) ))
) : ( ) : (
<> <>
<li className="flex items-start"> <li className="flex items-start gap-2">
<span className="text-green-600 mr-2"></span>1 NTT Optical Fiber (Flet&apos;s <span className="text-green-600 mt-0.5 flex-shrink-0"></span>
Hikari Next -{" "} <span>NTT Optical Fiber (Flet&apos;s Hikari Next)</span>
{plan.internetOfferingType?.includes("Apartment") ? "Mansion" : "Home"}{" "}
{plan.internetOfferingType?.includes("10G")
? "10Gbps"
: plan.internetOfferingType?.includes("100M")
? "100Mbps"
: "1Gbps"}
) Installation + Monthly
</li> </li>
<li className="flex items-start"> <li className="flex items-start gap-2">
<span className="text-green-600 mr-2"></span> <span className="text-green-600 mt-0.5 flex-shrink-0"></span>
Monthly: ¥{(plan.monthlyPrice ?? 0).toLocaleString()} <span>
{installations.length > 0 && minInstallationPrice > 0 && ( {plan.internetOfferingType?.includes("Apartment") ? "Mansion" : "Home"}{" "}
<span className="text-gray-600 text-sm ml-2"> {plan.internetOfferingType?.includes("10G")
(+ installation from ¥{minInstallationPrice.toLocaleString()}) ? "10Gbps"
</span> : plan.internetOfferingType?.includes("100M")
)} ? "100Mbps"
: "1Gbps"} connection
</span>
</li> </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> </ul>
</div> </div>
{/* Action Button */}
<Button <Button
className="w-full group" className="w-full"
disabled={isDisabled} disabled={isDisabled}
rightIcon={!isDisabled ? <ArrowRightIcon className="w-4 h-4" /> : undefined} rightIcon={!isDisabled ? <ArrowRightIcon className="w-4 h-4" /> : undefined}
onClick={() => { onClick={() => {

View File

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

View File

@ -1,171 +1,112 @@
"use client"; "use client";
import { useState, useCallback } from "react"; import { useState, useCallback, useEffect } from "react";
import { useRouter } from "next/navigation";
import type { import type {
InternetPlanCatalogItem, InternetPlanCatalogItem,
InternetInstallationCatalogItem, InternetInstallationCatalogItem,
InternetAddonCatalogItem, InternetAddonCatalogItem,
} from "@customer-portal/domain/catalog"; } from "@customer-portal/domain/catalog";
import type { AccessMode } from "../../../../hooks/useConfigureParams"; 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( export function useConfigureState(
plan: InternetPlanCatalogItem | null, plan: InternetPlanCatalogItem | null,
installations: InternetInstallationCatalogItem[], installations: InternetInstallationCatalogItem[],
addons: InternetAddonCatalogItem[] addons: InternetAddonCatalogItem[],
mode: AccessMode | null,
selectedInstallation: InternetInstallationCatalogItem | null
) { ) {
const [state, setState] = useState<ConfigureState>({ // Check if we should return to a specific step (from checkout navigation)
currentStep: 1, const getInitialStep = (): number => {
isTransitioning: false, // Check for router state (passed when navigating back from checkout)
mode: null, if (typeof window !== "undefined") {
selectedInstallation: null, const state = (window.history.state as any)?.state;
selectedInstallationType: null, if (state?.returnToStep) {
selectedAddonSkus: [], 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) => { const transitionToStep = useCallback((nextStep: number) => {
setState(prev => ({ ...prev, isTransitioning: true })); setIsTransitioning(true);
setTimeout(() => { setTimeout(() => {
setState(prev => ({ ...prev, currentStep: nextStep, isTransitioning: false })); setCurrentStep(nextStep);
setTimeout(() => setIsTransitioning(false), 50);
}, 150); }, 150);
}, []); }, []);
// Mode selection // UI validation - determines if user can proceed from current step
const setMode = useCallback((mode: AccessMode) => { // Note: Real validation should happen on BFF during order submission
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
const canProceedFromStep = useCallback( const canProceedFromStep = useCallback(
(step: number): boolean => { (step: number): boolean => {
switch (step) { switch (step) {
case 1: 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: case 2:
return state.selectedInstallation !== null; // Installation selection is required
return selectedInstallation !== null;
case 3: case 3:
return true; // Add-ons are optional // Add-ons are optional
return true;
case 4: case 4:
return true; // Review step // Review step - always can proceed
return true;
default: default:
return false; return false;
} }
}, },
[plan, state.mode, state.selectedInstallation] [plan, mode, selectedInstallation]
); );
return { return {
...state, currentStep,
...totals, isTransitioning,
setMode,
setSelectedInstallationSku,
setSelectedAddonSkus,
transitionToStep, transitionToStep,
canProceedFromStep, 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>
<div className="flex justify-between items-center pt-6 border-t"> <div className="flex justify-between pt-6 border-t">
<Button <Button
onClick={onBack} onClick={onBack}
variant="outline" variant="outline"
size="lg" leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
className="px-8 py-4 text-lg"
leftIcon={<ArrowLeftIcon className="w-5 h-5" />}
> >
Back to Add-ons Back to Add-ons
</Button> </Button>
<Button <Button
onClick={onConfirm} onClick={onConfirm}
size="lg" rightIcon={<ArrowRightIcon className="w-4 h-4" />}
className="px-12 py-4 text-lg font-semibold"
rightIcon={<ArrowRightIcon className="w-5 h-5" />}
> >
Proceed to Checkout Proceed to Checkout
</Button> </Button>

View File

@ -1,46 +1,59 @@
"use client"; "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 { AnimatedCard } from "@/components/molecules/AnimatedCard/AnimatedCard";
import { Button } from "@/components/atoms/button"; import { Button } from "@/components/atoms/button";
import { ArrowRightIcon } from "@heroicons/react/24/outline";
import type { SimCatalogProduct } from "@customer-portal/domain/catalog"; 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 }) { export function SimPlanCard({ plan, isFamily }: { plan: SimCatalogProduct; isFamily?: boolean }) {
const displayPrice = plan.monthlyPrice ?? plan.unitPrice ?? plan.oneTimePrice ?? 0; const displayPrice = plan.monthlyPrice ?? plan.unitPrice ?? plan.oneTimePrice ?? 0;
const isFamilyPlan = isFamily ?? Boolean(plan.simHasFamilyDiscount); const isFamilyPlan = isFamily ?? Boolean(plan.simHasFamilyDiscount);
return ( return (
<AnimatedCard variant={isFamilyPlan ? "success" : "default"} className="p-6 w-full max-w-sm"> <AnimatedCard
<div className="flex items-start justify-between mb-3"> variant={isFamilyPlan ? "success" : "default"}
<div> className="p-6 w-full max-w-sm flex flex-col h-full"
<div className="flex items-center gap-2 mb-1"> >
<DevicePhoneMobileIcon className="h-4 w-4 text-blue-600" /> {/* Header with data size and pricing */}
<span className="font-bold text-sm text-gray-900">{plan.simDataSize}</span> <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> </div>
{isFamilyPlan && ( {isFamilyPlan && (
<div className="flex items-center gap-1 mb-1"> <CardBadge text="Family Discount" variant="family" size="sm" />
<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>
)} )}
</div> </div>
</div> </div>
<div className="mb-3">
<div className="flex items-baseline gap-1"> {/* Pricing */}
<CurrencyYenIcon className="h-4 w-4 text-gray-600" /> <div className="mb-4">
<span className="text-xl font-bold text-gray-900">{displayPrice.toLocaleString()}</span> <CardPricing
<span className="text-gray-600 text-sm">/month</span> monthlyPrice={displayPrice}
</div> size="sm"
alignment="left"
/>
{isFamilyPlan && ( {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>
<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> </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 Configure
</Button> </Button>
</AnimatedCard> </AnimatedCard>

View File

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

View File

@ -41,7 +41,16 @@ export function useInternetConfigureParams() {
const accessMode: AccessMode | null = const accessMode: AccessMode | null =
accessModeParam === "IPoE-BYOR" || accessModeParam === "PPPoE" ? accessModeParam : null; accessModeParam === "IPoE-BYOR" || accessModeParam === "PPPoE" ? accessModeParam : null;
const installationSku = params.get("installationSku"); 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 { return {
accessMode, accessMode,

View File

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

View File

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

View File

@ -8,9 +8,41 @@ export function InternetConfigureContainer() {
const router = useRouter(); const router = useRouter();
const vm = useInternetConfigure(); 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 = () => { 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(); 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()}`); router.push(`/checkout?${params.toString()}`);
}; };

View File

@ -236,52 +236,52 @@ export function SimPlansContainer() {
</div> </div>
</div> </div>
<div className="min-h-[400px] relative"> <div className="min-h-[500px] relative">
<div {activeTab === "data-voice" && (
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"}`} <div className="animate-in fade-in duration-300">
> <SimPlanTypeSection
<SimPlanTypeSection title="Data + SMS + Voice Plans"
title="Data + SMS + Voice Plans" description={
description={ hasExistingSim
hasExistingSim ? "Family discount shown where eligible"
? "Family discount shown where eligible" : "Comprehensive plans with high-speed data, messaging, and calling"
: "Comprehensive plans with high-speed data, messaging, and calling" }
} icon={<DevicePhoneMobileIcon className="h-6 w-6 text-blue-600" />}
icon={<DevicePhoneMobileIcon className="h-6 w-6 text-blue-600" />} plans={plansByType.DataSmsVoice}
plans={plansByType.DataSmsVoice} showFamilyDiscount={hasExistingSim}
showFamilyDiscount={hasExistingSim} />
/> </div>
</div> )}
<div {activeTab === "data-only" && (
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"}`} <div className="animate-in fade-in duration-300">
> <SimPlanTypeSection
<SimPlanTypeSection title="Data Only Plans"
title="Data Only Plans" description={
description={ hasExistingSim
hasExistingSim ? "Family discount shown where eligible"
? "Family discount shown where eligible" : "Flexible data-only plans for internet usage"
: "Flexible data-only plans for internet usage" }
} icon={<GlobeAltIcon className="h-6 w-6 text-purple-600" />}
icon={<GlobeAltIcon className="h-6 w-6 text-purple-600" />} plans={plansByType.DataOnly}
plans={plansByType.DataOnly} showFamilyDiscount={hasExistingSim}
showFamilyDiscount={hasExistingSim} />
/> </div>
</div> )}
<div {activeTab === "voice-only" && (
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"}`} <div className="animate-in fade-in duration-300">
> <SimPlanTypeSection
<SimPlanTypeSection title="Voice + SMS Only Plans"
title="Voice + SMS Only Plans" description={
description={ hasExistingSim
hasExistingSim ? "Family discount shown where eligible"
? "Family discount shown where eligible" : "Plans focused on voice calling and messaging without data bundles"
: "Plans focused on voice calling and messaging without data bundles" }
} icon={<PhoneIcon className="h-6 w-6 text-orange-600" />}
icon={<PhoneIcon className="h-6 w-6 text-orange-600" />} plans={plansByType.VoiceOnly}
plans={plansByType.VoiceOnly} showFamilyDiscount={hasExistingSim}
showFamilyDiscount={hasExistingSim} />
/> </div>
</div> )}
</div> </div>
<div className="mt-8 bg-gray-50 rounded-2xl p-8 max-w-4xl mx-auto"> <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 navigateBackToConfigure = useCallback(() => {
const urlParams = new URLSearchParams(params.toString()); const urlParams = new URLSearchParams(params.toString());
const reviewStep = orderType === "Internet" ? "4" : "5"; // Remove the 'type' param as it's not needed in configure URLs
urlParams.set("step", reviewStep); urlParams.delete('type');
const configureUrl = const configureUrl =
orderType === "Internet" orderType === "Internet"
? `/catalog/internet/configure?${urlParams.toString()}` ? `/catalog/internet/configure?${urlParams.toString()}`
: `/catalog/sim/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]); }, [orderType, params, router]);
return { return {

View File

@ -1,5 +1,6 @@
import { apiClient, getDataOrThrow } from "@/lib/api"; import { apiClient, getDataOrThrow } from "@/lib/api";
import type { CheckoutCart, OrderConfigurations } from "@customer-portal/domain/orders"; import type { CheckoutCart, OrderConfigurations } from "@customer-portal/domain/orders";
import type { ApiSuccessResponse } from "@customer-portal/domain/common";
export const checkoutService = { export const checkoutService = {
/** /**
@ -10,7 +11,7 @@ export const checkoutService = {
selections: Record<string, string>, selections: Record<string, string>,
configuration?: OrderConfigurations configuration?: OrderConfigurations
): Promise<CheckoutCart> { ): Promise<CheckoutCart> {
const response = await apiClient.POST<CheckoutCart>("/checkout/cart", { const response = await apiClient.POST<ApiSuccessResponse<CheckoutCart>>("/api/checkout/cart", {
body: { body: {
orderType, orderType,
selections, 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 * Validate checkout cart
*/ */
async validateCart(cart: CheckoutCart): Promise<void> { 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} 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" 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>
<button <button
type="button" type="button"