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:
parent
530245f43a
commit
47414f10e0
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -11,6 +11,7 @@ import {
|
|||||||
import { Button } from "@/components/atoms/button";
|
import { Button } from "@/components/atoms/button";
|
||||||
import { CardBadge } from "@/features/catalog/components/base/CardBadge";
|
import { CardBadge } from "@/features/catalog/components/base/CardBadge";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { InternetTierPricingModal } from "@/features/catalog/components/internet/InternetTierPricingModal";
|
||||||
|
|
||||||
interface TierInfo {
|
interface TierInfo {
|
||||||
tier: "Silver" | "Gold" | "Platinum";
|
tier: "Silver" | "Gold" | "Platinum";
|
||||||
@ -84,6 +85,7 @@ export function InternetOfferingCard({
|
|||||||
previewMode = false,
|
previewMode = false,
|
||||||
}: InternetOfferingCardProps) {
|
}: InternetOfferingCardProps) {
|
||||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||||
|
const [pricingOpen, setPricingOpen] = useState(false);
|
||||||
|
|
||||||
const Icon = iconType === "home" ? HomeIcon : BuildingOfficeIcon;
|
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">
|
<div className="flex items-center gap-2 flex-shrink-0 mt-2">
|
||||||
<span className="text-sm text-muted-foreground hidden sm:inline">
|
<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>
|
</span>
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
<ChevronUpIcon className="h-5 w-5 text-muted-foreground" />
|
<ChevronUpIcon className="h-5 w-5 text-muted-foreground" />
|
||||||
@ -161,44 +169,48 @@ export function InternetOfferingCard({
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<span className={cn("font-bold", tierStyles[tier.tier].accent)}>{tier.tier}</span>
|
<span className={cn("font-bold", tierStyles[tier.tier].accent)}>{tier.tier}</span>
|
||||||
{tier.recommended && (
|
{tier.recommended ? (
|
||||||
<CardBadge text="Recommended" variant="recommended" size="xs" />
|
<CardBadge text="Recommended" variant="recommended" size="xs" />
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pricing */}
|
{/* Pricing (hidden in preview mode) */}
|
||||||
<div className="mb-3">
|
{!previewMode ? (
|
||||||
<div className="flex items-baseline gap-1">
|
<div className="mb-3">
|
||||||
<span className="text-2xl font-bold text-foreground">
|
<div className="flex items-baseline gap-1">
|
||||||
¥{tier.monthlyPrice.toLocaleString()}
|
<span className="text-2xl font-bold text-foreground">
|
||||||
</span>
|
¥{tier.monthlyPrice.toLocaleString()}
|
||||||
<span className="text-sm text-muted-foreground">/mo</span>
|
</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>
|
</div>
|
||||||
{tier.pricingNote && (
|
) : null}
|
||||||
<p className="text-xs text-warning mt-1">{tier.pricingNote}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<p className="text-sm text-muted-foreground mb-3">{tier.description}</p>
|
<p className="text-sm text-muted-foreground mb-3">{tier.description}</p>
|
||||||
|
|
||||||
{/* Features - flex-grow to push button to bottom */}
|
{/* Features */}
|
||||||
<ul className="space-y-1.5 mb-4 flex-grow">
|
<ul className={cn("space-y-1.5 mb-4 flex-grow", previewMode ? "opacity-90" : "")}>
|
||||||
{tier.features.map((feature, index) => (
|
{(previewMode ? tier.features.slice(0, 3) : tier.features).map(
|
||||||
<li key={index} className="flex items-start gap-2 text-sm">
|
(feature, index) => (
|
||||||
<BoltIcon className="h-3.5 w-3.5 text-success flex-shrink-0 mt-0.5" />
|
<li key={index} className="flex items-start gap-2 text-sm">
|
||||||
<span className="text-muted-foreground text-xs leading-relaxed">
|
<BoltIcon className="h-3.5 w-3.5 text-success flex-shrink-0 mt-0.5" />
|
||||||
{feature}
|
<span className="text-muted-foreground text-xs leading-relaxed">
|
||||||
</span>
|
{feature}
|
||||||
</li>
|
</span>
|
||||||
))}
|
</li>
|
||||||
|
)
|
||||||
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{/* Button/Info - always at bottom */}
|
{/* Button/Info - always at bottom */}
|
||||||
{previewMode ? (
|
{previewMode ? (
|
||||||
<div className="mt-auto pt-2 border-t border-border/50">
|
<div className="mt-auto pt-2 border-t border-border/50">
|
||||||
<p className="text-xs text-muted-foreground text-center">
|
<p className="text-xs text-muted-foreground text-center">
|
||||||
Available after verification
|
Prices shown after you click “See pricing”
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : disabled ? (
|
) : disabled ? (
|
||||||
@ -206,11 +218,11 @@ export function InternetOfferingCard({
|
|||||||
<Button variant="outline" size="sm" className="w-full" disabled>
|
<Button variant="outline" size="sm" className="w-full" disabled>
|
||||||
Unavailable
|
Unavailable
|
||||||
</Button>
|
</Button>
|
||||||
{disabledReason && (
|
{disabledReason ? (
|
||||||
<p className="text-xs text-muted-foreground text-center mt-2">
|
<p className="text-xs text-muted-foreground text-center mt-2">
|
||||||
{disabledReason}
|
{disabledReason}
|
||||||
</p>
|
</p>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
@ -227,11 +239,41 @@ export function InternetOfferingCard({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground text-center mt-4">
|
{previewMode ? (
|
||||||
+ ¥{setupFee.toLocaleString()} one-time installation (or 12/24-month installment)
|
<div className="mt-5 flex flex-col sm:flex-row items-stretch sm:items-center gap-3 sm:justify-between">
|
||||||
</p>
|
<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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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'll check what'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 };
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useSearchParams } from "next/navigation";
|
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 { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink";
|
||||||
import { InlineAuthSection } from "@/features/auth/components/InlineAuthSection/InlineAuthSection";
|
import { InlineAuthSection } from "@/features/auth/components/InlineAuthSection/InlineAuthSection";
|
||||||
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
|
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
|
||||||
@ -12,8 +12,7 @@ import { Skeleton } from "@/components/atoms/loading-skeleton";
|
|||||||
/**
|
/**
|
||||||
* Public Internet Configure View
|
* Public Internet Configure View
|
||||||
*
|
*
|
||||||
* Signup flow for internet ordering with honest expectations about
|
* Clean signup flow - auth form is the focus, "what happens next" is secondary info.
|
||||||
* the verification timeline (1-2 business days, not instant).
|
|
||||||
*/
|
*/
|
||||||
export function PublicInternetConfigureView() {
|
export function PublicInternetConfigureView() {
|
||||||
const shopBasePath = useShopBasePath();
|
const shopBasePath = useShopBasePath();
|
||||||
@ -42,147 +41,89 @@ export function PublicInternetConfigureView() {
|
|||||||
<CatalogBackLink href={`${shopBasePath}/internet`} label="Back to plans" />
|
<CatalogBackLink href={`${shopBasePath}/internet`} label="Back to plans" />
|
||||||
|
|
||||||
{/* Header */}
|
{/* 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 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">
|
<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-8 w-8 text-blue-500" />
|
<WifiIcon className="h-7 w-7 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl md:text-3xl font-bold text-foreground mb-3">
|
<h1 className="text-2xl md:text-3xl font-bold text-foreground mb-2">
|
||||||
Request Internet Service
|
Check Internet Service Availability
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground max-w-lg mx-auto">
|
<p className="text-muted-foreground max-w-md mx-auto text-sm">
|
||||||
Create an account to request an availability check for your address.
|
Create an account to see what's available at your address
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Plan Summary Card - only if plan is selected */}
|
{/* Plan Summary Card - only if plan is selected */}
|
||||||
{plan && (
|
{plan && (
|
||||||
<div className="mb-8 bg-card border border-border rounded-2xl p-6 shadow-[var(--cp-shadow-1)]">
|
<div className="mb-6 bg-card border border-border rounded-xl p-4 shadow-[var(--cp-shadow-1)]">
|
||||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">
|
<div className="flex items-center gap-3">
|
||||||
Selected Plan
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 border border-primary/20 flex-shrink-0">
|
||||||
</div>
|
<WifiIcon className="h-5 w-5 text-primary" />
|
||||||
<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>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<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>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-foreground">{plan.name}</h3>
|
<p className="text-xs text-muted-foreground">Selected plan</p>
|
||||||
{plan.description && (
|
<h3 className="text-sm font-semibold text-foreground">{plan.name}</h3>
|
||||||
<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" />
|
|
||||||
</div>
|
</div>
|
||||||
|
<CardPricing monthlyPrice={plan.monthlyPrice} size="sm" alignment="right" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* What happens after signup - honest timeline */}
|
{/* Auth Section - Primary focus */}
|
||||||
<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'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 */}
|
|
||||||
<InlineAuthSection
|
<InlineAuthSection
|
||||||
title="Create your account"
|
title="Create your account"
|
||||||
description="Enter your details including service address to get started."
|
description="Enter your details including service address to get started."
|
||||||
redirectTo={redirectTo}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { ServerIcon, ArrowRightIcon } from "@heroicons/react/24/outline";
|
import {
|
||||||
|
ArrowRightIcon,
|
||||||
|
SparklesIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronUpIcon,
|
||||||
|
} from "@heroicons/react/24/outline";
|
||||||
import { useInternetCatalog } from "@/features/catalog/hooks";
|
import { useInternetCatalog } from "@/features/catalog/hooks";
|
||||||
import type {
|
import type {
|
||||||
InternetPlanCatalogItem,
|
InternetPlanCatalogItem,
|
||||||
@ -10,365 +15,396 @@ import type {
|
|||||||
import { Skeleton } from "@/components/atoms/loading-skeleton";
|
import { Skeleton } from "@/components/atoms/loading-skeleton";
|
||||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||||
import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink";
|
import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink";
|
||||||
import { CatalogHero } from "@/features/catalog/components/base/CatalogHero";
|
|
||||||
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
|
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";
|
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
|
// Types
|
||||||
interface OfferingConfig {
|
interface GroupedOffering {
|
||||||
offeringType: string;
|
offeringType: string;
|
||||||
title: string;
|
title: string;
|
||||||
speedBadge: string;
|
speedBadge: string;
|
||||||
description: string;
|
description: string;
|
||||||
iconType: "home" | "apartment";
|
iconType: "home" | "apartment";
|
||||||
isPremium: boolean;
|
startingPrice: number;
|
||||||
displayOrder: number;
|
setupFee: number;
|
||||||
|
tiers: TierInfo[];
|
||||||
|
isPremium?: boolean;
|
||||||
|
showConnectionInfo?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display order optimized for UX:
|
// FAQ data
|
||||||
// 1. Apartment 1G - Most common in Tokyo/Japan (many people live in mansions/apartments)
|
const faqItems = [
|
||||||
// 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[] = [
|
|
||||||
{
|
{
|
||||||
offeringType: "Apartment 1G",
|
question: "How can I check if 10Gbps service is available at my address?",
|
||||||
title: "Apartment 1Gbps",
|
answer:
|
||||||
speedBadge: "1 Gbps",
|
"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.",
|
||||||
description: "High-speed fiber-to-the-unit for mansions and apartment buildings.",
|
|
||||||
iconType: "apartment",
|
|
||||||
isPremium: false,
|
|
||||||
displayOrder: 1,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
offeringType: "Apartment 100M",
|
question: "Why do apartment speeds vary by building?",
|
||||||
title: "Apartment 100Mbps",
|
answer:
|
||||||
speedBadge: "100 Mbps",
|
"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.",
|
||||||
description:
|
|
||||||
"Standard speed via VDSL or LAN for apartment buildings with shared infrastructure.",
|
|
||||||
iconType: "apartment",
|
|
||||||
isPremium: false,
|
|
||||||
displayOrder: 2,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
offeringType: "Home 1G",
|
question: "My home needs multiple WiFi routers for full coverage. Can you help?",
|
||||||
title: "Home 1Gbps",
|
answer:
|
||||||
speedBadge: "1 Gbps",
|
"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.",
|
||||||
description:
|
|
||||||
"High-speed fiber for standalone houses. The most popular choice for home internet.",
|
|
||||||
iconType: "home",
|
|
||||||
isPremium: false,
|
|
||||||
displayOrder: 3,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
offeringType: "Home 10G",
|
question: "Can I transfer my existing internet service to Assist Solutions?",
|
||||||
title: "Home 10Gbps",
|
answer:
|
||||||
speedBadge: "10 Gbps",
|
"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.",
|
||||||
description:
|
},
|
||||||
"Ultra-fast fiber for standalone houses with the highest speeds available in Japan.",
|
{
|
||||||
iconType: "home",
|
question: "What is the contract period?",
|
||||||
isPremium: true,
|
answer:
|
||||||
displayOrder: 4,
|
"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[] {
|
function FAQItem({
|
||||||
const filtered = plans.filter(p => p.internetOfferingType === offeringType);
|
question,
|
||||||
|
answer,
|
||||||
const tierOrder: ("Silver" | "Gold" | "Platinum")[] = ["Silver", "Gold", "Platinum"];
|
isOpen,
|
||||||
|
onToggle,
|
||||||
const tierDescriptions: Record<
|
}: {
|
||||||
string,
|
question: string;
|
||||||
{ description: string; features: string[]; pricingNote?: string }
|
answer: string;
|
||||||
> = {
|
isOpen: boolean;
|
||||||
Silver: {
|
onToggle: () => void;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
|
<div className="border-b border-border last:border-b-0">
|
||||||
<CatalogBackLink href={shopBasePath} label="Back to Services" className="mb-4" />
|
<button
|
||||||
|
type="button"
|
||||||
<CatalogHero
|
onClick={onToggle}
|
||||||
title="Internet Service Plans"
|
className="w-full py-4 flex items-start justify-between gap-3 text-left hover:text-primary transition-colors"
|
||||||
description="NTT Optical Fiber with full English support—reliable, high-speed internet for homes and apartments across Japan."
|
>
|
||||||
/>
|
<span className="text-sm font-medium text-foreground">{question}</span>
|
||||||
|
{isOpen ? (
|
||||||
{offeringCards.length > 0 ? (
|
<ChevronUpIcon className="h-4 w-4 text-muted-foreground flex-shrink-0 mt-0.5" />
|
||||||
<>
|
) : (
|
||||||
{/* SECTION 1: Why choose us - Build trust first */}
|
<ChevronDownIcon className="h-4 w-4 text-muted-foreground flex-shrink-0 mt-0.5" />
|
||||||
<div className="mb-10">
|
)}
|
||||||
<WhyChooseSection />
|
</button>
|
||||||
</div>
|
{isOpen && (
|
||||||
|
<div className="pb-4 pr-8">
|
||||||
{/* SECTION 2: How it works - Set expectations */}
|
<p className="text-sm text-muted-foreground leading-relaxed">{answer}</p>
|
||||||
<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'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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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'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'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] ?? [];
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user