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 { Module, forwardRef } from "@nestjs/common";
import { CatalogController } from "./catalog.controller.js"; import { CatalogController } from "./catalog.controller.js";
import { CatalogHealthController } from "./catalog-health.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 { IntegrationsModule } from "@bff/integrations/integrations.module.js";
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js"; import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
import { CoreConfigModule } from "@bff/core/config/config.module.js"; import { CoreConfigModule } from "@bff/core/config/config.module.js";
@ -21,7 +22,7 @@ import { CatalogCacheService } from "./services/catalog-cache.service.js";
CacheModule, CacheModule,
QueueModule, QueueModule,
], ],
controllers: [CatalogController, CatalogHealthController], controllers: [CatalogController, CatalogHealthController, InternetEligibilityController],
providers: [ providers: [
BaseCatalogService, BaseCatalogService,
InternetCatalogService, 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 { getErrorMessage } from "@bff/core/utils/error.util.js";
import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util.js"; import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util.js";
import { buildAccountEligibilityQuery } from "@bff/integrations/salesforce/utils/catalog-query-builder.js"; import { buildAccountEligibilityQuery } from "@bff/integrations/salesforce/utils/catalog-query-builder.js";
import type { InternetEligibilityCheckRequest } from "./internet-eligibility.types.js";
interface SalesforceAccount { interface SalesforceAccount {
Id: string; 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 { private checkPlanEligibility(plan: InternetPlanCatalogItem, eligibility: string): boolean {
// Simple match: user's eligibility field must equal plan's Salesforce offering type // Simple match: user's eligibility field must equal plan's Salesforce offering type
// e.g., eligibility "Home 1G" matches plan.internetOfferingType "Home 1G" // e.g., eligibility "Home 1G" matches plan.internetOfferingType "Home 1G"
return plan.internetOfferingType === eligibility; 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 * 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 }) { 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, icon: ServerIcon,
children: [{ name: "All Services", href: "/account/services" }], children: [{ name: "All Services", href: "/account/services" }],
}, },
{ name: "Shop", href: "/shop", icon: Squares2X2Icon }, { name: "Shop", href: "/account/shop", icon: Squares2X2Icon },
{ {
name: "Support", name: "Support",
icon: ChatBubbleLeftRightIcon, icon: ChatBubbleLeftRightIcon,

View File

@ -11,6 +11,7 @@ function SetPasswordContent() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const email = searchParams.get("email") ?? ""; const email = searchParams.get("email") ?? "";
const redirect = searchParams.get("redirect");
useEffect(() => { useEffect(() => {
if (!email) { if (!email) {
@ -19,7 +20,10 @@ function SetPasswordContent() {
}, [email, router]); }, [email, router]);
const handlePasswordSetSuccess = () => { const handlePasswordSetSuccess = () => {
// Redirect to dashboard after successful password setup if (redirect) {
router.push(redirect);
return;
}
router.push("/account"); 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 { useCatalogStore } from "@/features/catalog/services/catalog.store";
import { IS_DEVELOPMENT } from "@/config/environment"; import { IS_DEVELOPMENT } from "@/config/environment";
import { parsePlanName } from "@/features/catalog/components/internet/utils/planName"; import { parsePlanName } from "@/features/catalog/components/internet/utils/planName";
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
interface InternetPlanCardProps { interface InternetPlanCardProps {
plan: InternetPlanCatalogItem; plan: InternetPlanCatalogItem;
@ -52,6 +53,7 @@ export function InternetPlanCard({
configureHref, configureHref,
}: InternetPlanCardProps) { }: InternetPlanCardProps) {
const router = useRouter(); const router = useRouter();
const shopBasePath = useShopBasePath();
const tier = plan.internetPlanTier; const tier = plan.internetPlanTier;
const isGold = tier === "Gold"; const isGold = tier === "Gold";
const isPlatinum = tier === "Platinum"; const isPlatinum = tier === "Platinum";
@ -205,7 +207,9 @@ export function InternetPlanCard({
const { resetInternetConfig, setInternetConfig } = useCatalogStore.getState(); const { resetInternetConfig, setInternetConfig } = useCatalogStore.getState();
resetInternetConfig(); resetInternetConfig();
setInternetConfig({ planSku: plan.sku, currentStep: 1 }); 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); router.push(href);
}} }}
> >

View File

@ -20,6 +20,7 @@ import { AddonsStep } from "./steps/AddonsStep";
import { ReviewOrderStep } from "./steps/ReviewOrderStep"; import { ReviewOrderStep } from "./steps/ReviewOrderStep";
import { useConfigureState } from "./hooks/useConfigureState"; import { useConfigureState } from "./hooks/useConfigureState";
import { parsePlanName } from "@/features/catalog/components/internet/utils/planName"; import { parsePlanName } from "@/features/catalog/components/internet/utils/planName";
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
interface Props { interface Props {
plan: InternetPlanCatalogItem | null; plan: InternetPlanCatalogItem | null;
@ -231,13 +232,14 @@ export function InternetConfigureContainer({
} }
function PlanHeader({ plan }: { plan: InternetPlanCatalogItem }) { function PlanHeader({ plan }: { plan: InternetPlanCatalogItem }) {
const shopBasePath = useShopBasePath();
const { baseName: planBaseName, detail: planDetail } = parsePlanName(plan); const { baseName: planBaseName, detail: planDetail } = parsePlanName(plan);
return ( return (
<div className="text-center mb-8 animate-in fade-in duration-300"> <div className="text-center mb-8 animate-in fade-in duration-300">
<Button <Button
as="a" as="a"
href="/shop/internet" href={`${shopBasePath}/internet`}
variant="ghost" variant="ghost"
size="sm" size="sm"
leftIcon={<ArrowLeftIcon className="w-4 h-4" />} 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 { MnpForm } from "@/features/catalog/components/sim/MnpForm";
import { ProgressSteps } from "@/components/molecules"; import { ProgressSteps } from "@/components/molecules";
import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink"; import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink";
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
import { import {
ArrowLeftIcon, ArrowLeftIcon,
ArrowRightIcon, ArrowRightIcon,
@ -48,6 +49,7 @@ export function SimConfigureView({
setCurrentStep, setCurrentStep,
onConfirm, onConfirm,
}: Props) { }: Props) {
const shopBasePath = useShopBasePath();
const getRequiredActivationFee = ( const getRequiredActivationFee = (
fees: SimActivationFeeCatalogItem[] fees: SimActivationFeeCatalogItem[]
): SimActivationFeeCatalogItem | undefined => { ): SimActivationFeeCatalogItem | undefined => {
@ -161,7 +163,10 @@ export function SimConfigureView({
<ExclamationTriangleIcon className="h-12 w-12 mx-auto text-danger mb-4" /> <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> <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> <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 Return to SIM Plans
</a> </a>
</div> </div>
@ -185,7 +190,7 @@ export function SimConfigureView({
icon={<DevicePhoneMobileIcon className="h-6 w-6" />} icon={<DevicePhoneMobileIcon className="h-6 w-6" />}
> >
<div className="max-w-4xl mx-auto space-y-8"> <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"> <AnimatedCard variant="static" className="p-6">
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">

View File

@ -11,7 +11,7 @@ export function InternetConfigureContainer() {
const handleConfirm = () => { const handleConfirm = () => {
const params = vm.buildCheckoutSearchParams(); const params = vm.buildCheckoutSearchParams();
if (!params) return; if (!params) return;
router.push(`/checkout?${params.toString()}`); router.push(`/order?${params.toString()}`);
}; };
return <InternetConfigureView {...vm} onConfirm={handleConfirm} />; return <InternetConfigureView {...vm} onConfirm={handleConfirm} />;

View File

@ -15,7 +15,7 @@ export function SimConfigureContainer() {
if (!vm.plan || !vm.validate()) return; if (!vm.plan || !vm.validate()) return;
const params = vm.buildCheckoutSearchParams(); const params = vm.buildCheckoutSearchParams();
if (!params) return; if (!params) return;
router.push(`/checkout?${params.toString()}`); router.push(`/order?${params.toString()}`);
}; };
return <SimConfigureView {...vm} onConfirm={handleConfirm} />; return <SimConfigureView {...vm} onConfirm={handleConfirm} />;

View File

@ -2,3 +2,4 @@ export * from "./useCatalog";
export * from "./useConfigureParams"; export * from "./useConfigureParams";
export * from "./useSimConfigure"; export * from "./useSimConfigure";
export * from "./useInternetConfigure"; 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 { useRouter, useSearchParams } from "next/navigation";
import { useInternetCatalog, useInternetPlan } from "."; import { useInternetCatalog, useInternetPlan } from ".";
import { useCatalogStore } from "../services/catalog.store"; import { useCatalogStore } from "../services/catalog.store";
import { useShopBasePath } from "./useShopBasePath";
import type { AccessModeValue } from "@customer-portal/domain/orders"; import type { AccessModeValue } from "@customer-portal/domain/orders";
import type { import type {
InternetPlanCatalogItem, InternetPlanCatalogItem,
@ -41,6 +42,7 @@ export type UseInternetConfigureResult = {
*/ */
export function useInternetConfigure(): UseInternetConfigureResult { export function useInternetConfigure(): UseInternetConfigureResult {
const router = useRouter(); const router = useRouter();
const shopBasePath = useShopBasePath();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const paramsSignature = useMemo(() => searchParams.toString(), [searchParams]); const paramsSignature = useMemo(() => searchParams.toString(), [searchParams]);
const urlPlanSku = searchParams.get("plan"); const urlPlanSku = searchParams.get("plan");
@ -75,9 +77,17 @@ export function useInternetConfigure(): UseInternetConfigureResult {
// Redirect if no plan selected // Redirect if no plan selected
if (!urlPlanSku && !configState.planSku) { 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 // Auto-set default mode for Gold/Platinum plans if not already set
useEffect(() => { 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 { useRouter, useSearchParams } from "next/navigation";
import { useSimCatalog, useSimPlan } from "."; import { useSimCatalog, useSimPlan } from ".";
import { useCatalogStore } from "../services/catalog.store"; import { useCatalogStore } from "../services/catalog.store";
import { useShopBasePath } from "./useShopBasePath";
import { import {
simConfigureFormSchema, simConfigureFormSchema,
type SimConfigureFormData, type SimConfigureFormData,
@ -54,6 +55,7 @@ export type UseSimConfigureResult = {
*/ */
export function useSimConfigure(planId?: string): UseSimConfigureResult { export function useSimConfigure(planId?: string): UseSimConfigureResult {
const router = useRouter(); const router = useRouter();
const shopBasePath = useShopBasePath();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const urlPlanSku = searchParams.get("plan"); const urlPlanSku = searchParams.get("plan");
const paramsSignature = useMemo(() => searchParams.toString(), [searchParams]); const paramsSignature = useMemo(() => searchParams.toString(), [searchParams]);
@ -89,7 +91,7 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
// Redirect if no plan selected // Redirect if no plan selected
if (!effectivePlanSku && !configState.planSku) { if (!effectivePlanSku && !configState.planSku) {
router.push("/shop/sim"); router.push(`${shopBasePath}/sim`);
} }
}, [ }, [
configState.planSku, configState.planSku,
@ -98,6 +100,7 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
restoreFromParams, restoreFromParams,
router, router,
setConfig, setConfig,
shopBasePath,
urlPlanSku, urlPlanSku,
]); ]);

View File

@ -16,6 +16,7 @@ import {
type VpnCatalogCollection, type VpnCatalogCollection,
type VpnCatalogProduct, type VpnCatalogProduct,
} from "@customer-portal/domain/catalog"; } from "@customer-portal/domain/catalog";
import type { Address } from "@customer-portal/domain/customer";
export const catalogService = { export const catalogService = {
async getInternetCatalog(): Promise<InternetCatalogCollection> { async getInternetCatalog(): Promise<InternetCatalogCollection> {
@ -74,4 +75,24 @@ export const catalogService = {
const data = getDataOrDefault<VpnCatalogProduct[]>(response, []); const data = getDataOrDefault<VpnCatalogProduct[]>(response, []);
return vpnCatalogProductSchema.array().parse(data); 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"; } from "@heroicons/react/24/outline";
import { ServiceHeroCard } from "@/features/catalog/components/common/ServiceHeroCard"; import { ServiceHeroCard } from "@/features/catalog/components/common/ServiceHeroCard";
import { FeatureCard } from "@/features/catalog/components/common/FeatureCard"; import { FeatureCard } from "@/features/catalog/components/common/FeatureCard";
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
export function CatalogHomeView() { export function CatalogHomeView() {
const shopBasePath = useShopBasePath();
return ( return (
<PageLayout <PageLayout
icon={<Squares2X2Icon />} icon={<Squares2X2Icon />}
@ -45,7 +48,7 @@ export function CatalogHomeView() {
"Multiple access modes", "Multiple access modes",
"Professional installation", "Professional installation",
]} ]}
href="/shop/internet" href={`${shopBasePath}/internet`}
color="blue" color="blue"
/> />
<ServiceHeroCard <ServiceHeroCard
@ -58,7 +61,7 @@ export function CatalogHomeView() {
"Family discounts", "Family discounts",
"Multiple data options", "Multiple data options",
]} ]}
href="/shop/sim" href={`${shopBasePath}/sim`}
color="green" color="green"
/> />
<ServiceHeroCard <ServiceHeroCard
@ -71,7 +74,7 @@ export function CatalogHomeView() {
"Business & personal", "Business & personal",
"24/7 connectivity", "24/7 connectivity",
]} ]}
href="/shop/vpn" href={`${shopBasePath}/vpn`}
color="purple" color="purple"
/> />
</div> </div>

View File

@ -1,12 +1,13 @@
"use client"; "use client";
import { useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { useInternetConfigure } from "@/features/catalog/hooks/useInternetConfigure"; import { useInternetConfigure } from "@/features/catalog/hooks/useInternetConfigure";
import { InternetConfigureView as InternetConfigureInnerView } from "@/features/catalog/components/internet/InternetConfigureView"; import { InternetConfigureView as InternetConfigureInnerView } from "@/features/catalog/components/internet/InternetConfigureView";
export function InternetConfigureContainer() { export function InternetConfigureContainer() {
const router = useRouter(); const router = useRouter();
const pathname = usePathname();
const vm = useInternetConfigure(); const vm = useInternetConfigure();
// Debug: log current state // Debug: log current state
@ -46,7 +47,8 @@ export function InternetConfigureContainer() {
logger.debug("Navigating to checkout with params", { logger.debug("Navigating to checkout with params", {
params: params.toString(), 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} />; 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 { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
import { InternetPlanCard } from "@/features/catalog/components/internet/InternetPlanCard"; import { InternetPlanCard } from "@/features/catalog/components/internet/InternetPlanCard";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { Button } from "@/components/atoms/button";
import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink"; import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink";
import { CatalogHero } from "@/features/catalog/components/base/CatalogHero"; 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() { export function InternetPlansContainer() {
const shopBasePath = useShopBasePath();
const { user } = useAuthSession();
const { data, isLoading, error } = useInternetCatalog(); const { data, isLoading, error } = useInternetCatalog();
const eligibilityQuery = useInternetEligibility();
const eligibilityRequest = useRequestInternetEligibilityCheck();
const plans: InternetPlanCatalogItem[] = useMemo(() => data?.plans ?? [], [data?.plans]); const plans: InternetPlanCatalogItem[] = useMemo(() => data?.plans ?? [], [data?.plans]);
const installations: InternetInstallationCatalogItem[] = useMemo( const installations: InternetInstallationCatalogItem[] = useMemo(
() => data?.installations ?? [], () => data?.installations ?? [],
@ -39,11 +50,31 @@ export function InternetPlansContainer() {
[activeSubs] [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(() => { useEffect(() => {
if (eligibilityQuery.isSuccess) {
if (typeof eligibilityValue === "string" && eligibilityValue.trim().length > 0) {
setEligibility(eligibilityValue);
return;
}
if (eligibilityValue === null) {
setEligibility("");
return;
}
}
if (plans.length > 0) { if (plans.length > 0) {
setEligibility(plans[0].internetOfferingType || "Home 1G"); setEligibility(plans[0].internetOfferingType || "Home 1G");
} }
}, [plans]); }, [eligibilityQuery.isSuccess, eligibilityValue, plans]);
const getEligibilityIcon = (offeringType?: string) => { const getEligibilityIcon = (offeringType?: string) => {
const lower = (offeringType || "").toLowerCase(); const lower = (offeringType || "").toLowerCase();
@ -68,7 +99,7 @@ export function InternetPlansContainer() {
> >
<AsyncBlock isLoading={false} error={error}> <AsyncBlock isLoading={false} error={error}>
<div className="max-w-6xl mx-auto px-4"> <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 */} {/* Title + eligibility */}
<div className="text-center mb-12"> <div className="text-center mb-12">
@ -112,7 +143,7 @@ export function InternetPlansContainer() {
icon={<WifiIcon className="h-6 w-6" />} icon={<WifiIcon className="h-6 w-6" />}
> >
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20"> <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 <CatalogHero
title="Choose Your Internet Plan" title="Choose Your Internet Plan"
@ -133,6 +164,47 @@ export function InternetPlansContainer() {
)} )}
</CatalogHero> </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 && ( {hasActiveInternet && (
<AlertBanner <AlertBanner
variant="warning" variant="warning"
@ -161,10 +233,12 @@ export function InternetPlansContainer() {
<InternetPlanCard <InternetPlanCard
plan={plan} plan={plan}
installations={installations} installations={installations}
disabled={hasActiveInternet} disabled={hasActiveInternet || requiresAvailabilityCheck}
disabledReason={ disabledReason={
hasActiveInternet hasActiveInternet
? "Already subscribed — contact us to add another residence" ? "Already subscribed — contact us to add another residence"
: requiresAvailabilityCheck
? "Availability check required before ordering"
: undefined : undefined
} }
/> />
@ -197,7 +271,7 @@ export function InternetPlansContainer() {
We couldn&apos;t find any internet plans available for your location at this time. We couldn&apos;t find any internet plans available for your location at this time.
</p> </p>
<CatalogBackLink <CatalogBackLink
href="/shop" href={shopBasePath}
label="Back to Services" label="Back to Services"
align="center" align="center"
className="mt-0 mb-0" className="mt-0 mb-0"
@ -211,3 +285,5 @@ export function InternetPlansContainer() {
} }
// InternetPlanCard extracted to components/internet/InternetPlanCard // InternetPlanCard extracted to components/internet/InternetPlanCard
export default InternetPlansContainer;

View File

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

View File

@ -12,6 +12,7 @@ import { InternetPlanCard } from "@/features/catalog/components/internet/Interne
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink"; import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink";
import { CatalogHero } from "@/features/catalog/components/base/CatalogHero"; import { CatalogHero } from "@/features/catalog/components/base/CatalogHero";
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
/** /**
* Public Internet Plans View * Public Internet Plans View
@ -20,6 +21,7 @@ import { CatalogHero } from "@/features/catalog/components/base/CatalogHero";
* Simplified version without active subscription checks. * Simplified version without active subscription checks.
*/ */
export function PublicInternetPlansView() { export function PublicInternetPlansView() {
const shopBasePath = useShopBasePath();
const { data, isLoading, error } = useInternetCatalog(); const { data, isLoading, error } = useInternetCatalog();
const plans: InternetPlanCatalogItem[] = useMemo(() => data?.plans ?? [], [data?.plans]); const plans: InternetPlanCatalogItem[] = useMemo(() => data?.plans ?? [], [data?.plans]);
const installations: InternetInstallationCatalogItem[] = useMemo( const installations: InternetInstallationCatalogItem[] = useMemo(
@ -46,7 +48,7 @@ export function PublicInternetPlansView() {
if (isLoading) { if (isLoading) {
return ( return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20"> <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"> <div className="text-center mb-12">
<Skeleton className="h-10 w-96 mx-auto mb-4" /> <Skeleton className="h-10 w-96 mx-auto mb-4" />
@ -72,7 +74,7 @@ export function PublicInternetPlansView() {
if (error) { if (error) {
return ( return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20"> <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"> <AlertBanner variant="error" title="Failed to load plans">
{error instanceof Error ? error.message : "An unexpected error occurred"} {error instanceof Error ? error.message : "An unexpected error occurred"}
</AlertBanner> </AlertBanner>
@ -82,7 +84,7 @@ export function PublicInternetPlansView() {
return ( return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20"> <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 <CatalogHero
title="Choose Your Internet Plan" title="Choose Your Internet Plan"
@ -112,7 +114,7 @@ export function PublicInternetPlansView() {
plan={plan} plan={plan}
installations={installations} installations={installations}
disabled={false} disabled={false}
configureHref={`/shop/internet/configure?plan=${plan.sku}`} configureHref={`${shopBasePath}/internet/configure?plan=${encodeURIComponent(plan.sku)}`}
/> />
</div> </div>
))} ))}
@ -143,7 +145,7 @@ export function PublicInternetPlansView() {
We couldn&apos;t find any internet plans available at this time. We couldn&apos;t find any internet plans available at this time.
</p> </p>
<CatalogBackLink <CatalogBackLink
href="/shop" href={shopBasePath}
label="Back to Services" label="Back to Services"
align="center" align="center"
className="mt-0 mb-0" 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 { SimPlanTypeSection } from "@/features/catalog/components/sim/SimPlanTypeSection";
import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink"; import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink";
import { CatalogHero } from "@/features/catalog/components/base/CatalogHero"; import { CatalogHero } from "@/features/catalog/components/base/CatalogHero";
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
interface PlansByType { interface PlansByType {
DataOnly: SimCatalogProduct[]; DataOnly: SimCatalogProduct[];
@ -30,6 +31,7 @@ interface PlansByType {
* Simplified version without active subscription checks. * Simplified version without active subscription checks.
*/ */
export function PublicSimPlansView() { export function PublicSimPlansView() {
const shopBasePath = useShopBasePath();
const { data, isLoading, error } = useSimCatalog(); const { data, isLoading, error } = useSimCatalog();
const simPlans: SimCatalogProduct[] = useMemo(() => data?.plans ?? [], [data?.plans]); const simPlans: SimCatalogProduct[] = useMemo(() => data?.plans ?? [], [data?.plans]);
const [activeTab, setActiveTab] = useState<"data-voice" | "data-only" | "voice-only">( const [activeTab, setActiveTab] = useState<"data-voice" | "data-only" | "voice-only">(
@ -39,7 +41,7 @@ export function PublicSimPlansView() {
if (isLoading) { if (isLoading) {
return ( return (
<div className="max-w-6xl mx-auto px-4"> <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"> <div className="text-center mb-12">
<Skeleton className="h-10 w-80 mx-auto mb-4" /> <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> <div className="text-destructive/80 text-sm mt-1">{errorMessage}</div>
<Button <Button
as="a" as="a"
href="/shop" href={shopBasePath}
className="mt-4" className="mt-4"
leftIcon={<ArrowLeftIcon className="w-4 h-4" />} leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
> >
@ -96,7 +98,7 @@ export function PublicSimPlansView() {
return ( return (
<div className="max-w-6xl mx-auto px-4 pb-16"> <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 <CatalogHero
title="Choose Your SIM Plan" 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 { VpnPlanCard } from "@/features/catalog/components/vpn/VpnPlanCard";
import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink"; import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink";
import { CatalogHero } from "@/features/catalog/components/base/CatalogHero"; import { CatalogHero } from "@/features/catalog/components/base/CatalogHero";
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
/** /**
* Public VPN Plans View * Public VPN Plans View
@ -15,6 +16,7 @@ import { CatalogHero } from "@/features/catalog/components/base/CatalogHero";
* Displays VPN plans for unauthenticated users. * Displays VPN plans for unauthenticated users.
*/ */
export function PublicVpnPlansView() { export function PublicVpnPlansView() {
const shopBasePath = useShopBasePath();
const { data, isLoading, error } = useVpnCatalog(); const { data, isLoading, error } = useVpnCatalog();
const vpnPlans = data?.plans || []; const vpnPlans = data?.plans || [];
const activationFees = data?.activationFees || []; const activationFees = data?.activationFees || [];
@ -22,7 +24,7 @@ export function PublicVpnPlansView() {
if (isLoading || error) { if (isLoading || error) {
return ( return (
<div className="max-w-6xl mx-auto px-4"> <div className="max-w-6xl mx-auto px-4">
<CatalogBackLink href="/shop" label="Back to Services" /> <CatalogBackLink href={shopBasePath} label="Back to Services" />
<AsyncBlock <AsyncBlock
isLoading={isLoading} isLoading={isLoading}
@ -42,7 +44,7 @@ export function PublicVpnPlansView() {
return ( return (
<div className="max-w-6xl mx-auto px-4 pb-16"> <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 <CatalogHero
title="SonixNet VPN Router Service" title="SonixNet VPN Router Service"
@ -75,7 +77,7 @@ export function PublicVpnPlansView() {
We couldn&apos;t find any VPN plans available at this time. We couldn&apos;t find any VPN plans available at this time.
</p> </p>
<CatalogBackLink <CatalogBackLink
href="/shop" href={shopBasePath}
label="Back to Services" label="Back to Services"
align="center" align="center"
className="mt-4 mb-0" className="mt-4 mb-0"

View File

@ -1,12 +1,13 @@
"use client"; "use client";
import { useSearchParams, useRouter } from "next/navigation"; import { usePathname, useSearchParams, useRouter } from "next/navigation";
import { useSimConfigure } from "@/features/catalog/hooks/useSimConfigure"; import { useSimConfigure } from "@/features/catalog/hooks/useSimConfigure";
import { SimConfigureView as SimConfigureInnerView } from "@/features/catalog/components/sim/SimConfigureView"; import { SimConfigureView as SimConfigureInnerView } from "@/features/catalog/components/sim/SimConfigureView";
export function SimConfigureContainer() { export function SimConfigureContainer() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const router = useRouter(); const router = useRouter();
const pathname = usePathname();
const planId = searchParams.get("plan") || undefined; const planId = searchParams.get("plan") || undefined;
const vm = useSimConfigure(planId); const vm = useSimConfigure(planId);
@ -15,7 +16,8 @@ export function SimConfigureContainer() {
if (!vm.plan || !vm.validate()) return; if (!vm.plan || !vm.validate()) return;
const params = vm.buildCheckoutSearchParams(); const params = vm.buildCheckoutSearchParams();
if (!params) return; 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} />; 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 { SimPlanTypeSection } from "@/features/catalog/components/sim/SimPlanTypeSection";
import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink"; import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink";
import { CatalogHero } from "@/features/catalog/components/base/CatalogHero"; import { CatalogHero } from "@/features/catalog/components/base/CatalogHero";
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
interface PlansByType { interface PlansByType {
DataOnly: SimCatalogProduct[]; DataOnly: SimCatalogProduct[];
@ -25,6 +26,7 @@ interface PlansByType {
} }
export function SimPlansContainer() { export function SimPlansContainer() {
const shopBasePath = useShopBasePath();
const { data, isLoading, error } = useSimCatalog(); const { data, isLoading, error } = useSimCatalog();
const simPlans: SimCatalogProduct[] = useMemo(() => data?.plans ?? [], [data?.plans]); const simPlans: SimCatalogProduct[] = useMemo(() => data?.plans ?? [], [data?.plans]);
const [hasExistingSim, setHasExistingSim] = useState(false); const [hasExistingSim, setHasExistingSim] = useState(false);
@ -45,7 +47,7 @@ export function SimPlansContainer() {
icon={<DevicePhoneMobileIcon className="h-6 w-6" />} icon={<DevicePhoneMobileIcon className="h-6 w-6" />}
> >
<div className="max-w-6xl mx-auto px-4"> <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 */} {/* Title block */}
<div className="text-center mb-12"> <div className="text-center mb-12">
@ -110,7 +112,7 @@ export function SimPlansContainer() {
<div className="text-red-600 text-sm mt-1">{errorMessage}</div> <div className="text-red-600 text-sm mt-1">{errorMessage}</div>
<Button <Button
as="a" as="a"
href="/shop" href={shopBasePath}
className="mt-4" className="mt-4"
leftIcon={<ArrowLeftIcon className="w-4 h-4" />} leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
> >
@ -140,7 +142,7 @@ export function SimPlansContainer() {
icon={<DevicePhoneMobileIcon className="h-6 w-6" />} icon={<DevicePhoneMobileIcon className="h-6 w-6" />}
> >
<div className="max-w-6xl mx-auto px-4 pb-16"> <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 <CatalogHero
title="Choose Your SIM Plan" 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 { VpnPlanCard } from "@/features/catalog/components/vpn/VpnPlanCard";
import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink"; import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink";
import { CatalogHero } from "@/features/catalog/components/base/CatalogHero"; import { CatalogHero } from "@/features/catalog/components/base/CatalogHero";
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
export function VpnPlansView() { export function VpnPlansView() {
const shopBasePath = useShopBasePath();
const { data, isLoading, error } = useVpnCatalog(); const { data, isLoading, error } = useVpnCatalog();
const vpnPlans = data?.plans || []; const vpnPlans = data?.plans || [];
const activationFees = data?.activationFees || []; const activationFees = data?.activationFees || [];
@ -24,7 +26,7 @@ export function VpnPlansView() {
icon={<ShieldCheckIcon className="h-6 w-6" />} icon={<ShieldCheckIcon className="h-6 w-6" />}
> >
<div className="max-w-6xl mx-auto px-4"> <div className="max-w-6xl mx-auto px-4">
<CatalogBackLink href="/shop" label="Back to Services" /> <CatalogBackLink href={shopBasePath} label="Back to Services" />
<AsyncBlock <AsyncBlock
isLoading={isLoading} isLoading={isLoading}
@ -52,7 +54,7 @@ export function VpnPlansView() {
icon={<ShieldCheckIcon className="h-6 w-6" />} icon={<ShieldCheckIcon className="h-6 w-6" />}
> >
<div className="max-w-6xl mx-auto px-4 pb-16"> <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 <CatalogHero
title="SonixNet VPN Router Service" title="SonixNet VPN Router Service"
@ -89,7 +91,7 @@ export function VpnPlansView() {
We couldn&apos;t find any VPN plans available at this time. We couldn&apos;t find any VPN plans available at this time.
</p> </p>
<CatalogBackLink <CatalogBackLink
href="/shop" href={shopBasePath}
label="Back to Services" label="Back to Services"
align="center" align="center"
className="mt-4 mb-0" className="mt-4 mb-0"

View File

@ -10,7 +10,7 @@ interface Step {
description: string; description: string;
} }
const STEPS: Step[] = [ const DEFAULT_STEPS: Step[] = [
{ id: "account", name: "Account", description: "Your details" }, { id: "account", name: "Account", description: "Your details" },
{ id: "address", name: "Address", description: "Delivery info" }, { id: "address", name: "Address", description: "Delivery info" },
{ id: "payment", name: "Payment", description: "Payment method" }, { id: "payment", name: "Payment", description: "Payment method" },
@ -21,6 +21,7 @@ interface CheckoutProgressProps {
currentStep: CheckoutStep; currentStep: CheckoutStep;
onStepClick?: (step: CheckoutStep) => void; onStepClick?: (step: CheckoutStep) => void;
completedSteps?: CheckoutStep[]; completedSteps?: CheckoutStep[];
steps?: Step[];
} }
/** /**
@ -33,8 +34,10 @@ export function CheckoutProgress({
currentStep, currentStep,
onStepClick, onStepClick,
completedSteps = [], completedSteps = [],
steps = DEFAULT_STEPS,
}: CheckoutProgressProps) { }: CheckoutProgressProps) {
const currentIndex = STEPS.findIndex(s => s.id === currentStep); const currentIndex = steps.findIndex(s => s.id === currentStep);
const safeCurrentIndex = currentIndex >= 0 ? currentIndex : 0;
return ( return (
<nav aria-label="Checkout progress" className="mb-8"> <nav aria-label="Checkout progress" className="mb-8">
@ -42,29 +45,29 @@ export function CheckoutProgress({
<div className="sm:hidden"> <div className="sm:hidden">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-foreground"> <span className="text-sm font-medium text-foreground">
Step {currentIndex + 1} of {STEPS.length} Step {safeCurrentIndex + 1} of {steps.length}
</span> </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>
<div className="h-2 bg-muted rounded-full overflow-hidden"> <div className="h-2 bg-muted rounded-full overflow-hidden">
<div <div
className="h-full bg-primary transition-all duration-300" 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>
</div> </div>
{/* Desktop view */} {/* Desktop view */}
<ol className="hidden sm:flex items-center w-full"> <ol className="hidden sm:flex items-center w-full">
{STEPS.map((step, index) => { {steps.map((step, index) => {
const isCompleted = completedSteps.includes(step.id) || index < currentIndex; const isCompleted = completedSteps.includes(step.id) || index < safeCurrentIndex;
const isCurrent = step.id === currentStep; const isCurrent = step.id === currentStep;
const isClickable = onStepClick && (isCompleted || index <= currentIndex); const isClickable = onStepClick && (isCompleted || index <= safeCurrentIndex);
return ( return (
<li <li
key={step.id} 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 <button
type="button" type="button"
@ -111,11 +114,11 @@ export function CheckoutProgress({
</button> </button>
{/* Connector line */} {/* Connector line */}
{index < STEPS.length - 1 && ( {index < steps.length - 1 && (
<div <div
className={cn( className={cn(
"flex-1 h-0.5 mx-4 transition-colors duration-300", "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 type { CheckoutStep } from "@customer-portal/domain/checkout";
import { useAuthSession } from "@/features/auth/services/auth.store"; 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 * CheckoutWizard - Main checkout flow orchestrator
* *
@ -21,6 +39,9 @@ import { useAuthSession } from "@/features/auth/services/auth.store";
export function CheckoutWizard() { export function CheckoutWizard() {
const { isAuthenticated } = useAuthSession(); const { isAuthenticated } = useAuthSession();
const { cartItem, currentStep, setCurrentStep, registrationComplete } = useCheckoutStore(); 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(() => { useEffect(() => {
if ((isAuthenticated || registrationComplete) && currentStep === "account") { if ((isAuthenticated || registrationComplete) && currentStep === "account") {
@ -36,8 +57,10 @@ export function CheckoutWizard() {
// Calculate completed steps // Calculate completed steps
const getCompletedSteps = (): CheckoutStep[] => { const getCompletedSteps = (): CheckoutStep[] => {
const completed: CheckoutStep[] = []; const completed: CheckoutStep[] = [];
const stepOrder: CheckoutStep[] = ["account", "address", "payment", "review"];
const currentIndex = stepOrder.indexOf(currentStep); const currentIndex = stepOrder.indexOf(currentStep);
if (currentIndex < 0) {
return completed;
}
for (let i = 0; i < currentIndex; i++) { for (let i = 0; i < currentIndex; i++) {
completed.push(stepOrder[i]); completed.push(stepOrder[i]);
@ -48,12 +71,11 @@ export function CheckoutWizard() {
// Handle step click (only allow going back) // Handle step click (only allow going back)
const handleStepClick = (step: CheckoutStep) => { const handleStepClick = (step: CheckoutStep) => {
const stepOrder: CheckoutStep[] = ["account", "address", "payment", "review"];
const currentIndex = stepOrder.indexOf(currentStep); const currentIndex = stepOrder.indexOf(currentStep);
const targetIndex = stepOrder.indexOf(step); const targetIndex = stepOrder.indexOf(step);
// Only allow clicking on completed steps or current step // Only allow clicking on completed steps or current step
if (targetIndex <= currentIndex) { if (targetIndex >= 0 && currentIndex >= 0 && targetIndex <= currentIndex) {
setCurrentStep(step); setCurrentStep(step);
} }
}; };
@ -81,6 +103,7 @@ export function CheckoutWizard() {
currentStep={currentStep} currentStep={currentStep}
completedSteps={getCompletedSteps()} completedSteps={getCompletedSteps()}
onStepClick={handleStepClick} onStepClick={handleStepClick}
steps={steps}
/> />
{/* Main content */} {/* Main content */}

View File

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

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useState, useCallback } from "react"; import { useEffect, useMemo, useState, useCallback } from "react";
import { z } from "zod"; import { z } from "zod";
import { useCheckoutStore } from "../../stores/checkout.store"; import { useCheckoutStore } from "../../stores/checkout.store";
import { Button, Input } from "@/components/atoms"; import { Button, Input } from "@/components/atoms";
@ -15,6 +15,7 @@ import {
nameSchema, nameSchema,
phoneSchema, phoneSchema,
} from "@customer-portal/domain/common"; } from "@customer-portal/domain/common";
import { usePathname, useSearchParams } from "next/navigation";
// Form schema for guest info // Form schema for guest info
const accountFormSchema = z const accountFormSchema = z
@ -41,6 +42,8 @@ type AccountFormData = z.infer<typeof accountFormSchema>;
*/ */
export function AccountStep() { export function AccountStep() {
const { isAuthenticated } = useAuthSession(); const { isAuthenticated } = useAuthSession();
const pathname = usePathname();
const searchParams = useSearchParams();
const { const {
guestInfo, guestInfo,
updateGuestInfo, updateGuestInfo,
@ -48,7 +51,23 @@ export function AccountStep() {
registrationComplete, registrationComplete,
setRegistrationComplete, setRegistrationComplete,
} = useCheckoutStore(); } = 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( const handleSubmit = useCallback(
async (data: AccountFormData) => { async (data: AccountFormData) => {
@ -89,48 +108,113 @@ export function AccountStep() {
return null; 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Sign-in prompt */} {phase === "identify" ? (
<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>
</div>
<Button variant="outline" onClick={() => setMode(mode === "signin" ? "new" : "signin")}>
{mode === "signin" ? "Create Account" : "Sign In"}
</Button>
</div>
</div>
{mode === "signin" ? (
<SignInForm
onSuccess={() => setCurrentStep("address")}
onCancel={() => setMode("new")}
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>
</div>
{/* Guest info form */}
<div className="bg-card rounded-xl border border-border p-6 shadow-[var(--cp-shadow-1)]"> <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"> <div className="flex items-center gap-3 mb-6">
<UserIcon className="h-6 w-6 text-primary" /> <UserIcon className="h-6 w-6 text-primary" />
<h2 className="text-lg font-semibold text-foreground">Your Information</h2> <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>
{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={() => setPhase("identify")}
setRegistrationComplete={setRegistrationComplete}
/>
) : (
<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> </div>
<form onSubmit={event => void form.handleSubmit(event)} className="space-y-4"> <form onSubmit={event => void form.handleSubmit(event)} className="space-y-4">
@ -236,11 +320,18 @@ export function AccountStep() {
special character. special character.
</p> </p>
{/* Submit */} <div className="flex gap-3 pt-2">
<div className="pt-4"> <Button
type="button"
variant="ghost"
className="flex-1"
onClick={() => setPhase("identify")}
>
Back
</Button>
<Button <Button
type="submit" type="submit"
className="w-full" className="flex-1"
disabled={form.isSubmitting} disabled={form.isSubmitting}
isLoading={form.isSubmitting} isLoading={form.isSubmitting}
rightIcon={<ArrowRightIcon className="w-4 h-4" />} rightIcon={<ArrowRightIcon className="w-4 h-4" />}
@ -250,7 +341,6 @@ export function AccountStep() {
</div> </div>
</form> </form>
</div> </div>
</>
)} )}
</div> </div>
); );
@ -258,10 +348,12 @@ export function AccountStep() {
// Embedded sign-in form // Embedded sign-in form
function SignInForm({ function SignInForm({
initialEmail,
onSuccess, onSuccess,
onCancel, onCancel,
setRegistrationComplete, setRegistrationComplete,
}: { }: {
initialEmail: string;
onSuccess: () => void; onSuccess: () => void;
onCancel: () => void; onCancel: () => void;
setRegistrationComplete: (userId: string) => void; setRegistrationComplete: (userId: string) => void;
@ -296,7 +388,7 @@ function SignInForm({
email: z.string().email("Valid email required"), email: z.string().email("Valid email required"),
password: z.string().min(1, "Password is required"), password: z.string().min(1, "Password is required"),
}), }),
initialValues: { email: "", password: "" }, initialValues: { email: initialEmail, password: "" },
onSubmit: handleSubmit, onSubmit: handleSubmit,
}); });

View File

@ -19,6 +19,7 @@ import { checkoutRegisterResponseSchema } from "@customer-portal/domain/checkout
*/ */
export function AddressStep() { export function AddressStep() {
const { isAuthenticated } = useAuthSession(); const { isAuthenticated } = useAuthSession();
const user = useAuthStore(state => state.user);
const refreshUser = useAuthStore(state => state.refreshUser); const refreshUser = useAuthStore(state => state.refreshUser);
const { const {
address, address,
@ -86,13 +87,13 @@ export function AddressStep() {
const form = useZodForm<AddressFormData>({ const form = useZodForm<AddressFormData>({
schema: addressFormSchema, schema: addressFormSchema,
initialValues: { initialValues: {
address1: address?.address1 ?? "", address1: address?.address1 ?? user?.address?.address1 ?? "",
address2: address?.address2 ?? "", address2: address?.address2 ?? user?.address?.address2 ?? "",
city: address?.city ?? "", city: address?.city ?? user?.address?.city ?? "",
state: address?.state ?? "", state: address?.state ?? user?.address?.state ?? "",
postcode: address?.postcode ?? "", postcode: address?.postcode ?? user?.address?.postcode ?? "",
country: address?.country ?? "Japan", country: address?.country ?? user?.address?.country ?? "Japan",
countryCode: address?.countryCode ?? "JP", countryCode: address?.countryCode ?? user?.address?.countryCode ?? "JP",
}, },
onSubmit: handleSubmit, onSubmit: handleSubmit,
}); });

View File

@ -14,6 +14,7 @@ import {
CheckCircleIcon, CheckCircleIcon,
ExclamationTriangleIcon, ExclamationTriangleIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import type { PaymentMethodList } from "@customer-portal/domain/payments";
/** /**
* PaymentStep - Third step in checkout * PaymentStep - Third step in checkout
@ -41,16 +42,8 @@ export function PaymentStep() {
} }
try { try {
const response = await fetch("/api/payments/methods", { const response = await apiClient.GET<PaymentMethodList>("/api/invoices/payment-methods");
credentials: "include", const methods = response.data?.paymentMethods ?? [];
});
if (!response.ok) {
throw new Error("Failed to check payment methods");
}
const data = await response.json();
const methods = data.data?.paymentMethods ?? data.paymentMethods ?? [];
if (methods.length > 0) { if (methods.length > 0) {
const defaultMethod = const defaultMethod =

View File

@ -23,7 +23,7 @@ import {
*/ */
export function ReviewStep() { export function ReviewStep() {
const router = useRouter(); const router = useRouter();
const { user } = useAuthSession(); const { user, isAuthenticated } = useAuthSession();
const { const {
cartItem, cartItem,
guestInfo, guestInfo,
@ -92,6 +92,7 @@ export function ReviewStep() {
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<UserIcon className="h-4 w-4 text-primary" /> <UserIcon className="h-4 w-4 text-primary" />
<span className="font-medium text-sm text-foreground">Account</span> <span className="font-medium text-sm text-foreground">Account</span>
{!isAuthenticated && (
<Button <Button
variant="link" variant="link"
size="sm" size="sm"
@ -100,11 +101,16 @@ export function ReviewStep() {
> >
Edit Edit
</Button> </Button>
)}
</div> </div>
<p className="text-sm text-muted-foreground"> <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>
<p className="text-sm text-muted-foreground">{guestInfo?.email || user?.email}</p>
</div> </div>
{/* Address */} {/* Address */}
@ -122,13 +128,18 @@ export function ReviewStep() {
</Button> </Button>
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{address?.address1} {(address?.address1 ?? user?.address?.address1) || ""}
{address?.address2 && `, ${address.address2}`} {(address?.address2 ?? user?.address?.address2) &&
`, ${address?.address2 ?? user?.address?.address2}`}
</p> </p>
<p className="text-sm text-muted-foreground"> <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>
<p className="text-sm text-muted-foreground">{address?.country}</p>
</div> </div>
{/* Payment */} {/* Payment */}

View File

@ -223,8 +223,6 @@ export const useCheckoutStore = create<CheckoutStore>()(
cartParamsSignature: state.cartParamsSignature, cartParamsSignature: state.cartParamsSignature,
checkoutSessionId: state.checkoutSessionId, checkoutSessionId: state.checkoutSessionId,
checkoutSessionExpiresAt: state.checkoutSessionExpiresAt, checkoutSessionExpiresAt: state.checkoutSessionExpiresAt,
guestInfo: state.guestInfo,
address: state.address,
currentStep: state.currentStep, currentStep: state.currentStep,
cartUpdatedAt: state.cartUpdatedAt, cartUpdatedAt: state.cartUpdatedAt,
// Don't persist sensitive or transient state // Don't persist sensitive or transient state

View File

@ -144,6 +144,7 @@ export const queryKeys = {
products: () => ["catalog", "products"] as const, products: () => ["catalog", "products"] as const,
internet: { internet: {
combined: () => ["catalog", "internet", "combined"] as const, combined: () => ["catalog", "internet", "combined"] as const,
eligibility: () => ["catalog", "internet", "eligibility"] as const,
}, },
sim: { sim: {
combined: () => ["catalog", "sim", "combined"] as const, combined: () => ["catalog", "sim", "combined"] as const,