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:
parent
a6bc9666e1
commit
530245f43a
@ -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"),
|
||||
|
||||
|
||||
@ -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
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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() : "";
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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}</>;
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
@ -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'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>
|
||||
);
|
||||
}
|
||||
@ -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'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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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">
|
||||
We’re reviewing service availability for your address. Once confirmed, we’ll 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 isn’t 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. We’ll
|
||||
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">
|
||||
We’ll 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 we’ll 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't find any internet plans available for your location at this time.
|
||||
We couldn'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;
|
||||
|
||||
@ -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'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">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user