Assist_Design/apps/portal/src/features/services/views/PublicInternetPlans.tsx
tema 94b341dd93 Enhance Freebit Integration and Update UI Components
- Added FreebitTestTrackerService to FreebitClientService for improved API call tracking.
- Updated Freebit module to include the new test tracker service.
- Refactored HowItWorksSection and PublicInternetPlans to enhance user guidance with clearer steps and improved styling.
- Introduced a new VpnHowItWorksSection to provide users with a simplified setup process for VPN services.
- Enhanced PublicOfferingCard and VpnPlanCard components with updated styles and additional feature highlights for better user engagement.
2026-01-17 18:47:13 +09:00

523 lines
19 KiB
TypeScript

"use client";
import { useMemo, useState } from "react";
import {
ArrowRight,
Sparkles,
ChevronDown,
ChevronUp,
Wifi,
Zap,
Languages,
FileText,
Wrench,
Globe,
} from "lucide-react";
import { usePublicInternetCatalog } from "@/features/services/hooks";
import type {
InternetInstallationCatalogItem,
InternetPlanCatalogItem,
} from "@customer-portal/domain/services";
import { Skeleton } from "@/components/atoms/loading-skeleton";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink";
import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath";
import { Button } from "@/components/atoms/button";
import type { TierInfo } from "@/features/services/components/internet/PublicOfferingCard";
import {
ServiceHighlights,
HighlightFeature,
} from "@/features/services/components/base/ServiceHighlights";
import { HowItWorksSection } from "@/features/services/components/internet/HowItWorksSection";
import { cn } from "@/shared/utils";
// Tier styling - matching the design with left border accents
const tierStyles = {
Silver: {
card: "border-gray-200 bg-white border-l-4 border-l-gray-400",
accent: "text-gray-600",
header: "Silver",
},
Gold: {
card: "border-gray-200 bg-white border-l-4 border-l-amber-500",
accent: "text-amber-600",
header: "Gold",
},
Platinum: {
card: "border-gray-200 bg-white border-l-4 border-l-primary",
accent: "text-primary",
header: "Platinum",
},
} as const;
/**
* Consolidated Internet Card - Single card showing all tiers with price ranges
*/
function ConsolidatedInternetCard({
minPrice,
maxPrice,
setupFee,
tiers,
ctaPath,
ctaLabel,
onCtaClick,
}: {
minPrice: number;
maxPrice: number;
setupFee: number;
tiers: TierInfo[];
ctaPath: string;
ctaLabel: string;
onCtaClick?: (e: React.MouseEvent) => void;
}) {
return (
<div className="rounded-xl border border-border bg-card shadow-[var(--cp-shadow-1)] overflow-hidden">
{/* Header */}
<div className="p-5 border-b border-border bg-muted/20">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Wifi className="h-5 w-5" />
</div>
<div>
<h3 className="text-lg font-bold text-foreground">NTT Fiber Internet</h3>
<p className="text-sm text-muted-foreground">Home & Apartment plans available</p>
</div>
</div>
</div>
<div className="text-left sm:text-right">
<div className="flex items-baseline gap-1">
<span className="text-2xl font-bold text-foreground">
¥{minPrice.toLocaleString()}~{maxPrice.toLocaleString()}
</span>
<span className="text-sm text-muted-foreground">/mo</span>
</div>
<p className="text-xs text-muted-foreground">Price varies by location & tier</p>
</div>
</div>
</div>
{/* Tier Cards */}
<div className="p-5">
<p className="text-sm font-medium text-foreground mb-2">Choose your service tier:</p>
<p className="text-xs text-muted-foreground mb-4">
Price varies based on your connection type: Home 10Gbps (select areas), Home 1Gbps, or
Apartment (up to 1Gbps depending on building infrastructure). We&apos;ll confirm
availability at your address.
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
{tiers.map(tier => (
<div
key={tier.tier}
className={cn(
"rounded-xl border p-4 transition-all duration-200 flex flex-col relative",
tierStyles[tier.tier].card
)}
>
{/* Popular Badge for Gold */}
{tier.tier === "Gold" && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
<span className="inline-flex items-center gap-1 px-3 py-1 rounded-full bg-amber-500 text-white text-xs font-semibold shadow-sm">
<Sparkles className="h-3 w-3" />
Popular
</span>
</div>
)}
{/* Tier Name */}
<h4
className={cn(
"font-bold text-lg mb-2",
tier.tier === "Gold" ? "mt-2" : "",
tierStyles[tier.tier].accent
)}
>
{tier.tier}
</h4>
{/* Price Range */}
<div className="mb-3">
<div className="flex items-baseline gap-0.5 flex-wrap">
<span className="text-2xl font-bold text-foreground">
¥{tier.monthlyPrice.toLocaleString()}
{tier.maxMonthlyPrice &&
tier.maxMonthlyPrice > tier.monthlyPrice &&
`~${tier.maxMonthlyPrice.toLocaleString()}`}
</span>
<span className="text-sm text-muted-foreground">/mo</span>
</div>
{tier.pricingNote && (
<span
className={`text-xs ${tier.tier === "Platinum" ? "text-primary" : "text-amber-600"}`}
>
{tier.pricingNote}
</span>
)}
</div>
{/* Description */}
<p className="text-sm text-muted-foreground mb-3">{tier.description}</p>
{/* Features */}
<ul className="space-y-2 flex-grow">
{tier.features.map((feature, index) => (
<li key={index} className="flex items-start gap-2 text-sm">
<svg
className="h-4 w-4 text-gray-500 flex-shrink-0 mt-0.5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
<span className="text-muted-foreground">{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 justify-between gap-4 pt-4 border-t border-border">
<p className="text-sm text-muted-foreground">
<span className="font-semibold text-foreground">
+ ¥{setupFee.toLocaleString()} one-time setup
</span>{" "}
(or 12/24-month installment)
</p>
{onCtaClick ? (
<Button onClick={onCtaClick} size="lg" className="whitespace-nowrap">
{ctaLabel}
</Button>
) : (
<Button as="a" href={ctaPath} size="lg" className="whitespace-nowrap">
{ctaLabel}
</Button>
)}
</div>
</div>
</div>
);
}
// FAQ data
const faqItems = [
{
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.",
},
{
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.",
},
{
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.",
},
{
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.",
},
];
/**
* FAQ Item component with expand/collapse
*/
function FAQItem({
question,
answer,
isOpen,
onToggle,
}: {
question: string;
answer: string;
isOpen: boolean;
onToggle: () => void;
}) {
return (
<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 ? (
<ChevronUp className="h-4 w-4 text-muted-foreground flex-shrink-0 mt-0.5" />
) : (
<ChevronDown 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 interface PublicInternetPlansContentProps {
onCtaClick?: (e: React.MouseEvent) => void;
ctaPath?: string;
ctaLabel?: string;
heroTitle?: string;
heroDescription?: string;
}
/**
* Public Internet Plans Content - Reusable component
*/
export function PublicInternetPlansContent({
onCtaClick,
ctaPath: propCtaPath,
ctaLabel = "Check Availability",
heroTitle = "Internet Service Plans",
heroDescription = "NTT Optical Fiber with full English support",
}: PublicInternetPlansContentProps) {
const { data: servicesCatalog, error } = usePublicInternetCatalog();
// Simple loading check: show skeleton until we have data or an error
const isLoading = !servicesCatalog && !error;
const servicesBasePath = useServicesBasePath();
const defaultCtaPath = `${servicesBasePath}/internet/configure`;
const ctaPath = propCtaPath ?? defaultCtaPath;
const [openFaqIndex, setOpenFaqIndex] = useState<number | null>(null);
const internetFeatures: HighlightFeature[] = [
{
icon: <Wifi className="h-6 w-6" />,
title: "NTT Optical Fiber",
description: "Japan's most reliable network with speeds up to 10Gbps",
highlight: "99.9% uptime",
},
{
icon: <Zap className="h-6 w-6" />,
title: "IPv6/IPoE Ready",
description: "Next-gen protocol for congestion-free browsing",
highlight: "No peak-hour slowdowns",
},
{
icon: <Languages className="h-6 w-6" />,
title: "Full English Support",
description: "Native English service for setup, billing & technical help",
highlight: "No language barriers",
},
{
icon: <FileText className="h-6 w-6" />,
title: "One Bill, One Provider",
description: "NTT line + ISP + equipment bundled with simple billing",
highlight: "No hidden fees",
},
{
icon: <Wrench className="h-6 w-6" />,
title: "On-site Support",
description: "Technicians can visit for installation & troubleshooting",
highlight: "Professional setup",
},
{
icon: <Globe className="h-6 w-6" />,
title: "Flexible Options",
description: "Multiple ISP configs available, IPv4/PPPoE if needed",
highlight: "Customizable",
},
];
// Consolidated internet plans data - one card with all tiers
const consolidatedPlanData = useMemo(() => {
if (!servicesCatalog?.plans) return null;
// Get installation item for setup fee
const installationItem = servicesCatalog.installations?.[0] as
| InternetInstallationCatalogItem
| undefined;
const setupFee = installationItem?.oneTimePrice ?? 22800;
// Get all prices across all plan types to show full range
const allPrices = servicesCatalog.plans.map(p => p.monthlyPrice ?? 0).filter(p => p > 0);
const minPrice = Math.min(...allPrices);
const maxPrice = Math.max(...allPrices);
// Get unique tiers with their price ranges across all offering types
const tierOrder: Record<string, number> = { Silver: 0, Gold: 1, Platinum: 2 };
const tierData: Record<
string,
{ minPrice: number; maxPrice: number; plans: InternetPlanCatalogItem[] }
> = {};
for (const plan of servicesCatalog.plans) {
const tier = plan.internetPlanTier ?? "Silver";
if (!tierData[tier]) {
tierData[tier] = { minPrice: Infinity, maxPrice: 0, plans: [] };
}
const price = plan.monthlyPrice ?? 0;
tierData[tier].minPrice = Math.min(tierData[tier].minPrice, price);
tierData[tier].maxPrice = Math.max(tierData[tier].maxPrice, price);
tierData[tier].plans.push(plan);
}
// Build consolidated tier info with price ranges
const tiers: TierInfo[] = Object.entries(tierData)
.sort(([a], [b]) => (tierOrder[a] ?? 99) - (tierOrder[b] ?? 99))
.map(([tier, data]) => ({
tier: tier as TierInfo["tier"],
monthlyPrice: data.minPrice,
maxMonthlyPrice: data.maxPrice,
description: getTierDescription(tier),
features: getTierFeatures(tier),
pricingNote: tier === "Platinum" ? "+ equipment fees" : undefined,
}));
return {
minPrice,
maxPrice,
setupFee,
tiers,
};
}, [servicesCatalog]);
// Error state
if (error) {
return (
<div className="space-y-6">
<ServicesBackLink href={servicesBasePath} />
<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 */}
<ServicesBackLink href={servicesBasePath} />
{/* Hero - Clean and impactful */}
<div className="text-center py-4">
<h1 className="text-3xl md:text-4xl font-bold text-foreground tracking-tight">
{heroTitle}
</h1>
<p className="text-base text-muted-foreground mt-2 max-w-lg mx-auto">{heroDescription}</p>
</div>
{/* Service Highlights */}
<ServiceHighlights features={internetFeatures} />
{/* Consolidated Internet Plans Card */}
<section className="space-y-3">
{isLoading ? (
<Skeleton className="h-64 w-full rounded-xl" />
) : consolidatedPlanData ? (
<ConsolidatedInternetCard
minPrice={consolidatedPlanData.minPrice}
maxPrice={consolidatedPlanData.maxPrice}
setupFee={consolidatedPlanData.setupFee}
tiers={consolidatedPlanData.tiers}
ctaPath={ctaPath}
ctaLabel={ctaLabel}
onCtaClick={onCtaClick}
/>
) : null}
</section>
{/* How It Works Section */}
<HowItWorksSection />
{/* 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">
<Sparkles 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>
{onCtaClick ? (
<Button onClick={onCtaClick} size="lg" rightIcon={<ArrowRight className="h-4 w-4" />}>
{ctaLabel}
</Button>
) : (
<Button as="a" href={ctaPath} size="lg" rightIcon={<ArrowRight className="h-4 w-4" />}>
{ctaLabel}
</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>
);
}
/**
* Public Internet Plans page - Marketing/Conversion focused
* Clean, polished design optimized for conversion
*/
export function PublicInternetPlansView() {
return <PublicInternetPlansContent />;
}
// Helper functions
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",
"Two ISP connection protocols: IPoE (recommended) or PPPoE",
"Self-configuration of router (you provide your own)",
],
Gold: [
"NTT modem + wireless router (rental)",
"ISP (IPoE) configured automatically within 24 hours",
"Basic wireless router included",
],
Platinum: [
"NTT modem + Netgear INSIGHT Wi-Fi routers",
"Cloud management support for remote router management",
"Automatic updates and quicker support",
],
};
return features[tier] ?? [];
}