From 9d2c4ff9215fdda9eb862eefa6ca3762834a4d04 Mon Sep 17 00:00:00 2001 From: barsa Date: Wed, 17 Dec 2025 17:59:55 +0900 Subject: [PATCH] Add Internet Eligibility Features and Update Catalog Module - Introduced InternetEligibilityController to handle user eligibility checks for internet services. - Enhanced InternetCatalogService with methods for retrieving and requesting internet eligibility. - Updated catalog.module.ts to include the new InternetEligibilityController. - Refactored various components and views to utilize the new eligibility features, improving user experience and service accessibility. - Adjusted routing paths and links to align with the new catalog structure, ensuring seamless navigation for users. --- .../bff/src/modules/catalog/catalog.module.ts | 3 +- .../internet-eligibility.controller.ts | 51 +++ .../services/internet-catalog.service.ts | 91 +++++ .../services/internet-eligibility.types.ts | 7 + .../src/app/(public)/(catalog)/layout.tsx | 11 + .../shop/internet/configure/page.tsx | 0 .../{ => (catalog)}/shop/internet/page.tsx | 0 .../app/(public)/(catalog)/shop/layout.tsx | 9 + .../(public)/{ => (catalog)}/shop/loading.tsx | 0 .../(public)/{ => (catalog)}/shop/page.tsx | 0 .../shop/sim/configure/page.tsx | 0 .../{ => (catalog)}/shop/sim/page.tsx | 0 .../{ => (catalog)}/shop/vpn/page.tsx | 0 .../auth/forgot-password/page.tsx | 0 .../{ => (site)}/auth/link-whmcs/page.tsx | 0 .../(public)/{ => (site)}/auth/loading.tsx | 0 .../(public)/{ => (site)}/auth/login/page.tsx | 0 .../{ => (site)}/auth/reset-password/page.tsx | 0 .../{ => (site)}/auth/set-password/page.tsx | 0 .../{ => (site)}/auth/signup/page.tsx | 0 .../(public)/{ => (site)}/contact/page.tsx | 0 .../app/(public)/{ => (site)}/help/page.tsx | 0 .../portal/src/app/(public)/(site)/layout.tsx | 11 + .../src/app/(public)/{ => (site)}/loading.tsx | 0 .../src/app/(public)/{ => (site)}/page.tsx | 0 apps/portal/src/app/(public)/layout.tsx | 9 +- apps/portal/src/app/(public)/shop/layout.tsx | 16 - apps/portal/src/app/account/order/page.tsx | 11 + .../account/shop/internet/configure/page.tsx | 5 + .../src/app/account/shop/internet/page.tsx | 5 + apps/portal/src/app/account/shop/page.tsx | 5 + .../app/account/shop/sim/configure/page.tsx | 5 + apps/portal/src/app/account/shop/sim/page.tsx | 5 + apps/portal/src/app/account/shop/vpn/page.tsx | 5 + .../organisms/AppShell/navigation.ts | 2 +- .../features/auth/views/SetPasswordView.tsx | 6 +- .../components/internet/InternetPlanCard.tsx | 6 +- .../configure/InternetConfigureContainer.tsx | 4 +- .../components/sim/SimConfigureView.tsx | 9 +- .../catalog/containers/InternetConfigure.tsx | 2 +- .../catalog/containers/SimConfigure.tsx | 2 +- .../src/features/catalog/hooks/index.ts | 1 + .../catalog/hooks/useInternetConfigure.ts | 14 +- .../catalog/hooks/useInternetEligibility.ts | 26 ++ .../features/catalog/hooks/useShopBasePath.ts | 17 + .../features/catalog/hooks/useSimConfigure.ts | 5 +- .../catalog/services/catalog.service.ts | 21 + .../features/catalog/views/CatalogHome.tsx | 9 +- .../catalog/views/InternetConfigure.tsx | 6 +- .../features/catalog/views/InternetPlans.tsx | 88 ++++- .../catalog/views/PublicCatalogHome.tsx | 9 +- .../catalog/views/PublicInternetPlans.tsx | 12 +- .../features/catalog/views/PublicSimPlans.tsx | 8 +- .../features/catalog/views/PublicVpnPlans.tsx | 8 +- .../features/catalog/views/SimConfigure.tsx | 6 +- .../src/features/catalog/views/SimPlans.tsx | 8 +- .../src/features/catalog/views/VpnPlans.tsx | 8 +- .../checkout/components/CheckoutProgress.tsx | 25 +- .../checkout/components/CheckoutWizard.tsx | 29 +- .../checkout/components/EmptyCartRedirect.tsx | 8 +- .../checkout/components/steps/AccountStep.tsx | 368 +++++++++++------- .../checkout/components/steps/AddressStep.tsx | 15 +- .../checkout/components/steps/PaymentStep.tsx | 13 +- .../checkout/components/steps/ReviewStep.tsx | 41 +- .../checkout/stores/checkout.store.ts | 2 - apps/portal/src/lib/api/index.ts | 1 + 66 files changed, 774 insertions(+), 254 deletions(-) create mode 100644 apps/bff/src/modules/catalog/internet-eligibility.controller.ts create mode 100644 apps/bff/src/modules/catalog/services/internet-eligibility.types.ts create mode 100644 apps/portal/src/app/(public)/(catalog)/layout.tsx rename apps/portal/src/app/(public)/{ => (catalog)}/shop/internet/configure/page.tsx (100%) rename apps/portal/src/app/(public)/{ => (catalog)}/shop/internet/page.tsx (100%) create mode 100644 apps/portal/src/app/(public)/(catalog)/shop/layout.tsx rename apps/portal/src/app/(public)/{ => (catalog)}/shop/loading.tsx (100%) rename apps/portal/src/app/(public)/{ => (catalog)}/shop/page.tsx (100%) rename apps/portal/src/app/(public)/{ => (catalog)}/shop/sim/configure/page.tsx (100%) rename apps/portal/src/app/(public)/{ => (catalog)}/shop/sim/page.tsx (100%) rename apps/portal/src/app/(public)/{ => (catalog)}/shop/vpn/page.tsx (100%) rename apps/portal/src/app/(public)/{ => (site)}/auth/forgot-password/page.tsx (100%) rename apps/portal/src/app/(public)/{ => (site)}/auth/link-whmcs/page.tsx (100%) rename apps/portal/src/app/(public)/{ => (site)}/auth/loading.tsx (100%) rename apps/portal/src/app/(public)/{ => (site)}/auth/login/page.tsx (100%) rename apps/portal/src/app/(public)/{ => (site)}/auth/reset-password/page.tsx (100%) rename apps/portal/src/app/(public)/{ => (site)}/auth/set-password/page.tsx (100%) rename apps/portal/src/app/(public)/{ => (site)}/auth/signup/page.tsx (100%) rename apps/portal/src/app/(public)/{ => (site)}/contact/page.tsx (100%) rename apps/portal/src/app/(public)/{ => (site)}/help/page.tsx (100%) create mode 100644 apps/portal/src/app/(public)/(site)/layout.tsx rename apps/portal/src/app/(public)/{ => (site)}/loading.tsx (100%) rename apps/portal/src/app/(public)/{ => (site)}/page.tsx (100%) delete mode 100644 apps/portal/src/app/(public)/shop/layout.tsx create mode 100644 apps/portal/src/app/account/order/page.tsx create mode 100644 apps/portal/src/app/account/shop/internet/configure/page.tsx create mode 100644 apps/portal/src/app/account/shop/internet/page.tsx create mode 100644 apps/portal/src/app/account/shop/page.tsx create mode 100644 apps/portal/src/app/account/shop/sim/configure/page.tsx create mode 100644 apps/portal/src/app/account/shop/sim/page.tsx create mode 100644 apps/portal/src/app/account/shop/vpn/page.tsx create mode 100644 apps/portal/src/features/catalog/hooks/useInternetEligibility.ts create mode 100644 apps/portal/src/features/catalog/hooks/useShopBasePath.ts diff --git a/apps/bff/src/modules/catalog/catalog.module.ts b/apps/bff/src/modules/catalog/catalog.module.ts index a648b03a..e3521ee6 100644 --- a/apps/bff/src/modules/catalog/catalog.module.ts +++ b/apps/bff/src/modules/catalog/catalog.module.ts @@ -1,6 +1,7 @@ import { Module, forwardRef } from "@nestjs/common"; import { CatalogController } from "./catalog.controller.js"; import { CatalogHealthController } from "./catalog-health.controller.js"; +import { InternetEligibilityController } from "./internet-eligibility.controller.js"; import { IntegrationsModule } from "@bff/integrations/integrations.module.js"; import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js"; import { CoreConfigModule } from "@bff/core/config/config.module.js"; @@ -21,7 +22,7 @@ import { CatalogCacheService } from "./services/catalog-cache.service.js"; CacheModule, QueueModule, ], - controllers: [CatalogController, CatalogHealthController], + controllers: [CatalogController, CatalogHealthController, InternetEligibilityController], providers: [ BaseCatalogService, InternetCatalogService, diff --git a/apps/bff/src/modules/catalog/internet-eligibility.controller.ts b/apps/bff/src/modules/catalog/internet-eligibility.controller.ts new file mode 100644 index 00000000..03b35a9d --- /dev/null +++ b/apps/bff/src/modules/catalog/internet-eligibility.controller.ts @@ -0,0 +1,51 @@ +import { Body, Controller, Get, Post, Req, UseGuards, UsePipes } from "@nestjs/common"; +import { ZodValidationPipe } from "nestjs-zod"; +import { z } from "zod"; +import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; +import { RateLimit, RateLimitGuard } from "@bff/core/rate-limiting/index.js"; +import { InternetCatalogService } from "./services/internet-catalog.service.js"; +import { addressSchema } from "@customer-portal/domain/customer"; + +const eligibilityRequestSchema = z.object({ + notes: z.string().trim().max(2000).optional(), + address: addressSchema.partial().optional(), +}); + +type EligibilityRequest = z.infer; + +/** + * Internet Eligibility Controller + * + * Authenticated endpoints for: + * - fetching current Salesforce eligibility value + * - requesting a (manual) eligibility/availability check + * + * Note: CatalogController is @Public, so we keep these endpoints in a separate controller + * to ensure GlobalAuthGuard enforces authentication. + */ +@Controller("catalog/internet") +@UseGuards(RateLimitGuard) +export class InternetEligibilityController { + constructor(private readonly internetCatalog: InternetCatalogService) {} + + @Get("eligibility") + @RateLimit({ limit: 60, ttl: 60 }) // 60/min per IP (cheap) + async getEligibility(@Req() req: RequestWithUser): Promise<{ eligibility: string | null }> { + const eligibility = await this.internetCatalog.getEligibilityForUser(req.user.id); + return { eligibility }; + } + + @Post("eligibility-request") + @RateLimit({ limit: 5, ttl: 300 }) // 5 per 5 minutes per IP + @UsePipes(new ZodValidationPipe(eligibilityRequestSchema)) + async requestEligibility( + @Req() req: RequestWithUser, + @Body() body: EligibilityRequest + ): Promise<{ requestId: string }> { + const requestId = await this.internetCatalog.requestEligibilityCheckForUser(req.user.id, { + email: req.user.email, + ...body, + }); + return { requestId }; + } +} 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 8acf5640..17b01fd0 100644 --- a/apps/bff/src/modules/catalog/services/internet-catalog.service.ts +++ b/apps/bff/src/modules/catalog/services/internet-catalog.service.ts @@ -20,6 +20,7 @@ import { Logger } from "nestjs-pino"; import { getErrorMessage } from "@bff/core/utils/error.util.js"; import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util.js"; import { buildAccountEligibilityQuery } from "@bff/integrations/salesforce/utils/catalog-query-builder.js"; +import type { InternetEligibilityCheckRequest } from "./internet-eligibility.types.js"; interface SalesforceAccount { Id: string; @@ -218,9 +219,99 @@ export class InternetCatalogService extends BaseCatalogService { } } + async getEligibilityForUser(userId: string): Promise { + const mapping = await this.mappingsService.findByUserId(userId); + if (!mapping?.sfAccountId) { + return null; + } + + const sfAccountId = assertSalesforceId(mapping.sfAccountId, "sfAccountId"); + const eligibilityKey = this.catalogCache.buildEligibilityKey("internet", sfAccountId); + const account = await this.catalogCache.getCachedEligibility( + eligibilityKey, + async () => { + const soql = buildAccountEligibilityQuery(sfAccountId); + const accounts = await this.executeQuery(soql, "Customer Eligibility"); + return accounts.length > 0 ? (accounts[0] as unknown as SalesforceAccount) : null; + } + ); + + return account?.Internet_Eligibility__c ?? null; + } + + async requestEligibilityCheckForUser( + userId: string, + request: InternetEligibilityCheckRequest + ): Promise { + const mapping = await this.mappingsService.findByUserId(userId); + if (!mapping?.sfAccountId) { + throw new Error("No Salesforce mapping found for current user"); + } + + const sfAccountId = assertSalesforceId(mapping.sfAccountId, "sfAccountId"); + + const subject = "Internet availability check request (Portal)"; + const descriptionLines: string[] = [ + "Portal internet availability check requested.", + "", + `UserId: ${userId}`, + `Email: ${request.email}`, + `SalesforceAccountId: ${sfAccountId}`, + "", + request.notes ? `Notes: ${request.notes}` : "", + request.address ? `Address: ${formatAddressForLog(request.address)}` : "", + "", + `RequestedAt: ${new Date().toISOString()}`, + ].filter(Boolean); + + const taskPayload: Record = { + Subject: subject, + Description: descriptionLines.join("\n"), + WhatId: sfAccountId, + }; + + try { + const create = this.sf.sobject("Task")?.create; + if (!create) { + throw new Error("Salesforce Task create method not available"); + } + + const result = await create(taskPayload); + const id = (result as { id?: unknown })?.id; + if (typeof id !== "string" || id.trim().length === 0) { + throw new Error("Salesforce did not return a Task id"); + } + + this.logger.log("Created Salesforce Task for internet eligibility request", { + userId, + sfAccountIdTail: sfAccountId.slice(-4), + taskIdTail: id.slice(-4), + }); + + return id; + } catch (error) { + this.logger.error("Failed to create Salesforce Task for internet eligibility request", { + userId, + sfAccountId, + error: getErrorMessage(error), + }); + throw new Error("Failed to request availability check. Please try again later."); + } + } + private checkPlanEligibility(plan: InternetPlanCatalogItem, eligibility: string): boolean { // Simple match: user's eligibility field must equal plan's Salesforce offering type // e.g., eligibility "Home 1G" matches plan.internetOfferingType "Home 1G" return plan.internetOfferingType === eligibility; } } + +function formatAddressForLog(address: Record): string { + const address1 = typeof address.address1 === "string" ? address.address1.trim() : ""; + const address2 = typeof address.address2 === "string" ? address.address2.trim() : ""; + const city = typeof address.city === "string" ? address.city.trim() : ""; + const state = typeof address.state === "string" ? address.state.trim() : ""; + const postcode = typeof address.postcode === "string" ? address.postcode.trim() : ""; + const country = typeof address.country === "string" ? address.country.trim() : ""; + return [address1, address2, city, state, postcode, country].filter(Boolean).join(", "); +} diff --git a/apps/bff/src/modules/catalog/services/internet-eligibility.types.ts b/apps/bff/src/modules/catalog/services/internet-eligibility.types.ts new file mode 100644 index 00000000..972deebb --- /dev/null +++ b/apps/bff/src/modules/catalog/services/internet-eligibility.types.ts @@ -0,0 +1,7 @@ +import type { Address } from "@customer-portal/domain/customer"; + +export type InternetEligibilityCheckRequest = { + email: string; + notes?: string; + address?: Partial
; +}; diff --git a/apps/portal/src/app/(public)/(catalog)/layout.tsx b/apps/portal/src/app/(public)/(catalog)/layout.tsx new file mode 100644 index 00000000..c40e3123 --- /dev/null +++ b/apps/portal/src/app/(public)/(catalog)/layout.tsx @@ -0,0 +1,11 @@ +/** + * Public Catalog Layout + * + * Shop pages with catalog navigation and auth-aware header. + */ + +import { CatalogShell } from "@/components/templates/CatalogShell"; + +export default function PublicCatalogLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/apps/portal/src/app/(public)/shop/internet/configure/page.tsx b/apps/portal/src/app/(public)/(catalog)/shop/internet/configure/page.tsx similarity index 100% rename from apps/portal/src/app/(public)/shop/internet/configure/page.tsx rename to apps/portal/src/app/(public)/(catalog)/shop/internet/configure/page.tsx diff --git a/apps/portal/src/app/(public)/shop/internet/page.tsx b/apps/portal/src/app/(public)/(catalog)/shop/internet/page.tsx similarity index 100% rename from apps/portal/src/app/(public)/shop/internet/page.tsx rename to apps/portal/src/app/(public)/(catalog)/shop/internet/page.tsx diff --git a/apps/portal/src/app/(public)/(catalog)/shop/layout.tsx b/apps/portal/src/app/(public)/(catalog)/shop/layout.tsx new file mode 100644 index 00000000..93dcb7e4 --- /dev/null +++ b/apps/portal/src/app/(public)/(catalog)/shop/layout.tsx @@ -0,0 +1,9 @@ +/** + * Public Shop Layout + * + * CatalogShell is applied at `(public)/(catalog)/layout.tsx`. + */ + +export default function CatalogLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/apps/portal/src/app/(public)/shop/loading.tsx b/apps/portal/src/app/(public)/(catalog)/shop/loading.tsx similarity index 100% rename from apps/portal/src/app/(public)/shop/loading.tsx rename to apps/portal/src/app/(public)/(catalog)/shop/loading.tsx diff --git a/apps/portal/src/app/(public)/shop/page.tsx b/apps/portal/src/app/(public)/(catalog)/shop/page.tsx similarity index 100% rename from apps/portal/src/app/(public)/shop/page.tsx rename to apps/portal/src/app/(public)/(catalog)/shop/page.tsx diff --git a/apps/portal/src/app/(public)/shop/sim/configure/page.tsx b/apps/portal/src/app/(public)/(catalog)/shop/sim/configure/page.tsx similarity index 100% rename from apps/portal/src/app/(public)/shop/sim/configure/page.tsx rename to apps/portal/src/app/(public)/(catalog)/shop/sim/configure/page.tsx diff --git a/apps/portal/src/app/(public)/shop/sim/page.tsx b/apps/portal/src/app/(public)/(catalog)/shop/sim/page.tsx similarity index 100% rename from apps/portal/src/app/(public)/shop/sim/page.tsx rename to apps/portal/src/app/(public)/(catalog)/shop/sim/page.tsx diff --git a/apps/portal/src/app/(public)/shop/vpn/page.tsx b/apps/portal/src/app/(public)/(catalog)/shop/vpn/page.tsx similarity index 100% rename from apps/portal/src/app/(public)/shop/vpn/page.tsx rename to apps/portal/src/app/(public)/(catalog)/shop/vpn/page.tsx diff --git a/apps/portal/src/app/(public)/auth/forgot-password/page.tsx b/apps/portal/src/app/(public)/(site)/auth/forgot-password/page.tsx similarity index 100% rename from apps/portal/src/app/(public)/auth/forgot-password/page.tsx rename to apps/portal/src/app/(public)/(site)/auth/forgot-password/page.tsx diff --git a/apps/portal/src/app/(public)/auth/link-whmcs/page.tsx b/apps/portal/src/app/(public)/(site)/auth/link-whmcs/page.tsx similarity index 100% rename from apps/portal/src/app/(public)/auth/link-whmcs/page.tsx rename to apps/portal/src/app/(public)/(site)/auth/link-whmcs/page.tsx diff --git a/apps/portal/src/app/(public)/auth/loading.tsx b/apps/portal/src/app/(public)/(site)/auth/loading.tsx similarity index 100% rename from apps/portal/src/app/(public)/auth/loading.tsx rename to apps/portal/src/app/(public)/(site)/auth/loading.tsx diff --git a/apps/portal/src/app/(public)/auth/login/page.tsx b/apps/portal/src/app/(public)/(site)/auth/login/page.tsx similarity index 100% rename from apps/portal/src/app/(public)/auth/login/page.tsx rename to apps/portal/src/app/(public)/(site)/auth/login/page.tsx diff --git a/apps/portal/src/app/(public)/auth/reset-password/page.tsx b/apps/portal/src/app/(public)/(site)/auth/reset-password/page.tsx similarity index 100% rename from apps/portal/src/app/(public)/auth/reset-password/page.tsx rename to apps/portal/src/app/(public)/(site)/auth/reset-password/page.tsx diff --git a/apps/portal/src/app/(public)/auth/set-password/page.tsx b/apps/portal/src/app/(public)/(site)/auth/set-password/page.tsx similarity index 100% rename from apps/portal/src/app/(public)/auth/set-password/page.tsx rename to apps/portal/src/app/(public)/(site)/auth/set-password/page.tsx diff --git a/apps/portal/src/app/(public)/auth/signup/page.tsx b/apps/portal/src/app/(public)/(site)/auth/signup/page.tsx similarity index 100% rename from apps/portal/src/app/(public)/auth/signup/page.tsx rename to apps/portal/src/app/(public)/(site)/auth/signup/page.tsx diff --git a/apps/portal/src/app/(public)/contact/page.tsx b/apps/portal/src/app/(public)/(site)/contact/page.tsx similarity index 100% rename from apps/portal/src/app/(public)/contact/page.tsx rename to apps/portal/src/app/(public)/(site)/contact/page.tsx diff --git a/apps/portal/src/app/(public)/help/page.tsx b/apps/portal/src/app/(public)/(site)/help/page.tsx similarity index 100% rename from apps/portal/src/app/(public)/help/page.tsx rename to apps/portal/src/app/(public)/(site)/help/page.tsx diff --git a/apps/portal/src/app/(public)/(site)/layout.tsx b/apps/portal/src/app/(public)/(site)/layout.tsx new file mode 100644 index 00000000..a0693a1a --- /dev/null +++ b/apps/portal/src/app/(public)/(site)/layout.tsx @@ -0,0 +1,11 @@ +/** + * Public Site Layout + * + * Landing/auth/help/contact pages using the PublicShell header/footer. + */ + +import { PublicShell } from "@/components/templates"; + +export default function PublicSiteLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/apps/portal/src/app/(public)/loading.tsx b/apps/portal/src/app/(public)/(site)/loading.tsx similarity index 100% rename from apps/portal/src/app/(public)/loading.tsx rename to apps/portal/src/app/(public)/(site)/loading.tsx diff --git a/apps/portal/src/app/(public)/page.tsx b/apps/portal/src/app/(public)/(site)/page.tsx similarity index 100% rename from apps/portal/src/app/(public)/page.tsx rename to apps/portal/src/app/(public)/(site)/page.tsx diff --git a/apps/portal/src/app/(public)/layout.tsx b/apps/portal/src/app/(public)/layout.tsx index aa1e7f26..53269051 100644 --- a/apps/portal/src/app/(public)/layout.tsx +++ b/apps/portal/src/app/(public)/layout.tsx @@ -1,11 +1,12 @@ /** * Public Layout * - * Shared shell for public-facing pages (landing, auth, etc.) + * Shared wrapper for public route group. + * + * Note: Individual public sections (site, shop, checkout) each provide + * their own shells via nested route-group layouts. */ -import { PublicShell } from "@/components/templates"; - export default function PublicLayout({ children }: { children: React.ReactNode }) { - return {children}; + return children; } diff --git a/apps/portal/src/app/(public)/shop/layout.tsx b/apps/portal/src/app/(public)/shop/layout.tsx deleted file mode 100644 index 490f02b7..00000000 --- a/apps/portal/src/app/(public)/shop/layout.tsx +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Public Catalog Layout - * - * Layout for public catalog pages with catalog-specific navigation. - */ - -import { CatalogNav } from "@/components/templates/CatalogShell"; - -export default function CatalogLayout({ children }: { children: React.ReactNode }) { - return ( - <> - - {children} - - ); -} diff --git a/apps/portal/src/app/account/order/page.tsx b/apps/portal/src/app/account/order/page.tsx new file mode 100644 index 00000000..837ebfa0 --- /dev/null +++ b/apps/portal/src/app/account/order/page.tsx @@ -0,0 +1,11 @@ +/** + * Account Checkout Page + * + * Signed-in checkout experience inside the account shell. + */ + +import { CheckoutEntry } from "@/features/checkout/components/CheckoutEntry"; + +export default function AccountOrderPage() { + return ; +} diff --git a/apps/portal/src/app/account/shop/internet/configure/page.tsx b/apps/portal/src/app/account/shop/internet/configure/page.tsx new file mode 100644 index 00000000..f4c70ebb --- /dev/null +++ b/apps/portal/src/app/account/shop/internet/configure/page.tsx @@ -0,0 +1,5 @@ +import { InternetConfigureContainer } from "@/features/catalog/views/InternetConfigure"; + +export default function AccountInternetConfigurePage() { + return ; +} diff --git a/apps/portal/src/app/account/shop/internet/page.tsx b/apps/portal/src/app/account/shop/internet/page.tsx new file mode 100644 index 00000000..469f6ade --- /dev/null +++ b/apps/portal/src/app/account/shop/internet/page.tsx @@ -0,0 +1,5 @@ +import { InternetPlansContainer } from "@/features/catalog/views/InternetPlans"; + +export default function AccountInternetPlansPage() { + return ; +} diff --git a/apps/portal/src/app/account/shop/page.tsx b/apps/portal/src/app/account/shop/page.tsx new file mode 100644 index 00000000..b4b2c55c --- /dev/null +++ b/apps/portal/src/app/account/shop/page.tsx @@ -0,0 +1,5 @@ +import { CatalogHomeView } from "@/features/catalog/views/CatalogHome"; + +export default function AccountShopPage() { + return ; +} diff --git a/apps/portal/src/app/account/shop/sim/configure/page.tsx b/apps/portal/src/app/account/shop/sim/configure/page.tsx new file mode 100644 index 00000000..ab66d78c --- /dev/null +++ b/apps/portal/src/app/account/shop/sim/configure/page.tsx @@ -0,0 +1,5 @@ +import { SimConfigureContainer } from "@/features/catalog/views/SimConfigure"; + +export default function AccountSimConfigurePage() { + return ; +} diff --git a/apps/portal/src/app/account/shop/sim/page.tsx b/apps/portal/src/app/account/shop/sim/page.tsx new file mode 100644 index 00000000..e00978cf --- /dev/null +++ b/apps/portal/src/app/account/shop/sim/page.tsx @@ -0,0 +1,5 @@ +import { SimPlansContainer } from "@/features/catalog/views/SimPlans"; + +export default function AccountSimPlansPage() { + return ; +} diff --git a/apps/portal/src/app/account/shop/vpn/page.tsx b/apps/portal/src/app/account/shop/vpn/page.tsx new file mode 100644 index 00000000..701c37c7 --- /dev/null +++ b/apps/portal/src/app/account/shop/vpn/page.tsx @@ -0,0 +1,5 @@ +import { VpnPlansView } from "@/features/catalog/views/VpnPlans"; + +export default function AccountVpnPlansPage() { + return ; +} diff --git a/apps/portal/src/components/organisms/AppShell/navigation.ts b/apps/portal/src/components/organisms/AppShell/navigation.ts index 1f7663e7..1a39796c 100644 --- a/apps/portal/src/components/organisms/AppShell/navigation.ts +++ b/apps/portal/src/components/organisms/AppShell/navigation.ts @@ -41,7 +41,7 @@ export const baseNavigation: NavigationItem[] = [ icon: ServerIcon, children: [{ name: "All Services", href: "/account/services" }], }, - { name: "Shop", href: "/shop", icon: Squares2X2Icon }, + { name: "Shop", href: "/account/shop", icon: Squares2X2Icon }, { name: "Support", icon: ChatBubbleLeftRightIcon, diff --git a/apps/portal/src/features/auth/views/SetPasswordView.tsx b/apps/portal/src/features/auth/views/SetPasswordView.tsx index 0d78864e..17a90a8d 100644 --- a/apps/portal/src/features/auth/views/SetPasswordView.tsx +++ b/apps/portal/src/features/auth/views/SetPasswordView.tsx @@ -11,6 +11,7 @@ function SetPasswordContent() { const router = useRouter(); const searchParams = useSearchParams(); const email = searchParams.get("email") ?? ""; + const redirect = searchParams.get("redirect"); useEffect(() => { if (!email) { @@ -19,7 +20,10 @@ function SetPasswordContent() { }, [email, router]); const handlePasswordSetSuccess = () => { - // Redirect to dashboard after successful password setup + if (redirect) { + router.push(redirect); + return; + } router.push("/account"); }; diff --git a/apps/portal/src/features/catalog/components/internet/InternetPlanCard.tsx b/apps/portal/src/features/catalog/components/internet/InternetPlanCard.tsx index 69533ce3..213320dc 100644 --- a/apps/portal/src/features/catalog/components/internet/InternetPlanCard.tsx +++ b/apps/portal/src/features/catalog/components/internet/InternetPlanCard.tsx @@ -14,6 +14,7 @@ import type { BadgeVariant } from "@/features/catalog/components/base/CardBadge" import { useCatalogStore } from "@/features/catalog/services/catalog.store"; import { IS_DEVELOPMENT } from "@/config/environment"; import { parsePlanName } from "@/features/catalog/components/internet/utils/planName"; +import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath"; interface InternetPlanCardProps { plan: InternetPlanCatalogItem; @@ -52,6 +53,7 @@ export function InternetPlanCard({ configureHref, }: InternetPlanCardProps) { const router = useRouter(); + const shopBasePath = useShopBasePath(); const tier = plan.internetPlanTier; const isGold = tier === "Gold"; const isPlatinum = tier === "Platinum"; @@ -205,7 +207,9 @@ export function InternetPlanCard({ const { resetInternetConfig, setInternetConfig } = useCatalogStore.getState(); resetInternetConfig(); setInternetConfig({ planSku: plan.sku, currentStep: 1 }); - const href = configureHref ?? `/shop/internet/configure?plan=${plan.sku}`; + const href = + configureHref ?? + `${shopBasePath}/internet/configure?plan=${encodeURIComponent(plan.sku)}`; router.push(href); }} > diff --git a/apps/portal/src/features/catalog/components/internet/configure/InternetConfigureContainer.tsx b/apps/portal/src/features/catalog/components/internet/configure/InternetConfigureContainer.tsx index beb02db7..a00662dc 100644 --- a/apps/portal/src/features/catalog/components/internet/configure/InternetConfigureContainer.tsx +++ b/apps/portal/src/features/catalog/components/internet/configure/InternetConfigureContainer.tsx @@ -20,6 +20,7 @@ import { AddonsStep } from "./steps/AddonsStep"; import { ReviewOrderStep } from "./steps/ReviewOrderStep"; import { useConfigureState } from "./hooks/useConfigureState"; import { parsePlanName } from "@/features/catalog/components/internet/utils/planName"; +import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath"; interface Props { plan: InternetPlanCatalogItem | null; @@ -231,13 +232,14 @@ export function InternetConfigureContainer({ } function PlanHeader({ plan }: { plan: InternetPlanCatalogItem }) { + const shopBasePath = useShopBasePath(); const { baseName: planBaseName, detail: planDetail } = parsePlanName(plan); return (
@@ -185,7 +190,7 @@ export function SimConfigureView({ icon={} >
- +
diff --git a/apps/portal/src/features/catalog/containers/InternetConfigure.tsx b/apps/portal/src/features/catalog/containers/InternetConfigure.tsx index 4358d6a9..5b93545b 100644 --- a/apps/portal/src/features/catalog/containers/InternetConfigure.tsx +++ b/apps/portal/src/features/catalog/containers/InternetConfigure.tsx @@ -11,7 +11,7 @@ export function InternetConfigureContainer() { const handleConfirm = () => { const params = vm.buildCheckoutSearchParams(); if (!params) return; - router.push(`/checkout?${params.toString()}`); + router.push(`/order?${params.toString()}`); }; return ; diff --git a/apps/portal/src/features/catalog/containers/SimConfigure.tsx b/apps/portal/src/features/catalog/containers/SimConfigure.tsx index e74c296d..a750b84e 100644 --- a/apps/portal/src/features/catalog/containers/SimConfigure.tsx +++ b/apps/portal/src/features/catalog/containers/SimConfigure.tsx @@ -15,7 +15,7 @@ export function SimConfigureContainer() { if (!vm.plan || !vm.validate()) return; const params = vm.buildCheckoutSearchParams(); if (!params) return; - router.push(`/checkout?${params.toString()}`); + router.push(`/order?${params.toString()}`); }; return ; diff --git a/apps/portal/src/features/catalog/hooks/index.ts b/apps/portal/src/features/catalog/hooks/index.ts index 13663186..6e160ea3 100644 --- a/apps/portal/src/features/catalog/hooks/index.ts +++ b/apps/portal/src/features/catalog/hooks/index.ts @@ -2,3 +2,4 @@ export * from "./useCatalog"; export * from "./useConfigureParams"; export * from "./useSimConfigure"; export * from "./useInternetConfigure"; +export * from "./useInternetEligibility"; diff --git a/apps/portal/src/features/catalog/hooks/useInternetConfigure.ts b/apps/portal/src/features/catalog/hooks/useInternetConfigure.ts index b98438b1..4c6cc5c4 100644 --- a/apps/portal/src/features/catalog/hooks/useInternetConfigure.ts +++ b/apps/portal/src/features/catalog/hooks/useInternetConfigure.ts @@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { useInternetCatalog, useInternetPlan } from "."; import { useCatalogStore } from "../services/catalog.store"; +import { useShopBasePath } from "./useShopBasePath"; import type { AccessModeValue } from "@customer-portal/domain/orders"; import type { InternetPlanCatalogItem, @@ -41,6 +42,7 @@ export type UseInternetConfigureResult = { */ export function useInternetConfigure(): UseInternetConfigureResult { const router = useRouter(); + const shopBasePath = useShopBasePath(); const searchParams = useSearchParams(); const paramsSignature = useMemo(() => searchParams.toString(), [searchParams]); const urlPlanSku = searchParams.get("plan"); @@ -75,9 +77,17 @@ export function useInternetConfigure(): UseInternetConfigureResult { // Redirect if no plan selected if (!urlPlanSku && !configState.planSku) { - router.push("/shop/internet"); + router.push(`${shopBasePath}/internet`); } - }, [configState.planSku, paramsSignature, restoreFromParams, router, setConfig, urlPlanSku]); + }, [ + configState.planSku, + paramsSignature, + restoreFromParams, + router, + setConfig, + shopBasePath, + urlPlanSku, + ]); // Auto-set default mode for Gold/Platinum plans if not already set useEffect(() => { diff --git a/apps/portal/src/features/catalog/hooks/useInternetEligibility.ts b/apps/portal/src/features/catalog/hooks/useInternetEligibility.ts new file mode 100644 index 00000000..a5bb8976 --- /dev/null +++ b/apps/portal/src/features/catalog/hooks/useInternetEligibility.ts @@ -0,0 +1,26 @@ +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { queryKeys } from "@/lib/api"; +import { catalogService } from "@/features/catalog/services"; +import type { Address } from "@customer-portal/domain/customer"; + +export function useInternetEligibility() { + return useQuery({ + queryKey: queryKeys.catalog.internet.eligibility(), + queryFn: () => catalogService.getInternetEligibility(), + }); +} + +export function useRequestInternetEligibilityCheck() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (body?: { notes?: string; address?: Partial
}) => + catalogService.requestInternetEligibilityCheck(body), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: queryKeys.catalog.internet.eligibility() }); + await queryClient.invalidateQueries({ queryKey: queryKeys.catalog.internet.combined() }); + }, + }); +} diff --git a/apps/portal/src/features/catalog/hooks/useShopBasePath.ts b/apps/portal/src/features/catalog/hooks/useShopBasePath.ts new file mode 100644 index 00000000..1b4a9dbf --- /dev/null +++ b/apps/portal/src/features/catalog/hooks/useShopBasePath.ts @@ -0,0 +1,17 @@ +"use client"; + +import { usePathname } from "next/navigation"; + +/** + * Returns the active shop base path for the current shell. + * + * - Public shop: `/shop` + * - Account shop (inside AppShell): `/account/shop` + */ +export function useShopBasePath(): "/shop" | "/account/shop" { + const pathname = usePathname(); + if (pathname.startsWith("/account/shop")) { + return "/account/shop"; + } + return "/shop"; +} diff --git a/apps/portal/src/features/catalog/hooks/useSimConfigure.ts b/apps/portal/src/features/catalog/hooks/useSimConfigure.ts index f694d96f..5fc6e713 100644 --- a/apps/portal/src/features/catalog/hooks/useSimConfigure.ts +++ b/apps/portal/src/features/catalog/hooks/useSimConfigure.ts @@ -4,6 +4,7 @@ import { useEffect, useCallback, useMemo, useRef } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { useSimCatalog, useSimPlan } from "."; import { useCatalogStore } from "../services/catalog.store"; +import { useShopBasePath } from "./useShopBasePath"; import { simConfigureFormSchema, type SimConfigureFormData, @@ -54,6 +55,7 @@ export type UseSimConfigureResult = { */ export function useSimConfigure(planId?: string): UseSimConfigureResult { const router = useRouter(); + const shopBasePath = useShopBasePath(); const searchParams = useSearchParams(); const urlPlanSku = searchParams.get("plan"); const paramsSignature = useMemo(() => searchParams.toString(), [searchParams]); @@ -89,7 +91,7 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult { // Redirect if no plan selected if (!effectivePlanSku && !configState.planSku) { - router.push("/shop/sim"); + router.push(`${shopBasePath}/sim`); } }, [ configState.planSku, @@ -98,6 +100,7 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult { restoreFromParams, router, setConfig, + shopBasePath, urlPlanSku, ]); diff --git a/apps/portal/src/features/catalog/services/catalog.service.ts b/apps/portal/src/features/catalog/services/catalog.service.ts index c9035d07..0952db05 100644 --- a/apps/portal/src/features/catalog/services/catalog.service.ts +++ b/apps/portal/src/features/catalog/services/catalog.service.ts @@ -16,6 +16,7 @@ import { type VpnCatalogCollection, type VpnCatalogProduct, } from "@customer-portal/domain/catalog"; +import type { Address } from "@customer-portal/domain/customer"; export const catalogService = { async getInternetCatalog(): Promise { @@ -74,4 +75,24 @@ export const catalogService = { const data = getDataOrDefault(response, []); return vpnCatalogProductSchema.array().parse(data); }, + + async getInternetEligibility(): Promise<{ eligibility: string | null }> { + const response = await apiClient.GET<{ eligibility: string | null }>( + "/api/catalog/internet/eligibility" + ); + return getDataOrThrow(response, "Failed to load internet eligibility"); + }, + + async requestInternetEligibilityCheck(body?: { + notes?: string; + address?: Partial
; + }): Promise<{ requestId: string }> { + const response = await apiClient.POST<{ requestId: string }>( + "/api/catalog/internet/eligibility-request", + { + body: body ?? {}, + } + ); + return getDataOrThrow(response, "Failed to request availability check"); + }, }; diff --git a/apps/portal/src/features/catalog/views/CatalogHome.tsx b/apps/portal/src/features/catalog/views/CatalogHome.tsx index c148fcef..20088ff1 100644 --- a/apps/portal/src/features/catalog/views/CatalogHome.tsx +++ b/apps/portal/src/features/catalog/views/CatalogHome.tsx @@ -12,8 +12,11 @@ import { } from "@heroicons/react/24/outline"; import { ServiceHeroCard } from "@/features/catalog/components/common/ServiceHeroCard"; import { FeatureCard } from "@/features/catalog/components/common/FeatureCard"; +import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath"; export function CatalogHomeView() { + const shopBasePath = useShopBasePath(); + return ( } @@ -45,7 +48,7 @@ export function CatalogHomeView() { "Multiple access modes", "Professional installation", ]} - href="/shop/internet" + href={`${shopBasePath}/internet`} color="blue" />
diff --git a/apps/portal/src/features/catalog/views/InternetConfigure.tsx b/apps/portal/src/features/catalog/views/InternetConfigure.tsx index 8d85b033..fd78b7d9 100644 --- a/apps/portal/src/features/catalog/views/InternetConfigure.tsx +++ b/apps/portal/src/features/catalog/views/InternetConfigure.tsx @@ -1,12 +1,13 @@ "use client"; -import { useRouter } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; import { logger } from "@/lib/logger"; import { useInternetConfigure } from "@/features/catalog/hooks/useInternetConfigure"; import { InternetConfigureView as InternetConfigureInnerView } from "@/features/catalog/components/internet/InternetConfigureView"; export function InternetConfigureContainer() { const router = useRouter(); + const pathname = usePathname(); const vm = useInternetConfigure(); // Debug: log current state @@ -46,7 +47,8 @@ export function InternetConfigureContainer() { logger.debug("Navigating to checkout with params", { params: params.toString(), }); - router.push(`/checkout?${params.toString()}`); + const orderBasePath = pathname.startsWith("/account") ? "/account/order" : "/order"; + router.push(`${orderBasePath}?${params.toString()}`); }; return ; diff --git a/apps/portal/src/features/catalog/views/InternetPlans.tsx b/apps/portal/src/features/catalog/views/InternetPlans.tsx index 1d7b9b54..881bfa81 100644 --- a/apps/portal/src/features/catalog/views/InternetPlans.tsx +++ b/apps/portal/src/features/catalog/views/InternetPlans.tsx @@ -13,11 +13,22 @@ 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 { + useInternetEligibility, + useRequestInternetEligibilityCheck, +} from "@/features/catalog/hooks"; +import { useAuthSession } from "@/features/auth/services/auth.store"; export function InternetPlansContainer() { + const shopBasePath = useShopBasePath(); + const { user } = useAuthSession(); const { data, isLoading, error } = useInternetCatalog(); + const eligibilityQuery = useInternetEligibility(); + const eligibilityRequest = useRequestInternetEligibilityCheck(); const plans: InternetPlanCatalogItem[] = useMemo(() => data?.plans ?? [], [data?.plans]); const installations: InternetInstallationCatalogItem[] = useMemo( () => data?.installations ?? [], @@ -39,11 +50,31 @@ export function InternetPlansContainer() { [activeSubs] ); + const eligibilityValue = eligibilityQuery.data?.eligibility; + const requiresAvailabilityCheck = eligibilityQuery.isSuccess && eligibilityValue === null; + const hasServiceAddress = Boolean( + user?.address?.address1 && + user?.address?.city && + user?.address?.postcode && + (user?.address?.country || user?.address?.countryCode) + ); + useEffect(() => { + if (eligibilityQuery.isSuccess) { + if (typeof eligibilityValue === "string" && eligibilityValue.trim().length > 0) { + setEligibility(eligibilityValue); + return; + } + if (eligibilityValue === null) { + setEligibility(""); + return; + } + } + if (plans.length > 0) { setEligibility(plans[0].internetOfferingType || "Home 1G"); } - }, [plans]); + }, [eligibilityQuery.isSuccess, eligibilityValue, plans]); const getEligibilityIcon = (offeringType?: string) => { const lower = (offeringType || "").toLowerCase(); @@ -68,7 +99,7 @@ export function InternetPlansContainer() { >
- + {/* Title + eligibility */}
@@ -112,7 +143,7 @@ export function InternetPlansContainer() { icon={} >
- + + {requiresAvailabilityCheck && ( + +
+

+ Our team will verify NTT serviceability and update your eligible offerings. You can + request a check now; we’ll update your account once it’s confirmed. +

+ {hasServiceAddress ? ( + + ) : ( + + )} +
+
+ )} + {hasActiveInternet && (
@@ -197,7 +271,7 @@ export function InternetPlansContainer() { We couldn't find any internet plans available for your location at this time.

@@ -46,7 +49,7 @@ export function PublicCatalogHomeView() { "Multiple access modes", "Professional installation", ]} - href="/shop/internet" + href={`${shopBasePath}/internet`} color="blue" />
diff --git a/apps/portal/src/features/catalog/views/PublicInternetPlans.tsx b/apps/portal/src/features/catalog/views/PublicInternetPlans.tsx index e29f64eb..e80792e2 100644 --- a/apps/portal/src/features/catalog/views/PublicInternetPlans.tsx +++ b/apps/portal/src/features/catalog/views/PublicInternetPlans.tsx @@ -12,6 +12,7 @@ import { InternetPlanCard } from "@/features/catalog/components/internet/Interne 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"; /** * Public Internet Plans View @@ -20,6 +21,7 @@ import { CatalogHero } from "@/features/catalog/components/base/CatalogHero"; * Simplified version without active subscription checks. */ export function PublicInternetPlansView() { + const shopBasePath = useShopBasePath(); const { data, isLoading, error } = useInternetCatalog(); const plans: InternetPlanCatalogItem[] = useMemo(() => data?.plans ?? [], [data?.plans]); const installations: InternetInstallationCatalogItem[] = useMemo( @@ -46,7 +48,7 @@ export function PublicInternetPlansView() { if (isLoading) { return (
- +
@@ -72,7 +74,7 @@ export function PublicInternetPlansView() { if (error) { return (
- + {error instanceof Error ? error.message : "An unexpected error occurred"} @@ -82,7 +84,7 @@ export function PublicInternetPlansView() { return (
- +
))} @@ -143,7 +145,7 @@ export function PublicInternetPlansView() { We couldn't find any internet plans available at this time.

data?.plans ?? [], [data?.plans]); const [activeTab, setActiveTab] = useState<"data-voice" | "data-only" | "voice-only">( @@ -39,7 +41,7 @@ export function PublicSimPlansView() { if (isLoading) { return (
- +
@@ -72,7 +74,7 @@ export function PublicSimPlansView() {
{errorMessage}

diff --git a/apps/portal/src/features/checkout/components/steps/AccountStep.tsx b/apps/portal/src/features/checkout/components/steps/AccountStep.tsx index a106122d..cf1b1ae3 100644 --- a/apps/portal/src/features/checkout/components/steps/AccountStep.tsx +++ b/apps/portal/src/features/checkout/components/steps/AccountStep.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState, useCallback } from "react"; +import { useEffect, useMemo, useState, useCallback } from "react"; import { z } from "zod"; import { useCheckoutStore } from "../../stores/checkout.store"; import { Button, Input } from "@/components/atoms"; @@ -15,6 +15,7 @@ import { nameSchema, phoneSchema, } from "@customer-portal/domain/common"; +import { usePathname, useSearchParams } from "next/navigation"; // Form schema for guest info const accountFormSchema = z @@ -41,6 +42,8 @@ type AccountFormData = z.infer; */ export function AccountStep() { const { isAuthenticated } = useAuthSession(); + const pathname = usePathname(); + const searchParams = useSearchParams(); const { guestInfo, updateGuestInfo, @@ -48,7 +51,23 @@ export function AccountStep() { registrationComplete, setRegistrationComplete, } = useCheckoutStore(); - const [mode, setMode] = useState<"new" | "signin">("new"); + const checkPasswordNeeded = useAuthStore(state => state.checkPasswordNeeded); + + const [phase, setPhase] = useState<"identify" | "new" | "signin" | "set-password">("identify"); + const [identifyEmail, setIdentifyEmail] = useState(guestInfo?.email ?? ""); + const [identifyError, setIdentifyError] = useState(null); + const [identifyLoading, setIdentifyLoading] = useState(false); + + const redirectTarget = useMemo(() => { + const qs = searchParams?.toString() ?? ""; + return qs ? `${pathname}?${qs}` : pathname; + }, [pathname, searchParams]); + + const setPasswordHref = useMemo(() => { + const email = encodeURIComponent(identifyEmail.trim()); + const redirect = encodeURIComponent(redirectTarget); + return `/auth/set-password?email=${email}&redirect=${redirect}`; + }, [identifyEmail, redirectTarget]); const handleSubmit = useCallback( async (data: AccountFormData) => { @@ -89,168 +108,239 @@ export function AccountStep() { return null; } + const handleIdentify = async () => { + setIdentifyError(null); + const email = identifyEmail.trim().toLowerCase(); + const parsed = emailSchema.safeParse(email); + if (!parsed.success) { + setIdentifyError(parsed.error.issues?.[0]?.message ?? "Valid email required"); + return; + } + + setIdentifyLoading(true); + try { + const res = await checkPasswordNeeded(email); + // Keep email in checkout state so it carries forward into signup. + updateGuestInfo({ email }); + + if (res.userExists && res.needsPasswordSet) { + setPhase("set-password"); + return; + } + if (res.userExists) { + setPhase("signin"); + return; + } + setPhase("new"); + } catch (err) { + setIdentifyError(err instanceof Error ? err.message : "Unable to verify email"); + } finally { + setIdentifyLoading(false); + } + }; + return (

- {/* Sign-in prompt */} -
-
-
-

Already have an account?

-

- Sign in to use your saved information and get faster checkout -

+ {phase === "identify" ? ( +
+
+ +
+

Continue with email

+

+ We’ll check if you already have an account, then guide you through checkout. +

+
- -
-
- {mode === "signin" ? ( + {identifyError && ( + + {identifyError} + + )} + +
+ + setIdentifyEmail(e.target.value)} + placeholder="your@email.com" + /> + + + +
+
+ ) : phase === "set-password" ? ( + +
+

+ We found your account for {identifyEmail.trim()}, + but you still need to set a portal password. +

+
+ + +
+
+
+ ) : phase === "signin" ? ( setCurrentStep("address")} - onCancel={() => setMode("new")} + onCancel={() => setPhase("identify")} setRegistrationComplete={setRegistrationComplete} /> ) : ( - <> - {/* Divider */} -
-
-
-
-
- - Or continue as new customer - +
+
+ +
+

Create your account

+

+ Account is required to place an order and add a payment method. +

- {/* Guest info form */} -
-
- -

Your Information

-
+
void form.handleSubmit(event)} className="space-y-4"> + {/* Email */} + + form.setValue("email", e.target.value)} + onBlur={() => form.setTouchedField("email")} + placeholder="your@email.com" + /> + - void form.handleSubmit(event)} className="space-y-4"> - {/* Email */} + {/* Name fields */} +
form.setValue("email", e.target.value)} - onBlur={() => form.setTouchedField("email")} - placeholder="your@email.com" + value={form.values.firstName} + onChange={e => form.setValue("firstName", e.target.value)} + onBlur={() => form.setTouchedField("firstName")} + placeholder="John" /> - - {/* Name fields */} -
- - form.setValue("firstName", e.target.value)} - onBlur={() => form.setTouchedField("firstName")} - placeholder="John" - /> - - - form.setValue("lastName", e.target.value)} - onBlur={() => form.setTouchedField("lastName")} - placeholder="Doe" - /> - -
- - {/* Phone */} -
- form.setValue("phoneCountryCode", e.target.value)} - onBlur={() => form.setTouchedField("phoneCountryCode")} - className="w-24" - placeholder="+81" - /> - form.setValue("phone", e.target.value)} - onBlur={() => form.setTouchedField("phone")} - className="flex-1" - placeholder="90-1234-5678" - /> -
+ form.setValue("lastName", e.target.value)} + onBlur={() => form.setTouchedField("lastName")} + placeholder="Doe" + />
+
- {/* Password fields */} -
- - form.setValue("password", e.target.value)} - onBlur={() => form.setTouchedField("password")} - placeholder="••••••••" - /> - - - form.setValue("confirmPassword", e.target.value)} - onBlur={() => form.setTouchedField("confirmPassword")} - placeholder="••••••••" - /> - + {/* Phone */} + +
+ form.setValue("phoneCountryCode", e.target.value)} + onBlur={() => form.setTouchedField("phoneCountryCode")} + className="w-24" + placeholder="+81" + /> + form.setValue("phone", e.target.value)} + onBlur={() => form.setTouchedField("phone")} + className="flex-1" + placeholder="90-1234-5678" + />
+
-

- Password must be at least 8 characters with uppercase, lowercase, a number, and a - special character. -

+ {/* Password fields */} +
+ + form.setValue("password", e.target.value)} + onBlur={() => form.setTouchedField("password")} + placeholder="••••••••" + /> + + + form.setValue("confirmPassword", e.target.value)} + onBlur={() => form.setTouchedField("confirmPassword")} + placeholder="••••••••" + /> + +
- {/* Submit */} -
- -
- -
- +

+ Password must be at least 8 characters with uppercase, lowercase, a number, and a + special character. +

+ +
+ + +
+ +
)}
); @@ -258,10 +348,12 @@ export function AccountStep() { // Embedded sign-in form function SignInForm({ + initialEmail, onSuccess, onCancel, setRegistrationComplete, }: { + initialEmail: string; onSuccess: () => void; onCancel: () => void; setRegistrationComplete: (userId: string) => void; @@ -296,7 +388,7 @@ function SignInForm({ email: z.string().email("Valid email required"), password: z.string().min(1, "Password is required"), }), - initialValues: { email: "", password: "" }, + initialValues: { email: initialEmail, password: "" }, onSubmit: handleSubmit, }); diff --git a/apps/portal/src/features/checkout/components/steps/AddressStep.tsx b/apps/portal/src/features/checkout/components/steps/AddressStep.tsx index 64fab094..9a04a5e6 100644 --- a/apps/portal/src/features/checkout/components/steps/AddressStep.tsx +++ b/apps/portal/src/features/checkout/components/steps/AddressStep.tsx @@ -19,6 +19,7 @@ import { checkoutRegisterResponseSchema } from "@customer-portal/domain/checkout */ export function AddressStep() { const { isAuthenticated } = useAuthSession(); + const user = useAuthStore(state => state.user); const refreshUser = useAuthStore(state => state.refreshUser); const { address, @@ -86,13 +87,13 @@ export function AddressStep() { const form = useZodForm({ schema: addressFormSchema, initialValues: { - address1: address?.address1 ?? "", - address2: address?.address2 ?? "", - city: address?.city ?? "", - state: address?.state ?? "", - postcode: address?.postcode ?? "", - country: address?.country ?? "Japan", - countryCode: address?.countryCode ?? "JP", + address1: address?.address1 ?? user?.address?.address1 ?? "", + address2: address?.address2 ?? user?.address?.address2 ?? "", + city: address?.city ?? user?.address?.city ?? "", + state: address?.state ?? user?.address?.state ?? "", + postcode: address?.postcode ?? user?.address?.postcode ?? "", + country: address?.country ?? user?.address?.country ?? "Japan", + countryCode: address?.countryCode ?? user?.address?.countryCode ?? "JP", }, onSubmit: handleSubmit, }); diff --git a/apps/portal/src/features/checkout/components/steps/PaymentStep.tsx b/apps/portal/src/features/checkout/components/steps/PaymentStep.tsx index 50e4bc4b..cdd11a48 100644 --- a/apps/portal/src/features/checkout/components/steps/PaymentStep.tsx +++ b/apps/portal/src/features/checkout/components/steps/PaymentStep.tsx @@ -14,6 +14,7 @@ import { CheckCircleIcon, ExclamationTriangleIcon, } from "@heroicons/react/24/outline"; +import type { PaymentMethodList } from "@customer-portal/domain/payments"; /** * PaymentStep - Third step in checkout @@ -41,16 +42,8 @@ export function PaymentStep() { } try { - const response = await fetch("/api/payments/methods", { - credentials: "include", - }); - - if (!response.ok) { - throw new Error("Failed to check payment methods"); - } - - const data = await response.json(); - const methods = data.data?.paymentMethods ?? data.paymentMethods ?? []; + const response = await apiClient.GET("/api/invoices/payment-methods"); + const methods = response.data?.paymentMethods ?? []; if (methods.length > 0) { const defaultMethod = diff --git a/apps/portal/src/features/checkout/components/steps/ReviewStep.tsx b/apps/portal/src/features/checkout/components/steps/ReviewStep.tsx index 4f3921fc..c0917d32 100644 --- a/apps/portal/src/features/checkout/components/steps/ReviewStep.tsx +++ b/apps/portal/src/features/checkout/components/steps/ReviewStep.tsx @@ -23,7 +23,7 @@ import { */ export function ReviewStep() { const router = useRouter(); - const { user } = useAuthSession(); + const { user, isAuthenticated } = useAuthSession(); const { cartItem, guestInfo, @@ -92,19 +92,25 @@ export function ReviewStep() {
Account - + {!isAuthenticated && ( + + )}

- {guestInfo?.firstName || user?.firstname} {guestInfo?.lastName || user?.lastname} + {isAuthenticated + ? `${user?.firstname ?? ""} ${user?.lastname ?? ""}`.trim() + : `${guestInfo?.firstName ?? ""} ${guestInfo?.lastName ?? ""}`.trim()} +

+

+ {isAuthenticated ? user?.email : guestInfo?.email}

-

{guestInfo?.email || user?.email}

{/* Address */} @@ -122,13 +128,18 @@ export function ReviewStep() {

- {address?.address1} - {address?.address2 && `, ${address.address2}`} + {(address?.address1 ?? user?.address?.address1) || ""} + {(address?.address2 ?? user?.address?.address2) && + `, ${address?.address2 ?? user?.address?.address2}`}

- {address?.city}, {address?.state} {address?.postcode} + {(address?.city ?? user?.address?.city) || ""},{" "} + {(address?.state ?? user?.address?.state) || ""}{" "} + {(address?.postcode ?? user?.address?.postcode) || ""} +

+

+ {address?.country ?? user?.address?.country}

-

{address?.country}

{/* Payment */} diff --git a/apps/portal/src/features/checkout/stores/checkout.store.ts b/apps/portal/src/features/checkout/stores/checkout.store.ts index e5c9b6e2..77d24ba1 100644 --- a/apps/portal/src/features/checkout/stores/checkout.store.ts +++ b/apps/portal/src/features/checkout/stores/checkout.store.ts @@ -223,8 +223,6 @@ export const useCheckoutStore = create()( cartParamsSignature: state.cartParamsSignature, checkoutSessionId: state.checkoutSessionId, checkoutSessionExpiresAt: state.checkoutSessionExpiresAt, - guestInfo: state.guestInfo, - address: state.address, currentStep: state.currentStep, cartUpdatedAt: state.cartUpdatedAt, // Don't persist sensitive or transient state diff --git a/apps/portal/src/lib/api/index.ts b/apps/portal/src/lib/api/index.ts index f6148197..4fe9313a 100644 --- a/apps/portal/src/lib/api/index.ts +++ b/apps/portal/src/lib/api/index.ts @@ -144,6 +144,7 @@ export const queryKeys = { products: () => ["catalog", "products"] as const, internet: { combined: () => ["catalog", "internet", "combined"] as const, + eligibility: () => ["catalog", "internet", "eligibility"] as const, }, sim: { combined: () => ["catalog", "sim", "combined"] as const,