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:
parent
67bad7819a
commit
b3086a5593
@ -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>
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
12
apps/portal/src/features/catalog/components/base/index.ts
Normal file
12
apps/portal/src/features/catalog/components/base/index.ts
Normal 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";
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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'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'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={() => {
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 };
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
|
||||
@ -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()}`);
|
||||
};
|
||||
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 });
|
||||
},
|
||||
};
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user