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 { 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,
|
||||||
|
|||||||
@ -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 { 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(", ");
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
* 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
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,
|
||||||
|
|||||||
@ -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");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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" />}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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} />;
|
||||||
|
|||||||
@ -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} />;
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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(() => {
|
||||||
|
|||||||
@ -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 { 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,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@ -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");
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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} />;
|
||||||
|
|||||||
@ -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; 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 && (
|
{hasActiveInternet && (
|
||||||
<AlertBanner
|
<AlertBanner
|
||||||
variant="warning"
|
variant="warning"
|
||||||
@ -161,11 +233,13 @@ 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"
|
||||||
: undefined
|
: requiresAvailabilityCheck
|
||||||
|
? "Availability check required before ordering"
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -197,7 +271,7 @@ export function InternetPlansContainer() {
|
|||||||
We couldn't find any internet plans available for your location at this time.
|
We couldn'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;
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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't find any internet plans available at this time.
|
We couldn'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"
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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't find any VPN plans available at this time.
|
We couldn'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"
|
||||||
|
|||||||
@ -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} />;
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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't find any VPN plans available at this time.
|
We couldn'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"
|
||||||
|
|||||||
@ -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"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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 */}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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,168 +108,239 @@ 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="bg-card rounded-xl border border-border p-6 shadow-[var(--cp-shadow-1)]">
|
||||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
<div className="flex items-center gap-3 mb-6">
|
||||||
<div>
|
<UserIcon className="h-6 w-6 text-primary" />
|
||||||
<h3 className="font-semibold text-foreground">Already have an account?</h3>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<h2 className="text-lg font-semibold text-foreground">Continue with email</h2>
|
||||||
Sign in to use your saved information and get faster checkout
|
<p className="text-sm text-muted-foreground">
|
||||||
</p>
|
We’ll check if you already have an account, then guide you through checkout.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</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
|
<SignInForm
|
||||||
|
initialEmail={identifyEmail.trim()}
|
||||||
onSuccess={() => setCurrentStep("address")}
|
onSuccess={() => setCurrentStep("address")}
|
||||||
onCancel={() => setMode("new")}
|
onCancel={() => setPhase("identify")}
|
||||||
setRegistrationComplete={setRegistrationComplete}
|
setRegistrationComplete={setRegistrationComplete}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className="bg-card rounded-xl border border-border p-6 shadow-[var(--cp-shadow-1)]">
|
||||||
{/* Divider */}
|
<div className="flex items-center gap-3 mb-6">
|
||||||
<div className="relative">
|
<UserIcon className="h-6 w-6 text-primary" />
|
||||||
<div className="absolute inset-0 flex items-center">
|
<div>
|
||||||
<div className="w-full border-t border-border" />
|
<h2 className="text-lg font-semibold text-foreground">Create your account</h2>
|
||||||
</div>
|
<p className="text-sm text-muted-foreground">
|
||||||
<div className="relative flex justify-center text-sm">
|
Account is required to place an order and add a payment method.
|
||||||
<span className="bg-background px-4 text-muted-foreground">
|
</p>
|
||||||
Or continue as new customer
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Guest info form */}
|
<form onSubmit={event => void form.handleSubmit(event)} className="space-y-4">
|
||||||
<div className="bg-card rounded-xl border border-border p-6 shadow-[var(--cp-shadow-1)]">
|
{/* Email */}
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<FormField
|
||||||
<UserIcon className="h-6 w-6 text-primary" />
|
label="Email Address"
|
||||||
<h2 className="text-lg font-semibold text-foreground">Your Information</h2>
|
error={form.touched.email ? form.errors.email : undefined}
|
||||||
</div>
|
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">
|
{/* Name fields */}
|
||||||
{/* Email */}
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<FormField
|
<FormField
|
||||||
label="Email Address"
|
label="First Name"
|
||||||
error={form.touched.email ? form.errors.email : undefined}
|
error={form.touched.firstName ? form.errors.firstName : undefined}
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
value={form.values.firstName}
|
||||||
value={form.values.email}
|
onChange={e => form.setValue("firstName", e.target.value)}
|
||||||
onChange={e => form.setValue("email", e.target.value)}
|
onBlur={() => form.setTouchedField("firstName")}
|
||||||
onBlur={() => form.setTouchedField("email")}
|
placeholder="John"
|
||||||
placeholder="your@email.com"
|
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</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
|
<FormField
|
||||||
label="Phone Number"
|
label="Last Name"
|
||||||
error={form.touched.phone ? form.errors.phone : undefined}
|
error={form.touched.lastName ? form.errors.lastName : undefined}
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<div className="flex gap-2">
|
<Input
|
||||||
<Input
|
value={form.values.lastName}
|
||||||
value={form.values.phoneCountryCode}
|
onChange={e => form.setValue("lastName", e.target.value)}
|
||||||
onChange={e => form.setValue("phoneCountryCode", e.target.value)}
|
onBlur={() => form.setTouchedField("lastName")}
|
||||||
onBlur={() => form.setTouchedField("phoneCountryCode")}
|
placeholder="Doe"
|
||||||
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>
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Password fields */}
|
{/* Phone */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<FormField
|
||||||
<FormField
|
label="Phone Number"
|
||||||
label="Password"
|
error={form.touched.phone ? form.errors.phone : undefined}
|
||||||
error={form.touched.password ? form.errors.password : undefined}
|
required
|
||||||
required
|
>
|
||||||
>
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
value={form.values.phoneCountryCode}
|
||||||
value={form.values.password}
|
onChange={e => form.setValue("phoneCountryCode", e.target.value)}
|
||||||
onChange={e => form.setValue("password", e.target.value)}
|
onBlur={() => form.setTouchedField("phoneCountryCode")}
|
||||||
onBlur={() => form.setTouchedField("password")}
|
className="w-24"
|
||||||
placeholder="••••••••"
|
placeholder="+81"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
<Input
|
||||||
<FormField
|
value={form.values.phone}
|
||||||
label="Confirm Password"
|
onChange={e => form.setValue("phone", e.target.value)}
|
||||||
error={form.touched.confirmPassword ? form.errors.confirmPassword : undefined}
|
onBlur={() => form.setTouchedField("phone")}
|
||||||
required
|
className="flex-1"
|
||||||
>
|
placeholder="90-1234-5678"
|
||||||
<Input
|
/>
|
||||||
type="password"
|
|
||||||
value={form.values.confirmPassword}
|
|
||||||
onChange={e => form.setValue("confirmPassword", e.target.value)}
|
|
||||||
onBlur={() => form.setTouchedField("confirmPassword")}
|
|
||||||
placeholder="••••••••"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
</div>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground">
|
{/* Password fields */}
|
||||||
Password must be at least 8 characters with uppercase, lowercase, a number, and a
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
special character.
|
<FormField
|
||||||
</p>
|
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 */}
|
<p className="text-xs text-muted-foreground">
|
||||||
<div className="pt-4">
|
Password must be at least 8 characters with uppercase, lowercase, a number, and a
|
||||||
<Button
|
special character.
|
||||||
type="submit"
|
</p>
|
||||||
className="w-full"
|
|
||||||
disabled={form.isSubmitting}
|
<div className="flex gap-3 pt-2">
|
||||||
isLoading={form.isSubmitting}
|
<Button
|
||||||
rightIcon={<ArrowRightIcon className="w-4 h-4" />}
|
type="button"
|
||||||
>
|
variant="ghost"
|
||||||
Continue to Address
|
className="flex-1"
|
||||||
</Button>
|
onClick={() => setPhase("identify")}
|
||||||
</div>
|
>
|
||||||
</form>
|
Back
|
||||||
</div>
|
</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>
|
</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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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 =
|
||||||
|
|||||||
@ -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,19 +92,25 @@ 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>
|
||||||
<Button
|
{!isAuthenticated && (
|
||||||
variant="link"
|
<Button
|
||||||
size="sm"
|
variant="link"
|
||||||
className="ml-auto text-xs"
|
size="sm"
|
||||||
onClick={() => setCurrentStep("account")}
|
className="ml-auto text-xs"
|
||||||
>
|
onClick={() => setCurrentStep("account")}
|
||||||
Edit
|
>
|
||||||
</Button>
|
Edit
|
||||||
|
</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 */}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user