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.
This commit is contained in:
barsa 2025-12-17 17:59:55 +09:00
parent 4edf0e801e
commit 9d2c4ff921
66 changed files with 774 additions and 254 deletions

View File

@ -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,

View File

@ -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<typeof eligibilityRequestSchema>;
/**
* 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 };
}
}

View File

@ -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<string | null> {
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<SalesforceAccount | null>(
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<string> {
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<string, unknown> = {
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, unknown>): 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(", ");
}

View File

@ -0,0 +1,7 @@
import type { Address } from "@customer-portal/domain/customer";
export type InternetEligibilityCheckRequest = {
email: string;
notes?: string;
address?: Partial<Address>;
};

View File

@ -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 <CatalogShell>{children}</CatalogShell>;
}

View File

@ -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;
}

View File

@ -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 <PublicShell>{children}</PublicShell>;
}

View File

@ -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 <PublicShell>{children}</PublicShell>;
return children;
}

View File

@ -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 (
<>
<CatalogNav />
{children}
</>
);
}

View File

@ -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 <CheckoutEntry />;
}

View File

@ -0,0 +1,5 @@
import { InternetConfigureContainer } from "@/features/catalog/views/InternetConfigure";
export default function AccountInternetConfigurePage() {
return <InternetConfigureContainer />;
}

View File

@ -0,0 +1,5 @@
import { InternetPlansContainer } from "@/features/catalog/views/InternetPlans";
export default function AccountInternetPlansPage() {
return <InternetPlansContainer />;
}

View File

@ -0,0 +1,5 @@
import { CatalogHomeView } from "@/features/catalog/views/CatalogHome";
export default function AccountShopPage() {
return <CatalogHomeView />;
}

View File

@ -0,0 +1,5 @@
import { SimConfigureContainer } from "@/features/catalog/views/SimConfigure";
export default function AccountSimConfigurePage() {
return <SimConfigureContainer />;
}

View File

@ -0,0 +1,5 @@
import { SimPlansContainer } from "@/features/catalog/views/SimPlans";
export default function AccountSimPlansPage() {
return <SimPlansContainer />;
}

View File

@ -0,0 +1,5 @@
import { VpnPlansView } from "@/features/catalog/views/VpnPlans";
export default function AccountVpnPlansPage() {
return <VpnPlansView />;
}

View File

@ -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,

View File

@ -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");
};

View File

@ -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);
}}
>

View File

@ -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 (
<div className="text-center mb-8 animate-in fade-in duration-300">
<Button
as="a"
href="/shop/internet"
href={`${shopBasePath}/internet`}
variant="ghost"
size="sm"
leftIcon={<ArrowLeftIcon className="w-4 h-4" />}

View File

@ -10,6 +10,7 @@ import { ActivationForm } from "@/features/catalog/components/sim/ActivationForm
import { MnpForm } from "@/features/catalog/components/sim/MnpForm";
import { ProgressSteps } from "@/components/molecules";
import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink";
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
import {
ArrowLeftIcon,
ArrowRightIcon,
@ -48,6 +49,7 @@ export function SimConfigureView({
setCurrentStep,
onConfirm,
}: Props) {
const shopBasePath = useShopBasePath();
const getRequiredActivationFee = (
fees: SimActivationFeeCatalogItem[]
): SimActivationFeeCatalogItem | undefined => {
@ -161,7 +163,10 @@ export function SimConfigureView({
<ExclamationTriangleIcon className="h-12 w-12 mx-auto text-danger mb-4" />
<h2 className="text-xl font-semibold text-foreground mb-2">Plan Not Found</h2>
<p className="text-muted-foreground mb-4">The selected plan could not be found</p>
<a href="/shop/sim" className="text-primary hover:text-primary-hover font-medium">
<a
href={`${shopBasePath}/sim`}
className="text-primary hover:text-primary-hover font-medium"
>
Return to SIM Plans
</a>
</div>
@ -185,7 +190,7 @@ export function SimConfigureView({
icon={<DevicePhoneMobileIcon className="h-6 w-6" />}
>
<div className="max-w-4xl mx-auto space-y-8">
<CatalogBackLink href="/shop/sim" label="Back to SIM Plans" />
<CatalogBackLink href={`${shopBasePath}/sim`} label="Back to SIM Plans" />
<AnimatedCard variant="static" className="p-6">
<div className="flex justify-between items-start">

View File

@ -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 <InternetConfigureView {...vm} onConfirm={handleConfirm} />;

View File

@ -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 <SimConfigureView {...vm} onConfirm={handleConfirm} />;

View File

@ -2,3 +2,4 @@ export * from "./useCatalog";
export * from "./useConfigureParams";
export * from "./useSimConfigure";
export * from "./useInternetConfigure";
export * from "./useInternetEligibility";

View File

@ -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(() => {

View File

@ -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<Address> }) =>
catalogService.requestInternetEligibilityCheck(body),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.catalog.internet.eligibility() });
await queryClient.invalidateQueries({ queryKey: queryKeys.catalog.internet.combined() });
},
});
}

View File

@ -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";
}

View File

@ -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,
]);

View File

@ -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<InternetCatalogCollection> {
@ -74,4 +75,24 @@ export const catalogService = {
const data = getDataOrDefault<VpnCatalogProduct[]>(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<Address>;
}): 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");
},
};

View File

@ -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 (
<PageLayout
icon={<Squares2X2Icon />}
@ -45,7 +48,7 @@ export function CatalogHomeView() {
"Multiple access modes",
"Professional installation",
]}
href="/shop/internet"
href={`${shopBasePath}/internet`}
color="blue"
/>
<ServiceHeroCard
@ -58,7 +61,7 @@ export function CatalogHomeView() {
"Family discounts",
"Multiple data options",
]}
href="/shop/sim"
href={`${shopBasePath}/sim`}
color="green"
/>
<ServiceHeroCard
@ -71,7 +74,7 @@ export function CatalogHomeView() {
"Business & personal",
"24/7 connectivity",
]}
href="/shop/vpn"
href={`${shopBasePath}/vpn`}
color="purple"
/>
</div>

View File

@ -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 <InternetConfigureInnerView {...vm} onConfirm={handleConfirm} />;

View File

@ -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() {
>
<AsyncBlock isLoading={false} error={error}>
<div className="max-w-6xl mx-auto px-4">
<CatalogBackLink href="/shop" label="Back to Services" />
<CatalogBackLink href={shopBasePath} label="Back to Services" />
{/* Title + eligibility */}
<div className="text-center mb-12">
@ -112,7 +143,7 @@ export function InternetPlansContainer() {
icon={<WifiIcon className="h-6 w-6" />}
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
<CatalogBackLink href="/shop" label="Back to Services" />
<CatalogBackLink href={shopBasePath} label="Back to Services" />
<CatalogHero
title="Choose Your Internet Plan"
@ -133,6 +164,47 @@ export function InternetPlansContainer() {
)}
</CatalogHero>
{requiresAvailabilityCheck && (
<AlertBanner
variant="info"
title="We need to confirm availability for your address"
className="mb-8"
>
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<p className="text-sm text-foreground/80">
Our team will verify NTT serviceability and update your eligible offerings. You can
request a check now; well update your account once its confirmed.
</p>
{hasServiceAddress ? (
<Button
type="button"
size="sm"
disabled={eligibilityRequest.isPending}
isLoading={eligibilityRequest.isPending}
loadingText="Requesting…"
onClick={() =>
eligibilityRequest.mutate({
address: user?.address ?? undefined,
})
}
className="sm:ml-auto whitespace-nowrap"
>
Request availability check
</Button>
) : (
<Button
as="a"
href="/account/settings"
size="sm"
className="sm:ml-auto whitespace-nowrap"
>
Add address to continue
</Button>
)}
</div>
</AlertBanner>
)}
{hasActiveInternet && (
<AlertBanner
variant="warning"
@ -161,11 +233,13 @@ export function InternetPlansContainer() {
<InternetPlanCard
plan={plan}
installations={installations}
disabled={hasActiveInternet}
disabled={hasActiveInternet || requiresAvailabilityCheck}
disabledReason={
hasActiveInternet
? "Already subscribed — contact us to add another residence"
: undefined
: requiresAvailabilityCheck
? "Availability check required before ordering"
: undefined
}
/>
</div>
@ -197,7 +271,7 @@ export function InternetPlansContainer() {
We couldn&apos;t find any internet plans available for your location at this time.
</p>
<CatalogBackLink
href="/shop"
href={shopBasePath}
label="Back to Services"
align="center"
className="mt-0 mb-0"
@ -211,3 +285,5 @@ export function InternetPlansContainer() {
}
// InternetPlanCard extracted to components/internet/InternetPlanCard
export default InternetPlansContainer;

View File

@ -11,6 +11,7 @@ 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";
/**
* Public Catalog Home View
@ -19,6 +20,8 @@ import { FeatureCard } from "@/features/catalog/components/common/FeatureCard";
* Uses public catalog paths and doesn't require PageLayout with auth.
*/
export function PublicCatalogHomeView() {
const shopBasePath = useShopBasePath();
return (
<div className="max-w-6xl mx-auto">
<div className="mb-8">
@ -46,7 +49,7 @@ export function PublicCatalogHomeView() {
"Multiple access modes",
"Professional installation",
]}
href="/shop/internet"
href={`${shopBasePath}/internet`}
color="blue"
/>
<ServiceHeroCard
@ -59,7 +62,7 @@ export function PublicCatalogHomeView() {
"Family discounts",
"Multiple data options",
]}
href="/shop/sim"
href={`${shopBasePath}/sim`}
color="green"
/>
<ServiceHeroCard
@ -72,7 +75,7 @@ export function PublicCatalogHomeView() {
"Business & personal",
"24/7 connectivity",
]}
href="/shop/vpn"
href={`${shopBasePath}/vpn`}
color="purple"
/>
</div>

View File

@ -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 (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
<CatalogBackLink href="/shop" label="Back to Services" />
<CatalogBackLink href={shopBasePath} label="Back to Services" />
<div className="text-center mb-12">
<Skeleton className="h-10 w-96 mx-auto mb-4" />
@ -72,7 +74,7 @@ export function PublicInternetPlansView() {
if (error) {
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
<CatalogBackLink href="/shop" label="Back to Services" />
<CatalogBackLink href={shopBasePath} label="Back to Services" />
<AlertBanner variant="error" title="Failed to load plans">
{error instanceof Error ? error.message : "An unexpected error occurred"}
</AlertBanner>
@ -82,7 +84,7 @@ export function PublicInternetPlansView() {
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
<CatalogBackLink href="/shop" label="Back to Services" />
<CatalogBackLink href={shopBasePath} label="Back to Services" />
<CatalogHero
title="Choose Your Internet Plan"
@ -112,7 +114,7 @@ export function PublicInternetPlansView() {
plan={plan}
installations={installations}
disabled={false}
configureHref={`/shop/internet/configure?plan=${plan.sku}`}
configureHref={`${shopBasePath}/internet/configure?plan=${encodeURIComponent(plan.sku)}`}
/>
</div>
))}
@ -143,7 +145,7 @@ export function PublicInternetPlansView() {
We couldn&apos;t find any internet plans available at this time.
</p>
<CatalogBackLink
href="/shop"
href={shopBasePath}
label="Back to Services"
align="center"
className="mt-0 mb-0"

View File

@ -16,6 +16,7 @@ import type { SimCatalogProduct } from "@customer-portal/domain/catalog";
import { SimPlanTypeSection } from "@/features/catalog/components/sim/SimPlanTypeSection";
import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink";
import { CatalogHero } from "@/features/catalog/components/base/CatalogHero";
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
interface PlansByType {
DataOnly: SimCatalogProduct[];
@ -30,6 +31,7 @@ interface PlansByType {
* Simplified version without active subscription checks.
*/
export function PublicSimPlansView() {
const shopBasePath = useShopBasePath();
const { data, isLoading, error } = useSimCatalog();
const simPlans: SimCatalogProduct[] = useMemo(() => data?.plans ?? [], [data?.plans]);
const [activeTab, setActiveTab] = useState<"data-voice" | "data-only" | "voice-only">(
@ -39,7 +41,7 @@ export function PublicSimPlansView() {
if (isLoading) {
return (
<div className="max-w-6xl mx-auto px-4">
<CatalogBackLink href="/shop" label="Back to Services" />
<CatalogBackLink href={shopBasePath} label="Back to Services" />
<div className="text-center mb-12">
<Skeleton className="h-10 w-80 mx-auto mb-4" />
@ -72,7 +74,7 @@ export function PublicSimPlansView() {
<div className="text-destructive/80 text-sm mt-1">{errorMessage}</div>
<Button
as="a"
href="/shop"
href={shopBasePath}
className="mt-4"
leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
>
@ -96,7 +98,7 @@ export function PublicSimPlansView() {
return (
<div className="max-w-6xl mx-auto px-4 pb-16">
<CatalogBackLink href="/shop" label="Back to Services" />
<CatalogBackLink href={shopBasePath} label="Back to Services" />
<CatalogHero
title="Choose Your SIM Plan"

View File

@ -8,6 +8,7 @@ import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { VpnPlanCard } from "@/features/catalog/components/vpn/VpnPlanCard";
import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink";
import { CatalogHero } from "@/features/catalog/components/base/CatalogHero";
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
/**
* Public VPN Plans View
@ -15,6 +16,7 @@ import { CatalogHero } from "@/features/catalog/components/base/CatalogHero";
* Displays VPN plans for unauthenticated users.
*/
export function PublicVpnPlansView() {
const shopBasePath = useShopBasePath();
const { data, isLoading, error } = useVpnCatalog();
const vpnPlans = data?.plans || [];
const activationFees = data?.activationFees || [];
@ -22,7 +24,7 @@ export function PublicVpnPlansView() {
if (isLoading || error) {
return (
<div className="max-w-6xl mx-auto px-4">
<CatalogBackLink href="/shop" label="Back to Services" />
<CatalogBackLink href={shopBasePath} label="Back to Services" />
<AsyncBlock
isLoading={isLoading}
@ -42,7 +44,7 @@ export function PublicVpnPlansView() {
return (
<div className="max-w-6xl mx-auto px-4 pb-16">
<CatalogBackLink href="/shop" label="Back to Services" />
<CatalogBackLink href={shopBasePath} label="Back to Services" />
<CatalogHero
title="SonixNet VPN Router Service"
@ -75,7 +77,7 @@ export function PublicVpnPlansView() {
We couldn&apos;t find any VPN plans available at this time.
</p>
<CatalogBackLink
href="/shop"
href={shopBasePath}
label="Back to Services"
align="center"
className="mt-4 mb-0"

View File

@ -1,12 +1,13 @@
"use client";
import { useSearchParams, useRouter } from "next/navigation";
import { usePathname, useSearchParams, useRouter } from "next/navigation";
import { useSimConfigure } from "@/features/catalog/hooks/useSimConfigure";
import { SimConfigureView as SimConfigureInnerView } from "@/features/catalog/components/sim/SimConfigureView";
export function SimConfigureContainer() {
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const planId = searchParams.get("plan") || undefined;
const vm = useSimConfigure(planId);
@ -15,7 +16,8 @@ export function SimConfigureContainer() {
if (!vm.plan || !vm.validate()) return;
const params = vm.buildCheckoutSearchParams();
if (!params) return;
router.push(`/checkout?${params.toString()}`);
const orderBasePath = pathname.startsWith("/account") ? "/account/order" : "/order";
router.push(`${orderBasePath}?${params.toString()}`);
};
return <SimConfigureInnerView {...vm} onConfirm={handleConfirm} />;

View File

@ -17,6 +17,7 @@ import type { SimCatalogProduct } from "@customer-portal/domain/catalog";
import { SimPlanTypeSection } from "@/features/catalog/components/sim/SimPlanTypeSection";
import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink";
import { CatalogHero } from "@/features/catalog/components/base/CatalogHero";
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
interface PlansByType {
DataOnly: SimCatalogProduct[];
@ -25,6 +26,7 @@ interface PlansByType {
}
export function SimPlansContainer() {
const shopBasePath = useShopBasePath();
const { data, isLoading, error } = useSimCatalog();
const simPlans: SimCatalogProduct[] = useMemo(() => data?.plans ?? [], [data?.plans]);
const [hasExistingSim, setHasExistingSim] = useState(false);
@ -45,7 +47,7 @@ export function SimPlansContainer() {
icon={<DevicePhoneMobileIcon className="h-6 w-6" />}
>
<div className="max-w-6xl mx-auto px-4">
<CatalogBackLink href="/shop" label="Back to Services" />
<CatalogBackLink href={shopBasePath} label="Back to Services" />
{/* Title block */}
<div className="text-center mb-12">
@ -110,7 +112,7 @@ export function SimPlansContainer() {
<div className="text-red-600 text-sm mt-1">{errorMessage}</div>
<Button
as="a"
href="/shop"
href={shopBasePath}
className="mt-4"
leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
>
@ -140,7 +142,7 @@ export function SimPlansContainer() {
icon={<DevicePhoneMobileIcon className="h-6 w-6" />}
>
<div className="max-w-6xl mx-auto px-4 pb-16">
<CatalogBackLink href="/shop" label="Back to Services" />
<CatalogBackLink href={shopBasePath} label="Back to Services" />
<CatalogHero
title="Choose Your SIM Plan"

View File

@ -9,8 +9,10 @@ import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { VpnPlanCard } from "@/features/catalog/components/vpn/VpnPlanCard";
import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink";
import { CatalogHero } from "@/features/catalog/components/base/CatalogHero";
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
export function VpnPlansView() {
const shopBasePath = useShopBasePath();
const { data, isLoading, error } = useVpnCatalog();
const vpnPlans = data?.plans || [];
const activationFees = data?.activationFees || [];
@ -24,7 +26,7 @@ export function VpnPlansView() {
icon={<ShieldCheckIcon className="h-6 w-6" />}
>
<div className="max-w-6xl mx-auto px-4">
<CatalogBackLink href="/shop" label="Back to Services" />
<CatalogBackLink href={shopBasePath} label="Back to Services" />
<AsyncBlock
isLoading={isLoading}
@ -52,7 +54,7 @@ export function VpnPlansView() {
icon={<ShieldCheckIcon className="h-6 w-6" />}
>
<div className="max-w-6xl mx-auto px-4 pb-16">
<CatalogBackLink href="/shop" label="Back to Services" />
<CatalogBackLink href={shopBasePath} label="Back to Services" />
<CatalogHero
title="SonixNet VPN Router Service"
@ -89,7 +91,7 @@ export function VpnPlansView() {
We couldn&apos;t find any VPN plans available at this time.
</p>
<CatalogBackLink
href="/shop"
href={shopBasePath}
label="Back to Services"
align="center"
className="mt-4 mb-0"

View File

@ -10,7 +10,7 @@ interface Step {
description: string;
}
const STEPS: Step[] = [
const DEFAULT_STEPS: Step[] = [
{ id: "account", name: "Account", description: "Your details" },
{ id: "address", name: "Address", description: "Delivery info" },
{ id: "payment", name: "Payment", description: "Payment method" },
@ -21,6 +21,7 @@ interface CheckoutProgressProps {
currentStep: CheckoutStep;
onStepClick?: (step: CheckoutStep) => void;
completedSteps?: CheckoutStep[];
steps?: Step[];
}
/**
@ -33,8 +34,10 @@ export function CheckoutProgress({
currentStep,
onStepClick,
completedSteps = [],
steps = DEFAULT_STEPS,
}: CheckoutProgressProps) {
const currentIndex = STEPS.findIndex(s => s.id === currentStep);
const currentIndex = steps.findIndex(s => s.id === currentStep);
const safeCurrentIndex = currentIndex >= 0 ? currentIndex : 0;
return (
<nav aria-label="Checkout progress" className="mb-8">
@ -42,29 +45,29 @@ export function CheckoutProgress({
<div className="sm:hidden">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-foreground">
Step {currentIndex + 1} of {STEPS.length}
Step {safeCurrentIndex + 1} of {steps.length}
</span>
<span className="text-sm text-muted-foreground">{STEPS[currentIndex]?.name}</span>
<span className="text-sm text-muted-foreground">{steps[safeCurrentIndex]?.name}</span>
</div>
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${((currentIndex + 1) / STEPS.length) * 100}%` }}
style={{ width: `${((safeCurrentIndex + 1) / steps.length) * 100}%` }}
/>
</div>
</div>
{/* Desktop view */}
<ol className="hidden sm:flex items-center w-full">
{STEPS.map((step, index) => {
const isCompleted = completedSteps.includes(step.id) || index < currentIndex;
{steps.map((step, index) => {
const isCompleted = completedSteps.includes(step.id) || index < safeCurrentIndex;
const isCurrent = step.id === currentStep;
const isClickable = onStepClick && (isCompleted || index <= currentIndex);
const isClickable = onStepClick && (isCompleted || index <= safeCurrentIndex);
return (
<li
key={step.id}
className={cn("flex items-center", index < STEPS.length - 1 && "flex-1")}
className={cn("flex items-center", index < steps.length - 1 && "flex-1")}
>
<button
type="button"
@ -111,11 +114,11 @@ export function CheckoutProgress({
</button>
{/* Connector line */}
{index < STEPS.length - 1 && (
{index < steps.length - 1 && (
<div
className={cn(
"flex-1 h-0.5 mx-4 transition-colors duration-300",
index < currentIndex ? "bg-primary" : "bg-border"
index < safeCurrentIndex ? "bg-primary" : "bg-border"
)}
/>
)}

View File

@ -12,6 +12,24 @@ import { ReviewStep } from "./steps/ReviewStep";
import type { CheckoutStep } from "@customer-portal/domain/checkout";
import { useAuthSession } from "@/features/auth/services/auth.store";
type StepDef = { id: CheckoutStep; name: string; description: string };
const FULL_STEP_ORDER: CheckoutStep[] = ["account", "address", "payment", "review"];
const AUTH_STEP_ORDER: CheckoutStep[] = ["address", "payment", "review"];
const FULL_STEPS: StepDef[] = [
{ id: "account", name: "Account", description: "Your details" },
{ id: "address", name: "Address", description: "Delivery info" },
{ id: "payment", name: "Payment", description: "Payment method" },
{ id: "review", name: "Review", description: "Confirm order" },
];
const AUTH_STEPS: StepDef[] = [
{ id: "address", name: "Address", description: "Delivery info" },
{ id: "payment", name: "Payment", description: "Payment method" },
{ id: "review", name: "Review", description: "Confirm order" },
];
/**
* CheckoutWizard - Main checkout flow orchestrator
*
@ -21,6 +39,9 @@ import { useAuthSession } from "@/features/auth/services/auth.store";
export function CheckoutWizard() {
const { isAuthenticated } = useAuthSession();
const { cartItem, currentStep, setCurrentStep, registrationComplete } = useCheckoutStore();
const isAuthed = isAuthenticated || registrationComplete;
const stepOrder = isAuthed ? AUTH_STEP_ORDER : FULL_STEP_ORDER;
const steps = isAuthed ? AUTH_STEPS : FULL_STEPS;
useEffect(() => {
if ((isAuthenticated || registrationComplete) && currentStep === "account") {
@ -36,8 +57,10 @@ export function CheckoutWizard() {
// Calculate completed steps
const getCompletedSteps = (): CheckoutStep[] => {
const completed: CheckoutStep[] = [];
const stepOrder: CheckoutStep[] = ["account", "address", "payment", "review"];
const currentIndex = stepOrder.indexOf(currentStep);
if (currentIndex < 0) {
return completed;
}
for (let i = 0; i < currentIndex; i++) {
completed.push(stepOrder[i]);
@ -48,12 +71,11 @@ export function CheckoutWizard() {
// Handle step click (only allow going back)
const handleStepClick = (step: CheckoutStep) => {
const stepOrder: CheckoutStep[] = ["account", "address", "payment", "review"];
const currentIndex = stepOrder.indexOf(currentStep);
const targetIndex = stepOrder.indexOf(step);
// Only allow clicking on completed steps or current step
if (targetIndex <= currentIndex) {
if (targetIndex >= 0 && currentIndex >= 0 && targetIndex <= currentIndex) {
setCurrentStep(step);
}
};
@ -81,6 +103,7 @@ export function CheckoutWizard() {
currentStep={currentStep}
completedSteps={getCompletedSteps()}
onStepClick={handleStepClick}
steps={steps}
/>
{/* Main content */}

View File

@ -4,6 +4,7 @@ import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { ShoppingCartIcon } from "@heroicons/react/24/outline";
import { Button } from "@/components/atoms/button";
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
/**
* EmptyCartRedirect - Shown when checkout is accessed without a cart
@ -12,14 +13,15 @@ import { Button } from "@/components/atoms/button";
*/
export function EmptyCartRedirect() {
const router = useRouter();
const shopBasePath = useShopBasePath();
useEffect(() => {
const timer = setTimeout(() => {
router.push("/shop");
router.push(shopBasePath);
}, 5000);
return () => clearTimeout(timer);
}, [router]);
}, [router, shopBasePath]);
return (
<div className="max-w-md mx-auto text-center py-16">
@ -31,7 +33,7 @@ export function EmptyCartRedirect() {
<p className="text-muted-foreground mb-6">
Browse our services to find the perfect plan for your needs.
</p>
<Button as="a" href="/shop" className="w-full">
<Button as="a" href={shopBasePath} className="w-full">
Browse Services
</Button>
<p className="text-xs text-muted-foreground mt-4">

View File

@ -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<typeof accountFormSchema>;
*/
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<string>(guestInfo?.email ?? "");
const [identifyError, setIdentifyError] = useState<string | null>(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 (
<div className="space-y-6">
{/* Sign-in prompt */}
<div className="bg-muted/50 rounded-xl p-6 border border-border">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
<h3 className="font-semibold text-foreground">Already have an account?</h3>
<p className="text-sm text-muted-foreground">
Sign in to use your saved information and get faster checkout
</p>
{phase === "identify" ? (
<div className="bg-card rounded-xl border border-border p-6 shadow-[var(--cp-shadow-1)]">
<div className="flex items-center gap-3 mb-6">
<UserIcon className="h-6 w-6 text-primary" />
<div>
<h2 className="text-lg font-semibold text-foreground">Continue with email</h2>
<p className="text-sm text-muted-foreground">
Well check if you already have an account, then guide you through checkout.
</p>
</div>
</div>
<Button variant="outline" onClick={() => setMode(mode === "signin" ? "new" : "signin")}>
{mode === "signin" ? "Create Account" : "Sign In"}
</Button>
</div>
</div>
{mode === "signin" ? (
{identifyError && (
<AlertBanner variant="error" title="Unable to continue" className="mb-4">
{identifyError}
</AlertBanner>
)}
<div className="space-y-4">
<FormField label="Email Address" required>
<Input
type="email"
value={identifyEmail}
onChange={e => setIdentifyEmail(e.target.value)}
placeholder="your@email.com"
/>
</FormField>
<Button
type="button"
className="w-full"
onClick={() => void handleIdentify()}
disabled={identifyLoading}
isLoading={identifyLoading}
rightIcon={<ArrowRightIcon className="w-4 h-4" />}
>
Continue
</Button>
</div>
</div>
) : phase === "set-password" ? (
<AlertBanner variant="info" title="Set a password to continue" elevated>
<div className="space-y-3">
<p className="text-sm text-foreground/80">
We found your account for <span className="font-medium">{identifyEmail.trim()}</span>,
but you still need to set a portal password.
</p>
<div className="flex flex-col sm:flex-row gap-3">
<Button as="a" href={setPasswordHref} size="sm">
Set password
</Button>
<Button type="button" variant="ghost" size="sm" onClick={() => setPhase("identify")}>
Use a different email
</Button>
</div>
</div>
</AlertBanner>
) : phase === "signin" ? (
<SignInForm
initialEmail={identifyEmail.trim()}
onSuccess={() => setCurrentStep("address")}
onCancel={() => setMode("new")}
onCancel={() => setPhase("identify")}
setRegistrationComplete={setRegistrationComplete}
/>
) : (
<>
{/* Divider */}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-border" />
</div>
<div className="relative flex justify-center text-sm">
<span className="bg-background px-4 text-muted-foreground">
Or continue as new customer
</span>
<div className="bg-card rounded-xl border border-border p-6 shadow-[var(--cp-shadow-1)]">
<div className="flex items-center gap-3 mb-6">
<UserIcon className="h-6 w-6 text-primary" />
<div>
<h2 className="text-lg font-semibold text-foreground">Create your account</h2>
<p className="text-sm text-muted-foreground">
Account is required to place an order and add a payment method.
</p>
</div>
</div>
{/* Guest info form */}
<div className="bg-card rounded-xl border border-border p-6 shadow-[var(--cp-shadow-1)]">
<div className="flex items-center gap-3 mb-6">
<UserIcon className="h-6 w-6 text-primary" />
<h2 className="text-lg font-semibold text-foreground">Your Information</h2>
</div>
<form onSubmit={event => void form.handleSubmit(event)} className="space-y-4">
{/* Email */}
<FormField
label="Email Address"
error={form.touched.email ? form.errors.email : undefined}
required
>
<Input
type="email"
value={form.values.email}
onChange={e => form.setValue("email", e.target.value)}
onBlur={() => form.setTouchedField("email")}
placeholder="your@email.com"
/>
</FormField>
<form onSubmit={event => void form.handleSubmit(event)} className="space-y-4">
{/* Email */}
{/* Name fields */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<FormField
label="Email Address"
error={form.touched.email ? form.errors.email : undefined}
label="First Name"
error={form.touched.firstName ? form.errors.firstName : undefined}
required
>
<Input
type="email"
value={form.values.email}
onChange={e => 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"
/>
</FormField>
{/* Name fields */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<FormField
label="First Name"
error={form.touched.firstName ? form.errors.firstName : undefined}
required
>
<Input
value={form.values.firstName}
onChange={e => form.setValue("firstName", e.target.value)}
onBlur={() => form.setTouchedField("firstName")}
placeholder="John"
/>
</FormField>
<FormField
label="Last Name"
error={form.touched.lastName ? form.errors.lastName : undefined}
required
>
<Input
value={form.values.lastName}
onChange={e => form.setValue("lastName", e.target.value)}
onBlur={() => form.setTouchedField("lastName")}
placeholder="Doe"
/>
</FormField>
</div>
{/* Phone */}
<FormField
label="Phone Number"
error={form.touched.phone ? form.errors.phone : undefined}
label="Last Name"
error={form.touched.lastName ? form.errors.lastName : undefined}
required
>
<div className="flex gap-2">
<Input
value={form.values.phoneCountryCode}
onChange={e => form.setValue("phoneCountryCode", e.target.value)}
onBlur={() => form.setTouchedField("phoneCountryCode")}
className="w-24"
placeholder="+81"
/>
<Input
value={form.values.phone}
onChange={e => form.setValue("phone", e.target.value)}
onBlur={() => form.setTouchedField("phone")}
className="flex-1"
placeholder="90-1234-5678"
/>
</div>
<Input
value={form.values.lastName}
onChange={e => form.setValue("lastName", e.target.value)}
onBlur={() => form.setTouchedField("lastName")}
placeholder="Doe"
/>
</FormField>
</div>
{/* Password fields */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<FormField
label="Password"
error={form.touched.password ? form.errors.password : undefined}
required
>
<Input
type="password"
value={form.values.password}
onChange={e => form.setValue("password", e.target.value)}
onBlur={() => form.setTouchedField("password")}
placeholder="••••••••"
/>
</FormField>
<FormField
label="Confirm Password"
error={form.touched.confirmPassword ? form.errors.confirmPassword : undefined}
required
>
<Input
type="password"
value={form.values.confirmPassword}
onChange={e => form.setValue("confirmPassword", e.target.value)}
onBlur={() => form.setTouchedField("confirmPassword")}
placeholder="••••••••"
/>
</FormField>
{/* Phone */}
<FormField
label="Phone Number"
error={form.touched.phone ? form.errors.phone : undefined}
required
>
<div className="flex gap-2">
<Input
value={form.values.phoneCountryCode}
onChange={e => form.setValue("phoneCountryCode", e.target.value)}
onBlur={() => form.setTouchedField("phoneCountryCode")}
className="w-24"
placeholder="+81"
/>
<Input
value={form.values.phone}
onChange={e => form.setValue("phone", e.target.value)}
onBlur={() => form.setTouchedField("phone")}
className="flex-1"
placeholder="90-1234-5678"
/>
</div>
</FormField>
<p className="text-xs text-muted-foreground">
Password must be at least 8 characters with uppercase, lowercase, a number, and a
special character.
</p>
{/* Password fields */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<FormField
label="Password"
error={form.touched.password ? form.errors.password : undefined}
required
>
<Input
type="password"
value={form.values.password}
onChange={e => form.setValue("password", e.target.value)}
onBlur={() => form.setTouchedField("password")}
placeholder="••••••••"
/>
</FormField>
<FormField
label="Confirm Password"
error={form.touched.confirmPassword ? form.errors.confirmPassword : undefined}
required
>
<Input
type="password"
value={form.values.confirmPassword}
onChange={e => form.setValue("confirmPassword", e.target.value)}
onBlur={() => form.setTouchedField("confirmPassword")}
placeholder="••••••••"
/>
</FormField>
</div>
{/* Submit */}
<div className="pt-4">
<Button
type="submit"
className="w-full"
disabled={form.isSubmitting}
isLoading={form.isSubmitting}
rightIcon={<ArrowRightIcon className="w-4 h-4" />}
>
Continue to Address
</Button>
</div>
</form>
</div>
</>
<p className="text-xs text-muted-foreground">
Password must be at least 8 characters with uppercase, lowercase, a number, and a
special character.
</p>
<div className="flex gap-3 pt-2">
<Button
type="button"
variant="ghost"
className="flex-1"
onClick={() => setPhase("identify")}
>
Back
</Button>
<Button
type="submit"
className="flex-1"
disabled={form.isSubmitting}
isLoading={form.isSubmitting}
rightIcon={<ArrowRightIcon className="w-4 h-4" />}
>
Continue to Address
</Button>
</div>
</form>
</div>
)}
</div>
);
@ -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,
});

View File

@ -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<AddressFormData>({
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,
});

View File

@ -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<PaymentMethodList>("/api/invoices/payment-methods");
const methods = response.data?.paymentMethods ?? [];
if (methods.length > 0) {
const defaultMethod =

View File

@ -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() {
<div className="flex items-center gap-2 mb-2">
<UserIcon className="h-4 w-4 text-primary" />
<span className="font-medium text-sm text-foreground">Account</span>
<Button
variant="link"
size="sm"
className="ml-auto text-xs"
onClick={() => setCurrentStep("account")}
>
Edit
</Button>
{!isAuthenticated && (
<Button
variant="link"
size="sm"
className="ml-auto text-xs"
onClick={() => setCurrentStep("account")}
>
Edit
</Button>
)}
</div>
<p className="text-sm text-muted-foreground">
{guestInfo?.firstName || user?.firstname} {guestInfo?.lastName || user?.lastname}
{isAuthenticated
? `${user?.firstname ?? ""} ${user?.lastname ?? ""}`.trim()
: `${guestInfo?.firstName ?? ""} ${guestInfo?.lastName ?? ""}`.trim()}
</p>
<p className="text-sm text-muted-foreground">
{isAuthenticated ? user?.email : guestInfo?.email}
</p>
<p className="text-sm text-muted-foreground">{guestInfo?.email || user?.email}</p>
</div>
{/* Address */}
@ -122,13 +128,18 @@ export function ReviewStep() {
</Button>
</div>
<p className="text-sm text-muted-foreground">
{address?.address1}
{address?.address2 && `, ${address.address2}`}
{(address?.address1 ?? user?.address?.address1) || ""}
{(address?.address2 ?? user?.address?.address2) &&
`, ${address?.address2 ?? user?.address?.address2}`}
</p>
<p className="text-sm text-muted-foreground">
{address?.city}, {address?.state} {address?.postcode}
{(address?.city ?? user?.address?.city) || ""},{" "}
{(address?.state ?? user?.address?.state) || ""}{" "}
{(address?.postcode ?? user?.address?.postcode) || ""}
</p>
<p className="text-sm text-muted-foreground">
{address?.country ?? user?.address?.country}
</p>
<p className="text-sm text-muted-foreground">{address?.country}</p>
</div>
{/* Payment */}

View File

@ -223,8 +223,6 @@ export const useCheckoutStore = create<CheckoutStore>()(
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

View File

@ -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,