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 && (
|
{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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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";
|
"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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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's
|
<span className="text-green-600 mt-0.5 flex-shrink-0">✓</span>
|
||||||
Hikari Next -{" "}
|
<span>NTT Optical Fiber (Flet'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={() => {
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
});
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Step navigation
|
// 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 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;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 };
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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()}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user