diff --git a/apps/bff/src/core/config/env.validation.ts b/apps/bff/src/core/config/env.validation.ts index a6969389..51a329c3 100644 --- a/apps/bff/src/core/config/env.validation.ts +++ b/apps/bff/src/core/config/env.validation.ts @@ -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"), diff --git a/apps/bff/src/integrations/salesforce/events/catalog-cdc.subscriber.ts b/apps/bff/src/integrations/salesforce/events/catalog-cdc.subscriber.ts index 1353c865..27668fb1 100644 --- a/apps/bff/src/integrations/salesforce/events/catalog-cdc.subscriber.ts +++ b/apps/bff/src/integrations/salesforce/events/catalog-cdc.subscriber.ts @@ -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 }); } diff --git a/apps/bff/src/integrations/salesforce/services/salesforce-case.service.ts b/apps/bff/src/integrations/salesforce/services/salesforce-case.service.ts index e9c1b4aa..a4d01721 100644 --- a/apps/bff/src/integrations/salesforce/services/salesforce-case.service.ts +++ b/apps/bff/src/integrations/salesforce/services/salesforce-case.service.ts @@ -284,7 +284,7 @@ export class SalesforceCaseService { }); const casePayload: Record = { - Origin: "Portal", + Origin: SALESFORCE_CASE_ORIGIN.PORTAL_WEBSITE, Status: SALESFORCE_CASE_STATUS.NEW, Priority: SALESFORCE_CASE_PRIORITY.MEDIUM, Subject: params.subject, diff --git a/apps/bff/src/modules/catalog/services/catalog-cache.service.ts b/apps/bff/src/modules/catalog/services/catalog-cache.service.ts index 7a6013a6..7209a292 100644 --- a/apps/bff/src/modules/catalog/services/catalog-cache.service.ts +++ b/apps/bff/src/modules/catalog/services/catalog-cache.service.ts @@ -173,10 +173,14 @@ export class CatalogCacheService { eligibility: string | null | undefined ): Promise { 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 { diff --git a/apps/bff/src/modules/catalog/services/internet-catalog.service.ts b/apps/bff/src/modules/catalog/services/internet-catalog.service.ts index 6cb1f60d..e59ed167 100644 --- a/apps/bff/src/modules/catalog/services/internet-catalog.service.ts +++ b/apps/bff/src/modules/catalog/services/internet-catalog.service.ts @@ -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( - 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(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("ACCOUNT_INTERNET_ELIGIBILITY_NOTES_FIELD") ?? - "Internet_Eligibility_Notes__c", - "ACCOUNT_INTERNET_ELIGIBILITY_NOTES_FIELD" - ); - const caseIdField = assertSoqlFieldName( - this.config.get("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 { + private async updateAccountEligibilityRequestState(sfAccountId: string): Promise { const statusField = assertSoqlFieldName( this.config.get("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("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 { const address1 = typeof address.address1 === "string" ? address.address1.trim() : ""; const address2 = typeof address.address2 === "string" ? address.address2.trim() : ""; diff --git a/apps/bff/src/modules/me-status/me-status.module.ts b/apps/bff/src/modules/me-status/me-status.module.ts index fbebbf30..e568dade 100644 --- a/apps/bff/src/modules/me-status/me-status.module.ts +++ b/apps/bff/src/modules/me-status/me-status.module.ts @@ -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], diff --git a/apps/bff/src/modules/me-status/me-status.service.ts b/apps/bff/src/modules/me-status/me-status.service.ts index e1fa8dec..9ece5def 100644 --- a/apps/bff/src/modules/me-status/me-status.service.ts +++ b/apps/bff/src/modules/me-status/me-status.service.ts @@ -33,32 +33,37 @@ export class MeStatusService { ) {} async getStatusForUser(userId: string): Promise { - 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 { diff --git a/apps/portal/src/app/account/shop/layout.tsx b/apps/portal/src/app/account/shop/layout.tsx index ead5f5f5..f0737f4d 100644 --- a/apps/portal/src/app/account/shop/layout.tsx +++ b/apps/portal/src/app/account/shop/layout.tsx @@ -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 ( -
- - {children} -
- ); + return <>{children}; } diff --git a/apps/portal/src/components/organisms/AppShell/Header.tsx b/apps/portal/src/components/organisms/AppShell/Header.tsx index 911725d1..db6a9618 100644 --- a/apps/portal/src/components/organisms/AppShell/Header.tsx +++ b/apps/portal/src/components/organisms/AppShell/Header.tsx @@ -25,7 +25,7 @@ export const Header = memo(function Header({ onMenuClick, user, profileReady }: : displayName.slice(0, 2).toUpperCase(); return ( -
+
+ + {/* Expanded content - Tier options */} + {isExpanded && ( +
+
+ {tiers.map(tier => ( +
+ {/* Header */} +
+ {tier.tier} + {tier.recommended && ( + + )} +
+ + {/* Pricing */} +
+
+ + ¥{tier.monthlyPrice.toLocaleString()} + + /mo +
+ {tier.pricingNote && ( +

{tier.pricingNote}

+ )} +
+ + {/* Description */} +

{tier.description}

+ + {/* Features - flex-grow to push button to bottom */} +
    + {tier.features.map((feature, index) => ( +
  • + + + {feature} + +
  • + ))} +
+ + {/* Button/Info - always at bottom */} + {previewMode ? ( +
+

+ Available after verification +

+
+ ) : disabled ? ( +
+ + {disabledReason && ( +

+ {disabledReason} +

+ )} +
+ ) : ( + + )} +
+ ))} +
+ +

+ + ¥{setupFee.toLocaleString()} one-time installation (or 12/24-month installment) +

+
+ )} +
+ ); +} + +export type { InternetOfferingCardProps, TierInfo }; diff --git a/apps/portal/src/features/catalog/components/internet/PlanComparisonGuide.tsx b/apps/portal/src/features/catalog/components/internet/PlanComparisonGuide.tsx new file mode 100644 index 00000000..50b07478 --- /dev/null +++ b/apps/portal/src/features/catalog/components/internet/PlanComparisonGuide.tsx @@ -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 ( +
+
+
+ {icon} +
+
+
+

{title}

+ {highlight && ( + + Most Popular + + )} +
+

{idealFor}

+

{description}

+
+
+
+ ); +} + +export function PlanComparisonGuide() { + return ( +
+
+

Which plan is right for you?

+

+ All plans include the same connection speed. The difference is in equipment and support. +

+
+ + {/* Stacked rows - always vertical for cleaner reading */} +
+ } + 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." + /> + + } + 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 + /> + + } + 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." + /> +
+ + {/* Platinum additional info */} +
+

+ About Platinum plans: 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. +

+
+
+ ); +} diff --git a/apps/portal/src/features/catalog/components/internet/WhyChooseSection.tsx b/apps/portal/src/features/catalog/components/internet/WhyChooseSection.tsx new file mode 100644 index 00000000..f3fc9c2a --- /dev/null +++ b/apps/portal/src/features/catalog/components/internet/WhyChooseSection.tsx @@ -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 ( +
+
+ {icon} +
+
+

{title}

+

{description}

+
+
+ ); +} + +export function WhyChooseSection() { + return ( +
+
+

Why choose our internet service?

+

+ Japan's most reliable fiber network with dedicated English support. +

+
+ +
+ } + title="NTT Fiber Network" + description="Powered by Japan's largest and most reliable optical fiber infrastructure, delivering speeds up to 10Gbps." + /> + + } + title="IPoE Connection" + description="Modern IPv6/IPoE technology for congestion-free access, even during peak hours. PPPoE also available." + /> + + } + title="Flexible ISP Options" + description="Multiple connection protocols within a single contract. Switch between IPoE and PPPoE as needed." + /> + + } + title="One-Stop Solution" + description="NTT line, ISP service, and optional equipment—all managed through one provider. One bill, one contact point." + /> + + } + title="Full English Support" + description="Native English customer service for setup, billing questions, and technical support. No language barriers." + /> + + } + title="On-Site Assistance" + description="Need help at home? Our technicians can visit for setup, troubleshooting, or network optimization." + /> +
+
+ ); +} diff --git a/apps/portal/src/features/catalog/components/internet/utils/groupPlansByOfferingType.ts b/apps/portal/src/features/catalog/components/internet/utils/groupPlansByOfferingType.ts new file mode 100644 index 00000000..83d91b63 --- /dev/null +++ b/apps/portal/src/features/catalog/components/internet/utils/groupPlansByOfferingType.ts @@ -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(); + + 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; +} diff --git a/apps/portal/src/features/catalog/views/InternetPlans.tsx b/apps/portal/src/features/catalog/views/InternetPlans.tsx index aaf4ee16..af5dffef 100644 --- a/apps/portal/src/features/catalog/views/InternetPlans.tsx +++ b/apps/portal/src/features/catalog/views/InternetPlans.tsx @@ -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> = { + "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 ; - if (lower.includes("apartment")) return ; - return ; - }; - - 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 ( } > -
- +
+ - {/* Title + eligibility */}
-
-
-
-
-
-
+ +
- {/* Active internet warning slot */} -
- - {/* Plans grid */} -
- {Array.from({ length: 6 }).map((_, i) => ( +
+ {Array.from({ length: 2 }).map((_, i) => (
- - - +
+ +
+ + + +
+
))}
- - {/* Important Notes */} -
@@ -230,12 +487,12 @@ export function InternetPlansContainer() { description="High-speed internet services for your home or business" icon={} > -
- +
+ {eligibilityLoading ? (
@@ -243,7 +500,7 @@ export function InternetPlansContainer() { Checking availability…

- We're verifying whether our service is available at your residence. + We're verifying what service is available at your residence.

) : autoRequestStatus === "submitting" ? ( @@ -282,7 +539,7 @@ export function InternetPlansContainer() { Availability review in progress

- 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.

@@ -292,24 +549,13 @@ export function InternetPlansContainer() { Not available for this address

- Our team reviewed your address and determined service isn’t available right now. -

-
- ) : eligibility ? ( -
-
- {getEligibilityIcon(eligibility)} - Eligible for: {eligibility} -
-

- Plans shown are tailored to your house type and local infrastructure. + Our team reviewed your address and determined service isn't available right now.

) : null} + {/* Auto-request status alerts */} {autoRequestStatus === "submitting" && ( We're sending your request now. You'll see updated eligibility once the review begins. @@ -340,13 +586,14 @@ export function InternetPlansContainer() { )} + {/* Eligibility request section */} {isNotRequested && autoRequestStatus !== "submitting" && autoRequestStatus !== "submitted" && (

- 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.

{hasServiceAddress ? ( @@ -389,7 +636,7 @@ export function InternetPlansContainer() {

- We’ll notify you when review is complete. + We'll notify you when review is complete.

{requestedAt ? (

@@ -407,7 +654,7 @@ export function InternetPlansContainer() {

{rejectionNotes}

) : (

- 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.

)} +
+
+ + {/* SECTION 4: Plan tiers explained - Educational */} +
+
+

Service tiers explained

+

+ All connection types offer three service levels. You'll choose your tier after we + verify your address. +

+
+ +
+ + {/* SECTION 5: Available connection types - Preview only */} +
+
+

Available connection types

+

+ Which type applies to you depends on your building. Expand any card to preview + pricing. +

+
+ +
+ {offeringCards.map(card => ( + ))}
- )} -
- - {silverPlans.length > 0 ? ( - <> -
- {silverPlans.map(plan => ( -
- -
- ))} + {/* Note about preview mode */} +

+ Pricing shown is for reference. Your actual options will be confirmed after address + verification. +

-
+ {/* SECTION 6: Important notes */} +
+ + {/* SECTION 7: Final CTA */} +
+

+ Not sure which plan is right for you? +

+

+ 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. +

+ +
+ + ) : (