Enhance Internet Offering Card and Public Internet Views

- Integrated InternetTierPricingModal into InternetOfferingCard for improved pricing visibility in preview mode.
- Updated pricing display logic to conditionally show pricing based on preview mode.
- Refined feature display in InternetOfferingCard to enhance user experience.
- Revamped PublicInternetConfigure and PublicInternetPlans views for a cleaner, more focused signup flow and improved FAQ section.
- Streamlined offering card presentation and added dynamic FAQ item expansion for better user engagement.
This commit is contained in:
barsa 2025-12-24 14:58:56 +09:00
parent 530245f43a
commit 47414f10e0
7 changed files with 1142 additions and 484 deletions

View File

@ -0,0 +1,141 @@
"use client";
import { useEffect, useId, useRef } from "react";
import { XMarkIcon } from "@heroicons/react/24/outline";
import { cn } from "@/lib/utils";
interface InternetModalShellProps {
isOpen: boolean;
onClose: () => void;
title: string;
description?: string;
children: React.ReactNode;
size?: "md" | "lg";
}
const sizeMap: Record<NonNullable<InternetModalShellProps["size"]>, string> = {
md: "max-w-lg",
lg: "max-w-3xl",
};
/**
* Lightweight modal shell (overlay + card) used by the Internet shop experience.
* Implements:
* - Backdrop click to close
* - Escape to close
* - Simple focus trap + focus restore (pattern aligned with SessionTimeoutWarning)
*/
export function InternetModalShell({
isOpen,
onClose,
title,
description,
children,
size = "lg",
}: InternetModalShellProps) {
const titleId = useId();
const descriptionId = useId();
const dialogRef = useRef<HTMLDivElement | null>(null);
const previouslyFocusedElement = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!isOpen) return;
previouslyFocusedElement.current = document.activeElement as HTMLElement | null;
const focusTimer = window.setTimeout(() => {
dialogRef.current?.focus();
}, 0);
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
onClose();
}
if (event.key === "Tab") {
const focusableElements = dialogRef.current?.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (!focusableElements || focusableElements.length === 0) {
event.preventDefault();
return;
}
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (!event.shiftKey && document.activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
if (event.shiftKey && document.activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
}
}
};
document.addEventListener("keydown", handleKeyDown);
return () => {
clearTimeout(focusTimer);
document.removeEventListener("keydown", handleKeyDown);
previouslyFocusedElement.current?.focus();
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
role="presentation"
onClick={e => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="absolute inset-0 bg-background/80 backdrop-blur-sm" aria-hidden="true" />
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={description ? descriptionId : undefined}
tabIndex={-1}
className={cn(
"relative z-10 w-full rounded-2xl border border-border bg-card text-card-foreground shadow-[var(--cp-shadow-3)] max-h-[90vh] overflow-y-auto outline-none",
sizeMap[size]
)}
onClick={e => e.stopPropagation()}
>
<div className="sticky top-0 z-10 flex items-start justify-between gap-4 border-b border-border bg-card px-6 py-4">
<div className="min-w-0">
<h2 id={titleId} className="text-lg font-semibold text-foreground">
{title}
</h2>
{description ? (
<p id={descriptionId} className="mt-1 text-sm text-muted-foreground">
{description}
</p>
) : null}
</div>
<button
onClick={onClose}
className="text-muted-foreground transition-colors hover:text-foreground flex-shrink-0"
aria-label="Close modal"
type="button"
>
<XMarkIcon className="h-5 w-5" />
</button>
</div>
<div className="p-6">{children}</div>
</div>
</div>
);
}

View File

