Refactor Internet Eligibility Handling and Update Configuration

- Updated env.validation.ts to retain unused Salesforce fields for future compatibility.
- Modified CatalogCdcSubscriber to set requestId as undefined, reflecting its non-use in the current environment.
- Adjusted CatalogCacheService to initialize eligibility payload with null values for unused fields.
- Enhanced InternetCatalogService to remove references to unused fields and improve cache data validation.
- Updated PublicInternetPlans and InternetPlansContainer to streamline plan display and improve user experience with new offering configurations.
This commit is contained in:
barsa 2025-12-24 13:24:13 +09:00
parent a6bc9666e1
commit 530245f43a
17 changed files with 1531 additions and 311 deletions

View File

@ -139,6 +139,7 @@ export const envSchema = z.object({
ACCOUNT_INTERNET_ELIGIBILITY_CHECKED_AT_FIELD: z
.string()
.default("Internet_Eligibility_Checked_Date_Time__c"),
// Note: These fields are not used in the current Salesforce environment but kept in config schema for future compatibility
ACCOUNT_INTERNET_ELIGIBILITY_NOTES_FIELD: z.string().default("Internet_Eligibility_Notes__c"),
ACCOUNT_INTERNET_ELIGIBILITY_CASE_ID_FIELD: z.string().default("Internet_Eligibility_Case_Id__c"),

View File

@ -279,8 +279,9 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy {
const checkedAt = this.extractStringField(payload, [
"Internet_Eligibility_Checked_Date_Time__c",
]);
const notes = this.extractStringField(payload, ["Internet_Eligibility_Notes__c"]);
const requestId = this.extractStringField(payload, ["Internet_Eligibility_Case_Id__c"]);
// Note: Request ID field is not used in this environment
const requestId = undefined;
// Also extract ID verification fields for notifications
const verificationStatus = this.extractStringField(payload, ["Id_Verification_Status__c"]);
@ -302,9 +303,7 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy {
});
await this.catalogCache.invalidateEligibility(accountId);
const hasDetails = Boolean(
status || eligibility || requestedAt || checkedAt || notes || requestId
);
const hasDetails = Boolean(status || eligibility || requestedAt || checkedAt || requestId);
if (hasDetails) {
await this.catalogCache.setEligibilityDetails(accountId, {
status: this.mapEligibilityStatus(status, eligibility),
@ -312,7 +311,7 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy {
requestId: requestId ?? null,
requestedAt: requestedAt ?? null,
checkedAt: checkedAt ?? null,
notes: notes ?? null,
notes: null, // Field not used
});
}

View File

@ -284,7 +284,7 @@ export class SalesforceCaseService {
});
const casePayload: Record<string, unknown> = {
Origin: "Portal",
Origin: SALESFORCE_CASE_ORIGIN.PORTAL_WEBSITE,
Status: SALESFORCE_CASE_STATUS.NEW,
Priority: SALESFORCE_CASE_PRIORITY.MEDIUM,
Subject: params.subject,

View File

@ -173,10 +173,14 @@ export class CatalogCacheService {
eligibility: string | null | undefined
): Promise<void> {
const key = this.buildEligibilityKey("", accountId);
const payload =
typeof eligibility === "string"
? { Id: accountId, Internet_Eligibility__c: eligibility }
: null;
const payload = {
status: eligibility ? "eligible" : "not_requested",
eligibility: typeof eligibility === "string" ? eligibility : null,
requestId: null,
requestedAt: null,
checkedAt: null,
notes: null,
};
if (this.ELIGIBILITY_TTL === null) {
await this.cache.set(key, payload);
} else {

View File

@ -28,11 +28,7 @@ import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util
import { assertSoqlFieldName } from "@bff/integrations/salesforce/utils/soql.util.js";
import type { InternetEligibilityCheckRequest } from "./internet-eligibility.types.js";
import type { SalesforceResponse } from "@customer-portal/domain/common";
import {
OPPORTUNITY_STAGE,
OPPORTUNITY_SOURCE,
OPPORTUNITY_PRODUCT_TYPE,
} from "@customer-portal/domain/opportunity";
// (removed unused opportunity constants import)
@Injectable()
export class InternetCatalogService extends BaseCatalogService {
@ -65,11 +61,15 @@ export class InternetCatalogService extends BaseCatalogService {
"Internet Plans"
);
return records.map(record => {
const plans = records.map(record => {
const entry = this.extractPricebookEntry(record);
const plan = CatalogProviders.Salesforce.mapInternetPlan(record, entry);
return enrichInternetPlanMetadata(plan);
});
// Prefer ordering by offering type (for shop UX) over Product2.Name.
// We still respect Catalog_Order__c (mapped to displayOrder) within each offering type.
return plans.sort(compareInternetPlansForShop);
},
{
resolveDependencies: plans => ({
@ -245,10 +245,34 @@ export class InternetCatalogService extends BaseCatalogService {
const sfAccountId = assertSalesforceId(mapping.sfAccountId, "sfAccountId");
const eligibilityKey = this.catalogCache.buildEligibilityKey("internet", sfAccountId);
return this.catalogCache.getCachedEligibility<InternetEligibilityDetails>(
eligibilityKey,
async () => this.queryEligibilityDetails(sfAccountId)
);
// Explicitly define the validator to handle potential malformed cache data
// If the cache returns undefined or missing fields, we treat it as a cache miss or malformed data
// and force a re-fetch or ensure safe defaults are applied.
return this.catalogCache
.getCachedEligibility<InternetEligibilityDetails>(eligibilityKey, async () =>
this.queryEligibilityDetails(sfAccountId)
)
.then(data => {
// Safety check: ensure the data matches the schema before returning.
// This protects against cache corruption (e.g. missing fields treated as undefined).
const result = internetEligibilityDetailsSchema.safeParse(data);
if (!result.success) {
this.logger.warn("Cached eligibility data was malformed, treating as cache miss", {
userId,
sfAccountId,
errors: result.error.format(),
});
// Invalidate bad cache and re-fetch
this.catalogCache.invalidateEligibility(sfAccountId).catch((error: unknown) =>
this.logger.error("Failed to invalidate malformed eligibility cache", {
error: getErrorMessage(error),
})
);
return this.queryEligibilityDetails(sfAccountId);
}
return result.data;
});
}
async requestEligibilityCheckForUser(
@ -277,15 +301,32 @@ export class InternetCatalogService extends BaseCatalogService {
const caseId = await this.lockService.withLock(
lockKey,
async () => {
// Idempotency: if we already have a pending request, return the existing request id.
// Idempotency: if we already have a pending request, do not create a new Case.
// The Case creation is a signal of interest; if status is pending, interest is already signaled/active.
const existing = await this.queryEligibilityDetails(sfAccountId);
if (existing.status === "pending" && existing.requestId) {
this.logger.log("Eligibility request already pending; returning existing request id", {
if (existing.status === "pending") {
this.logger.log("Eligibility request already pending; skipping new case creation", {
userId,
sfAccountIdTail: sfAccountId.slice(-4),
caseIdTail: existing.requestId.slice(-4),
});
return existing.requestId;
// Try to find the existing open case to return its ID (best effort)
try {
const cases = await this.caseService.getCasesForAccount(sfAccountId);
const openCase = cases.find(
c => c.status !== "Closed" && c.subject.includes("Internet availability check")
);
if (openCase) {
return openCase.id;
}
} catch (error) {
this.logger.warn("Failed to lookup existing case for pending request", { error });
}
// If we can't find the case ID but status is pending, we return a placeholder or empty string.
// The frontend primarily relies on the status change.
return "";
}
// 1) Find or create Opportunity for Internet eligibility
@ -317,7 +358,7 @@ export class InternetCatalogService extends BaseCatalogService {
});
// 4) Update Account eligibility status
await this.updateAccountEligibilityRequestState(sfAccountId, createdCaseId);
await this.updateAccountEligibilityRequestState(sfAccountId);
await this.catalogCache.invalidateEligibility(sfAccountId);
this.logger.log("Created eligibility Case linked to Opportunity", {
@ -370,19 +411,10 @@ export class InternetCatalogService extends BaseCatalogService {
"Internet_Eligibility_Checked_Date_Time__c",
"ACCOUNT_INTERNET_ELIGIBILITY_CHECKED_AT_FIELD"
);
const notesField = assertSoqlFieldName(
this.config.get<string>("ACCOUNT_INTERNET_ELIGIBILITY_NOTES_FIELD") ??
"Internet_Eligibility_Notes__c",
"ACCOUNT_INTERNET_ELIGIBILITY_NOTES_FIELD"
);
const caseIdField = assertSoqlFieldName(
this.config.get<string>("ACCOUNT_INTERNET_ELIGIBILITY_CASE_ID_FIELD") ??
"Internet_Eligibility_Case_Id__c",
"ACCOUNT_INTERNET_ELIGIBILITY_CASE_ID_FIELD"
);
// Note: Notes and Case ID fields removed as they are not present/needed in the Salesforce schema
const soql = `
SELECT Id, ${eligibilityField}, ${statusField}, ${requestedAtField}, ${checkedAtField}, ${notesField}, ${caseIdField}
SELECT Id, ${eligibilityField}, ${statusField}, ${requestedAtField}, ${checkedAtField}
FROM Account
WHERE Id = '${sfAccountId}'
LIMIT 1
@ -423,13 +455,8 @@ export class InternetCatalogService extends BaseCatalogService {
? "eligible"
: "not_requested";
const requestIdRaw = record[caseIdField];
const requestId =
typeof requestIdRaw === "string" && requestIdRaw.trim() ? requestIdRaw.trim() : null;
const requestedAtRaw = record[requestedAtField];
const checkedAtRaw = record[checkedAtField];
const notesRaw = record[notesField];
const requestedAt =
typeof requestedAtRaw === "string"
@ -443,25 +470,21 @@ export class InternetCatalogService extends BaseCatalogService {
: checkedAtRaw instanceof Date
? checkedAtRaw.toISOString()
: null;
const notes = typeof notesRaw === "string" && notesRaw.trim() ? notesRaw.trim() : null;
return internetEligibilityDetailsSchema.parse({
status,
eligibility,
requestId,
requestId: null, // Always null as field is not used
requestedAt,
checkedAt,
notes,
notes: null, // Always null as field is not used
});
}
// Note: createEligibilityCaseOrTask was removed - now using this.caseService.createEligibilityCase()
// which links the Case to the Opportunity
private async updateAccountEligibilityRequestState(
sfAccountId: string,
requestId: string
): Promise<void> {
private async updateAccountEligibilityRequestState(sfAccountId: string): Promise<void> {
const statusField = assertSoqlFieldName(
this.config.get<string>("ACCOUNT_INTERNET_ELIGIBILITY_STATUS_FIELD") ??
"Internet_Eligibility_Status__c",
@ -472,11 +495,6 @@ export class InternetCatalogService extends BaseCatalogService {
"Internet_Eligibility_Request_Date_Time__c",
"ACCOUNT_INTERNET_ELIGIBILITY_REQUESTED_AT_FIELD"
);
const caseIdField = assertSoqlFieldName(
this.config.get<string>("ACCOUNT_INTERNET_ELIGIBILITY_CASE_ID_FIELD") ??
"Internet_Eligibility_Case_Id__c",
"ACCOUNT_INTERNET_ELIGIBILITY_CASE_ID_FIELD"
);
const update = this.sf.sobject("Account")?.update;
if (!update) {
@ -487,11 +505,31 @@ export class InternetCatalogService extends BaseCatalogService {
Id: sfAccountId,
[statusField]: "Pending",
[requestedAtField]: new Date().toISOString(),
[caseIdField]: requestId,
});
}
}
function normalizeCatalogString(value: unknown): string {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
function compareInternetPlansForShop(
a: InternetPlanCatalogItem,
b: InternetPlanCatalogItem
): number {
const aOffering = normalizeCatalogString(a.internetOfferingType);
const bOffering = normalizeCatalogString(b.internetOfferingType);
if (aOffering !== bOffering) return aOffering.localeCompare(bOffering);
const aOrder = typeof a.displayOrder === "number" ? a.displayOrder : Number.MAX_SAFE_INTEGER;
const bOrder = typeof b.displayOrder === "number" ? b.displayOrder : Number.MAX_SAFE_INTEGER;
if (aOrder !== bOrder) return aOrder - bOrder;
const aName = normalizeCatalogString(a.name);
const bName = normalizeCatalogString(b.name);
return aName.localeCompare(bName);
}
function formatAddressForLog(address: Record<string, unknown>): string {
const address1 = typeof address.address1 === "string" ? address.address1.trim() : "";
const address2 = typeof address.address2 === "string" ? address.address2.trim() : "";

View File

@ -8,6 +8,7 @@ import { VerificationModule } from "@bff/modules/verification/verification.modul
import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module.js";
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js";
import { SalesforceModule } from "@bff/integrations/salesforce/salesforce.module.js";
@Module({
imports: [
@ -18,6 +19,7 @@ import { NotificationsModule } from "@bff/modules/notifications/notifications.mo
WhmcsModule,
MappingsModule,
NotificationsModule,
SalesforceModule,
],
controllers: [MeStatusController],
providers: [MeStatusService],

View File

@ -33,32 +33,37 @@ export class MeStatusService {
) {}
async getStatusForUser(userId: string): Promise<MeStatus> {
const [summary, internetEligibility, residenceCardVerification, orders] = await Promise.all([
this.users.getUserSummary(userId),
this.internetCatalog.getEligibilityDetailsForUser(userId),
this.residenceCards.getStatusForUser(userId),
this.safeGetOrders(userId),
]);
try {
const [summary, internetEligibility, residenceCardVerification, orders] = await Promise.all([
this.users.getUserSummary(userId),
this.internetCatalog.getEligibilityDetailsForUser(userId),
this.residenceCards.getStatusForUser(userId),
this.safeGetOrders(userId),
]);
const paymentMethods = await this.safeGetPaymentMethodsStatus(userId);
const paymentMethods = await this.safeGetPaymentMethodsStatus(userId);
const tasks = this.computeTasks({
summary,
paymentMethods,
internetEligibility,
residenceCardVerification,
orders,
});
const tasks = this.computeTasks({
summary,
paymentMethods,
internetEligibility,
residenceCardVerification,
orders,
});
await this.maybeCreateInvoiceDueNotification(userId, summary);
await this.maybeCreateInvoiceDueNotification(userId, summary);
return meStatusSchema.parse({
summary,
paymentMethods,
internetEligibility,
residenceCardVerification,
tasks,
});
return meStatusSchema.parse({
summary,
paymentMethods,
internetEligibility,
residenceCardVerification,
tasks,
});
} catch (error) {
this.logger.error({ userId, err: error }, "Failed to get status for user");
throw error;
}
}
private async safeGetOrders(userId: string): Promise<OrderSummary[] | null> {

View File

@ -1,11 +1,5 @@
import type { ReactNode } from "react";
import { ShopTabs } from "@/features/catalog/components/base/ShopTabs";
export default function AccountShopLayout({ children }: { children: ReactNode }) {
return (
<div>
<ShopTabs basePath="/account/shop" />
{children}
</div>
);
return <>{children}</>;
}

View File

@ -25,7 +25,7 @@ export const Header = memo(function Header({ onMenuClick, user, profileReady }:
: displayName.slice(0, 2).toUpperCase();
return (
<div className="bg-header border-b border-header-border/50 backdrop-blur-xl">
<div className="relative z-40 bg-header border-b border-header-border/50 backdrop-blur-xl">
<div className="flex items-center h-16 gap-3 px-4 sm:px-6">
<button
type="button"

View File

@ -0,0 +1,97 @@
"use client";
import {
UserPlusIcon,
MagnifyingGlassIcon,
CheckBadgeIcon,
RocketLaunchIcon,
} from "@heroicons/react/24/outline";
interface StepProps {
number: number;
icon: React.ReactNode;
title: string;
description: string;
isLast?: boolean;
}
function Step({ number, icon, title, description, isLast = false }: StepProps) {
return (
<div className="relative flex items-start gap-4">
{/* Step number with icon */}
<div className="flex flex-col items-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 border-2 border-primary/30 text-primary">
{icon}
</div>
{/* Connector line */}
{!isLast && (
<div className="w-0.5 h-full min-h-[3rem] bg-gradient-to-b from-primary/30 to-transparent mt-2" />
)}
</div>
{/* Content */}
<div className="flex-1 pb-8">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-bold text-primary uppercase tracking-wider">
Step {number}
</span>
</div>
<h4 className="font-semibold text-foreground mb-1">{title}</h4>
<p className="text-sm text-muted-foreground leading-relaxed">{description}</p>
</div>
</div>
);
}
export function HowItWorksSection() {
const steps = [
{
icon: <UserPlusIcon className="h-5 w-5" />,
title: "Create your account",
description:
"Sign up with your email and provide your service address. This only takes a minute.",
},
{
icon: <MagnifyingGlassIcon className="h-5 w-5" />,
title: "We verify with NTT",
description:
"Our team checks what service is available at your address. This takes 1-2 business days.",
},
{
icon: <CheckBadgeIcon className="h-5 w-5" />,
title: "Choose your plan",
description:
"Once verified, you'll see exactly which plans are available and can select your tier (Silver, Gold, or Platinum).",
},
{
icon: <RocketLaunchIcon className="h-5 w-5" />,
title: "Get connected",
description:
"We coordinate NTT installation and set up your service. You'll be online in no time.",
},
];
return (
<section className="bg-card rounded-xl border border-border shadow-[var(--cp-shadow-1)] p-6">
<div className="mb-6">
<h3 className="text-lg font-bold text-foreground mb-1">How it works</h3>
<p className="text-sm text-muted-foreground">
Getting connected is simple. Here's what to expect.
</p>
</div>
<div className="space-y-0">
{steps.map((step, index) => (
<Step
key={index}
number={index + 1}
icon={step.icon}
title={step.title}
description={step.description}
isLast={index === steps.length - 1}
/>
))}
</div>
</section>
);
}

View File

@ -1,22 +1,58 @@
"use client";
import { InformationCircleIcon } from "@heroicons/react/24/outline";
export function InternetImportantNotes() {
return (
<details className="bg-card rounded-xl border border-border shadow-[var(--cp-shadow-1)] p-5">
<summary className="cursor-pointer select-none font-semibold text-foreground">
Important notes
</summary>
<div className="pt-4">
<ul className="list-disc list-inside space-y-2 text-sm text-muted-foreground">
<li>Theoretical internet speed is the same for all three packages</li>
<li>One-time fee (¥22,800) can be paid upfront or in 12- or 24-month installments</li>
<li>
Home phone line (Hikari Denwa) can be added to GOLD or PLATINUM plans (¥450/month +
¥1,000-3,000 one-time)
</li>
<li>In-home technical assistance available (¥15,000 onsite visiting fee)</li>
</ul>
<section className="bg-card rounded-xl border border-border shadow-[var(--cp-shadow-1)] p-5">
<div className="flex items-start gap-3 mb-4">
<InformationCircleIcon className="h-5 w-5 text-info flex-shrink-0 mt-0.5" />
<div>
<h3 className="font-semibold text-foreground">Before you choose a plan</h3>
<p className="text-sm text-muted-foreground mt-1">
A few things to keep in mind when selecting your internet service.
</p>
</div>
</div>
</details>
<ul className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-3 text-sm text-muted-foreground">
<li className="flex items-start gap-2">
<span className="text-foreground font-medium"></span>
<span>
<span className="text-foreground font-medium">Same speeds across tiers</span>
Silver, Gold, and Platinum all provide the same connection speed. The difference is in
equipment and support level.
</span>
</li>
<li className="flex items-start gap-2">
<span className="text-foreground font-medium"></span>
<span>
<span className="text-foreground font-medium">Flexible installation payment</span>
The ¥22,800 setup fee can be paid upfront or spread across 12 or 24 monthly
installments.
</span>
</li>
<li className="flex items-start gap-2">
<span className="text-foreground font-medium"></span>
<span>
<span className="text-foreground font-medium">Home phone available</span>
Hikari Denwa (IP phone) can be added to Gold or Platinum plans for ¥450/month +
one-time setup (¥1,000¥3,000).
</span>
</li>
<li className="flex items-start gap-2">
<span className="text-foreground font-medium"></span>
<span>
<span className="text-foreground font-medium">On-site help if needed</span>
Our technicians can visit your home for setup or troubleshooting (¥15,000 per visit).
</span>
</li>
</ul>
<p className="text-xs text-muted-foreground mt-4 pt-3 border-t border-border">
All prices shown exclude 10% consumption tax. Final pricing confirmed after address
verification.
</p>
</section>
);
}

View File

@ -0,0 +1,239 @@
"use client";
import { useState } from "react";
import {
ChevronDownIcon,
ChevronUpIcon,
HomeIcon,
BuildingOfficeIcon,
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";
interface TierInfo {
tier: "Silver" | "Gold" | "Platinum";
monthlyPrice: number;
description: string;
features: string[];
recommended?: boolean;
/** Additional pricing note (e.g., for Platinum's equipment fees) */
pricingNote?: string;
}
interface InternetOfferingCardProps {
/** Offering type identifier */
offeringType: string;
/** Display title */
title: string;
/** Speed badge text */
speedBadge: string;
/** Short description */
description: string;
/** Icon type */
iconType: "home" | "apartment";
/** Starting monthly price */
startingPrice: number;
/** Setup fee */
setupFee: number;
/** Tier options */
tiers: TierInfo[];
/** Whether this is a premium/select-area option */
isPremium?: boolean;
/** CTA path */
ctaPath: string;
/** Whether to expand by default */
defaultExpanded?: boolean;
/** Whether the card is disabled (e.g., already subscribed) */
disabled?: boolean;
/** Reason for being disabled */
disabledReason?: string;
/** Preview mode - hides action buttons, shows informational text instead */
previewMode?: 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;
export function InternetOfferingCard({
title,
speedBadge,
description,
iconType,
startingPrice,
setupFee,
tiers,
isPremium = false,
ctaPath,
defaultExpanded = false,
disabled = false,
disabledReason,
previewMode = false,
}: InternetOfferingCardProps) {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
const Icon = iconType === "home" ? HomeIcon : BuildingOfficeIcon;
return (
<div
className={cn(
"rounded-2xl border bg-card shadow-[var(--cp-shadow-1)] overflow-hidden transition-all duration-300",
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-5 sm:p-6 flex items-start justify-between gap-4 text-left hover:bg-muted/30 transition-colors"
>
<div className="flex items-start gap-4">
<div
className={cn(
"flex h-12 w-12 items-center justify-center rounded-xl 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-6 w-6" />
</div>
<div className="space-y-1.5">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-lg 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-1">
<span className="text-xs text-muted-foreground">From</span>
<span className="text-xl font-bold text-foreground">
¥{startingPrice.toLocaleString()}
</span>
<span className="text-sm text-muted-foreground">/month</span>
<span className="text-xs text-muted-foreground ml-2">
+ ¥{setupFee.toLocaleString()} setup
</span>
</div>
</div>
</div>
<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"}
</span>
{isExpanded ? (
<ChevronUpIcon className="h-5 w-5 text-muted-foreground" />
) : (
<ChevronDownIcon className="h-5 w-5 text-muted-foreground" />
)}
</div>
</button>
{/* Expanded content - Tier options */}
{isExpanded && (
<div className="border-t border-border px-5 sm:px-6 py-5 bg-muted/20">
<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 transition-all duration-200 hover:shadow-md flex flex-col",
tierStyles[tier.tier].card,
tier.recommended && "ring-2 ring-warning/30"
)}
>
{/* Header */}
<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" />
)}
</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>
</div>
{tier.pricingNote && (
<p className="text-xs text-warning mt-1">{tier.pricingNote}</p>
)}
</div>
{/* 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>
))}
</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
</p>
</div>
) : disabled ? (
<div className="mt-auto">
<Button variant="outline" size="sm" className="w-full" disabled>
Unavailable
</Button>
{disabledReason && (
<p className="text-xs text-muted-foreground text-center mt-2">
{disabledReason}
</p>
)}
</div>
) : (
<Button
as="a"
href={ctaPath}
variant={tier.recommended ? "default" : "outline"}
size="sm"
className="w-full mt-auto"
>
Get Started
</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>
);
}
export type { InternetOfferingCardProps, TierInfo };

View File

@ -0,0 +1,134 @@
"use client";
import {
WrenchScrewdriverIcon,
SparklesIcon,
CubeTransparentIcon,
} from "@heroicons/react/24/outline";
import { cn } from "@/lib/utils";
interface PlanGuideItemProps {
tier: "Silver" | "Gold" | "Platinum";
icon: React.ReactNode;
title: string;
idealFor: string;
description: string;
highlight?: boolean;
}
const tierColors = {
Silver: {
bg: "bg-muted/30",
border: "border-muted-foreground/20",
icon: "bg-muted text-muted-foreground border-muted-foreground/20",
title: "text-muted-foreground",
},
Gold: {
bg: "bg-warning-soft/30",
border: "border-warning/30",
icon: "bg-warning-soft text-warning border-warning/30",
title: "text-warning",
},
Platinum: {
bg: "bg-info-soft/30",
border: "border-primary/30",
icon: "bg-info-soft text-primary border-primary/30",
title: "text-primary",
},
};
function PlanGuideItem({
tier,
icon,
title,
idealFor,
description,
highlight,
}: PlanGuideItemProps) {
const colors = tierColors[tier];
return (
<div
className={cn(
"rounded-xl border p-4 transition-all duration-200",
colors.bg,
colors.border,
highlight && "ring-2 ring-warning/30"
)}
>
<div className="flex items-start gap-4">
<div
className={cn(
"flex h-10 w-10 items-center justify-center rounded-lg border flex-shrink-0",
colors.icon
)}
>
{icon}
</div>
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2 mb-1">
<h4 className={cn("font-bold", colors.title)}>{title}</h4>
{highlight && (
<span className="text-xs bg-success-bg text-success px-2 py-0.5 rounded-full border border-success-border">
Most Popular
</span>
)}
</div>
<p className="text-sm font-medium text-foreground mb-1">{idealFor}</p>
<p className="text-sm text-muted-foreground leading-relaxed">{description}</p>
</div>
</div>
</div>
);
}
export function PlanComparisonGuide() {
return (
<section className="bg-card rounded-2xl border border-border shadow-[var(--cp-shadow-1)] p-5 sm:p-6">
<div className="mb-5">
<h3 className="text-lg font-bold text-foreground mb-1">Which plan is right for you?</h3>
<p className="text-sm text-muted-foreground">
All plans include the same connection speed. The difference is in equipment and support.
</p>
</div>
{/* Stacked rows - always vertical for cleaner reading */}
<div className="space-y-3">
<PlanGuideItem
tier="Silver"
icon={<WrenchScrewdriverIcon className="h-5 w-5" />}
title="Silver"
idealFor="Tech-savvy users with their own router"
description="You get the NTT modem and ISP connection. Bring your own WiFi router and configure the network yourself. Best for those comfortable with networking."
/>
<PlanGuideItem
tier="Gold"
icon={<SparklesIcon className="h-5 w-5" />}
title="Gold"
idealFor="Most customers—hassle-free setup"
description="We provide everything: NTT modem, WiFi router, and pre-configured ISP. Just plug in and connect. Optional range extender available if needed."
highlight
/>
<PlanGuideItem
tier="Platinum"
icon={<CubeTransparentIcon className="h-5 w-5" />}
title="Platinum"
idealFor="Larger homes needing custom coverage"
description="For residences 50m²+ where one router isn't enough. We design a custom mesh network with Netgear INSIGHT routers, cloud management, and professional setup."
/>
</div>
{/* Platinum additional info */}
<div className="mt-4 p-4 bg-info-soft/30 border border-primary/20 rounded-xl">
<p className="text-sm text-foreground">
<span className="font-semibold text-primary">About Platinum plans:</span> After verifying
your address, we&apos;ll assess your space and create a tailored proposal. This may
include multiple mesh routers, LAN wiring, or other equipment based on your layout and
needs. Final pricing depends on your specific setup requirements.
</p>
</div>
</section>
);
}

View File

@ -0,0 +1,81 @@
"use client";
import {
WifiIcon,
GlobeAltIcon,
WrenchScrewdriverIcon,
ChatBubbleLeftRightIcon,
UserGroupIcon,
HomeModernIcon,
} from "@heroicons/react/24/outline";
interface FeatureItemProps {
icon: React.ReactNode;
title: string;
description: string;
}
function FeatureItem({ icon, title, description }: FeatureItemProps) {
return (
<div className="flex items-start gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10 text-primary border border-primary/20 flex-shrink-0">
{icon}
</div>
<div>
<h4 className="font-semibold text-foreground mb-1">{title}</h4>
<p className="text-sm text-muted-foreground leading-relaxed">{description}</p>
</div>
</div>
);
}
export function WhyChooseSection() {
return (
<section className="bg-card rounded-2xl border border-border shadow-[var(--cp-shadow-1)] p-6 sm:p-8">
<div className="mb-6">
<h3 className="text-xl font-bold text-foreground mb-2">Why choose our internet service?</h3>
<p className="text-sm text-muted-foreground">
Japan&apos;s most reliable fiber network with dedicated English support.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<FeatureItem
icon={<WifiIcon className="h-5 w-5" />}
title="NTT Fiber Network"
description="Powered by Japan's largest and most reliable optical fiber infrastructure, delivering speeds up to 10Gbps."
/>
<FeatureItem
icon={<GlobeAltIcon className="h-5 w-5" />}
title="IPoE Connection"
description="Modern IPv6/IPoE technology for congestion-free access, even during peak hours. PPPoE also available."
/>
<FeatureItem
icon={<WrenchScrewdriverIcon className="h-5 w-5" />}
title="Flexible ISP Options"
description="Multiple connection protocols within a single contract. Switch between IPoE and PPPoE as needed."
/>
<FeatureItem
icon={<HomeModernIcon className="h-5 w-5" />}
title="One-Stop Solution"
description="NTT line, ISP service, and optional equipment—all managed through one provider. One bill, one contact point."
/>
<FeatureItem
icon={<ChatBubbleLeftRightIcon className="h-5 w-5" />}
title="Full English Support"
description="Native English customer service for setup, billing questions, and technical support. No language barriers."
/>
<FeatureItem
icon={<UserGroupIcon className="h-5 w-5" />}
title="On-Site Assistance"
description="Need help at home? Our technicians can visit for setup, troubleshooting, or network optimization."
/>
</div>
</section>
);
}

View File

@ -0,0 +1,31 @@
import type { InternetPlanCatalogItem } from "@customer-portal/domain/catalog";
export type InternetOfferingTypeGroup = {
offeringType: string;
plans: InternetPlanCatalogItem[];
};
/**
* Group plans by `internetOfferingType`, preserving input order.
* If the offering type is missing, plans are grouped under "Other".
*/
export function groupPlansByOfferingType(
plans: InternetPlanCatalogItem[]
): InternetOfferingTypeGroup[] {
const groups: InternetOfferingTypeGroup[] = [];
const indexByKey = new Map<string, number>();
for (const plan of plans) {
const offeringType = String(plan.internetOfferingType || "").trim() || "Other";
const key = offeringType.toLowerCase();
const idx = indexByKey.get(key);
if (typeof idx === "number") {
groups[idx]?.plans.push(plan);
continue;
}
indexByKey.set(key, groups.length);
groups.push({ offeringType, plans: [plan] });
}
return groups;
}

View File

@ -3,7 +3,14 @@
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { PageLayout } from "@/components/templates/PageLayout";
import { WifiIcon, ServerIcon, HomeIcon, BuildingOfficeIcon } from "@heroicons/react/24/outline";
import {
WifiIcon,
ServerIcon,
HomeIcon,
BuildingOfficeIcon,
CheckCircleIcon,
BoltIcon,
} from "@heroicons/react/24/outline";
import { useInternetCatalog } from "@/features/catalog/hooks";
import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscriptions";
import type {
@ -12,21 +19,271 @@ import type {
} from "@customer-portal/domain/catalog";
import { Skeleton } from "@/components/atoms/loading-skeleton";
import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
import { InternetPlanCard } from "@/features/catalog/components/internet/InternetPlanCard";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { Button } from "@/components/atoms/button";
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 { PlanComparisonGuide } from "@/features/catalog/components/internet/PlanComparisonGuide";
import {
useInternetEligibility,
useRequestInternetEligibilityCheck,
} from "@/features/catalog/hooks";
import { useAuthSession } from "@/features/auth/services/auth.store";
import { cn } from "@/lib/utils";
type AutoRequestStatus = "idle" | "submitting" | "submitted" | "failed" | "missing_address";
// Offering configuration for display
interface OfferingConfig {
offeringType: string;
title: string;
speedBadge: string;
description: string;
iconType: "home" | "apartment";
isPremium: boolean;
displayOrder: number;
/** If true, this is an alternative speed option (e.g., 1G when 10G is available) */
isAlternative?: boolean;
alternativeNote?: string;
}
const OFFERING_CONFIGS: Record<string, Omit<OfferingConfig, "offeringType">> = {
"Home 10G": {
title: "Home 10Gbps",
speedBadge: "10 Gbps",
description: "Ultra-fast fiber with the highest speeds available in Japan.",
iconType: "home",
isPremium: true,
displayOrder: 1,
},
"Home 1G": {
title: "Home 1Gbps",
speedBadge: "1 Gbps",
description: "High-speed fiber. The most popular choice for home internet.",
iconType: "home",
isPremium: false,
displayOrder: 2,
},
"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,
},
"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,
},
};
/**
* Get tier info from plans
*/
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;
}
/**
* Determine which offering types are available based on eligibility
* Returns an array of offering configs, potentially with alternatives
*/
function getAvailableOfferings(
eligibility: string | null,
plans: InternetPlanCatalogItem[]
): OfferingConfig[] {
if (!eligibility) return [];
const results: OfferingConfig[] = [];
const eligibilityLower = eligibility.toLowerCase();
// Check if this is a "Home 10G" eligibility - they can also choose 1G
if (eligibilityLower.includes("home 10g")) {
const config10g = OFFERING_CONFIGS["Home 10G"];
const config1g = OFFERING_CONFIGS["Home 1G"];
// Add 10G as primary
if (config10g && plans.some(p => p.internetOfferingType === "Home 10G")) {
results.push({
offeringType: "Home 10G",
...config10g,
});
}
// Add 1G as alternative (lower cost option)
if (config1g && plans.some(p => p.internetOfferingType === "Home 1G")) {
results.push({
offeringType: "Home 1G",
...config1g,
isAlternative: true,
alternativeNote: "Choose this if you prefer a lower monthly cost",
});
}
}
// Home 1G only - cannot upgrade to 10G
else if (eligibilityLower.includes("home 1g")) {
const config = OFFERING_CONFIGS["Home 1G"];
if (config && plans.some(p => p.internetOfferingType === "Home 1G")) {
results.push({
offeringType: "Home 1G",
...config,
});
}
}
// Apartment 1G
else if (eligibilityLower.includes("apartment 1g")) {
const config = OFFERING_CONFIGS["Apartment 1G"];
if (config && plans.some(p => p.internetOfferingType === "Apartment 1G")) {
results.push({
offeringType: "Apartment 1G",
...config,
});
}
}
// Apartment 100M (VDSL/LAN)
else if (eligibilityLower.includes("apartment 100m")) {
const config = OFFERING_CONFIGS["Apartment 100M"];
if (config && plans.some(p => p.internetOfferingType === "Apartment 100M")) {
results.push({
offeringType: "Apartment 100M",
...config,
});
}
}
return results.sort((a, b) => a.displayOrder - b.displayOrder);
}
/**
* Format eligibility for display
*/
function formatEligibilityDisplay(eligibility: string): {
residenceType: "home" | "apartment";
speed: string;
label: string;
description: string;
} {
const lower = eligibility.toLowerCase();
if (lower.includes("home 10g")) {
return {
residenceType: "home",
speed: "10 Gbps",
label: "Standalone House (10Gbps available)",
description:
"Your address supports our fastest 10Gbps service. You can also choose 1Gbps for lower monthly cost.",
};
}
if (lower.includes("home 1g")) {
return {
residenceType: "home",
speed: "1 Gbps",
label: "Standalone House (1Gbps)",
description: "Your address supports high-speed 1Gbps fiber connection.",
};
}
if (lower.includes("apartment 1g")) {
return {
residenceType: "apartment",
speed: "1 Gbps",
label: "Apartment/Mansion (1Gbps)",
description: "Your building has fiber-to-the-unit infrastructure supporting 1Gbps speeds.",
};
}
if (lower.includes("apartment 100m")) {
return {
residenceType: "apartment",
speed: "100 Mbps",
label: "Apartment/Mansion (100Mbps)",
description: "Your building uses VDSL or LAN infrastructure with up to 100Mbps speeds.",
};
}
// Default fallback
return {
residenceType: "home",
speed: eligibility,
label: eligibility,
description: "Service is available at your address.",
};
}
export function InternetPlansContainer() {
const shopBasePath = useShopBasePath();
const searchParams = useSearchParams();
@ -94,10 +351,42 @@ export function InternetPlansContainer() {
}, [user?.address]);
const eligibility = useMemo(() => {
if (!isEligible) return "";
return eligibilityValue.trim();
if (!isEligible) return null;
return eligibilityValue?.trim() ?? null;
}, [eligibilityValue, isEligible]);
const setupFee = useMemo(() => getSetupFee(installations), [installations]);
// Get available offerings based on eligibility
const availableOfferings = useMemo(() => {
if (!eligibility) return [];
return getAvailableOfferings(eligibility, plans);
}, [eligibility, plans]);
// Format eligibility for display
const eligibilityDisplay = useMemo(() => {
if (!eligibility) return null;
return formatEligibilityDisplay(eligibility);
}, [eligibility]);
// Build offering cards data
const offeringCards = useMemo(() => {
return availableOfferings
.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);
}, [availableOfferings, plans, setupFee]);
useEffect(() => {
if (!autoEligibilityRequest) return;
if (autoRequestStatus !== "idle") return;
@ -153,31 +442,6 @@ export function InternetPlansContainer() {
user?.address,
]);
const getEligibilityIcon = (offeringType?: string) => {
const lower = (offeringType || "").toLowerCase();
if (lower.includes("home")) return <HomeIcon className="h-5 w-5" />;
if (lower.includes("apartment")) return <BuildingOfficeIcon className="h-5 w-5" />;
return <HomeIcon className="h-5 w-5" />;
};
const getEligibilityColor = (offeringType?: string) => {
const lower = (offeringType || "").toLowerCase();
if (lower.includes("home")) return "text-info bg-info-soft border-info/25";
if (lower.includes("apartment")) return "text-success bg-success-soft border-success/25";
return "text-muted-foreground bg-muted border-border";
};
const silverPlans: InternetPlanCatalogItem[] = useMemo(
() =>
plans.filter(
p =>
String(p.internetPlanTier || "")
.trim()
.toLowerCase() === "silver"
) ?? [],
[plans]
);
if (isLoading || error) {
return (
<PageLayout
@ -186,38 +450,31 @@ export function InternetPlansContainer() {
icon={<WifiIcon className="h-6 w-6" />}
>
<AsyncBlock isLoading={false} error={error}>
<div className="max-w-6xl mx-auto px-4">
<CatalogBackLink href={shopBasePath} label="Back to Services" />
<div className="max-w-4xl mx-auto px-4">
<CatalogBackLink href={shopBasePath} label="Back to Services" className="mb-4" />
{/* Title + eligibility */}
<div className="text-center mb-12">
<div className="h-10 w-96 bg-muted rounded mx-auto mb-4" />
<div className="mt-6 inline-flex items-center gap-2 px-6 py-3 rounded-2xl border border-border bg-card">
<div className="h-5 w-5 bg-muted rounded" />
<div className="h-4 w-56 bg-muted rounded" />
</div>
<div className="h-4 w-[32rem] max-w-full bg-muted rounded mx-auto mt-2" />
<Skeleton className="h-10 w-96 mx-auto mb-4" />
<Skeleton className="h-4 w-[32rem] max-w-full mx-auto" />
</div>
{/* Active internet warning slot */}
<div className="mb-8 h-20 bg-warning-soft border border-warning/25 rounded-xl" />
{/* Plans grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{Array.from({ length: 6 }).map((_, i) => (
<div className="space-y-4">
{Array.from({ length: 2 }).map((_, i) => (
<div
key={i}
className="bg-card rounded-xl border border-border p-6 space-y-3 shadow-[var(--cp-shadow-1)]"
className="bg-card rounded-xl border border-border p-6 shadow-[var(--cp-shadow-1)]"
>
<Skeleton className="h-6 w-1/2" />
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-10 w-full" />
<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>
{/* Important Notes */}
<div className="mt-12 h-24 bg-info-soft border border-info/25 rounded-xl" />
</div>
</AsyncBlock>
</PageLayout>
@ -230,12 +487,12 @@ export function InternetPlansContainer() {
description="High-speed internet services for your home or business"
icon={<WifiIcon className="h-6 w-6" />}
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
<CatalogBackLink href={shopBasePath} label="Back to Services" />
<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="Choose Your Internet Type"
description="Compare apartment vs home options and pick the speed that fits your address."
title="Your Internet Options"
description="Plans tailored to your residence and available infrastructure."
>
{eligibilityLoading ? (
<div className="flex flex-col items-center gap-2">
@ -243,7 +500,7 @@ export function InternetPlansContainer() {
<span className="font-semibold text-foreground">Checking availability</span>
</div>
<p className="text-sm text-muted-foreground text-center max-w-md">
We're verifying whether our service is available at your residence.
We're verifying what service is available at your residence.
</p>
</div>
) : autoRequestStatus === "submitting" ? (
@ -282,7 +539,7 @@ export function InternetPlansContainer() {
<span className="font-semibold">Availability review in progress</span>
</div>
<p className="text-sm text-muted-foreground text-center max-w-md">
Were reviewing service availability for your address. Once confirmed, well unlock
We're reviewing service availability for your address. Once confirmed, we'll unlock
your personalized internet plans.
</p>
</div>
@ -292,24 +549,13 @@ export function InternetPlansContainer() {
<span className="font-semibold">Not available for this address</span>
</div>
<p className="text-sm text-muted-foreground text-center max-w-md">
Our team reviewed your address and determined service isnt available right now.
</p>
</div>
) : eligibility ? (
<div className="flex flex-col items-center gap-2">
<div
className={`inline-flex items-center gap-2 px-4 py-2 rounded-full border ${getEligibilityColor(eligibility)} shadow-[var(--cp-shadow-1)]`}
>
{getEligibilityIcon(eligibility)}
<span className="font-semibold">Eligible for: {eligibility}</span>
</div>
<p className="text-sm text-muted-foreground text-center max-w-md">
Plans shown are tailored to your house type and local infrastructure.
Our team reviewed your address and determined service isn't available right now.
</p>
</div>
) : null}
</CatalogHero>
{/* Auto-request status alerts */}
{autoRequestStatus === "submitting" && (
<AlertBanner variant="info" title="Submitting availability request" className="mb-8">
We're sending your request now. You'll see updated eligibility once the review begins.
@ -340,13 +586,14 @@ export function InternetPlansContainer() {
</AlertBanner>
)}
{/* Eligibility request section */}
{isNotRequested &&
autoRequestStatus !== "submitting" &&
autoRequestStatus !== "submitted" && (
<AlertBanner variant="info" title="Request an eligibility review" className="mb-8">
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<p className="text-sm text-foreground/80">
Our team will verify NTT serviceability and update your eligible offerings. Well
Our team will verify NTT serviceability and update your eligible offerings. We'll
notify you when review is complete.
</p>
{hasServiceAddress ? (
@ -389,7 +636,7 @@ export function InternetPlansContainer() {
<AlertBanner variant="info" title="Review in progress" className="mb-8">
<div className="space-y-2">
<p className="text-sm text-foreground/80">
Well notify you when review is complete.
We'll notify you when review is complete.
</p>
{requestedAt ? (
<p className="text-xs text-muted-foreground">
@ -407,7 +654,7 @@ export function InternetPlansContainer() {
<p className="text-sm text-foreground/80">{rejectionNotes}</p>
) : (
<p className="text-sm text-foreground/80">
If you believe this is incorrect, contact support and well take another look.
If you believe this is incorrect, contact support and we'll take another look.
</p>
)}
<Button as="a" href="/account/support/new" size="sm">
@ -437,66 +684,193 @@ export function InternetPlansContainer() {
</AlertBanner>
)}
{plans.length > 0 ? (
{/* ELIGIBLE - Show personalized plans */}
{isEligible && eligibilityDisplay && offeringCards.length > 0 && (
<>
{orderingLocked && (
<AlertBanner
variant="info"
title={
isIneligible
? "Ordering unavailable"
: isNotRequested
? "Eligibility review required"
: "Availability review in progress"
}
className="mb-8"
elevated
>
<p className="text-sm text-foreground/80">
{isIneligible
? "Service is not available for your address."
: isNotRequested
? "Request an eligibility review to unlock ordering for your residence."
: "You can browse standard pricing below, but ordering stays locked until we confirm service availability for your residence."}
{/* Eligibility confirmation box */}
<div className="bg-success-soft border border-success/25 rounded-xl p-5 mb-8">
<div className="flex items-start gap-4">
<div
className={cn(
"flex h-12 w-12 items-center justify-center rounded-xl border flex-shrink-0",
eligibilityDisplay.residenceType === "home"
? "bg-info-soft text-info border-info/25"
: "bg-success-soft text-success border-success/25"
)}
>
{eligibilityDisplay.residenceType === "home" ? (
<HomeIcon className="h-6 w-6" />
) : (
<BuildingOfficeIcon className="h-6 w-6" />
)}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<CheckCircleIcon className="h-5 w-5 text-success" />
<h3 className="font-semibold text-foreground">Service Available</h3>
</div>
<p className="text-sm font-medium text-foreground mb-1">
{eligibilityDisplay.label}
</p>
<p className="text-sm text-muted-foreground">{eligibilityDisplay.description}</p>
</div>
<div className="hidden sm:flex items-center gap-2 px-4 py-2 rounded-full bg-primary/10 border border-primary/20">
<BoltIcon className="h-4 w-4 text-primary" />
<span className="text-sm font-semibold text-primary">
Up to {eligibilityDisplay.speed}
</span>
</div>
</div>
</div>
{/* Plan comparison guide */}
<div className="mb-8">
<PlanComparisonGuide />
</div>
{/* Available speed options */}
{offeringCards.length > 1 && (
<div className="mb-6">
<h2 className="text-lg font-semibold text-foreground mb-2">Choose your speed</h2>
<p className="text-sm text-muted-foreground">
Your address supports multiple speed options. Pick the one that fits your needs
and budget.
</p>
</AlertBanner>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 lg:gap-8">
{(orderingLocked ? silverPlans : plans).map(plan => (
<div key={plan.id}>
<InternetPlanCard
plan={plan}
installations={installations}
titlePriority="base"
disabled={hasActiveInternet || orderingLocked}
{/* Offering cards */}
<div className="space-y-4 mb-8">
{offeringCards.map((card, index) => (
<div key={card.offeringType}>
{card.isAlternative && (
<div className="flex items-center gap-2 mb-3">
<div className="h-px flex-1 bg-border" />
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Alternative option
</span>
<div className="h-px flex-1 bg-border" />
</div>
)}
<InternetOfferingCard
offeringType={card.offeringType}
title={card.title}
speedBadge={card.speedBadge}
description={card.alternativeNote ?? card.description}
iconType={card.iconType}
startingPrice={card.startingPrice}
setupFee={card.setupFee}
tiers={card.tiers}
ctaPath={card.ctaPath}
isPremium={card.isPremium}
defaultExpanded={index === 0}
disabled={hasActiveInternet}
disabledReason={
hasActiveInternet
? "Already subscribed — contact us to add another residence"
: isIneligible
? "Service not available for this address"
: isNotRequested
? "Request an eligibility review to continue"
: isPending
? "Ordering locked until availability is confirmed"
: undefined
: undefined
}
/>
</div>
))}
</div>
<div className="mt-16">
<InternetImportantNotes />
</div>
{/* Important notes */}
<InternetImportantNotes />
<CatalogBackLink
href={shopBasePath}
label="Back to Services"
align="center"
className="mt-12 mb-0"
/>
</>
) : (
)}
{/* NOT ELIGIBLE YET - Show locked state */}
{orderingLocked && !isEligible && plans.length > 0 && (
<>
<AlertBanner
variant="info"
title={
isIneligible
? "Ordering unavailable"
: isNotRequested
? "Eligibility review required"
: "Availability review in progress"
}
className="mb-8"
elevated
>
<p className="text-sm text-foreground/80">
{isIneligible
? "Service is not available for your address."
: isNotRequested
? "Request an eligibility review to unlock ordering for your residence."
: "You can browse plan options below, but ordering stays locked until we confirm service availability for your residence."}
</p>
</AlertBanner>
{/* Show plan comparison guide even when locked */}
<div className="mb-8">
<PlanComparisonGuide />
</div>
<div className="opacity-60 pointer-events-none">
<p className="text-center text-sm text-muted-foreground mb-6">
Preview of available plans (ordering locked)
</p>
{/* Show a simplified preview */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{Object.entries(OFFERING_CONFIGS)
.slice(0, 4)
.map(([key, config]) => (
<div
key={key}
className="bg-card rounded-xl border border-border p-5 shadow-[var(--cp-shadow-1)]"
>
<div className="flex items-center gap-3">
<div
className={cn(
"flex h-10 w-10 items-center justify-center rounded-xl border",
config.iconType === "home"
? "bg-info-soft text-info border-info/25"
: "bg-success-soft text-success border-success/25"
)}
>
{config.iconType === "home" ? (
<HomeIcon className="h-5 w-5" />
) : (
<BuildingOfficeIcon className="h-5 w-5" />
)}
</div>
<div>
<h3 className="font-semibold text-foreground">{config.title}</h3>
<p className="text-xs text-muted-foreground">{config.speedBadge}</p>
</div>
</div>
</div>
))}
</div>
</div>
<CatalogBackLink
href={shopBasePath}
label="Back to Services"
align="center"
className="mt-12 mb-0"
/>
</>
)}
{/* No plans available */}
{plans.length === 0 && !isLoading && (
<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 for your location at this time.
We couldn&apos;t find any internet plans available at this time.
</p>
<CatalogBackLink
href={shopBasePath}
@ -512,6 +886,4 @@ export function InternetPlansContainer() {
);
}
// InternetPlanCard extracted to components/internet/InternetPlanCard
export default InternetPlansContainer;

View File

@ -1,25 +1,162 @@
"use client";
import { useMemo } from "react";
import { ServerIcon, HomeIcon, BuildingOfficeIcon } from "@heroicons/react/24/outline";
import { ServerIcon, ArrowRightIcon } from "@heroicons/react/24/outline";
import { useInternetCatalog } from "@/features/catalog/hooks";
import type {
InternetPlanCatalogItem,
InternetInstallationCatalogItem,
} from "@customer-portal/domain/catalog";
import { Skeleton } from "@/components/atoms/loading-skeleton";
import { InternetPlanCard } from "@/features/catalog/components/internet/InternetPlanCard";
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";
// Types
interface OfferingConfig {
offeringType: string;
title: string;
speedBadge: string;
description: string;
iconType: "home" | "apartment";
isPremium: boolean;
displayOrder: number;
}
// 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[] = [
{
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,
},
{
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,
},
{
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,
},
{
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,
},
];
/**
* Get tier info from plans
*/
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.
* Simplified version without active subscription checks.
* 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();
@ -30,59 +167,50 @@ export function PublicInternetPlansView() {
[data?.installations]
);
const silverPlans: InternetPlanCatalogItem[] = useMemo(
() =>
plans.filter(
p =>
String(p.internetPlanTier || "")
.trim()
.toLowerCase() === "silver"
) ?? [],
[plans]
);
const setupFee = useMemo(() => getSetupFee(installations), [installations]);
const offeringTypes = useMemo(() => {
const set = new Set<string>();
for (const plan of silverPlans) {
const value = String(plan.internetOfferingType || "").trim();
if (value) set.add(value);
}
return Array.from(set).sort((a, b) => a.localeCompare(b));
}, [silverPlans]);
// 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;
const getEligibilityIcon = (offeringType?: string) => {
const lower = (offeringType || "").toLowerCase();
if (lower.includes("home")) return <HomeIcon className="h-5 w-5" />;
if (lower.includes("apartment")) return <BuildingOfficeIcon className="h-5 w-5" />;
return <HomeIcon className="h-5 w-5" />;
};
const getEligibilityColor = (offeringType?: string) => {
const lower = (offeringType || "").toLowerCase();
if (lower.includes("home")) return "text-info bg-info-soft border-info/25";
if (lower.includes("apartment")) return "text-success bg-success-soft border-success/25";
return "text-muted-foreground bg-muted border-border";
};
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-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
<CatalogBackLink href={shopBasePath} label="Back to Services" />
<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="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{Array.from({ length: 6 }).map((_, i) => (
<div className="space-y-4">
{Array.from({ length: 4 }).map((_, i) => (
<div
key={i}
className="bg-card rounded-xl border border-border p-6 space-y-3 shadow-[var(--cp-shadow-1)]"
className="bg-card rounded-xl border border-border p-6 shadow-[var(--cp-shadow-1)]"
>
<Skeleton className="h-6 w-1/2" />
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-10 w-full" />
<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>
@ -92,76 +220,135 @@ export function PublicInternetPlansView() {
if (error) {
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
<CatalogBackLink href={shopBasePath} label="Back to Services" />
<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">
{error instanceof Error ? error.message : "An unexpected error occurred"}
Please try again later or contact support if the problem persists.
</AlertBanner>
</div>
);
}
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
<CatalogBackLink href={shopBasePath} label="Back to Services" />
<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="High-speed fiber internet for homes and apartments."
>
<div className="flex flex-col items-center gap-4">
{/* Availability notice */}
<div className="bg-info-soft border border-info/25 rounded-xl px-4 py-3 max-w-xl">
<p className="text-sm text-foreground text-center">
<span className="font-medium">Availability check required:</span>{" "}
<span className="text-muted-foreground">
After signup, we verify your address with NTT (1-2 business days). You&apos;ll
receive an email when your personalized plans are ready.
</span>
</p>
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>
{offeringTypes.length > 0 && (
<div className="flex flex-wrap justify-center gap-2">
{offeringTypes.map(type => (
<div
key={type}
className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-full border ${getEligibilityColor(type)} shadow-[var(--cp-shadow-1)]`}
>
{getEligibilityIcon(type)}
<span className="text-sm font-semibold">{type}</span>
</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>
)}
</div>
</CatalogHero>
{silverPlans.length > 0 ? (
<>
<div id="plans" className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 lg:gap-8">
{silverPlans.map(plan => (
<div key={plan.id}>
<InternetPlanCard
plan={plan}
installations={installations}
titlePriority="detail"
disabled={false}
pricingPrefix="Starting from"
action={{
label: "Get started",
href: `/shop/internet/configure?planSku=${encodeURIComponent(plan.sku)}`,
}}
showTierBadge={false}
showPlanSubtitle={false}
showFeatures={false}
/>
</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>
<div className="mt-16">
{/* 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">