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:
parent
4edf0e801e
commit
9d2c4ff921
@ -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,
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
}
|
||||
@ -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(", ");
|
||||
}
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
import type { Address } from "@customer-portal/domain/customer";
|
||||
|
||||
export type InternetEligibilityCheckRequest = {
|
||||
email: string;
|
||||
notes?: string;
|
||||
address?: Partial<Address>;
|
||||
};
|
||||
11
apps/portal/src/app/(public)/(catalog)/layout.tsx
Normal file
11
apps/portal/src/app/(public)/(catalog)/layout.tsx
Normal 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>;
|
||||
}
|
||||
9
apps/portal/src/app/(public)/(catalog)/shop/layout.tsx
Normal file
9
apps/portal/src/app/(public)/(catalog)/shop/layout.tsx
Normal 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;
|
||||
}
|
||||
11
apps/portal/src/app/(public)/(site)/layout.tsx
Normal file
11
apps/portal/src/app/(public)/(site)/layout.tsx
Normal 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>;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
11
apps/portal/src/app/account/order/page.tsx
Normal file
11
apps/portal/src/app/account/order/page.tsx
Normal 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 />;
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import { InternetConfigureContainer } from "@/features/catalog/views/InternetConfigure";
|
||||
|
||||
export default function AccountInternetConfigurePage() {
|
||||
return <InternetConfigureContainer />;
|
||||
}
|
||||
5
apps/portal/src/app/account/shop/internet/page.tsx
Normal file
5
apps/portal/src/app/account/shop/internet/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { InternetPlansContainer } from "@/features/catalog/views/InternetPlans";
|
||||
|
||||
export default function AccountInternetPlansPage() {
|
||||
return <InternetPlansContainer />;
|
||||
}
|
||||
5
apps/portal/src/app/account/shop/page.tsx
Normal file
5
apps/portal/src/app/account/shop/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { CatalogHomeView } from "@/features/catalog/views/CatalogHome";
|
||||
|
||||
export default function AccountShopPage() {
|
||||
return <CatalogHomeView />;
|
||||
}
|
||||
5
apps/portal/src/app/account/shop/sim/configure/page.tsx
Normal file
5
apps/portal/src/app/account/shop/sim/configure/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { SimConfigureContainer } from "@/features/catalog/views/SimConfigure";
|
||||
|
||||
export default function AccountSimConfigurePage() {
|
||||
return <SimConfigureContainer />;
|
||||
}
|
||||
5
apps/portal/src/app/account/shop/sim/page.tsx
Normal file
5
apps/portal/src/app/account/shop/sim/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { SimPlansContainer } from "@/features/catalog/views/SimPlans";
|
||||
|
||||
export default function AccountSimPlansPage() {
|
||||
return <SimPlansContainer />;
|
||||
}
|
||||
5
apps/portal/src/app/account/shop/vpn/page.tsx
Normal file
5
apps/portal/src/app/account/shop/vpn/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { VpnPlansView } from "@/features/catalog/views/VpnPlans";
|
||||
|
||||
export default function AccountVpnPlansPage() {
|
||||
return <VpnPlansView />;
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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");
|
||||
};
|
||||
|
||||
|
||||
@ -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);
|
||||
}}
|
||||
>
|
||||
|
||||
@ -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" />}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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} />;
|
||||
|
||||
@ -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} />;
|
||||
|
||||
@ -2,3 +2,4 @@ export * from "./useCatalog";
|
||||
export * from "./useConfigureParams";
|
||||
export * from "./useSimConfigure";
|
||||
export * from "./useInternetConfigure";
|
||||
export * from "./useInternetEligibility";
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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() });
|
||||
},
|
||||
});
|
||||
}
|
||||
17
apps/portal/src/features/catalog/hooks/useShopBasePath.ts
Normal file
17
apps/portal/src/features/catalog/hooks/useShopBasePath.ts
Normal 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";
|
||||
}
|
||||
@ -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,
|
||||
]);
|
||||
|
||||
|
||||
@ -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");
|
||||
},
|
||||
};
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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} />;
|
||||
|
||||
@ -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; we’ll update your account once it’s 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'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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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'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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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'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"
|
||||
|
||||
@ -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} />;
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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'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"
|
||||
|
||||
@ -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"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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">
|
||||
We’ll 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,
|
||||
});
|
||||
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user