@ -11,6 +11,7 @@ import {
import { Button } from "@/components/atoms/button";
import { CardBadge } from "@/features/catalog/components/base/CardBadge";
import { cn } from "@/lib/utils";
import { InternetTierPricingModal } from "@/features/catalog/components/internet/InternetTierPricingModal";
interface TierInfo {
tier: "Silver" | "Gold" | "Platinum";
@ -84,6 +85,7 @@ export function InternetOfferingCard({
previewMode = false,
}: InternetOfferingCardProps) {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
const [pricingOpen, setPricingOpen] = useState(false);
const Icon = iconType === "home" ? HomeIcon : BuildingOfficeIcon;
@ -135,7 +137,13 @@ export function InternetOfferingCard({
<div className="flex items-center gap-2 flex-shrink-0 mt-2">
<span className="text-sm text-muted-foreground hidden sm:inline">
{isExpanded ? "Hide plans" : "View plans"}
{previewMode
? isExpanded
? "Hide tiers"
: "Preview tiers"
: isExpanded
? "Hide plans"
: "View plans"}
</span>
{isExpanded ? (
<ChevronUpIcon className="h-5 w-5 text-muted-foreground" />
@ -161,44 +169,48 @@ export function InternetOfferingCard({
{/* Header */}
<div className="flex items-center gap-2 mb-3">
<span className={cn("font-bold", tierStyles[tier.tier].accent)}>{tier.tier}</span>
{tier.recommended && (
{tier.recommended ? (
<CardBadge text="Recommended" variant="recommended" size="xs" />
)}
) : null}
</div>
{/* Pricing */}
<div className="mb-3">
<div className="flex items-baseline gap-1">
<span className="text-2xl font-bold text-foreground">
¥{tier.monthlyPrice.toLocaleString()}
</span>
<span className="text-sm text-muted-foreground">/mo</span>
{/* Pricing (hidden in preview mode) */}
{!previewMode ? (
<div className="mb-3">
<div className="flex items-baseline gap-1">
<span className="text-2xl font-bold text-foreground">
¥{tier.monthlyPrice.toLocaleString()}
</span>
<span className="text-sm text-muted-foreground">/mo</span>
</div>
{tier.pricingNote ? (
<p className="text-xs text-warning mt-1">{tier.pricingNote}</p>
) : null}
</div>
{tier.pricingNote && (
<p className="text-xs text-warning mt-1">{tier.pricingNote}</p>
)}
</div>
) : null}
{/* Description */}
<p className="text-sm text-muted-foreground mb-3">{tier.description}</p>
{/* Features - flex-grow to push button to bottom */}
<ul className="space-y-1.5 mb-4 flex-grow">
{tier.features.map((feature, index) => (
<li key={index} className="flex items-start gap-2 text-sm">
<BoltIcon className="h-3.5 w-3.5 text-success flex-shrink-0 mt-0.5" />
<span className="text-muted-foreground text-xs leading-relaxed">
{feature}
</span>
</li>
))}
{/* Features */}
<ul className={cn("space-y-1.5 mb-4 flex-grow", previewMode ? "opacity-90" : "")}>
{(previewMode ? tier.features.slice(0, 3) : tier.features).map(
(feature, index) => (
<li key={index} className="flex items-start gap-2 text-sm">
<BoltIcon className="h-3.5 w-3.5 text-success flex-shrink-0 mt-0.5" />
<span className="text-muted-foreground text-xs leading-relaxed">
{feature}
</span>
</li>
)
)}
</ul>
{/* Button/Info - always at bottom */}
{previewMode ? (
<div className="mt-auto pt-2 border-t border-border/50">
<p className="text-xs text-muted-foreground text-center">
Available after verification
Prices shown after you click See pricing
</p>
</div>
) : disabled ? (
@ -206,11 +218,11 @@ export function InternetOfferingCard({
<Button variant="outline" size="sm" className="w-full" disabled>
Unavailable
</Button>
{disabledReason && (
{disabledReason ? (
<p className="text-xs text-muted-foreground text-center mt-2">
{disabledReason}
</p>
)}
) : null}
</div>
) : (
<Button
@ -227,11 +239,41 @@ export function InternetOfferingCard({
))}
</div>
<p className="text-xs text-muted-foreground text-center mt-4">
+ ¥{setupFee.toLocaleString()} one-time installation (or 12/24-month installment)
</p>
{previewMode ? (
<div className="mt-5 flex flex-col sm:flex-row items-stretch sm:items-center gap-3 sm:justify-between">
<p className="text-xs text-muted-foreground">
Setup is typically ¥{setupFee.toLocaleString()}. Your actual options are confirmed
after address verification.
</p>
<div className="flex flex-col sm:flex-row gap-2 sm:flex-shrink-0">
<Button type="button" variant="outline" onClick={() => setPricingOpen(true)}>
See pricing
</Button>
<Button as="a" href={ctaPath}>
Check availability
</Button>
</div>
</div>
) : (
<p className="text-xs text-muted-foreground text-center mt-4">
+ ¥{setupFee.toLocaleString()} one-time installation (or 12/24-month installment)
</p>
)}
</div>
)}
{/* Pricing modal (public preview mode only) */}
{previewMode ? (
<InternetTierPricingModal
isOpen={pricingOpen}
onClose={() => setPricingOpen(false)}
offeringTitle={title}
offeringSubtitle={`${speedBadge}${isPremium ? " · select areas" : ""}`}
tiers={tiers}
setupFee={setupFee}
ctaHref={ctaPath}
/>
) : null}
</div>
);
}

View File

@ -0,0 +1,120 @@
"use client";
import { BoltIcon } from "@heroicons/react/24/outline";
import { Button } from "@/components/atoms/button";
import { CardBadge } from "@/features/catalog/components/base/CardBadge";
import { cn } from "@/lib/utils";
import type { TierInfo } from "@/features/catalog/components/internet/InternetOfferingCard";
import { InternetModalShell } from "@/features/catalog/components/internet/InternetModalShell";
interface InternetTierPricingModalProps {
isOpen: boolean;
onClose: () => void;
offeringTitle: string;
offeringSubtitle?: string;
tiers: TierInfo[];
setupFee: number;
ctaHref: string;
}
const tierStyles = {
Silver: {
card: "border-muted-foreground/20 bg-card",
accent: "text-muted-foreground",
},
Gold: {
card: "border-warning/30 bg-warning-soft/20",
accent: "text-warning",
},
Platinum: {
card: "border-primary/30 bg-info-soft/20",
accent: "text-primary",
},
} as const;
export function InternetTierPricingModal({
isOpen,
onClose,
offeringTitle,
offeringSubtitle,
tiers,
setupFee,
ctaHref,
}: InternetTierPricingModalProps) {
return (
<InternetModalShell
isOpen={isOpen}
onClose={onClose}
title={`Tier pricing — ${offeringTitle}`}
description="Pricing shown is for reference. Your actual options will be confirmed after address verification."
size="lg"
>
<div className="space-y-5">
{offeringSubtitle ? (
<div className="rounded-xl border border-border bg-muted/20 p-4">
<div className="text-sm text-foreground font-medium">{offeringTitle}</div>
<div className="text-xs text-muted-foreground mt-1">{offeringSubtitle}</div>
</div>
) : null}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{tiers.map(tier => (
<div
key={tier.tier}
className={cn(
"rounded-xl border p-4 flex flex-col shadow-[var(--cp-shadow-1)]",
tierStyles[tier.tier].card,
tier.recommended && "ring-2 ring-warning/30"
)}
>
<div className="flex items-center gap-2 mb-3">
<span className={cn("font-bold", tierStyles[tier.tier].accent)}>{tier.tier}</span>
{tier.recommended ? (
<CardBadge text="Recommended" variant="recommended" size="xs" />
) : null}
</div>
<div className="mb-2">
<div className="flex items-baseline gap-1">
<span className="text-2xl font-bold text-foreground">
¥{tier.monthlyPrice.toLocaleString()}
</span>
<span className="text-sm text-muted-foreground">/mo</span>
</div>
{tier.pricingNote ? (
<p className="text-xs text-warning mt-1">{tier.pricingNote}</p>
) : null}
</div>
<p className="text-sm text-muted-foreground mb-3">{tier.description}</p>
<ul className="space-y-1.5 flex-grow">
{tier.features.map((feature, index) => (
<li key={index} className="flex items-start gap-2 text-sm">
<BoltIcon className="h-3.5 w-3.5 text-success flex-shrink-0 mt-0.5" />
<span className="text-muted-foreground text-xs leading-relaxed">{feature}</span>
</li>
))}
</ul>
<div className="mt-4 pt-3 border-t border-border/50">
<p className="text-[11px] text-muted-foreground text-center">
+ ¥{setupFee.toLocaleString()} setup
</p>
</div>
</div>
))}
</div>
<div className="flex flex-col sm:flex-row gap-3 sm:justify-end">
<Button type="button" variant="outline" onClick={onClose}>
Close
</Button>
<Button as="a" href={ctaHref}>
Check availability
</Button>
</div>
</div>
</InternetModalShell>
);
}

View File

@ -0,0 +1,272 @@
"use client";
import { useState } from "react";
import {
ChevronDownIcon,
ChevronUpIcon,
HomeIcon,
BuildingOfficeIcon,
BoltIcon,
InformationCircleIcon,
XMarkIcon,
} from "@heroicons/react/24/outline";
import { Button } from "@/components/atoms/button";
import { CardBadge } from "@/features/catalog/components/base/CardBadge";
import { cn } from "@/lib/utils";
interface TierInfo {
tier: "Silver" | "Gold" | "Platinum";
monthlyPrice: number;
description: string;
features: string[];
pricingNote?: string;
}
interface PublicOfferingCardProps {
offeringType: string;
title: string;
speedBadge: string;
description: string;
iconType: "home" | "apartment";
startingPrice: number;
setupFee: number;
tiers: TierInfo[];
isPremium?: boolean;
ctaPath: string;
defaultExpanded?: boolean;
/** Show info tooltip explaining connection types (for Apartment) */
showConnectionInfo?: boolean;
}
const tierStyles = {
Silver: {
card: "border-muted-foreground/20 bg-card",
accent: "text-muted-foreground",
},
Gold: {
card: "border-warning/30 bg-warning-soft/20",
accent: "text-warning",
},
Platinum: {
card: "border-primary/30 bg-info-soft/20",
accent: "text-primary",
},
} as const;
/**
* Info panel explaining apartment connection types
*/
function ConnectionTypeInfo({ onClose }: { onClose: () => void }) {
return (
<div className="bg-info-soft/50 border border-info/20 rounded-lg p-4 mb-4">
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex items-center gap-2">
<InformationCircleIcon className="h-5 w-5 text-info flex-shrink-0" />
<h4 className="font-semibold text-sm text-foreground">
Why does speed vary by building?
</h4>
</div>
<button
type="button"
onClick={onClose}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<XMarkIcon className="h-4 w-4" />
</button>
</div>
<div className="space-y-3 text-xs text-muted-foreground">
<p>
Apartment buildings in Japan have different fiber infrastructure installed by NTT. Your
available speed depends on what your building supports:
</p>
<div className="grid gap-2">
<div className="flex gap-2">
<span className="font-semibold text-foreground whitespace-nowrap">FTTH (1Gbps)</span>
<span>
Fiber directly to your unit. Fastest option, available in newer buildings.
</span>
</div>
<div className="flex gap-2">
<span className="font-semibold text-foreground whitespace-nowrap">VDSL (100Mbps)</span>
<span>
Fiber to building, then phone line to your unit. Most common in older buildings.
</span>
</div>
<div className="flex gap-2">
<span className="font-semibold text-foreground whitespace-nowrap">LAN (100Mbps)</span>
<span>
Fiber to building, then ethernet to your unit. Common in some mansion types.
</span>
</div>
</div>
<p className="text-foreground font-medium pt-1">
Good news: All types have the same monthly price (¥4,800~). We&apos;ll check what&apos;s
available at your address.
</p>
</div>
</div>
);
}
/**
* Public-facing offering card that shows pricing inline
* No modals - all information is visible or expandable within the card
*/
export function PublicOfferingCard({
title,
speedBadge,
description,
iconType,
startingPrice,
setupFee,
tiers,
isPremium = false,
ctaPath,
defaultExpanded = false,
showConnectionInfo = false,
}: PublicOfferingCardProps) {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
const [showInfo, setShowInfo] = useState(false);
const Icon = iconType === "home" ? HomeIcon : BuildingOfficeIcon;
return (
<div
className={cn(
"rounded-xl border bg-card shadow-[var(--cp-shadow-1)] overflow-hidden transition-all duration-200",
isExpanded ? "shadow-[var(--cp-shadow-2)]" : "",
isPremium ? "border-primary/30" : "border-border"
)}
>
{/* Header - Always visible */}
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="w-full p-4 flex items-start justify-between gap-3 text-left hover:bg-muted/20 transition-colors"
>
<div className="flex items-start gap-3">
<div
className={cn(
"flex h-10 w-10 items-center justify-center rounded-lg border flex-shrink-0",
iconType === "home"
? "bg-info-soft/50 text-info border-info/20"
: "bg-success-soft/50 text-success border-success/20"
)}
>
<Icon className="h-5 w-5" />
</div>
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-base font-bold text-foreground">{title}</h3>
<CardBadge text={speedBadge} variant={isPremium ? "new" : "default"} size="sm" />
{isPremium && <span className="text-xs text-muted-foreground">(select areas)</span>}
</div>
<p className="text-sm text-muted-foreground">{description}</p>
<div className="flex items-baseline gap-1 pt-0.5">
<span className="text-xs text-muted-foreground">From</span>
<span className="text-lg font-bold text-foreground">
¥{startingPrice.toLocaleString()}
</span>
<span className="text-sm text-muted-foreground">/mo</span>
</div>
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0 mt-1">
<span className="text-xs text-muted-foreground hidden sm:inline">
{isExpanded ? "Hide" : "View tiers"}
</span>
{isExpanded ? (
<ChevronUpIcon className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDownIcon className="h-4 w-4 text-muted-foreground" />
)}
</div>
</button>
{/* Expanded content - Tier pricing shown inline */}
{isExpanded && (
<div className="border-t border-border px-4 py-4 bg-muted/10">
{/* Connection type info button (for Apartment) */}
{showConnectionInfo && !showInfo && (
<button
type="button"
onClick={() => setShowInfo(true)}
className="flex items-center gap-1.5 text-xs text-info hover:text-info/80 transition-colors mb-3"
>
<InformationCircleIcon className="h-4 w-4" />
<span>Why does speed vary by building?</span>
</button>
)}
{/* Connection type info panel */}
{showConnectionInfo && showInfo && (
<ConnectionTypeInfo onClose={() => setShowInfo(false)} />
)}
{/* Tier cards - 3 columns on desktop */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-4">
{tiers.map(tier => (
<div
key={tier.tier}
className={cn(
"rounded-lg border p-3 transition-all duration-200 flex flex-col",
tierStyles[tier.tier].card
)}
>
{/* Header */}
<div className="flex items-center gap-2 mb-2">
<span className={cn("font-semibold text-sm", tierStyles[tier.tier].accent)}>
{tier.tier}
</span>
</div>
{/* Price - Always visible */}
<div className="mb-2">
<div className="flex items-baseline gap-0.5 flex-wrap">
<span className="text-xl font-bold text-foreground">
¥{tier.monthlyPrice.toLocaleString()}
</span>
<span className="text-xs text-muted-foreground">/mo</span>
{tier.pricingNote && (
<span className="text-[10px] text-warning ml-1">{tier.pricingNote}</span>
)}
</div>
</div>
{/* Description */}
<p className="text-xs text-muted-foreground mb-2">{tier.description}</p>
{/* Features */}
<ul className="space-y-1 flex-grow">
{tier.features.slice(0, 3).map((feature, index) => (
<li key={index} className="flex items-start gap-1.5 text-xs">
<BoltIcon className="h-3 w-3 text-success flex-shrink-0 mt-0.5" />
<span className="text-muted-foreground leading-relaxed">{feature}</span>
</li>
))}
</ul>
</div>
))}
</div>
{/* Footer with setup fee and CTA */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 pt-3 border-t border-border/50">
<p className="text-xs text-muted-foreground flex-1">
<span className="font-semibold text-foreground">
+ ¥{setupFee.toLocaleString()} one-time setup
</span>{" "}
(or 12/24-month installment)
</p>
<Button as="a" href={ctaPath} size="sm" className="whitespace-nowrap">
Check availability
</Button>
</div>
</div>
)}
</div>
);
}
export type { PublicOfferingCardProps, TierInfo };

View File

@ -0,0 +1,106 @@
"use client";
import {
WifiIcon,
ChatBubbleLeftRightIcon,
BoltIcon,
WrenchScrewdriverIcon,
DocumentTextIcon,
GlobeAltIcon,
} from "@heroicons/react/24/outline";
import { CheckCircleIcon } from "@heroicons/react/24/solid";
interface FeatureProps {
icon: React.ReactNode;
title: string;
description: string;
highlight?: string;
}
function FeatureCard({ icon, title, description, highlight }: FeatureProps) {
return (
<div className="flex items-start gap-3 p-4">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-gradient-to-br from-primary/15 to-primary/5 text-primary flex-shrink-0">
{icon}
</div>
<div className="min-w-0 flex-1">
<h3 className="font-semibold text-foreground text-sm leading-tight">{title}</h3>
<p className="text-xs text-muted-foreground leading-relaxed mt-0.5">{description}</p>
{highlight && (
<span className="inline-flex items-center gap-1 mt-1.5 text-[10px] font-medium text-success">
<CheckCircleIcon className="h-3 w-3" />
{highlight}
</span>
)}
</div>
</div>
);
}
/**
* Why Choose Us - Clean feature grid
* 6 key differentiators in a 3x2 grid on desktop, 2x3 on tablet, stacked on mobile
*/
export function WhyChooseUsPillars() {
const features: FeatureProps[] = [
{
icon: <WifiIcon className="h-[18px] w-[18px]" />,
title: "NTT Optical Fiber",
description: "Japan's most reliable network with speeds up to 10Gbps",
highlight: "99.9% uptime",
},
{
icon: <BoltIcon className="h-[18px] w-[18px]" />,
title: "IPv6/IPoE Ready",
description: "Next-gen protocol for congestion-free browsing",
highlight: "No peak-hour slowdowns",
},
{
icon: <ChatBubbleLeftRightIcon className="h-[18px] w-[18px]" />,
title: "Full English Support",
description: "Native English service for setup, billing & technical help",
highlight: "No language barriers",
},
{
icon: <DocumentTextIcon className="h-[18px] w-[18px]" />,
title: "One Bill, One Provider",
description: "NTT line + ISP + equipment bundled with simple billing",
highlight: "No hidden fees",
},
{
icon: <WrenchScrewdriverIcon className="h-[18px] w-[18px]" />,
title: "On-site Support",
description: "Technicians can visit for installation & troubleshooting",
highlight: "Professional setup",
},
{
icon: <GlobeAltIcon className="h-[18px] w-[18px]" />,
title: "Flexible Options",
description: "Multiple ISP configs available, IPv4/PPPoE if needed",
highlight: "Customizable",
},
];
return (
<div className="rounded-2xl border border-border bg-card shadow-[var(--cp-shadow-1)] overflow-hidden">
{/* Header */}
<div className="px-4 py-2.5 border-b border-border bg-muted/30">
<h2 className="text-sm font-bold text-foreground text-center tracking-tight">
Why Choose Us
</h2>
</div>
{/* Feature grid - 3 columns on large, 2 on medium, 1 on mobile */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
{features.map((feature, index) => (
<div
key={index}
className="border-b border-border sm:border-b-0 sm:border-r last:border-b-0 sm:last:border-r-0 sm:[&:nth-child(2n)]:border-r-0 lg:[&:nth-child(2n)]:border-r lg:[&:nth-child(3n)]:border-r-0 sm:[&:nth-child(n+3)]:border-t lg:[&:nth-child(n+3)]:border-t-0 lg:[&:nth-child(n+4)]:border-t"
>
<FeatureCard {...feature} />
</div>
))}
</div>
</div>
);
}

View File

@ -1,7 +1,7 @@
"use client";
import { useSearchParams } from "next/navigation";
import { WifiIcon, CheckIcon, ClockIcon, EnvelopeIcon } from "@heroicons/react/24/outline";
import { WifiIcon, ClockIcon, EnvelopeIcon, CheckCircleIcon } from "@heroicons/react/24/outline";
import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink";
import { InlineAuthSection } from "@/features/auth/components/InlineAuthSection/InlineAuthSection";
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
@ -12,8 +12,7 @@ import { Skeleton } from "@/components/atoms/loading-skeleton";
/**
* Public Internet Configure View
*
* Signup flow for internet ordering with honest expectations about
* the verification timeline (1-2 business days, not instant).
* Clean signup flow - auth form is the focus, "what happens next" is secondary info.
*/
export function PublicInternetConfigureView() {
const shopBasePath = useShopBasePath();
@ -42,147 +41,89 @@ export function PublicInternetConfigureView() {
<CatalogBackLink href={`${shopBasePath}/internet`} label="Back to plans" />
{/* Header */}
<div className="mt-6 mb-8 text-center">
<div className="mt-6 mb-6 text-center">
<div className="flex justify-center mb-4">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-blue-500/20 to-blue-500/5 border border-blue-500/20 shadow-lg shadow-blue-500/10">
<WifiIcon className="h-8 w-8 text-blue-500" />
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-primary/20 to-primary/5 border border-primary/20">
<WifiIcon className="h-7 w-7 text-primary" />
</div>
</div>
<h1 className="text-2xl md:text-3xl font-bold text-foreground mb-3">
Request Internet Service
<h1 className="text-2xl md:text-3xl font-bold text-foreground mb-2">
Check Internet Service Availability
</h1>
<p className="text-muted-foreground max-w-lg mx-auto">
Create an account to request an availability check for your address.
<p className="text-muted-foreground max-w-md mx-auto text-sm">
Create an account to see what&apos;s available at your address
</p>
</div>
{/* Plan Summary Card - only if plan is selected */}
{plan && (
<div className="mb-8 bg-card border border-border rounded-2xl p-6 shadow-[var(--cp-shadow-1)]">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">
Selected Plan
</div>
<div className="flex items-start gap-4">
<div className="flex-shrink-0">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-blue-500/10 border border-blue-500/20">
<WifiIcon className="h-6 w-6 text-blue-500" />
</div>
<div className="mb-6 bg-card border border-border rounded-xl p-4 shadow-[var(--cp-shadow-1)]">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 border border-primary/20 flex-shrink-0">
<WifiIcon className="h-5 w-5 text-primary" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div>
<h3 className="text-lg font-semibold text-foreground">{plan.name}</h3>
{plan.description && (
<p className="text-sm text-muted-foreground mt-1">{plan.description}</p>
)}
{(plan.catalogMetadata?.tierDescription ||
plan.internetPlanTier ||
plan.internetOfferingType) && (
<div className="flex flex-wrap gap-2 mt-3">
{(plan.catalogMetadata?.tierDescription || plan.internetPlanTier) && (
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-info/10 text-info border border-info/20">
{plan.catalogMetadata?.tierDescription || plan.internetPlanTier}
</span>
)}
{plan.internetOfferingType && (
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-muted text-muted-foreground">
{plan.internetOfferingType}
</span>
)}
</div>
)}
</div>
<div className="text-right">
<CardPricing monthlyPrice={plan.monthlyPrice} size="md" alignment="right" />
<p className="text-xs text-muted-foreground">Selected plan</p>
<h3 className="text-sm font-semibold text-foreground">{plan.name}</h3>
</div>
<CardPricing monthlyPrice={plan.monthlyPrice} size="sm" alignment="right" />
</div>
</div>
</div>
</div>
)}
{/* What happens after signup - honest timeline */}
<div className="mb-8 bg-card border border-border rounded-2xl p-6 shadow-[var(--cp-shadow-1)]">
<h2 className="text-base font-semibold text-foreground mb-4">What happens next</h2>
<div className="space-y-4">
<div className="flex items-start gap-4">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-primary text-sm font-bold flex-shrink-0">
1
</div>
<div>
<p className="text-sm font-medium text-foreground">Create your account</p>
<p className="text-sm text-muted-foreground">
Sign up with your service address to start the process.
</p>
</div>
</div>
<div className="flex items-start gap-4">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-muted-foreground text-sm font-bold flex-shrink-0">
2
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-foreground">We verify availability</p>
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-amber-500/10 text-amber-600 border border-amber-500/20">
<ClockIcon className="h-3 w-3" />
1-2 business days
</span>
</div>
<p className="text-sm text-muted-foreground">
Our team checks service availability with NTT for your specific address.
</p>
</div>
</div>
<div className="flex items-start gap-4">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-muted-foreground text-sm font-bold flex-shrink-0">
3
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-foreground">
You receive email notification
</p>
<EnvelopeIcon className="h-4 w-4 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground">
We&apos;ll email you when your personalized plans are ready to view.
</p>
</div>
</div>
<div className="flex items-start gap-4">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-muted-foreground text-sm font-bold flex-shrink-0">
4
</div>
<div>
<p className="text-sm font-medium text-foreground">Complete your order</p>
<p className="text-sm text-muted-foreground">
Choose your plan options, add payment, and schedule installation.
</p>
</div>
</div>
</div>
</div>
{/* Important note */}
<div className="mb-8 bg-info-soft border border-info/25 rounded-xl p-4">
<div className="flex items-start gap-3">
<CheckIcon className="h-5 w-5 text-info mt-0.5 flex-shrink-0" />
<div>
<p className="text-sm font-medium text-foreground">Your account is ready immediately</p>
<p className="text-sm text-muted-foreground mt-1">
While we verify your address, you can explore your account, add payment methods, and
browse our other services like SIM and VPN.
</p>
</div>
</div>
</div>
{/* Auth Section */}
{/* Auth Section - Primary focus */}
<InlineAuthSection
title="Create your account"
description="Enter your details including service address to get started."
redirectTo={redirectTo}
/>
{/* What happens next - Below auth, secondary info */}
<div className="mt-8 border-t border-border pt-6">
<h3 className="text-sm font-semibold text-foreground mb-4">What happens next</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="flex items-start gap-3">
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-primary/10 text-primary text-xs font-bold flex-shrink-0">
1
</div>
<div>
<p className="text-xs font-medium text-foreground">We verify your address</p>
<p className="text-xs text-muted-foreground flex items-center gap-1 mt-0.5">
<ClockIcon className="h-3 w-3" />
1-2 business days
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-muted text-muted-foreground text-xs font-bold flex-shrink-0">
2
</div>
<div>
<p className="text-xs font-medium text-foreground">You get notified</p>
<p className="text-xs text-muted-foreground flex items-center gap-1 mt-0.5">
<EnvelopeIcon className="h-3 w-3" />
Email when ready
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-muted text-muted-foreground text-xs font-bold flex-shrink-0">
3
</div>
<div>
<p className="text-xs font-medium text-foreground">Complete your order</p>
<p className="text-xs text-muted-foreground flex items-center gap-1 mt-0.5">
<CheckCircleIcon className="h-3 w-3" />
Choose plan & schedule
</p>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -1,7 +1,12 @@
"use client";
import { useMemo } from "react";
import { ServerIcon, ArrowRightIcon } from "@heroicons/react/24/outline";
import { useMemo, useState } from "react";
import {
ArrowRightIcon,
SparklesIcon,
ChevronDownIcon,
ChevronUpIcon,
} from "@heroicons/react/24/outline";
import { useInternetCatalog } from "@/features/catalog/hooks";
import type {
InternetPlanCatalogItem,
@ -10,365 +15,396 @@ import type {
import { Skeleton } from "@/components/atoms/loading-skeleton";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink";
import { CatalogHero } from "@/features/catalog/components/base/CatalogHero";
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
import { InternetImportantNotes } from "@/features/catalog/components/internet/InternetImportantNotes";
import {
InternetOfferingCard,
type TierInfo,
} from "@/features/catalog/components/internet/InternetOfferingCard";
import { WhyChooseSection } from "@/features/catalog/components/internet/WhyChooseSection";
import { PlanComparisonGuide } from "@/features/catalog/components/internet/PlanComparisonGuide";
import { HowItWorksSection } from "@/features/catalog/components/internet/HowItWorksSection";
import { Button } from "@/components/atoms/button";
// Streamlined components
import { WhyChooseUsPillars } from "@/features/catalog/components/internet/WhyChooseUsPillars";
import { PublicOfferingCard } from "@/features/catalog/components/internet/PublicOfferingCard";
import type { TierInfo } from "@/features/catalog/components/internet/PublicOfferingCard";
// Types
interface OfferingConfig {
interface GroupedOffering {
offeringType: string;
title: string;
speedBadge: string;
description: string;
iconType: "home" | "apartment";
isPremium: boolean;
displayOrder: number;
startingPrice: number;
setupFee: number;
tiers: TierInfo[];
isPremium?: boolean;
showConnectionInfo?: boolean;
}
// Display order optimized for UX:
// 1. Apartment 1G - Most common in Tokyo/Japan (many people live in mansions/apartments)
// 2. Apartment 100M - Second most common for apartments (older buildings)
// 3. Home 1G - Most common for houses
// 4. Home 10G - Premium option, select areas only
const OFFERING_CONFIGS: OfferingConfig[] = [
// FAQ data
const faqItems = [
{
offeringType: "Apartment 1G",
title: "Apartment 1Gbps",
speedBadge: "1 Gbps",
description: "High-speed fiber-to-the-unit for mansions and apartment buildings.",
iconType: "apartment",
isPremium: false,
displayOrder: 1,
question: "How can I check if 10Gbps service is available at my address?",
answer:
"10Gbps service is currently available in select areas, primarily in Tokyo and surrounding regions. When you check availability with your address, we'll show you exactly which speed options are available at your location.",
},
{
offeringType: "Apartment 100M",
title: "Apartment 100Mbps",
speedBadge: "100 Mbps",
description:
"Standard speed via VDSL or LAN for apartment buildings with shared infrastructure.",
iconType: "apartment",
isPremium: false,
displayOrder: 2,
question: "Why do apartment speeds vary by building?",
answer:
"Apartment buildings have different NTT fiber infrastructure. Newer buildings often have FTTH (fiber-to-the-home) supporting up to 1Gbps, while older buildings may use VDSL or LAN connections at 100Mbps. The good news: all apartment types have the same monthly price.",
},
{
offeringType: "Home 1G",
title: "Home 1Gbps",
speedBadge: "1 Gbps",
description:
"High-speed fiber for standalone houses. The most popular choice for home internet.",
iconType: "home",
isPremium: false,
displayOrder: 3,
question: "My home needs multiple WiFi routers for full coverage. Can you help?",
answer:
"Yes! Our Platinum tier includes a mesh WiFi system designed for larger homes. During setup, our team will assess your space and recommend the best equipment configuration for full coverage.",
},
{
offeringType: "Home 10G",
title: "Home 10Gbps",
speedBadge: "10 Gbps",
description:
"Ultra-fast fiber for standalone houses with the highest speeds available in Japan.",
iconType: "home",
isPremium: true,
displayOrder: 4,
question: "Can I transfer my existing internet service to Assist Solutions?",
answer:
"In most cases, yes. If you already have an NTT line, we can often take over the service without a new installation. Contact us with your current provider details and we'll guide you through the process.",
},
{
question: "What is the contract period?",
answer:
"Our standard contract is 2 years. Early termination fees may apply if you cancel before the contract ends. The setup fee can be paid upfront or spread across 12 or 24 monthly installments.",
},
{
question: "How are invoices sent?",
answer:
"E-statements (available only in English) will be sent to your primary email address. The service fee will be charged automatically to your registered credit card on file. For corporate plans, please contact us with your requests.",
},
];
/**
* Get tier info from plans
* FAQ Item component with expand/collapse
*/
function getTierInfo(plans: InternetPlanCatalogItem[], offeringType: string): TierInfo[] {
const filtered = plans.filter(p => p.internetOfferingType === offeringType);
const tierOrder: ("Silver" | "Gold" | "Platinum")[] = ["Silver", "Gold", "Platinum"];
const tierDescriptions: Record<
string,
{ description: string; features: string[]; pricingNote?: string }
> = {
Silver: {
description: "Essential setup—bring your own router",
features: [
"NTT modem + ISP connection",
"IPoE or PPPoE protocols",
"Self-configuration required",
],
},
Gold: {
description: "All-inclusive with router rental",
features: [
"Everything in Silver, plus:",
"WiFi router included",
"Auto-configured within 24hrs",
"Range extender option (+¥500/mo)",
],
},
Platinum: {
description: "Tailored setup for larger residences",
features: [
"Netgear INSIGHT mesh routers",
"Cloud-managed WiFi network",
"Remote support & auto-updates",
"Custom setup for your space",
],
pricingNote: "+ equipment fees based on your home",
},
};
const result: TierInfo[] = [];
for (const tier of tierOrder) {
const plan = filtered.find(p => p.internetPlanTier?.toLowerCase() === tier.toLowerCase());
if (!plan) continue;
const config = tierDescriptions[tier];
result.push({
tier,
monthlyPrice: plan.monthlyPrice ?? 0,
description: config.description,
features: config.features,
recommended: tier === "Gold",
pricingNote: config.pricingNote,
});
}
return result;
}
/**
* Get the setup fee from installations
*/
function getSetupFee(installations: InternetInstallationCatalogItem[]): number {
const basic = installations.find(i => i.sku?.toLowerCase().includes("basic"));
return basic?.oneTimePrice ?? 22800;
}
/**
* Public Internet Plans View
*
* Displays internet plans for unauthenticated users.
* Uses an informational approach - users can browse plans but must sign up
* and verify their address before they can actually order.
*/
export function PublicInternetPlansView() {
const shopBasePath = useShopBasePath();
const { data, isLoading, error } = useInternetCatalog();
const plans: InternetPlanCatalogItem[] = useMemo(() => data?.plans ?? [], [data?.plans]);
const installations: InternetInstallationCatalogItem[] = useMemo(
() => data?.installations ?? [],
[data?.installations]
);
const setupFee = useMemo(() => getSetupFee(installations), [installations]);
// Build offering cards data
const offeringCards = useMemo(() => {
return OFFERING_CONFIGS.map(config => {
const tiers = getTierInfo(plans, config.offeringType);
const startingPrice = tiers.length > 0 ? Math.min(...tiers.map(t => t.monthlyPrice)) : 0;
return {
...config,
tiers,
startingPrice,
setupFee,
ctaPath: `/shop/internet/configure`,
};
})
.filter(card => card.tiers.length > 0)
.sort((a, b) => a.displayOrder - b.displayOrder);
}, [plans, setupFee]);
if (isLoading) {
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
<CatalogBackLink href={shopBasePath} label="Back to Services" className="mb-4" />
<div className="text-center mb-12">
<Skeleton className="h-10 w-96 mx-auto mb-4" />
<Skeleton className="h-4 w-[32rem] max-w-full mx-auto" />
</div>
<div className="space-y-4">
{Array.from({ length: 4 }).map((_, i) => (
<div
key={i}
className="bg-card rounded-xl border border-border p-6 shadow-[var(--cp-shadow-1)]"
>
<div className="flex items-start gap-4">
<Skeleton className="h-12 w-12 rounded-xl" />
<div className="flex-1 space-y-2">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-72" />
<Skeleton className="h-6 w-32" />
</div>
</div>
</div>
))}
</div>
</div>
);
}
if (error) {
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
<CatalogBackLink href={shopBasePath} label="Back to Services" className="mb-4" />
<AlertBanner variant="error" title="Failed to load plans">
Please try again later or contact support if the problem persists.
</AlertBanner>
</div>
);
}
function FAQItem({
question,
answer,
isOpen,
onToggle,
}: {
question: string;
answer: string;
isOpen: boolean;
onToggle: () => void;
}) {
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
<CatalogBackLink href={shopBasePath} label="Back to Services" className="mb-4" />
<CatalogHero
title="Internet Service Plans"
description="NTT Optical Fiber with full English support—reliable, high-speed internet for homes and apartments across Japan."
/>
{offeringCards.length > 0 ? (
<>
{/* SECTION 1: Why choose us - Build trust first */}
<div className="mb-10">
<WhyChooseSection />
</div>
{/* SECTION 2: How it works - Set expectations */}
<div className="mb-10">
<HowItWorksSection />
</div>
{/* SECTION 3: Primary CTA - Get started */}
<div className="bg-primary/5 border border-primary/20 rounded-xl p-6 mb-10">
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
<div className="flex-1">
<h3 className="text-lg font-bold text-foreground mb-1">Ready to get connected?</h3>
<p className="text-sm text-muted-foreground">
Create an account and we'll verify what service is available at your address.
You'll receive an email within 1-2 business days.
</p>
</div>
<Button
as="a"
href="/shop/internet/configure"
size="lg"
rightIcon={<ArrowRightIcon className="h-4 w-4" />}
className="whitespace-nowrap"
>
Check Availability
</Button>
</div>
</div>
{/* SECTION 4: Plan tiers explained - Educational */}
<div className="mb-10">
<div className="mb-4">
<h2 className="text-xl font-bold text-foreground mb-1">Service tiers explained</h2>
<p className="text-sm text-muted-foreground">
All connection types offer three service levels. You'll choose your tier after we
verify your address.
</p>
</div>
<PlanComparisonGuide />
</div>
{/* SECTION 5: Available connection types - Preview only */}
<div className="mb-10">
<div className="mb-4">
<h2 className="text-xl font-bold text-foreground mb-1">Available connection types</h2>
<p className="text-sm text-muted-foreground">
Which type applies to you depends on your building. Expand any card to preview
pricing.
</p>
</div>
<div className="space-y-4">
{offeringCards.map(card => (
<InternetOfferingCard
key={card.offeringType}
offeringType={card.offeringType}
title={card.title}
speedBadge={card.speedBadge}
description={card.description}
iconType={card.iconType}
startingPrice={card.startingPrice}
setupFee={card.setupFee}
tiers={card.tiers}
isPremium={card.isPremium}
ctaPath={card.ctaPath}
previewMode
/>
))}
</div>
{/* Note about preview mode */}
<p className="text-xs text-muted-foreground text-center mt-4 italic">
Pricing shown is for reference. Your actual options will be confirmed after address
verification.
</p>
</div>
{/* SECTION 6: Important notes */}
<div className="mb-10">
<InternetImportantNotes />
</div>
{/* SECTION 7: Final CTA */}
<div className="bg-card border border-border rounded-xl p-6 text-center">
<h3 className="text-lg font-bold text-foreground mb-2">
Not sure which plan is right for you?
</h3>
<p className="text-sm text-muted-foreground mb-4 max-w-md mx-auto">
Don't worry—just sign up and we'll figure it out together. Our team will verify your
address and show you exactly which plans are available.
</p>
<Button
as="a"
href="/shop/internet/configure"
rightIcon={<ArrowRightIcon className="h-4 w-4" />}
>
Get Started
</Button>
</div>
<CatalogBackLink
href={shopBasePath}
label="Back to Services"
align="center"
className="mt-12 mb-0"
/>
</>
) : (
<div className="text-center py-16">
<div className="bg-card rounded-2xl shadow-[var(--cp-shadow-1)] border border-border p-12 max-w-md mx-auto">
<ServerIcon className="h-16 w-16 text-muted-foreground mx-auto mb-6" />
<h3 className="text-xl font-semibold text-foreground mb-2">No Plans Available</h3>
<p className="text-muted-foreground mb-8">
We couldn&apos;t find any internet plans available at this time.
</p>
<CatalogBackLink
href={shopBasePath}
label="Back to Services"
align="center"
className="mt-0 mb-0"
/>
</div>
<div className="border-b border-border last:border-b-0">
<button
type="button"
onClick={onToggle}
className="w-full py-4 flex items-start justify-between gap-3 text-left hover:text-primary transition-colors"
>
<span className="text-sm font-medium text-foreground">{question}</span>
{isOpen ? (
<ChevronUpIcon className="h-4 w-4 text-muted-foreground flex-shrink-0 mt-0.5" />
) : (
<ChevronDownIcon className="h-4 w-4 text-muted-foreground flex-shrink-0 mt-0.5" />
)}
</button>
{isOpen && (
<div className="pb-4 pr-8">
<p className="text-sm text-muted-foreground leading-relaxed">{answer}</p>
</div>
)}
</div>
);
}
export default PublicInternetPlansView;
/**
* Public Internet Plans page - Marketing/Conversion focused
* Clean, polished design optimized for conversion
*
* Note: Apartment types (FTTH 1G, VDSL 100M, LAN 100M) are consolidated into a single
* "Apartment" offering since they all have the same pricing. The actual connection type
* is determined by the building infrastructure during eligibility check.
*/
export function PublicInternetPlansView() {
const { data: catalog, isLoading, error } = useInternetCatalog();
const shopBasePath = useShopBasePath();
const ctaPath = `${shopBasePath}/internet/configure`;
const [openFaqIndex, setOpenFaqIndex] = useState<number | null>(null);
// Group catalog items by offering type
const groupedOfferings = useMemo(() => {
if (!catalog?.plans) return [];
const plansByType = catalog.plans.reduce(
(acc, plan) => {
const key = plan.internetOfferingType ?? "unknown";
if (!acc[key]) acc[key] = [];
acc[key].push(plan);
return acc;
},
{} as Record<string, InternetPlanCatalogItem[]>
);
// Get installation item for setup fee
const installationItem = catalog.installations?.[0] as
| InternetInstallationCatalogItem
| undefined;
const setupFee = installationItem?.oneTimePrice ?? 22800;
// Create grouped offerings
const offerings: GroupedOffering[] = [];
// Consolidate apartment types (they all have the same price)
// Connection type (FTTH, VDSL, LAN) depends on building infrastructure
const apartmentTypes = ["Apartment 1G", "Apartment 100M"];
const apartmentPlans: InternetPlanCatalogItem[] = [];
for (const type of apartmentTypes) {
if (plansByType[type]) {
apartmentPlans.push(...plansByType[type]);
}
}
// Define offering metadata
// Order: Home 10G first (premium), then Home 1G, then consolidated Apartment
const offeringMeta: Record<
string,
{
title: string;
description: string;
iconType: "home" | "apartment";
order: number;
isPremium?: boolean;
}
> = {
"Home 10G": {
title: "Home 10Gbps",
description: "Ultra-fast fiber with the highest speeds available in Japan.",
iconType: "home",
order: 1,
isPremium: true,
},
"Home 1G": {
title: "Home 1Gbps",
description: "High-speed fiber. The most popular choice for home internet.",
iconType: "home",
order: 2,
},
Apartment: {
title: "Apartment",
description:
"For mansions and apartment buildings. Speed depends on your building (up to 1Gbps).",
iconType: "apartment",
order: 3,
},
};
// Process Home offerings
for (const [offeringType, plans] of Object.entries(plansByType)) {
// Skip apartment types - we'll handle them separately
if (apartmentTypes.includes(offeringType)) continue;
const meta = offeringMeta[offeringType];
if (!meta) continue;
// Sort plans by tier: Silver, Gold, Platinum
const tierOrder: Record<string, number> = { Silver: 0, Gold: 1, Platinum: 2 };
const sortedPlans = [...plans].sort(
(a, b) =>
(tierOrder[a.internetPlanTier ?? ""] ?? 99) - (tierOrder[b.internetPlanTier ?? ""] ?? 99)
);
// Calculate starting price
const startingPrice = Math.min(...sortedPlans.map(p => p.monthlyPrice ?? 0));
// Get speed from offering type
const speedBadge = getSpeedBadge(offeringType);
// Build tier info (no recommended badge in public view)
const tiers: TierInfo[] = sortedPlans.map(plan => ({
tier: (plan.internetPlanTier ?? "Silver") as TierInfo["tier"],
monthlyPrice: plan.monthlyPrice ?? 0,
description: getTierDescription(plan.internetPlanTier ?? ""),
features: plan.catalogMetadata?.features ?? getTierFeatures(plan.internetPlanTier ?? ""),
pricingNote: plan.internetPlanTier === "Platinum" ? "+ equipment fees" : undefined,
}));
offerings.push({
offeringType,
title: meta.title,
speedBadge,
description: meta.description,
iconType: meta.iconType,
startingPrice,
setupFee,
tiers,
isPremium: meta.isPremium,
});
}
// Add consolidated Apartment offering (use any apartment plan for tiers - prices are the same)
if (apartmentPlans.length > 0) {
const meta = offeringMeta["Apartment"];
// Get unique tiers from apartment plans (they all have same prices)
const tierOrder: Record<string, number> = { Silver: 0, Gold: 1, Platinum: 2 };
const uniqueTiers = new Map<string, InternetPlanCatalogItem>();
for (const plan of apartmentPlans) {
const tier = plan.internetPlanTier ?? "Silver";
// Keep first occurrence of each tier (prices are same across apartment types)
if (!uniqueTiers.has(tier)) {
uniqueTiers.set(tier, plan);
}
}
const sortedTierPlans = Array.from(uniqueTiers.values()).sort(
(a, b) =>
(tierOrder[a.internetPlanTier ?? ""] ?? 99) - (tierOrder[b.internetPlanTier ?? ""] ?? 99)
);
const startingPrice = Math.min(...sortedTierPlans.map(p => p.monthlyPrice ?? 0));
const tiers: TierInfo[] = sortedTierPlans.map(plan => ({
tier: (plan.internetPlanTier ?? "Silver") as TierInfo["tier"],
monthlyPrice: plan.monthlyPrice ?? 0,
description: getTierDescription(plan.internetPlanTier ?? ""),
features: plan.catalogMetadata?.features ?? getTierFeatures(plan.internetPlanTier ?? ""),
pricingNote: plan.internetPlanTier === "Platinum" ? "+ equipment fees" : undefined,
}));
offerings.push({
offeringType: "Apartment",
title: meta.title,
speedBadge: "Up to 1Gbps",
description: meta.description,
iconType: meta.iconType,
startingPrice,
setupFee,
tiers,
showConnectionInfo: true, // Show the info tooltip for Apartment
});
}
// Sort by order
return offerings.sort((a, b) => {
const orderA = offeringMeta[a.offeringType]?.order ?? 99;
const orderB = offeringMeta[b.offeringType]?.order ?? 99;
return orderA - orderB;
});
}, [catalog]);
// Error state
if (error) {
return (
<div className="space-y-6">
<CatalogBackLink href={shopBasePath} />
<AlertBanner variant="error" title="Unable to load plans">
We couldn&apos;t load internet plans. Please try again later.
</AlertBanner>
</div>
);
}
return (
<div className="space-y-6">
{/* Back link */}
<CatalogBackLink href={shopBasePath} />
{/* Hero - Clean and impactful */}
<div className="text-center py-4">
<h1 className="text-3xl md:text-4xl font-bold text-foreground tracking-tight">
Internet Service Plans
</h1>
<p className="text-base text-muted-foreground mt-2 max-w-lg mx-auto">
NTT Optical Fiber with full English support
</p>
</div>
{/* Why choose us - 3 pillars */}
<WhyChooseUsPillars />
{/* Connection types - no extra header text */}
<section className="space-y-3">
{isLoading ? (
<div className="space-y-3">
{[1, 2, 3].map(i => (
<Skeleton key={i} className="h-28 w-full rounded-xl" />
))}
</div>
) : (
<div className="space-y-3">
{groupedOfferings.map((offering, index) => (
<PublicOfferingCard
key={offering.offeringType}
offeringType={offering.offeringType}
title={offering.title}
speedBadge={offering.speedBadge}
description={offering.description}
iconType={offering.iconType}
startingPrice={offering.startingPrice}
setupFee={offering.setupFee}
tiers={offering.tiers}
isPremium={offering.isPremium}
ctaPath={ctaPath}
defaultExpanded={index === 0}
showConnectionInfo={offering.showConnectionInfo}
/>
))}
</div>
)}
</section>
{/* Final CTA - Polished */}
<section className="text-center py-8 mt-4 rounded-2xl bg-gradient-to-br from-primary/5 via-transparent to-info/5 border border-border/50">
<div className="inline-flex items-center gap-1.5 text-xs font-medium text-primary mb-3 px-3 py-1 rounded-full bg-primary/10">
<SparklesIcon className="h-3.5 w-3.5" />
Get started in minutes
</div>
<h2 className="text-xl font-bold text-foreground mb-2">Ready to get connected?</h2>
<p className="text-sm text-muted-foreground mb-5 max-w-sm mx-auto">
Enter your address to see what&apos;s available at your location
</p>
<Button as="a" href={ctaPath} size="lg" rightIcon={<ArrowRightIcon className="h-4 w-4" />}>
Check Availability
</Button>
</section>
{/* FAQ Section */}
<section className="py-6">
<h2 className="text-lg font-bold text-foreground mb-4">Frequently Asked Questions</h2>
<div className="bg-card border border-border rounded-xl px-4">
{faqItems.map((item, index) => (
<FAQItem
key={index}
question={item.question}
answer={item.answer}
isOpen={openFaqIndex === index}
onToggle={() => setOpenFaqIndex(openFaqIndex === index ? null : index)}
/>
))}
</div>
</section>
</div>
);
}
// Helper functions
function getSpeedBadge(offeringType: string): string {
const speeds: Record<string, string> = {
"Apartment 100M": "100Mbps",
"Apartment 1G": "1Gbps",
"Home 1G": "1Gbps",
"Home 10G": "10Gbps",
};
return speeds[offeringType] ?? "1Gbps";
}
function getTierDescription(tier: string): string {
const descriptions: Record<string, string> = {
Silver: "Use your own router. Best for tech-savvy users.",
Gold: "Includes WiFi router rental. Our most popular choice.",
Platinum: "Premium equipment with mesh WiFi for larger homes.",
};
return descriptions[tier] ?? "";
}
function getTierFeatures(tier: string): string[] {
const features: Record<string, string[]> = {
Silver: ["NTT modem + ISP connection", "Use your own router", "Email/ticket support"],
Gold: ["NTT modem + ISP connection", "WiFi router included", "Priority phone support"],
Platinum: ["NTT modem + ISP connection", "Mesh WiFi system included", "Dedicated support line"],
};
return features[tier] ?? [];
}