Implement Account Portal Restructuring and Component Updates

- Introduced a new plan for restructuring the account portal, resulting in over 60 file operations including the addition of new components and the deletion of outdated routes.
- Updated various components to enhance user experience, including the addition of AccountRouteGuard and AccountDashboardPage.
- Refactored routing paths to align with the new account structure, ensuring seamless navigation across billing, orders, and support sections.
- Enhanced the checkout process by integrating new components and improving error handling for better user feedback.
- Updated public-facing features to streamline contact and support functionalities, improving accessibility for users.
This commit is contained in:
barsa 2025-12-17 15:44:46 +09:00
parent 3fe74b72dd
commit 963e30e817
121 changed files with 1543 additions and 1322 deletions

View File

@ -0,0 +1,390 @@
---
name: Restructure to Account Portal
overview: Restructure the app to have public pages under (public)/ and all authenticated portal pages under /account/*, with auth-aware headers in public shells.
todos:
- id: auth-aware-public-shell
content: "Make PublicShell auth-aware: show 'My Account' for logged-in users, 'Sign in' for guests"
status: pending
- id: auth-aware-catalog-shell
content: Make CatalogShell auth-aware with same pattern
status: pending
- id: create-account-layout
content: Create account/layout.tsx with AppShell and auth guard redirect
status: pending
- id: move-dashboard-to-account
content: Move dashboard page to account/page.tsx
status: pending
- id: move-billing-to-account
content: Move billing pages to account/billing/*
status: pending
- id: move-subscriptions-to-services
content: Move subscriptions to account/services/*
status: pending
- id: move-orders-to-account
content: Move orders to account/orders/*
status: pending
- id: move-support-to-account
content: Move support cases to account/support/*
status: pending
- id: move-profile-to-settings
content: Move account/profile to account/settings/*
status: pending
- id: fix-shop-double-header
content: Fix shop layout to not create double header - add CatalogNav only
status: pending
- id: create-contact-route
content: Create (public)/contact/page.tsx for contact form
status: pending
- id: update-navigation
content: Update AppShell navigation.ts with /account/* paths
status: pending
- id: update-catalog-links
content: Replace all /catalog links with /shop
status: pending
- id: update-portal-links
content: Replace all old portal links with /account/* paths
status: pending
- id: remove-sfnumber
content: Remove sfNumber from domain schema and signup components
status: pending
- id: delete-old-authenticated
content: Delete (authenticated)/ directory after migration
status: pending
- id: rebuild-test
content: Rebuild domain package and test all routes
status: pending
---
# Restructure Portal to /account/\* Architecture
## Target Architecture
```mermaid
flowchart TB
subgraph public ["(public)/ - Public Pages"]
P1["/"] --> Home["Homepage"]
P2["/auth/*"] --> Auth["Login, Signup, etc"]
P3["/shop/*"] --> Shop["Product Catalog"]
P4["/help"] --> Help["FAQ & Knowledge Base"]
P5["/contact"] --> Contact["Contact Form"]
P6["/order/*"] --> Order["Checkout Flow"]
end
subgraph account ["/account/* - My Portal"]
A1["/account"] --> Dashboard["Dashboard"]
A2["/account/billing"] --> Billing["Invoices & Payments"]
A3["/account/services"] --> Services["My Subscriptions"]
A4["/account/orders"] --> Orders["Order History"]
A5["/account/support"] --> Support["My Tickets"]
A6["/account/settings"] --> Settings["Profile Settings"]
end
public -.->|"Auth-aware header"| account
```
---
## Phase 1: Make Shells Auth-Aware
### 1.1 Update PublicShell
**File:** `apps/portal/src/components/templates/PublicShell/PublicShell.tsx`
Add auth detection to header navigation:
```tsx
"use client";
import { useAuthStore } from "@/features/auth/services/auth.store";
export function PublicShell({ children }: PublicShellProps) {
const { isAuthenticated } = useAuthStore();
return (
<div className="min-h-screen...">
<header>
<nav>
<Link href="/shop">Services</Link>
<Link href="/help">Support</Link>
{isAuthenticated ? (
<Link href="/account" className="primary-button">
My Account
</Link>
) : (
<Link href="/auth/login" className="primary-button">
Sign in
</Link>
)}
</nav>
</header>
...
</div>
);
}
```
### 1.2 Update CatalogShell
**File:** `apps/portal/src/components/templates/CatalogShell/CatalogShell.tsx`
Same auth-aware pattern - show "My Account" or "Sign in" based on auth state.
---
## Phase 2: Create /account Route Structure
### 2.1 Create Account Layout with Auth Guard
**File:** `apps/portal/src/app/account/layout.tsx` (NEW)
```tsx
import { redirect } from "next/navigation";
import { cookies } from "next/headers";
import { AppShell } from "@/components/organisms/AppShell";
export default async function AccountLayout({ children }: { children: React.ReactNode }) {
const cookieStore = await cookies();
const hasAuthToken = cookieStore.has("access_token");
if (!hasAuthToken) {
redirect("/auth/login?redirect=/account");
}
return <AppShell>{children}</AppShell>;
}
```
### 2.2 Create Account Pages
Move and rename pages:
| Current Path | New Path | New File |
|--------------|----------|----------|
| `(authenticated)/dashboard/page.tsx` | `/account` | `account/page.tsx` |
| `(authenticated)/billing/*` | `/account/billing/*` | `account/billing/*` |
| `(authenticated)/subscriptions/*` | `/account/services/*` | `account/services/*` |
| `(authenticated)/orders/*` | `/account/orders/*` | `account/orders/*` |
| `(authenticated)/support/*` | `/account/support/*` | `account/support/*` |
| `(authenticated)/account/*` | `/account/settings/*` | `account/settings/*` |
---
## Phase 3: Update Navigation
### 3.1 Update AppShell Navigation
**File:** `apps/portal/src/components/organisms/AppShell/navigation.ts`
Update all paths to use `/account/*`:
```typescript
export const baseNavigation: NavigationItem[] = [
{ name: "Dashboard", href: "/account", icon: HomeIcon },
{ name: "Orders", href: "/account/orders", icon: ClipboardDocumentListIcon },
{
name: "Billing",
icon: CreditCardIcon,
children: [
{ name: "Invoices", href: "/account/billing/invoices" },
{ name: "Payment Methods", href: "/account/billing/payments" },
],
},
{
name: "My Services",
icon: ServerIcon,
children: [{ name: "All Services", href: "/account/services" }],
},
{ name: "Shop", href: "/shop", icon: Squares2X2Icon }, // Links to public shop
{
name: "Support",
icon: ChatBubbleLeftRightIcon,
children: [
{ name: "My Tickets", href: "/account/support" },
{ name: "New Ticket", href: "/account/support/new" },
],
},
{ name: "Settings", href: "/account/settings", icon: UserIcon },
{ name: "Log out", href: "#", icon: ArrowRightStartOnRectangleIcon, isLogout: true },
];
```
---
## Phase 4: Fix Public Routes
### 4.1 Fix Double Header in Shop
Remove the nested shell issue by having CatalogShell NOT render a full page wrapper, or by not nesting it under PublicShell.
**Option A:** Move shop out of (public) to its own route with CatalogShell only
**Option B:** Have (public)/shop/layout.tsx return just children with catalog nav (no shell)
Recommended: **Option B** - Keep shop under (public) but have shop layout add only catalog navigation, not a full shell.
**File:** `apps/portal/src/app/(public)/shop/layout.tsx`
```tsx
import { CatalogNav } from "@/components/templates/CatalogShell";
export default function ShopLayout({ children }: { children: React.ReactNode }) {
// Don't wrap with another shell - parent (public) layout already has PublicShell
return (
<>
<CatalogNav />
{children}
</>
);
}
```
**File:** `apps/portal/src/components/templates/CatalogShell/CatalogShell.tsx`
Split into two exports:
- `CatalogShell` - full shell (if ever needed standalone)
- `CatalogNav` - just the navigation bar
### 4.2 Create /contact Route
**File:** `apps/portal/src/app/(public)/contact/page.tsx` (NEW)
Move content from `(public)/help/contact/` to `(public)/contact/`.
---
## Phase 5: Delete Old Routes
### 5.1 Delete (authenticated) Directory
After moving all content to /account/:
- Delete entire `apps/portal/src/app/(authenticated)/` directory
### 5.2 Clean Up Unused Files
- Delete `(public)/help/contact/` (moved to /contact)
- Keep `(public)/help/page.tsx` for FAQ
---
## Phase 6: Update All Internal Links
### 6.1 Update /catalog to /shop Links
Replace in feature components (11 files, 27 occurrences):
```
/catalog → /shop
/catalog/internet → /shop/internet
/catalog/sim → /shop/sim
/catalog/vpn → /shop/vpn
```
### 6.2 Update Dashboard/Portal Links
Replace throughout codebase:
```
/dashboard → /account
/billing → /account/billing
/subscriptions → /account/services
/orders → /account/orders
/support/cases → /account/support
```
---
## Phase 7: Remove sfNumber from Signup
### 7.1 Update Domain Schema
**File:** `packages/domain/auth/schema.ts`
```typescript
// Line 44: Remove required sfNumber
// Before:
sfNumber: z.string().min(6, "Customer number must be at least 6 characters"),
// After:
sfNumber: z.string().optional(),
```
Also update `validateSignupRequestSchema` to not require sfNumber.
### 7.2 Update SignupForm Components
- `SignupForm.tsx` - Remove sfNumber from initialValues and validation
- `AccountStep.tsx` - Remove Customer Number form field
- `ReviewStep.tsx` - Remove Customer Number display
---
## Phase 8: Rebuild and Test
### 8.1 Rebuild Domain Package
```bash
pnpm --filter @customer-portal/domain build
```
### 8.2 Test Matrix
| Scenario | URL | Expected |
|----------|-----|----------|
| Public homepage | `/` | PublicShell, homepage content |
| Public shop | `/shop` | CatalogShell (auth-aware), products |
| Auth user in shop | `/shop` | "My Account" button, personalized pricing |
| Public help | `/help` | FAQ content |
| Public contact | `/contact` | Contact form, prefills if logged in |
| Login | `/auth/login` | Login form |
| Signup | `/auth/signup` | No sfNumber field |
| Account dashboard | `/account` | AppShell, dashboard (redirect if not auth) |
| My services | `/account/services` | Subscriptions list |
| My tickets | `/account/support` | Support cases |
| Checkout | `/order` | CheckoutShell, wizard |
---
## Files Summary
| Category | Action | Count |
|----------|--------|-------|
| New account/ routes | Create | ~15 files |
| Shell components | Modify | 2 (PublicShell, CatalogShell) |
| Shop layout | Modify | 1 |
| Navigation | Modify | 1 |
| Link updates | Modify | ~20 files |
| Domain schema | Modify | 1 |
| Signup components | Modify | 3 |
| Delete old routes | Delete | ~20 files |
**Total: ~60+ file operations**

View File

@ -14,6 +14,7 @@ import { UsersFacade } from "@bff/modules/users/application/users.facade.js";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js"; import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js";
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service.js"; import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service.js";
import { SalesforceAccountService } from "@bff/integrations/salesforce/services/salesforce-account.service.js";
import { PrismaService } from "@bff/infra/database/prisma.service.js"; import { PrismaService } from "@bff/infra/database/prisma.service.js";
import { AuthTokenService } from "../../token/token.service.js"; import { AuthTokenService } from "../../token/token.service.js";
import { AuthRateLimitService } from "../../rate-limiting/auth-rate-limit.service.js"; import { AuthRateLimitService } from "../../rate-limiting/auth-rate-limit.service.js";
@ -55,6 +56,7 @@ export class SignupWorkflowService {
private readonly mappingsService: MappingsService, private readonly mappingsService: MappingsService,
private readonly whmcsService: WhmcsService, private readonly whmcsService: WhmcsService,
private readonly salesforceService: SalesforceService, private readonly salesforceService: SalesforceService,
private readonly salesforceAccountService: SalesforceAccountService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly auditService: AuditService, private readonly auditService: AuditService,
@ -66,14 +68,30 @@ export class SignupWorkflowService {
async validateSignup(validateData: ValidateSignupRequest, request?: Request) { async validateSignup(validateData: ValidateSignupRequest, request?: Request) {
const { sfNumber } = validateData; const { sfNumber } = validateData;
const normalizedCustomerNumber = this.normalizeCustomerNumber(sfNumber);
if (!normalizedCustomerNumber) {
await this.auditService.logAuthEvent(
AuditAction.SIGNUP,
undefined,
{ sfNumber: sfNumber ?? null, reason: "no_customer_number_provided" },
request,
true
);
return {
valid: true,
message: "Customer number is not required for signup",
};
}
try { try {
const accountSnapshot = await this.getAccountSnapshot(sfNumber); const accountSnapshot = await this.getAccountSnapshot(normalizedCustomerNumber);
if (!accountSnapshot) { if (!accountSnapshot) {
await this.auditService.logAuthEvent( await this.auditService.logAuthEvent(
AuditAction.SIGNUP, AuditAction.SIGNUP,
undefined, undefined,
{ sfNumber, reason: "SF number not found" }, { sfNumber: normalizedCustomerNumber, reason: "SF number not found" },
request, request,
false, false,
"Customer number not found in Salesforce" "Customer number not found in Salesforce"
@ -118,7 +136,7 @@ export class SignupWorkflowService {
await this.auditService.logAuthEvent( await this.auditService.logAuthEvent(
AuditAction.SIGNUP, AuditAction.SIGNUP,
undefined, undefined,
{ sfNumber, sfAccountId: accountSnapshot.id, step: "validation" }, { sfNumber: normalizedCustomerNumber, sfAccountId: accountSnapshot.id, step: "validation" },
request, request,
true true
); );
@ -136,7 +154,7 @@ export class SignupWorkflowService {
await this.auditService.logAuthEvent( await this.auditService.logAuthEvent(
AuditAction.SIGNUP, AuditAction.SIGNUP,
undefined, undefined,
{ sfNumber, error: getErrorMessage(error) }, { sfNumber: normalizedCustomerNumber, error: getErrorMessage(error) },
request, request,
false, false,
getErrorMessage(error) getErrorMessage(error)
@ -189,19 +207,89 @@ export class SignupWorkflowService {
const passwordHash = await argon2.hash(password); const passwordHash = await argon2.hash(password);
try { try {
const accountSnapshot = await this.getAccountSnapshot(sfNumber); const normalizedCustomerNumber = this.normalizeCustomerNumber(sfNumber);
if (!accountSnapshot) { let accountSnapshot: SignupAccountSnapshot;
let customerNumberForWhmcs: string | null = normalizedCustomerNumber;
if (normalizedCustomerNumber) {
const resolved = await this.getAccountSnapshot(normalizedCustomerNumber);
if (!resolved) {
throw new BadRequestException( throw new BadRequestException(
`Salesforce account not found for Customer Number: ${sfNumber}` `Salesforce account not found for Customer Number: ${normalizedCustomerNumber}`
); );
} }
if (accountSnapshot.WH_Account__c && accountSnapshot.WH_Account__c.trim() !== "") { if (resolved.WH_Account__c && resolved.WH_Account__c.trim() !== "") {
throw new ConflictException( throw new ConflictException(
"You already have an account. Please use the login page to access your existing account." "You already have an account. Please use the login page to access your existing account."
); );
} }
accountSnapshot = resolved;
} else {
const normalizedEmail = email.toLowerCase().trim();
const existingAccount = await this.salesforceAccountService.findByEmail(normalizedEmail);
if (existingAccount) {
throw new ConflictException(
"An account already exists for this email. Please sign in or transfer your account."
);
}
if (
!address?.address1 ||
!address?.city ||
!address?.state ||
!address?.postcode ||
!address?.country
) {
throw new BadRequestException(
"Complete address information is required for account creation"
);
}
if (!phone) {
throw new BadRequestException("Phone number is required for account creation");
}
const created = await this.salesforceAccountService.createAccount({
firstName,
lastName,
email: normalizedEmail,
phone,
address: {
address1: address.address1,
address2: address.address2 || undefined,
city: address.city,
state: address.state,
postcode: address.postcode,
country: address.country,
},
});
await this.salesforceAccountService.createContact({
accountId: created.accountId,
firstName,
lastName,
email: normalizedEmail,
phone,
address: {
address1: address.address1,
address2: address.address2 || undefined,
city: address.city,
state: address.state,
postcode: address.postcode,
country: address.country,
},
});
accountSnapshot = {
id: created.accountId,
Name: `${firstName} ${lastName}`,
WH_Account__c: null,
};
customerNumberForWhmcs = created.accountNumber;
}
let whmcsClient: { clientId: number }; let whmcsClient: { clientId: number };
try { try {
try { try {
@ -232,7 +320,9 @@ export class SignupWorkflowService {
const nationalityFieldId = this.configService.get<string>("WHMCS_NATIONALITY_FIELD_ID"); const nationalityFieldId = this.configService.get<string>("WHMCS_NATIONALITY_FIELD_ID");
const customfieldsMap: Record<string, string> = {}; const customfieldsMap: Record<string, string> = {};
if (customerNumberFieldId) customfieldsMap[customerNumberFieldId] = sfNumber; if (customerNumberFieldId && customerNumberForWhmcs) {
customfieldsMap[customerNumberFieldId] = customerNumberForWhmcs;
}
if (dobFieldId && dateOfBirth) customfieldsMap[dobFieldId] = dateOfBirth; if (dobFieldId && dateOfBirth) customfieldsMap[dobFieldId] = dateOfBirth;
if (genderFieldId && gender) customfieldsMap[genderFieldId] = gender; if (genderFieldId && gender) customfieldsMap[genderFieldId] = gender;
if (nationalityFieldId && nationality) customfieldsMap[nationalityFieldId] = nationality; if (nationalityFieldId && nationality) customfieldsMap[nationalityFieldId] = nationality;
@ -253,7 +343,12 @@ export class SignupWorkflowService {
throw new BadRequestException("Phone number is required for billing account creation"); throw new BadRequestException("Phone number is required for billing account creation");
} }
this.logger.log("Creating WHMCS client", { email, firstName, lastName, sfNumber }); this.logger.log("Creating WHMCS client", {
email,
firstName,
lastName,
sfNumber: customerNumberForWhmcs,
});
whmcsClient = await this.whmcsService.addClient({ whmcsClient = await this.whmcsService.addClient({
firstname: firstName, firstname: firstName,
@ -399,6 +494,7 @@ export class SignupWorkflowService {
async signupPreflight(signupData: SignupRequest) { async signupPreflight(signupData: SignupRequest) {
const { email, sfNumber } = signupData; const { email, sfNumber } = signupData;
const normalizedEmail = email.toLowerCase().trim(); const normalizedEmail = email.toLowerCase().trim();
const normalizedCustomerNumber = this.normalizeCustomerNumber(sfNumber);
const result: { const result: {
ok: boolean; ok: boolean;
@ -440,7 +536,59 @@ export class SignupWorkflowService {
return result; return result;
} }
const accountSnapshot = await this.getAccountSnapshot(sfNumber); if (!normalizedCustomerNumber) {
try {
const client = await this.whmcsService.getClientDetailsByEmail(normalizedEmail);
if (client) {
result.whmcs.clientExists = true;
result.whmcs.clientId = client.id;
try {
const mapped = await this.mappingsService.findByWhmcsClientId(client.id);
if (mapped) {
result.nextAction = "login";
result.messages.push("This billing account is already linked. Please sign in.");
return result;
}
} catch {
// ignore; treat as unmapped
}
result.nextAction = "link_whmcs";
result.messages.push(
"We found an existing billing account for this email. Please transfer your account."
);
return result;
}
} catch (err) {
if (!(err instanceof NotFoundException)) {
this.logger.warn("WHMCS preflight check failed", { error: getErrorMessage(err) });
result.messages.push("Unable to verify billing system. Please try again later.");
result.nextAction = "blocked";
return result;
}
}
try {
const existingSf = await this.salesforceAccountService.findByEmail(normalizedEmail);
if (existingSf) {
result.nextAction = "blocked";
result.messages.push(
"We found an existing customer record for this email. Please transfer your account or contact support."
);
return result;
}
} catch (sfErr) {
this.logger.warn("Salesforce preflight check failed", { error: getErrorMessage(sfErr) });
}
result.canProceed = true;
result.nextAction = "proceed_signup";
result.messages.push("All checks passed. Ready to create your account.");
return result;
}
const accountSnapshot = await this.getAccountSnapshot(normalizedCustomerNumber);
if (!accountSnapshot) { if (!accountSnapshot) {
result.nextAction = "fix_input"; result.nextAction = "fix_input";
result.messages.push("Customer number not found in Salesforce"); result.messages.push("Customer number not found in Salesforce");
@ -494,7 +642,9 @@ export class SignupWorkflowService {
return result; return result;
} }
private async getAccountSnapshot(sfNumber: string): Promise<SignupAccountSnapshot | null> { private async getAccountSnapshot(
sfNumber?: string | null
): Promise<SignupAccountSnapshot | null> {
const normalized = this.normalizeCustomerNumber(sfNumber); const normalized = this.normalizeCustomerNumber(sfNumber);
if (!normalized) { if (!normalized) {
return null; return null;
@ -519,7 +669,7 @@ export class SignupWorkflowService {
return resolved; return resolved;
} }
private normalizeCustomerNumber(sfNumber: string): string | null { private normalizeCustomerNumber(sfNumber?: string | null): string | null {
if (typeof sfNumber !== "string") { if (typeof sfNumber !== "string") {
return null; return null;
} }

View File

@ -1,12 +1,25 @@
import { Body, Controller, Post, Request, UsePipes, Inject, UseGuards } from "@nestjs/common"; import {
Body,
Controller,
Get,
Param,
Post,
Request,
UseGuards,
UsePipes,
Inject,
} from "@nestjs/common";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { ZodValidationPipe } from "nestjs-zod"; import { ZodValidationPipe } from "nestjs-zod";
import { Public } from "@bff/modules/auth/decorators/public.decorator.js"; import { Public } from "@bff/modules/auth/decorators/public.decorator.js";
import { CheckoutService } from "../services/checkout.service.js"; import { CheckoutService } from "../services/checkout.service.js";
import { CheckoutSessionService } from "../services/checkout-session.service.js";
import { import {
checkoutItemSchema,
checkoutCartSchema, checkoutCartSchema,
checkoutBuildCartRequestSchema, checkoutBuildCartRequestSchema,
checkoutBuildCartResponseSchema, checkoutBuildCartResponseSchema,
checkoutTotalsSchema,
} from "@customer-portal/domain/orders"; } from "@customer-portal/domain/orders";
import type { CheckoutCart, CheckoutBuildCartRequest } from "@customer-portal/domain/orders"; import type { CheckoutCart, CheckoutBuildCartRequest } from "@customer-portal/domain/orders";
import { apiSuccessResponseSchema } from "@customer-portal/domain/common"; import { apiSuccessResponseSchema } from "@customer-portal/domain/common";
@ -15,12 +28,28 @@ import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js"; import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js";
const validateCartResponseSchema = apiSuccessResponseSchema(z.object({ valid: z.boolean() })); const validateCartResponseSchema = apiSuccessResponseSchema(z.object({ valid: z.boolean() }));
const checkoutSessionIdParamSchema = z.object({ sessionId: z.string().uuid() });
const checkoutCartSummarySchema = z.object({
items: z.array(checkoutItemSchema),
totals: checkoutTotalsSchema,
});
const checkoutSessionResponseSchema = apiSuccessResponseSchema(
z.object({
sessionId: z.string().uuid(),
expiresAt: z.string(),
orderType: z.enum(["Internet", "SIM", "VPN", "Other"]),
cart: checkoutCartSummarySchema,
})
);
@Controller("checkout") @Controller("checkout")
@Public() // Cart building and validation can be done without authentication @Public() // Cart building and validation can be done without authentication
export class CheckoutController { export class CheckoutController {
constructor( constructor(
private readonly checkoutService: CheckoutService, private readonly checkoutService: CheckoutService,
private readonly checkoutSessions: CheckoutSessionService,
@Inject(Logger) private readonly logger: Logger @Inject(Logger) private readonly logger: Logger
) {} ) {}
@ -55,6 +84,61 @@ export class CheckoutController {
} }
} }
/**
* Create a short-lived checkout session to avoid trusting client-side state.
* This returns a cart summary (items + totals) and stores the full request+cart server-side.
*/
@Post("session")
@UseGuards(SalesforceReadThrottleGuard)
@UsePipes(new ZodValidationPipe(checkoutBuildCartRequestSchema))
async createSession(@Request() req: RequestWithUser, @Body() body: CheckoutBuildCartRequest) {
this.logger.log("Creating checkout session", {
userId: req.user?.id,
orderType: body.orderType,
});
const cart = await this.checkoutService.buildCart(
body.orderType,
body.selections,
body.configuration,
req.user?.id
);
const session = await this.checkoutSessions.createSession(body, cart);
return checkoutSessionResponseSchema.parse({
success: true,
data: {
sessionId: session.sessionId,
expiresAt: session.expiresAt,
orderType: body.orderType,
cart: {
items: cart.items,
totals: cart.totals,
},
},
});
}
@Get("session/:sessionId")
@UseGuards(SalesforceReadThrottleGuard)
@UsePipes(new ZodValidationPipe(checkoutSessionIdParamSchema))
async getSession(@Param() params: { sessionId: string }) {
const session = await this.checkoutSessions.getSession(params.sessionId);
return checkoutSessionResponseSchema.parse({
success: true,
data: {
sessionId: params.sessionId,
expiresAt: session.expiresAt,
orderType: session.request.orderType,
cart: {
items: session.cart.items,
totals: session.cart.totals,
},
},
});
}
@Post("validate") @Post("validate")
@UsePipes(new ZodValidationPipe(checkoutCartSchema)) @UsePipes(new ZodValidationPipe(checkoutCartSchema))
validateCart(@Body() cart: CheckoutCart) { validateCart(@Body() cart: CheckoutCart) {

View File

@ -29,12 +29,21 @@ import { Observable } from "rxjs";
import { OrderEventsService } from "./services/order-events.service.js"; import { OrderEventsService } from "./services/order-events.service.js";
import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js"; import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js";
import { SalesforceWriteThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-write-throttle.guard.js"; import { SalesforceWriteThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-write-throttle.guard.js";
import { CheckoutService } from "./services/checkout.service.js";
import { CheckoutSessionService } from "./services/checkout-session.service.js";
import { z } from "zod";
const checkoutSessionCreateOrderSchema = z.object({
checkoutSessionId: z.string().uuid(),
});
@Controller("orders") @Controller("orders")
@UseGuards(RateLimitGuard) @UseGuards(RateLimitGuard)
export class OrdersController { export class OrdersController {
constructor( constructor(
private orderOrchestrator: OrderOrchestrator, private orderOrchestrator: OrderOrchestrator,
private readonly checkoutService: CheckoutService,
private readonly checkoutSessions: CheckoutSessionService,
private readonly orderEvents: OrderEventsService, private readonly orderEvents: OrderEventsService,
private readonly logger: Logger private readonly logger: Logger
) {} ) {}
@ -71,6 +80,58 @@ export class OrdersController {
} }
} }
@Post("from-checkout-session")
@UseGuards(SalesforceWriteThrottleGuard)
@RateLimit({ limit: 5, ttl: 60 }) // 5 order creations per minute
@UsePipes(new ZodValidationPipe(checkoutSessionCreateOrderSchema))
async createFromCheckoutSession(
@Request() req: RequestWithUser,
@Body() body: { checkoutSessionId: string }
) {
this.logger.log(
{
userId: req.user?.id,
checkoutSessionId: body.checkoutSessionId,
},
"Order creation from checkout session request received"
);
const session = await this.checkoutSessions.getSession(body.checkoutSessionId);
const cart = await this.checkoutService.buildCart(
session.request.orderType,
session.request.selections,
session.request.configuration,
req.user?.id
);
const uniqueSkus = Array.from(
new Set(
cart.items
.map(item => item.sku)
.filter((sku): sku is string => typeof sku === "string" && sku.trim().length > 0)
)
);
if (uniqueSkus.length === 0) {
throw new NotFoundException("Checkout session contains no items");
}
const orderBody: CreateOrderRequest = {
orderType: session.request.orderType,
skus: uniqueSkus,
...(Object.keys(cart.configuration ?? {}).length > 0
? { configurations: cart.configuration }
: {}),
};
const result = await this.orderOrchestrator.createOrder(req.user.id, orderBody);
await this.checkoutSessions.deleteSession(body.checkoutSessionId);
return this.createOrderResponseSchema.parse({ success: true, data: result });
}
@Get("user") @Get("user")
@UseGuards(SalesforceReadThrottleGuard) @UseGuards(SalesforceReadThrottleGuard)
async getUserOrders(@Request() req: RequestWithUser) { async getUserOrders(@Request() req: RequestWithUser) {

View File

@ -17,6 +17,7 @@ import { OrderPricebookService } from "./services/order-pricebook.service.js";
import { OrderOrchestrator } from "./services/order-orchestrator.service.js"; import { OrderOrchestrator } from "./services/order-orchestrator.service.js";
import { PaymentValidatorService } from "./services/payment-validator.service.js"; import { PaymentValidatorService } from "./services/payment-validator.service.js";
import { CheckoutService } from "./services/checkout.service.js"; import { CheckoutService } from "./services/checkout.service.js";
import { CheckoutSessionService } from "./services/checkout-session.service.js";
import { OrderEventsService } from "./services/order-events.service.js"; import { OrderEventsService } from "./services/order-events.service.js";
import { OrdersCacheService } from "./services/orders-cache.service.js"; import { OrdersCacheService } from "./services/orders-cache.service.js";
@ -54,6 +55,7 @@ import { OrderFieldConfigModule } from "./config/order-field-config.module.js";
OrderOrchestrator, OrderOrchestrator,
OrdersCacheService, OrdersCacheService,
CheckoutService, CheckoutService,
CheckoutSessionService,
// Order fulfillment services (modular) // Order fulfillment services (modular)
OrderFulfillmentValidator, OrderFulfillmentValidator,

View File

@ -1,7 +0,0 @@
"use client";
import ProfileContainer from "@/features/account/views/ProfileContainer";
export default function AccountPage() {
return <ProfileContainer />;
}

View File

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

View File

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

View File

@ -1,20 +0,0 @@
import { RouteLoading } from "@/components/molecules/RouteLoading";
import { Squares2X2Icon } from "@heroicons/react/24/outline";
import { LoadingCard } from "@/components/atoms/loading-skeleton";
export default function CatalogLoading() {
return (
<RouteLoading
icon={<Squares2X2Icon />}
title="Catalog"
description="Loading catalog..."
mode="content"
>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{Array.from({ length: 6 }).map((_, i) => (
<LoadingCard key={i} />
))}
</div>
</RouteLoading>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,23 +0,0 @@
import { RouteLoading } from "@/components/molecules/RouteLoading";
import { ShieldCheckIcon } from "@heroicons/react/24/outline";
import { LoadingCard, Skeleton } from "@/components/atoms/loading-skeleton";
export default function CheckoutLoading() {
return (
<RouteLoading
icon={<ShieldCheckIcon />}
title="Checkout"
description="Verifying details and preparing your order..."
mode="content"
>
<div className="max-w-2xl mx-auto space-y-6">
<LoadingCard />
<LoadingCard />
<div className="flex gap-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
</div>
</RouteLoading>
);
}

View File

@ -1,5 +0,0 @@
import CheckoutContainer from "@/features/checkout/views/CheckoutContainer";
export default function CheckoutPage() {
return <CheckoutContainer />;
}

View File

@ -1,28 +0,0 @@
import { RouteLoading } from "@/components/molecules/RouteLoading";
import { HomeIcon } from "@heroicons/react/24/outline";
import { LoadingStats, LoadingCard } from "@/components/atoms/loading-skeleton";
export default function DashboardLoading() {
return (
<RouteLoading
icon={<HomeIcon />}
title="Dashboard"
description="Loading your overview..."
mode="content"
>
<div className="space-y-6">
<LoadingStats count={4} />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
<LoadingCard />
<LoadingCard />
</div>
<div className="space-y-6">
<LoadingCard />
<LoadingCard />
</div>
</div>
</div>
</RouteLoading>
);
}

View File

@ -1,116 +0,0 @@
import { RouteLoading } from "@/components/molecules/RouteLoading";
import { ClipboardDocumentCheckIcon, ArrowLeftIcon } from "@heroicons/react/24/outline";
import { Button } from "@/components/atoms/button";
export default function OrderDetailLoading() {
return (
<RouteLoading
icon={<ClipboardDocumentCheckIcon />}
title="Order Details"
description="Loading order details..."
mode="content"
>
<div className="mb-6">
<Button
as="a"
href="/orders"
size="sm"
variant="ghost"
leftIcon={<ArrowLeftIcon className="h-4 w-4" />}
className="text-gray-600 hover:text-gray-900"
>
Back to orders
</Button>
</div>
<div className="animate-pulse">
<div className="rounded-3xl border border-slate-200 bg-white shadow-sm">
{/* Header Section */}
<div className="border-b border-slate-200 bg-gradient-to-br from-white to-slate-50 px-6 py-6 sm:px-8">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
{/* Left: Title & Status */}
<div className="flex flex-col gap-2">
<div className="flex items-center gap-3">
<div className="h-8 w-48 rounded bg-slate-200" />
<div className="h-6 w-24 rounded-full bg-slate-200" />
</div>
<div className="h-4 w-64 rounded bg-slate-100" />
</div>
{/* Right: Pricing Section */}
<div className="flex items-start gap-6 sm:gap-8">
<div className="text-right space-y-2">
<div className="h-3 w-16 rounded bg-slate-100" />
<div className="h-9 w-24 rounded bg-slate-200" />
</div>
<div className="text-right space-y-2">
<div className="h-3 w-16 rounded bg-slate-100" />
<div className="h-9 w-24 rounded bg-slate-200" />
</div>
</div>
</div>
</div>
{/* Body Section */}
<div className="px-6 py-6 sm:px-8">
<div className="flex flex-col gap-6">
{/* Order Items Section */}
<div>
<div className="mb-2 h-3 w-32 rounded bg-slate-200" />
<div className="space-y-4">
{/* Item 1 */}
<div className="flex flex-col gap-3 rounded-xl border border-slate-200 bg-white px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-1 items-start gap-3">
<div className="h-6 w-6 rounded-lg bg-slate-100 flex-shrink-0" />
<div className="flex flex-1 items-baseline justify-between gap-4">
<div className="min-w-0 flex-1 space-y-2">
<div className="h-5 w-3/4 rounded bg-slate-200" />
<div className="h-3 w-24 rounded bg-slate-100" />
</div>
<div className="flex flex-col items-end gap-1">
<div className="h-6 w-28 rounded bg-slate-200" />
</div>
</div>
</div>
</div>
{/* Item 2 */}
<div className="flex flex-col gap-3 rounded-xl border border-slate-200 bg-white px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-1 items-start gap-3">
<div className="h-6 w-6 rounded-lg bg-slate-100 flex-shrink-0" />
<div className="flex flex-1 items-baseline justify-between gap-4">
<div className="min-w-0 flex-1 space-y-2">
<div className="h-5 w-2/3 rounded bg-slate-200" />
<div className="h-3 w-20 rounded bg-slate-100" />
</div>
<div className="flex flex-col items-end gap-1">
<div className="h-6 w-24 rounded bg-slate-200" />
</div>
</div>
</div>
</div>
{/* Item 3 */}
<div className="flex flex-col gap-3 rounded-xl border border-slate-200 bg-white px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-1 items-start gap-3">
<div className="h-6 w-6 rounded-lg bg-slate-100 flex-shrink-0" />
<div className="flex flex-1 items-baseline justify-between gap-4">
<div className="min-w-0 flex-1 space-y-2">
<div className="h-5 w-1/2 rounded bg-slate-200" />
<div className="h-3 w-16 rounded bg-slate-100" />
</div>
<div className="flex flex-col items-end gap-1">
<div className="h-6 w-20 rounded bg-slate-200" />
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</RouteLoading>
);
}

View File

@ -1,12 +0,0 @@
import { SupportHomeView } from "@/features/support";
import { AgentforceWidget } from "@/components";
export default function SupportPage() {
return (
<>
<SupportHomeView />
<AgentforceWidget />
</>
);
}

View File

@ -6,6 +6,6 @@
import { PublicContactView } from "@/features/support/views/PublicContactView"; import { PublicContactView } from "@/features/support/views/PublicContactView";
export default function PublicContactPage() { export default function ContactPage() {
return <PublicContactView />; return <PublicContactView />;
} }

View File

@ -4,8 +4,8 @@
* Multi-step checkout wizard for completing orders. * Multi-step checkout wizard for completing orders.
*/ */
import { CheckoutWizard } from "@/features/checkout/components/CheckoutWizard"; import { CheckoutEntry } from "@/features/checkout/components/CheckoutEntry";
export default function CheckoutPage() { export default function CheckoutPage() {
return <CheckoutWizard />; return <CheckoutEntry />;
} }

View File

@ -4,8 +4,13 @@
* Layout for public catalog pages with catalog-specific navigation. * Layout for public catalog pages with catalog-specific navigation.
*/ */
import { CatalogShell } from "@/components/templates"; import { CatalogNav } from "@/components/templates/CatalogShell";
export default function CatalogLayout({ children }: { children: React.ReactNode }) { export default function CatalogLayout({ children }: { children: React.ReactNode }) {
return <CatalogShell>{children}</CatalogShell>; return (
<>
<CatalogNav />
{children}
</>
);
} }

View File

@ -0,0 +1,31 @@
"use client";
import { useEffect } from "react";
import { usePathname, useRouter } from "next/navigation";
import { useAuthStore } from "@/features/auth/services/auth.store";
export function AccountRouteGuard() {
const router = useRouter();
const pathname = usePathname();
const isAuthenticated = useAuthStore(state => state.isAuthenticated);
const hasCheckedAuth = useAuthStore(state => state.hasCheckedAuth);
const loading = useAuthStore(state => state.loading);
const checkAuth = useAuthStore(state => state.checkAuth);
useEffect(() => {
if (!hasCheckedAuth) {
void checkAuth();
}
}, [checkAuth, hasCheckedAuth]);
useEffect(() => {
if (!hasCheckedAuth || loading || isAuthenticated) {
return;
}
const destination = pathname || "/account";
router.replace(`/auth/login?redirect=${encodeURIComponent(destination)}`);
}, [hasCheckedAuth, isAuthenticated, loading, pathname, router]);
return null;
}

View File

@ -2,7 +2,7 @@ import { RouteLoading } from "@/components/molecules/RouteLoading";
import { CreditCardIcon } from "@heroicons/react/24/outline"; import { CreditCardIcon } from "@heroicons/react/24/outline";
import { LoadingCard, Skeleton } from "@/components/atoms/loading-skeleton"; import { LoadingCard, Skeleton } from "@/components/atoms/loading-skeleton";
export default function InvoiceDetailLoading() { export default function AccountInvoiceDetailLoading() {
return ( return (
<RouteLoading <RouteLoading
icon={<CreditCardIcon />} icon={<CreditCardIcon />}

View File

@ -1,5 +1,5 @@
import InvoiceDetailContainer from "@/features/billing/views/InvoiceDetail"; import InvoiceDetailContainer from "@/features/billing/views/InvoiceDetail";
export default function InvoiceDetailPage() { export default function AccountInvoiceDetailPage() {
return <InvoiceDetailContainer />; return <InvoiceDetailContainer />;
} }

View File

@ -2,7 +2,7 @@ import { RouteLoading } from "@/components/molecules/RouteLoading";
import { CreditCardIcon } from "@heroicons/react/24/outline"; import { CreditCardIcon } from "@heroicons/react/24/outline";
import { LoadingTable } from "@/components/atoms/loading-skeleton"; import { LoadingTable } from "@/components/atoms/loading-skeleton";
export default function InvoicesLoading() { export default function AccountInvoicesLoading() {
return ( return (
<RouteLoading <RouteLoading
icon={<CreditCardIcon />} icon={<CreditCardIcon />}

View File

@ -1,5 +1,5 @@
import InvoicesListContainer from "@/features/billing/views/InvoicesList"; import InvoicesListContainer from "@/features/billing/views/InvoicesList";
export default function InvoicesPage() { export default function AccountInvoicesPage() {
return <InvoicesListContainer />; return <InvoicesListContainer />;
} }

View File

@ -2,7 +2,7 @@ import { RouteLoading } from "@/components/molecules/RouteLoading";
import { CreditCardIcon } from "@heroicons/react/24/outline"; import { CreditCardIcon } from "@heroicons/react/24/outline";
import { LoadingCard } from "@/components/atoms/loading-skeleton"; import { LoadingCard } from "@/components/atoms/loading-skeleton";
export default function PaymentsLoading() { export default function AccountPaymentsLoading() {
return ( return (
<RouteLoading <RouteLoading
icon={<CreditCardIcon />} icon={<CreditCardIcon />}

View File

@ -1,5 +1,5 @@
import PaymentMethodsContainer from "@/features/billing/views/PaymentMethods"; import PaymentMethodsContainer from "@/features/billing/views/PaymentMethods";
export default function PaymentMethodsPage() { export default function AccountPaymentMethodsPage() {
return <PaymentMethodsContainer />; return <PaymentMethodsContainer />;
} }

View File

@ -1,10 +1,12 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { AppShell } from "@/components/organisms"; import { AppShell } from "@/components/organisms";
import { AccountEventsListener } from "@/features/realtime/components/AccountEventsListener"; import { AccountEventsListener } from "@/features/realtime/components/AccountEventsListener";
import { AccountRouteGuard } from "./AccountRouteGuard";
export default function PortalLayout({ children }: { children: ReactNode }) { export default function AccountLayout({ children }: { children: ReactNode }) {
return ( return (
<AppShell> <AppShell>
<AccountRouteGuard />
<AccountEventsListener /> <AccountEventsListener />
{children} {children}
</AppShell> </AppShell>

View File

@ -0,0 +1,83 @@
import { RouteLoading } from "@/components/molecules/RouteLoading";
import { ClipboardDocumentCheckIcon, ArrowLeftIcon } from "@heroicons/react/24/outline";
import { Button } from "@/components/atoms/button";
export default function AccountOrderDetailLoading() {
return (
<RouteLoading
icon={<ClipboardDocumentCheckIcon />}
title="Order Details"
description="Loading order details..."
mode="content"
>
<div className="mb-6">
<Button
as="a"
href="/account/orders"
size="sm"
variant="ghost"
leftIcon={<ArrowLeftIcon className="h-4 w-4" />}
className="text-gray-600 hover:text-gray-900"
>
Back to orders
</Button>
</div>
<div className="animate-pulse">
<div className="rounded-3xl border border-slate-200 bg-white shadow-sm">
<div className="border-b border-slate-200 bg-gradient-to-br from-white to-slate-50 px-6 py-6 sm:px-8">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-3">
<div className="h-8 w-48 rounded bg-slate-200" />
<div className="h-6 w-24 rounded-full bg-slate-200" />
</div>
<div className="h-4 w-64 rounded bg-slate-100" />
</div>
<div className="flex items-start gap-6 sm:gap-8">
<div className="text-right space-y-2">
<div className="h-3 w-16 rounded bg-slate-100" />
<div className="h-9 w-24 rounded bg-slate-200" />
</div>
<div className="text-right space-y-2">
<div className="h-3 w-16 rounded bg-slate-100" />
<div className="h-9 w-24 rounded bg-slate-200" />
</div>
</div>
</div>
</div>
<div className="px-6 py-6 sm:px-8">
<div className="flex flex-col gap-6">
<div>
<div className="mb-2 h-3 w-32 rounded bg-slate-200" />
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, idx) => (
<div
key={idx}
className="flex flex-col gap-3 rounded-xl border border-slate-200 bg-white px-4 py-4 sm:flex-row sm:items-center sm:justify-between"
>
<div className="flex flex-1 items-start gap-3">
<div className="h-6 w-6 rounded-lg bg-slate-100 flex-shrink-0" />
<div className="flex flex-1 items-baseline justify-between gap-4">
<div className="min-w-0 flex-1 space-y-2">
<div className="h-5 w-3/4 rounded bg-slate-200" />
<div className="h-3 w-24 rounded bg-slate-100" />
</div>
<div className="flex flex-col items-end gap-1">
<div className="h-6 w-28 rounded bg-slate-200" />
</div>
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
</div>
</RouteLoading>
);
}

View File

@ -1,5 +1,5 @@
import OrderDetailContainer from "@/features/orders/views/OrderDetail"; import OrderDetailContainer from "@/features/orders/views/OrderDetail";
export default function OrderDetailPage() { export default function AccountOrderDetailPage() {
return <OrderDetailContainer />; return <OrderDetailContainer />;
} }

View File

@ -2,11 +2,11 @@ import { RouteLoading } from "@/components/molecules/RouteLoading";
import { ClipboardDocumentListIcon } from "@heroicons/react/24/outline"; import { ClipboardDocumentListIcon } from "@heroicons/react/24/outline";
import { OrderCardSkeleton } from "@/features/orders/components/OrderCardSkeleton"; import { OrderCardSkeleton } from "@/features/orders/components/OrderCardSkeleton";
export default function OrdersLoading() { export default function AccountOrdersLoading() {
return ( return (
<RouteLoading <RouteLoading
icon={<ClipboardDocumentListIcon />} icon={<ClipboardDocumentListIcon />}
title="My Orders" title="Orders"
description="View and track all your orders" description="View and track all your orders"
mode="content" mode="content"
> >

View File

@ -1,5 +1,5 @@
import OrdersListContainer from "@/features/orders/views/OrdersList"; import OrdersListContainer from "@/features/orders/views/OrdersList";
export default function OrdersPage() { export default function AccountOrdersPage() {
return <OrdersListContainer />; return <OrdersListContainer />;
} }

View File

@ -1,5 +1,5 @@
import { DashboardView } from "@/features/dashboard"; import { DashboardView } from "@/features/dashboard";
export default function DashboardPage() { export default function AccountDashboardPage() {
return <DashboardView />; return <DashboardView />;
} }

View File

@ -2,12 +2,12 @@ import { RouteLoading } from "@/components/molecules/RouteLoading";
import { ServerIcon } from "@heroicons/react/24/outline"; import { ServerIcon } from "@heroicons/react/24/outline";
import { LoadingCard } from "@/components/atoms/loading-skeleton"; import { LoadingCard } from "@/components/atoms/loading-skeleton";
export default function SubscriptionDetailLoading() { export default function AccountServiceDetailLoading() {
return ( return (
<RouteLoading <RouteLoading
icon={<ServerIcon />} icon={<ServerIcon />}
title="Subscription" title="Service"
description="Subscription details" description="Service details"
mode="content" mode="content"
> >
<div className="space-y-4"> <div className="space-y-4">

View File

@ -1,5 +1,5 @@
import SubscriptionDetailContainer from "@/features/subscriptions/views/SubscriptionDetail"; import SubscriptionDetailContainer from "@/features/subscriptions/views/SubscriptionDetail";
export default function SubscriptionDetailPage() { export default function AccountServiceDetailPage() {
return <SubscriptionDetailContainer />; return <SubscriptionDetailContainer />;
} }

View File

@ -1,6 +1,5 @@
import SimCallHistoryContainer from "@/features/subscriptions/views/SimCallHistory"; import SimCallHistoryContainer from "@/features/subscriptions/views/SimCallHistory";
export default function SimCallHistoryPage() { export default function AccountSimCallHistoryPage() {
return <SimCallHistoryContainer />; return <SimCallHistoryContainer />;
} }

View File

@ -1,5 +1,5 @@
import SimCancelContainer from "@/features/subscriptions/views/SimCancel"; import SimCancelContainer from "@/features/subscriptions/views/SimCancel";
export default function SimCancelPage() { export default function AccountSimCancelPage() {
return <SimCancelContainer />; return <SimCancelContainer />;
} }

View File

@ -1,5 +1,5 @@
import SimChangePlanContainer from "@/features/subscriptions/views/SimChangePlan"; import SimChangePlanContainer from "@/features/subscriptions/views/SimChangePlan";
export default function SimChangePlanPage() { export default function AccountSimChangePlanPage() {
return <SimChangePlanContainer />; return <SimChangePlanContainer />;
} }

View File

@ -1,5 +1,5 @@
import SimReissueContainer from "@/features/subscriptions/views/SimReissue"; import SimReissueContainer from "@/features/subscriptions/views/SimReissue";
export default function SimReissuePage() { export default function AccountSimReissuePage() {
return <SimReissueContainer />; return <SimReissueContainer />;
} }

View File

@ -1,5 +1,5 @@
import SimTopUpContainer from "@/features/subscriptions/views/SimTopUp"; import SimTopUpContainer from "@/features/subscriptions/views/SimTopUp";
export default function SimTopUpPage() { export default function AccountSimTopUpPage() {
return <SimTopUpContainer />; return <SimTopUpContainer />;
} }

View File

@ -2,12 +2,12 @@ import { RouteLoading } from "@/components/molecules/RouteLoading";
import { ServerIcon } from "@heroicons/react/24/outline"; import { ServerIcon } from "@heroicons/react/24/outline";
import { LoadingTable } from "@/components/atoms/loading-skeleton"; import { LoadingTable } from "@/components/atoms/loading-skeleton";
export default function SubscriptionsLoading() { export default function AccountServicesLoading() {
return ( return (
<RouteLoading <RouteLoading
icon={<ServerIcon />} icon={<ServerIcon />}
title="Subscriptions" title="Services"
description="View and manage your subscriptions" description="View and manage your services"
mode="content" mode="content"
> >
<LoadingTable rows={6} columns={5} /> <LoadingTable rows={6} columns={5} />

View File

@ -1,5 +1,5 @@
import SubscriptionsListContainer from "@/features/subscriptions/views/SubscriptionsList"; import SubscriptionsListContainer from "@/features/subscriptions/views/SubscriptionsList";
export default function SubscriptionsPage() { export default function AccountServicesPage() {
return <SubscriptionsListContainer />; return <SubscriptionsListContainer />;
} }

View File

@ -2,11 +2,11 @@ import { RouteLoading } from "@/components/molecules/RouteLoading";
import { UserIcon } from "@heroicons/react/24/outline"; import { UserIcon } from "@heroicons/react/24/outline";
import { LoadingCard, Skeleton } from "@/components/atoms/loading-skeleton"; import { LoadingCard, Skeleton } from "@/components/atoms/loading-skeleton";
export default function AccountLoading() { export default function AccountSettingsLoading() {
return ( return (
<RouteLoading <RouteLoading
icon={<UserIcon />} icon={<UserIcon />}
title="Account" title="Settings"
description="Loading your profile..." description="Loading your profile..."
mode="content" mode="content"
> >

View File

@ -1,5 +1,5 @@
import ProfileContainer from "@/features/account/views/ProfileContainer"; import ProfileContainer from "@/features/account/views/ProfileContainer";
export default function ProfilePage() { export default function AccountSettingsPage() {
return <ProfileContainer />; return <ProfileContainer />;
} }

View File

@ -4,8 +4,7 @@ interface PageProps {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
} }
export default async function SupportCaseDetailPage({ params }: PageProps) { export default async function AccountSupportCaseDetailPage({ params }: PageProps) {
const { id } = await params; const { id } = await params;
return <SupportCaseDetailView caseId={id} />; return <SupportCaseDetailView caseId={id} />;
} }

View File

@ -2,7 +2,7 @@ import { RouteLoading } from "@/components/molecules/RouteLoading";
import { ChatBubbleLeftRightIcon } from "@heroicons/react/24/outline"; import { ChatBubbleLeftRightIcon } from "@heroicons/react/24/outline";
import { LoadingTable } from "@/components/atoms/loading-skeleton"; import { LoadingTable } from "@/components/atoms/loading-skeleton";
export default function SupportCasesLoading() { export default function AccountSupportLoading() {
return ( return (
<RouteLoading <RouteLoading
icon={<ChatBubbleLeftRightIcon />} icon={<ChatBubbleLeftRightIcon />}

View File

@ -2,7 +2,7 @@ import { RouteLoading } from "@/components/molecules/RouteLoading";
import { PencilSquareIcon } from "@heroicons/react/24/outline"; import { PencilSquareIcon } from "@heroicons/react/24/outline";
import { LoadingCard, Skeleton } from "@/components/atoms/loading-skeleton"; import { LoadingCard, Skeleton } from "@/components/atoms/loading-skeleton";
export default function NewSupportLoading() { export default function AccountSupportNewLoading() {
return ( return (
<RouteLoading <RouteLoading
icon={<PencilSquareIcon />} icon={<PencilSquareIcon />}

View File

@ -1,5 +1,5 @@
import { NewSupportCaseView } from "@/features/support"; import { NewSupportCaseView } from "@/features/support";
export default function NewSupportCasePage() { export default function AccountNewSupportCasePage() {
return <NewSupportCaseView />; return <NewSupportCaseView />;
} }

View File

@ -1,5 +1,5 @@
import { SupportCasesView } from "@/features/support"; import { SupportCasesView } from "@/features/support";
export default function SupportCasesPage() { export default function AccountSupportPage() {
return <SupportCasesView />; return <SupportCasesView />;
} }

View File

@ -67,9 +67,10 @@ export function AppShell({ children }: AppShellProps) {
useEffect(() => { useEffect(() => {
if (hasCheckedAuth && !isAuthenticated && !loading) { if (hasCheckedAuth && !isAuthenticated && !loading) {
router.push("/auth/login"); const destination = pathname || "/account";
router.push(`/auth/login?redirect=${encodeURIComponent(destination)}`);
} }
}, [hasCheckedAuth, isAuthenticated, loading, router]); }, [hasCheckedAuth, isAuthenticated, loading, pathname, router]);
// Hydrate full profile once after auth so header name is consistent across pages // Hydrate full profile once after auth so header name is consistent across pages
useEffect(() => { useEffect(() => {
@ -97,10 +98,10 @@ export function AppShell({ children }: AppShellProps) {
useEffect(() => { useEffect(() => {
setExpandedItems(prev => { setExpandedItems(prev => {
const next = new Set(prev); const next = new Set(prev);
if (pathname.startsWith("/subscriptions")) next.add("Subscriptions"); if (pathname.startsWith("/account/services")) next.add("My Services");
if (pathname.startsWith("/billing")) next.add("Billing"); if (pathname.startsWith("/account/billing")) next.add("Billing");
if (pathname.startsWith("/support")) next.add("Support"); if (pathname.startsWith("/account/support")) next.add("Support");
if (pathname.startsWith("/account")) next.add("Account"); if (pathname.startsWith("/account/settings")) next.add("Settings");
const result = Array.from(next); const result = Array.from(next);
// Avoid state update if unchanged // Avoid state update if unchanged
if (result.length === prev.length && result.every(v => prev.includes(v))) return prev; if (result.length === prev.length && result.every(v => prev.includes(v))) return prev;

View File

@ -39,7 +39,7 @@ export const Header = memo(function Header({ onMenuClick, user, profileReady }:
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Link <Link
href="/support" href="/account/support"
prefetch prefetch
aria-label="Help" aria-label="Help"
className="hidden sm:inline-flex p-2.5 rounded-xl text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-all duration-200" className="hidden sm:inline-flex p-2.5 rounded-xl text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-all duration-200"
@ -49,7 +49,7 @@ export const Header = memo(function Header({ onMenuClick, user, profileReady }:
</Link> </Link>
<Link <Link
href="/account" href="/account/settings"
prefetch prefetch
className="group flex items-center gap-2.5 px-3 py-1.5 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/60 rounded-xl transition-all duration-200" className="group flex items-center gap-2.5 px-3 py-1.5 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/60 rounded-xl transition-all duration-200"
> >

View File

@ -26,33 +26,33 @@ export interface NavigationItem {
} }
export const baseNavigation: NavigationItem[] = [ export const baseNavigation: NavigationItem[] = [
{ name: "Dashboard", href: "/dashboard", icon: HomeIcon }, { name: "Dashboard", href: "/account", icon: HomeIcon },
{ name: "Orders", href: "/orders", icon: ClipboardDocumentListIcon }, { name: "Orders", href: "/account/orders", icon: ClipboardDocumentListIcon },
{ {
name: "Billing", name: "Billing",
icon: CreditCardIcon, icon: CreditCardIcon,
children: [ children: [
{ name: "Invoices", href: "/billing/invoices" }, { name: "Invoices", href: "/account/billing/invoices" },
{ name: "Payment Methods", href: "/billing/payments" }, { name: "Payment Methods", href: "/account/billing/payments" },
], ],
}, },
{ {
name: "Subscriptions", name: "My Services",
icon: ServerIcon, icon: ServerIcon,
children: [{ name: "All Subscriptions", href: "/subscriptions" }], children: [{ name: "All Services", href: "/account/services" }],
}, },
{ name: "Catalog", href: "/catalog", icon: Squares2X2Icon }, { name: "Shop", href: "/shop", icon: Squares2X2Icon },
{ {
name: "Support", name: "Support",
icon: ChatBubbleLeftRightIcon, icon: ChatBubbleLeftRightIcon,
children: [ children: [
{ name: "Cases", href: "/support/cases" }, { name: "Cases", href: "/account/support" },
{ name: "New Case", href: "/support/new" }, { name: "New Case", href: "/account/support/new" },
], ],
}, },
{ {
name: "Account", name: "Settings",
href: "/account", href: "/account/settings",
icon: UserIcon, icon: UserIcon,
}, },
{ name: "Log out", href: "#", icon: ArrowRightStartOnRectangleIcon, isLogout: true }, { name: "Log out", href: "#", icon: ArrowRightStartOnRectangleIcon, isLogout: true },
@ -64,17 +64,17 @@ export function computeNavigation(activeSubscriptions?: Subscription[]): Navigat
children: item.children ? [...item.children] : undefined, children: item.children ? [...item.children] : undefined,
})); }));
const subIdx = nav.findIndex(n => n.name === "Subscriptions"); const subIdx = nav.findIndex(n => n.name === "My Services");
if (subIdx >= 0) { if (subIdx >= 0) {
const dynamicChildren = (activeSubscriptions || []).map(sub => ({ const dynamicChildren = (activeSubscriptions || []).map(sub => ({
name: truncate(sub.productName || `Subscription ${sub.id}`, 28), name: truncate(sub.productName || `Subscription ${sub.id}`, 28),
href: `/subscriptions/${sub.id}`, href: `/account/services/${sub.id}`,
tooltip: sub.productName || `Subscription ${sub.id}`, tooltip: sub.productName || `Subscription ${sub.id}`,
})); }));
nav[subIdx] = { nav[subIdx] = {
...nav[subIdx], ...nav[subIdx],
children: [{ name: "All Subscriptions", href: "/subscriptions" }, ...dynamicChildren], children: [{ name: "All Services", href: "/account/services" }, ...dynamicChildren],
}; };
} }

View File

@ -5,15 +5,64 @@
* Extends the PublicShell with catalog navigation tabs. * Extends the PublicShell with catalog navigation tabs.
*/ */
"use client";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useEffect } from "react";
import Link from "next/link"; import Link from "next/link";
import { Logo } from "@/components/atoms/logo"; import { Logo } from "@/components/atoms/logo";
import { useAuthStore } from "@/features/auth/services/auth.store";
export interface CatalogShellProps { export interface CatalogShellProps {
children: ReactNode; children: ReactNode;
} }
export function CatalogNav() {
return (
<div className="border-b border-border/50 bg-background/60 backdrop-blur-xl">
<div className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-page-padding)] py-2">
<nav className="flex items-center gap-1 overflow-x-auto">
<Link
href="/shop"
className="whitespace-nowrap inline-flex items-center rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
>
All Services
</Link>
<Link
href="/shop/internet"
className="whitespace-nowrap inline-flex items-center rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
>
Internet
</Link>
<Link
href="/shop/sim"
className="whitespace-nowrap inline-flex items-center rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
>
SIM
</Link>
<Link
href="/shop/vpn"
className="whitespace-nowrap inline-flex items-center rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
>
VPN
</Link>
</nav>
</div>
</div>
);
}
export function CatalogShell({ children }: CatalogShellProps) { export function CatalogShell({ children }: CatalogShellProps) {
const isAuthenticated = useAuthStore(state => state.isAuthenticated);
const hasCheckedAuth = useAuthStore(state => state.hasCheckedAuth);
const checkAuth = useAuthStore(state => state.checkAuth);
useEffect(() => {
if (!hasCheckedAuth) {
void checkAuth();
}
}, [checkAuth, hasCheckedAuth]);
return ( return (
<div className="min-h-screen flex flex-col bg-background text-foreground"> <div className="min-h-screen flex flex-col bg-background text-foreground">
{/* Subtle background pattern */} {/* Subtle background pattern */}
@ -34,39 +83,11 @@ export function CatalogShell({ children }: CatalogShellProps) {
Assist Solutions Assist Solutions
</span> </span>
<span className="block text-xs text-muted-foreground leading-tight truncate"> <span className="block text-xs text-muted-foreground leading-tight truncate">
Customer Portal Account Portal
</span> </span>
</span> </span>
</Link> </Link>
{/* Catalog Navigation */}
<nav className="hidden md:flex items-center gap-1">
<Link
href="/shop"
className="inline-flex items-center rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
>
All Services
</Link>
<Link
href="/shop/internet"
className="inline-flex items-center rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
>
Internet
</Link>
<Link
href="/shop/sim"
className="inline-flex items-center rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
>
SIM
</Link>
<Link
href="/shop/vpn"
className="inline-flex items-center rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
>
VPN
</Link>
</nav>
{/* Right side actions */} {/* Right side actions */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link <Link
@ -75,16 +96,27 @@ export function CatalogShell({ children }: CatalogShellProps) {
> >
Support Support
</Link> </Link>
{isAuthenticated ? (
<Link
href="/account"
className="inline-flex items-center rounded-lg px-4 py-2 text-sm font-medium bg-primary text-primary-foreground hover:bg-primary-hover shadow-sm shadow-primary/20 transition-all hover:shadow-md hover:shadow-primary/30"
>
My Account
</Link>
) : (
<Link <Link
href="/auth/login" href="/auth/login"
className="inline-flex items-center rounded-lg px-4 py-2 text-sm font-medium bg-primary text-primary-foreground hover:bg-primary-hover shadow-sm shadow-primary/20 transition-all hover:shadow-md hover:shadow-primary/30" className="inline-flex items-center rounded-lg px-4 py-2 text-sm font-medium bg-primary text-primary-foreground hover:bg-primary-hover shadow-sm shadow-primary/20 transition-all hover:shadow-md hover:shadow-primary/30"
> >
Sign in Sign in
</Link> </Link>
)}
</div> </div>
</div> </div>
</header> </header>
<CatalogNav />
<main className="flex-1"> <main className="flex-1">
<div className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-page-padding)] py-8 sm:py-12"> <div className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-page-padding)] py-8 sm:py-12">
{children} {children}

View File

@ -1,2 +1,2 @@
export { CatalogShell } from "./CatalogShell"; export { CatalogNav, CatalogShell } from "./CatalogShell";
export type { CatalogShellProps } from "./CatalogShell"; export type { CatalogShellProps } from "./CatalogShell";

View File

@ -1,12 +1,32 @@
/**
* PublicShell
*
* Shared shell for public-facing pages with an auth-aware header.
*/
"use client";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useEffect } from "react";
import Link from "next/link"; import Link from "next/link";
import { Logo } from "@/components/atoms/logo"; import { Logo } from "@/components/atoms/logo";
import { useAuthStore } from "@/features/auth/services/auth.store";
export interface PublicShellProps { export interface PublicShellProps {
children: ReactNode; children: ReactNode;
} }
export function PublicShell({ children }: PublicShellProps) { export function PublicShell({ children }: PublicShellProps) {
const isAuthenticated = useAuthStore(state => state.isAuthenticated);
const hasCheckedAuth = useAuthStore(state => state.hasCheckedAuth);
const checkAuth = useAuthStore(state => state.checkAuth);
useEffect(() => {
if (!hasCheckedAuth) {
void checkAuth();
}
}, [checkAuth, hasCheckedAuth]);
return ( return (
<div className="min-h-screen flex flex-col bg-background text-foreground"> <div className="min-h-screen flex flex-col bg-background text-foreground">
{/* Subtle background pattern */} {/* Subtle background pattern */}
@ -26,7 +46,7 @@ export function PublicShell({ children }: PublicShellProps) {
Assist Solutions Assist Solutions
</span> </span>
<span className="block text-xs text-muted-foreground leading-tight truncate"> <span className="block text-xs text-muted-foreground leading-tight truncate">
Customer Portal Account Portal
</span> </span>
</span> </span>
</Link> </Link>
@ -44,12 +64,21 @@ export function PublicShell({ children }: PublicShellProps) {
> >
Support Support
</Link> </Link>
{isAuthenticated ? (
<Link
href="/account"
className="inline-flex items-center rounded-lg px-4 py-2 text-sm font-medium bg-primary text-primary-foreground hover:bg-primary-hover shadow-sm shadow-primary/20 transition-all hover:shadow-md hover:shadow-primary/30"
>
My Account
</Link>
) : (
<Link <Link
href="/auth/login" href="/auth/login"
className="inline-flex items-center rounded-lg px-4 py-2 text-sm font-medium bg-primary text-primary-foreground hover:bg-primary-hover shadow-sm shadow-primary/20 transition-all hover:shadow-md hover:shadow-primary/30" className="inline-flex items-center rounded-lg px-4 py-2 text-sm font-medium bg-primary text-primary-foreground hover:bg-primary-hover shadow-sm shadow-primary/20 transition-all hover:shadow-md hover:shadow-primary/30"
> >
Sign in Sign in
</Link> </Link>
)}
</nav> </nav>
</div> </div>
</header> </header>

View File

@ -27,7 +27,7 @@ import { ReviewStep } from "./steps/ReviewStep";
* - phoneCountryCode: Separate field for country code input * - phoneCountryCode: Separate field for country code input
* - address: Required addressFormSchema (domain schema makes it optional) * - address: Required addressFormSchema (domain schema makes it optional)
*/ */
const signupFormBaseSchema = signupInputSchema.extend({ const signupFormBaseSchema = signupInputSchema.omit({ sfNumber: true }).extend({
confirmPassword: z.string().min(1, "Please confirm your password"), confirmPassword: z.string().min(1, "Please confirm your password"),
phoneCountryCode: z.string().regex(/^\+\d{1,4}$/, "Enter a valid country code (e.g., +81)"), phoneCountryCode: z.string().regex(/^\+\d{1,4}$/, "Enter a valid country code (e.g., +81)"),
address: addressFormSchema, address: addressFormSchema,
@ -75,16 +75,7 @@ const STEPS = [
] as const; ] as const;
const STEP_FIELD_KEYS: Record<(typeof STEPS)[number]["key"], Array<keyof SignupFormData>> = { const STEP_FIELD_KEYS: Record<(typeof STEPS)[number]["key"], Array<keyof SignupFormData>> = {
account: [ account: ["firstName", "lastName", "email", "phone", "phoneCountryCode", "dateOfBirth", "gender"],
"sfNumber",
"firstName",
"lastName",
"email",
"phone",
"phoneCountryCode",
"dateOfBirth",
"gender",
],
address: ["address"], address: ["address"],
password: ["password", "confirmPassword"], password: ["password", "confirmPassword"],
review: ["acceptTerms"], review: ["acceptTerms"],
@ -92,7 +83,6 @@ const STEP_FIELD_KEYS: Record<(typeof STEPS)[number]["key"], Array<keyof SignupF
const STEP_VALIDATION_SCHEMAS: Record<(typeof STEPS)[number]["key"], z.ZodTypeAny | undefined> = { const STEP_VALIDATION_SCHEMAS: Record<(typeof STEPS)[number]["key"], z.ZodTypeAny | undefined> = {
account: signupFormBaseSchema.pick({ account: signupFormBaseSchema.pick({
sfNumber: true,
firstName: true, firstName: true,
lastName: true, lastName: true,
email: true, email: true,
@ -130,7 +120,6 @@ export function SignupForm({ onSuccess, onError, className = "" }: SignupFormPro
const form = useZodForm<SignupFormData>({ const form = useZodForm<SignupFormData>({
schema: signupFormSchema, schema: signupFormSchema,
initialValues: { initialValues: {
sfNumber: "",
firstName: "", firstName: "",
lastName: "", lastName: "",
email: "", email: "",

View File

@ -1,5 +1,5 @@
/** /**
* Account Step - Customer number and contact info * Account Step - Contact info
*/ */
"use client"; "use client";
@ -10,7 +10,6 @@ import { FormField } from "@/components/molecules/FormField/FormField";
interface AccountStepProps { interface AccountStepProps {
form: { form: {
values: { values: {
sfNumber: string;
firstName: string; firstName: string;
lastName: string; lastName: string;
email: string; email: string;
@ -33,24 +32,6 @@ export function AccountStep({ form }: AccountStepProps) {
return ( return (
<div className="space-y-5"> <div className="space-y-5">
{/* Customer Number - Highlighted */}
<div className="bg-info-soft border border-info/25 rounded-xl p-4">
<FormField
label="Customer Number"
error={getError("sfNumber")}
required
helperText="Your Assist Solutions customer number"
>
<Input
value={values.sfNumber}
onChange={e => setValue("sfNumber", e.target.value)}
onBlur={() => setTouchedField("sfNumber")}
placeholder="e.g., AST-123456"
autoFocus
/>
</FormField>
</div>
{/* Name Fields */} {/* Name Fields */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="First Name" error={getError("firstName")} required> <FormField label="First Name" error={getError("firstName")} required>
@ -61,6 +42,7 @@ export function AccountStep({ form }: AccountStepProps) {
onBlur={() => setTouchedField("firstName")} onBlur={() => setTouchedField("firstName")}
placeholder="Taro" placeholder="Taro"
autoComplete="given-name" autoComplete="given-name"
autoFocus
/> />
</FormField> </FormField>
<FormField label="Last Name" error={getError("lastName")} required> <FormField label="Last Name" error={getError("lastName")} required>

View File

@ -68,7 +68,6 @@ interface ReviewStepProps {
email: string; email: string;
phone: string; phone: string;
phoneCountryCode: string; phoneCountryCode: string;
sfNumber: string;
company?: string; company?: string;
dateOfBirth?: string; dateOfBirth?: string;
gender?: "male" | "female" | "other"; gender?: "male" | "female" | "other";
@ -116,10 +115,6 @@ export function ReviewStep({ form }: ReviewStepProps) {
Account Summary Account Summary
</h4> </h4>
<dl className="space-y-3 text-sm"> <dl className="space-y-3 text-sm">
<div className="flex justify-between py-2 border-b border-border/60">
<dt className="text-muted-foreground">Customer Number</dt>
<dd className="text-foreground font-medium">{values.sfNumber}</dd>
</div>
<div className="flex justify-between py-2 border-b border-border/60"> <div className="flex justify-between py-2 border-b border-border/60">
<dt className="text-muted-foreground">Name</dt> <dt className="text-muted-foreground">Name</dt>
<dd className="text-foreground font-medium"> <dd className="text-foreground font-medium">

View File

@ -1,8 +1,8 @@
import type { ReadonlyURLSearchParams } from "next/navigation"; import type { ReadonlyURLSearchParams } from "next/navigation";
export function getPostLoginRedirect(searchParams: ReadonlyURLSearchParams): string { export function getPostLoginRedirect(searchParams: ReadonlyURLSearchParams): string {
const dest = searchParams.get("redirect") || "/dashboard"; const dest = searchParams.get("redirect") || "/account";
// prevent open redirects // prevent open redirects
if (dest.startsWith("http://") || dest.startsWith("https://")) return "/dashboard"; if (dest.startsWith("http://") || dest.startsWith("https://")) return "/account";
return dest; return dest;
} }

View File

@ -44,7 +44,7 @@ export function LinkWhmcsView() {
if (result.needsPasswordSet) { if (result.needsPasswordSet) {
router.push(`/auth/set-password?email=${encodeURIComponent(result.user.email)}`); router.push(`/auth/set-password?email=${encodeURIComponent(result.user.email)}`);
} else { } else {
router.push("/dashboard"); router.push("/account");
} }
}} }}
/> />
@ -83,7 +83,7 @@ export function LinkWhmcsView() {
<p className="text-center text-sm text-muted-foreground"> <p className="text-center text-sm text-muted-foreground">
Need help?{" "} Need help?{" "}
<Link href="/support" className="text-primary hover:underline"> <Link href="/contact" className="text-primary hover:underline">
Contact support Contact support
</Link> </Link>
</p> </p>

View File

@ -20,7 +20,7 @@ function SetPasswordContent() {
const handlePasswordSetSuccess = () => { const handlePasswordSetSuccess = () => {
// Redirect to dashboard after successful password setup // Redirect to dashboard after successful password setup
router.push("/dashboard"); router.push("/account");
}; };
if (!email) { if (!email) {

View File

@ -91,7 +91,7 @@ const BillingSummary = forwardRef<HTMLDivElement, BillingSummaryProps>(
</div> </div>
{!compact && ( {!compact && (
<Link <Link
href="/billing/invoices" href="/account/billing/invoices"
className="inline-flex items-center text-sm text-primary hover:text-primary-hover font-medium" className="inline-flex items-center text-sm text-primary hover:text-primary-hover font-medium"
> >
View All View All
@ -158,7 +158,7 @@ const BillingSummary = forwardRef<HTMLDivElement, BillingSummaryProps>(
{compact && ( {compact && (
<div className="mt-4 pt-4 border-t border-border"> <div className="mt-4 pt-4 border-t border-border">
<Link <Link
href="/billing/invoices" href="/account/billing/invoices"
className="inline-flex items-center text-sm text-primary hover:text-primary-hover font-medium" className="inline-flex items-center text-sm text-primary hover:text-primary-hover font-medium"
> >
View All Invoices View All Invoices

View File

@ -104,7 +104,7 @@ export function InvoiceItems({ items = [], currency }: InvoiceItemsProps) {
if (isLinked) { if (isLinked) {
return ( return (
<Link key={item.id} href={`/subscriptions/${item.serviceId}`} className="block"> <Link key={item.id} href={`/account/services/${item.serviceId}`} className="block">
{itemContent} {itemContent}
</Link> </Link>
); );

View File

@ -25,7 +25,7 @@ export function InvoiceItemRow({
? "border-blue-200 bg-blue-50 hover:bg-blue-100 cursor-pointer hover:shadow-sm" ? "border-blue-200 bg-blue-50 hover:bg-blue-100 cursor-pointer hover:shadow-sm"
: "border-gray-200 bg-gray-50" : "border-gray-200 bg-gray-50"
}`} }`}
onClick={serviceId ? () => router.push(`/subscriptions/${serviceId}`) : undefined} onClick={serviceId ? () => router.push(`/account/services/${serviceId}`) : undefined}
> >
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div <div

View File

@ -65,7 +65,7 @@ export function InvoiceTable({
if (onInvoiceClick) { if (onInvoiceClick) {
onInvoiceClick(invoice); onInvoiceClick(invoice);
} else { } else {
router.push(`/billing/invoices/${invoice.id}`); router.push(`/account/billing/invoices/${invoice.id}`);
} }
}, },
[onInvoiceClick, router] [onInvoiceClick, router]

View File

@ -91,7 +91,7 @@ export function InvoiceDetailContainer() {
variant="page" variant="page"
/> />
<div className="mt-4"> <div className="mt-4">
<Link href="/billing/invoices" className="text-primary font-medium"> <Link href="/account/billing/invoices" className="text-primary font-medium">
Back to invoices Back to invoices
</Link> </Link>
</div> </div>
@ -105,8 +105,8 @@ export function InvoiceDetailContainer() {
title={`Invoice #${invoice.id}`} title={`Invoice #${invoice.id}`}
description="Invoice details and actions" description="Invoice details and actions"
breadcrumbs={[ breadcrumbs={[
{ label: "Billing", href: "/billing/invoices" }, { label: "Billing", href: "/account/billing/invoices" },
{ label: "Invoices", href: "/billing/invoices" }, { label: "Invoices", href: "/account/billing/invoices" },
{ label: `#${invoice.id}` }, { label: `#${invoice.id}` },
]} ]}
> >

View File

@ -20,7 +20,7 @@ interface InternetPlanCardProps {
installations: InternetInstallationCatalogItem[]; installations: InternetInstallationCatalogItem[];
disabled?: boolean; disabled?: boolean;
disabledReason?: string; disabledReason?: string;
/** Override the default configure href (default: /catalog/internet/configure?plan=...) */ /** Override the default configure href (default: /shop/internet/configure?plan=...) */
configureHref?: string; configureHref?: string;
} }
@ -205,7 +205,7 @@ 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 ?? `/catalog/internet/configure?plan=${plan.sku}`; const href = configureHref ?? `/shop/internet/configure?plan=${plan.sku}`;
router.push(href); router.push(href);
}} }}
> >

View File

@ -237,7 +237,7 @@ function PlanHeader({ plan }: { plan: InternetPlanCatalogItem }) {
<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="/catalog/internet" href="/shop/internet"
variant="ghost" variant="ghost"
size="sm" size="sm"
leftIcon={<ArrowLeftIcon className="w-4 h-4" />} leftIcon={<ArrowLeftIcon className="w-4 h-4" />}

View File

@ -161,7 +161,7 @@ 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="/catalog/sim" className="text-primary hover:text-primary-hover font-medium"> <a href="/shop/sim" className="text-primary hover:text-primary-hover font-medium">
Return to SIM Plans Return to SIM Plans
</a> </a>
</div> </div>
@ -185,7 +185,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="/catalog/sim" label="Back to SIM Plans" /> <CatalogBackLink href="/shop/sim" label="Back to SIM Plans" />
<AnimatedCard variant="static" className="p-6"> <AnimatedCard variant="static" className="p-6">
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">

View File

@ -32,11 +32,11 @@ export function VpnPlanCard({ plan }: VpnPlanCardProps) {
<div className="mt-auto"> <div className="mt-auto">
<Button <Button
as="a" as="a"
href={`/catalog/vpn/configure?plan=${plan.sku}`} href={`/order?type=vpn&planSku=${encodeURIComponent(plan.sku)}`}
className="w-full" className="w-full"
rightIcon={<ArrowRightIcon className="w-4 h-4" />} rightIcon={<ArrowRightIcon className="w-4 h-4" />}
> >
Configure Plan Continue to Checkout
</Button> </Button>
</div> </div>
</AnimatedCard> </AnimatedCard>

View File

@ -75,7 +75,7 @@ export function useInternetConfigure(): UseInternetConfigureResult {
// Redirect if no plan selected // Redirect if no plan selected
if (!urlPlanSku && !configState.planSku) { if (!urlPlanSku && !configState.planSku) {
router.push("/catalog/internet"); router.push("/shop/internet");
} }
}, [configState.planSku, paramsSignature, restoreFromParams, router, setConfig, urlPlanSku]); }, [configState.planSku, paramsSignature, restoreFromParams, router, setConfig, urlPlanSku]);

View File

@ -89,7 +89,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("/catalog/sim"); router.push("/shop/sim");
} }
}, [ }, [
configState.planSku, configState.planSku,

View File

@ -45,7 +45,7 @@ export function CatalogHomeView() {
"Multiple access modes", "Multiple access modes",
"Professional installation", "Professional installation",
]} ]}
href="/catalog/internet" href="/shop/internet"
color="blue" color="blue"
/> />
<ServiceHeroCard <ServiceHeroCard
@ -58,7 +58,7 @@ export function CatalogHomeView() {
"Family discounts", "Family discounts",
"Multiple data options", "Multiple data options",
]} ]}
href="/catalog/sim" href="/shop/sim"
color="green" color="green"
/> />
<ServiceHeroCard <ServiceHeroCard
@ -71,7 +71,7 @@ export function CatalogHomeView() {
"Business & personal", "Business & personal",
"24/7 connectivity", "24/7 connectivity",
]} ]}
href="/catalog/vpn" href="/shop/vpn"
color="purple" color="purple"
/> />
</div> </div>

View File

@ -68,7 +68,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="/catalog" label="Back to Services" /> <CatalogBackLink href="/shop" label="Back to Services" />
{/* Title + eligibility */} {/* Title + eligibility */}
<div className="text-center mb-12"> <div className="text-center mb-12">
@ -112,7 +112,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="/catalog" label="Back to Services" /> <CatalogBackLink href="/shop" label="Back to Services" />
<CatalogHero <CatalogHero
title="Choose Your Internet Plan" title="Choose Your Internet Plan"
@ -143,7 +143,7 @@ export function InternetPlansContainer() {
You already have an Internet subscription with us. If you want another subscription You already have an Internet subscription with us. If you want another subscription
for a different residence, please{" "} for a different residence, please{" "}
<a <a
href="/support/new" href="/account/support/new"
className="underline text-primary hover:text-primary-hover font-medium transition-colors" className="underline text-primary hover:text-primary-hover font-medium transition-colors"
> >
contact us contact us
@ -197,7 +197,7 @@ export function InternetPlansContainer() {
We couldn&apos;t find any internet plans available for your location at this time. We couldn&apos;t find any internet plans available for your location at this time.
</p> </p>
<CatalogBackLink <CatalogBackLink
href="/catalog" href="/shop"
label="Back to Services" label="Back to Services"
align="center" align="center"
className="mt-0 mb-0" className="mt-0 mb-0"

View File

@ -46,7 +46,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="/catalog" label="Back to Services" /> <CatalogBackLink href="/shop" 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 +72,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="/catalog" label="Back to Services" /> <CatalogBackLink href="/shop" 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 +82,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="/catalog" label="Back to Services" /> <CatalogBackLink href="/shop" label="Back to Services" />
<CatalogHero <CatalogHero
title="Choose Your Internet Plan" title="Choose Your Internet Plan"
@ -143,7 +143,7 @@ export function PublicInternetPlansView() {
We couldn&apos;t find any internet plans available at this time. We couldn&apos;t find any internet plans available at this time.
</p> </p>
<CatalogBackLink <CatalogBackLink
href="/catalog" href="/shop"
label="Back to Services" label="Back to Services"
align="center" align="center"
className="mt-0 mb-0" className="mt-0 mb-0"

View File

@ -39,7 +39,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="/catalog" label="Back to Services" /> <CatalogBackLink href="/shop" 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 +72,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="/catalog" href="/shop"
className="mt-4" className="mt-4"
leftIcon={<ArrowLeftIcon className="w-4 h-4" />} leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
> >
@ -96,7 +96,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="/catalog" label="Back to Services" /> <CatalogBackLink href="/shop" label="Back to Services" />
<CatalogHero <CatalogHero
title="Choose Your SIM Plan" title="Choose Your SIM Plan"

View File

@ -22,7 +22,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="/catalog" label="Back to Services" /> <CatalogBackLink href="/shop" label="Back to Services" />
<AsyncBlock <AsyncBlock
isLoading={isLoading} isLoading={isLoading}
@ -42,7 +42,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="/catalog" label="Back to Services" /> <CatalogBackLink href="/shop" label="Back to Services" />
<CatalogHero <CatalogHero
title="SonixNet VPN Router Service" title="SonixNet VPN Router Service"
@ -75,7 +75,7 @@ export function PublicVpnPlansView() {
We couldn&apos;t find any VPN plans available at this time. We couldn&apos;t find any VPN plans available at this time.
</p> </p>
<CatalogBackLink <CatalogBackLink
href="/catalog" href="/shop"
label="Back to Services" label="Back to Services"
align="center" align="center"
className="mt-4 mb-0" className="mt-4 mb-0"

View File

@ -45,7 +45,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="/catalog" label="Back to Services" /> <CatalogBackLink href="/shop" label="Back to Services" />
{/* Title block */} {/* Title block */}
<div className="text-center mb-12"> <div className="text-center mb-12">
@ -110,7 +110,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="/catalog" href="/shop"
className="mt-4" className="mt-4"
leftIcon={<ArrowLeftIcon className="w-4 h-4" />} leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
> >
@ -140,7 +140,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="/catalog" label="Back to Services" /> <CatalogBackLink href="/shop" label="Back to Services" />
<CatalogHero <CatalogHero
title="Choose Your SIM Plan" title="Choose Your SIM Plan"

View File

@ -24,7 +24,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="/catalog" label="Back to Services" /> <CatalogBackLink href="/shop" label="Back to Services" />
<AsyncBlock <AsyncBlock
isLoading={isLoading} isLoading={isLoading}
@ -52,7 +52,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="/catalog" label="Back to Services" /> <CatalogBackLink href="/shop" label="Back to Services" />
<CatalogHero <CatalogHero
title="SonixNet VPN Router Service" title="SonixNet VPN Router Service"
@ -89,7 +89,7 @@ export function VpnPlansView() {
We couldn&apos;t find any VPN plans available at this time. We couldn&apos;t find any VPN plans available at this time.
</p> </p>
<CatalogBackLink <CatalogBackLink
href="/catalog" href="/shop"
label="Back to Services" label="Back to Services"
align="center" align="center"
className="mt-4 mb-0" className="mt-4 mb-0"

View File

@ -0,0 +1,209 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import type { CartItem, OrderType as CheckoutOrderType } from "@customer-portal/domain/checkout";
import type { CheckoutCart, OrderTypeValue } from "@customer-portal/domain/orders";
import { ORDER_TYPE } from "@customer-portal/domain/orders";
import { checkoutService } from "@/features/checkout/services/checkout.service";
import { CheckoutParamsService } from "@/features/checkout/services/checkout-params.service";
import { useCheckoutStore } from "@/features/checkout/stores/checkout.store";
import { CheckoutWizard } from "@/features/checkout/components/CheckoutWizard";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { Button } from "@/components/atoms/button";
import { Spinner } from "@/components/atoms";
import { EmptyCartRedirect } from "@/features/checkout/components/EmptyCartRedirect";
const signatureFromSearchParams = (params: URLSearchParams): string => {
const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b));
return entries.map(([key, value]) => `${key}=${value}`).join("&");
};
const mapOrderTypeToCheckout = (orderType: OrderTypeValue): CheckoutOrderType => {
switch (orderType) {
case ORDER_TYPE.SIM:
return "SIM";
case ORDER_TYPE.VPN:
return "VPN";
case ORDER_TYPE.INTERNET:
case ORDER_TYPE.OTHER:
default:
return "INTERNET";
}
};
type CheckoutCartSummary = { items: CheckoutCart["items"]; totals: CheckoutCart["totals"] };
const cartItemFromCheckoutCart = (
cart: CheckoutCartSummary,
orderType: OrderTypeValue
): CartItem => {
const planItem = cart.items.find(item => item.itemType === "plan") ?? cart.items[0];
const planSku = planItem?.sku;
if (!planSku) {
throw new Error("Checkout cart did not include a plan. Please re-select your plan.");
}
const addonSkus = Array.from(
new Set(cart.items.map(item => item.sku).filter(sku => sku && sku !== planSku))
);
return {
orderType: mapOrderTypeToCheckout(orderType),
planSku,
planName: planItem?.name ?? planSku,
addonSkus,
configuration: {},
pricing: {
monthlyTotal: cart.totals.monthlyTotal,
oneTimeTotal: cart.totals.oneTimeTotal,
breakdown: cart.items.map(item => ({
label: item.name,
sku: item.sku,
monthlyPrice: item.monthlyPrice,
oneTimePrice: item.oneTimePrice,
quantity: item.quantity,
})),
},
};
};
export function CheckoutEntry() {
const searchParams = useSearchParams();
const paramsKey = useMemo(() => searchParams.toString(), [searchParams]);
const signature = useMemo(
() => signatureFromSearchParams(new URLSearchParams(paramsKey)),
[paramsKey]
);
const {
cartItem,
cartParamsSignature,
checkoutSessionId,
setCartItem,
setCartItemFromParams,
setCheckoutSession,
isCartStale,
clear,
} = useCheckoutStore();
const [status, setStatus] = useState<"idle" | "loading" | "error">("idle");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
useEffect(() => {
if (!paramsKey) {
return;
}
let mounted = true;
setStatus("loading");
setErrorMessage(null);
void (async () => {
try {
const snapshot = CheckoutParamsService.buildSnapshot(new URLSearchParams(paramsKey));
if (!snapshot.planReference) {
throw new Error("No plan selected. Please go back and select a plan.");
}
const session = await checkoutService.createSession(
snapshot.orderType,
snapshot.selections,
snapshot.configuration
);
if (!mounted) return;
const nextCartItem = cartItemFromCheckoutCart(session.cart, session.orderType);
setCartItemFromParams(nextCartItem, signature);
setCheckoutSession({ id: session.sessionId, expiresAt: session.expiresAt });
setStatus("idle");
} catch (error) {
if (!mounted) return;
setStatus("error");
setErrorMessage(error instanceof Error ? error.message : "Failed to load checkout");
}
})();
return () => {
mounted = false;
};
}, [paramsKey, setCartItemFromParams, setCheckoutSession, signature]);
useEffect(() => {
if (paramsKey) return;
if (isCartStale()) {
clear();
return;
}
if (!checkoutSessionId) {
return;
}
let mounted = true;
setStatus("loading");
setErrorMessage(null);
void (async () => {
try {
const session = await checkoutService.getSession(checkoutSessionId);
if (!mounted) return;
setCheckoutSession({ id: session.sessionId, expiresAt: session.expiresAt });
const nextCartItem = cartItemFromCheckoutCart(session.cart, session.orderType);
// Session-based entry: don't tie progress to URL params.
setCartItem(nextCartItem);
setStatus("idle");
} catch (error) {
if (!mounted) return;
setStatus("error");
setErrorMessage(error instanceof Error ? error.message : "Failed to load checkout");
}
})();
return () => {
mounted = false;
};
}, [checkoutSessionId, clear, isCartStale, paramsKey, setCartItem, setCheckoutSession]);
const shouldWaitForCart =
(Boolean(paramsKey) && (!cartItem || cartParamsSignature !== signature)) ||
(!paramsKey && Boolean(checkoutSessionId) && !cartItem);
if (status === "loading" && shouldWaitForCart) {
return (
<div className="max-w-2xl mx-auto py-12">
<div className="bg-card rounded-xl border border-border p-8 shadow-[var(--cp-shadow-1)] text-center">
<Spinner className="mx-auto mb-4" />
<p className="text-sm text-muted-foreground">Preparing your checkout</p>
</div>
</div>
);
}
if (status === "error") {
return (
<div className="max-w-2xl mx-auto py-8">
<AlertBanner variant="error" title="Unable to start checkout" elevated>
<div className="space-y-3">
<div className="text-sm text-foreground/80">{errorMessage}</div>
<div className="flex flex-col sm:flex-row gap-3">
<Button as="a" href="/shop" size="sm">
Back to Shop
</Button>
<Link href="/contact" className="text-sm text-primary hover:underline self-center">
Contact support
</Link>
</div>
</div>
</AlertBanner>
</div>
);
}
if (!paramsKey && !checkoutSessionId) {
return <EmptyCartRedirect />;
}
return <CheckoutWizard />;
}

View File

@ -58,7 +58,7 @@ export class CheckoutErrorBoundary extends Component<Props, State> {
</div> </div>
<p className="text-xs text-muted-foreground mt-6"> <p className="text-xs text-muted-foreground mt-6">
If this problem persists, please{" "} If this problem persists, please{" "}
<Link href="/support/contact" className="text-primary hover:underline"> <Link href="/contact" className="text-primary hover:underline">
contact support contact support
</Link> </Link>
. .

View File

@ -1,9 +1,11 @@
"use client"; "use client";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useEffect } from "react";
import Link from "next/link"; import Link from "next/link";
import { Logo } from "@/components/atoms/logo"; import { Logo } from "@/components/atoms/logo";
import { ShieldCheckIcon } from "@heroicons/react/24/outline"; import { ShieldCheckIcon } from "@heroicons/react/24/outline";
import { useAuthStore } from "@/features/auth/services/auth.store";
interface CheckoutShellProps { interface CheckoutShellProps {
children: ReactNode; children: ReactNode;
@ -19,6 +21,15 @@ interface CheckoutShellProps {
* - Clean, focused design * - Clean, focused design
*/ */
export function CheckoutShell({ children }: CheckoutShellProps) { export function CheckoutShell({ children }: CheckoutShellProps) {
const hasCheckedAuth = useAuthStore(state => state.hasCheckedAuth);
const checkAuth = useAuthStore(state => state.checkAuth);
useEffect(() => {
if (!hasCheckedAuth) {
void checkAuth();
}
}, [checkAuth, hasCheckedAuth]);
return ( return (
<div className="min-h-screen flex flex-col bg-background text-foreground"> <div className="min-h-screen flex flex-col bg-background text-foreground">
{/* Subtle background pattern */} {/* Subtle background pattern */}
@ -51,7 +62,7 @@ export function CheckoutShell({ children }: CheckoutShellProps) {
<span>Secure Checkout</span> <span>Secure Checkout</span>
</div> </div>
<Link <Link
href="/support" href="/help"
className="inline-flex items-center rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors" className="inline-flex items-center rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
> >
Need Help? Need Help?

View File

@ -1,5 +1,6 @@
"use client"; "use client";
import { useEffect } from "react";
import { useCheckoutStore } from "../stores/checkout.store"; import { useCheckoutStore } from "../stores/checkout.store";
import { CheckoutProgress } from "./CheckoutProgress"; import { CheckoutProgress } from "./CheckoutProgress";
import { OrderSummaryCard } from "./OrderSummaryCard"; import { OrderSummaryCard } from "./OrderSummaryCard";
@ -9,6 +10,7 @@ import { AddressStep } from "./steps/AddressStep";
import { PaymentStep } from "./steps/PaymentStep"; import { PaymentStep } from "./steps/PaymentStep";
import { ReviewStep } from "./steps/ReviewStep"; 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";
/** /**
* CheckoutWizard - Main checkout flow orchestrator * CheckoutWizard - Main checkout flow orchestrator
@ -17,8 +19,15 @@ import type { CheckoutStep } from "@customer-portal/domain/checkout";
* appropriate content based on current step. * appropriate content based on current step.
*/ */
export function CheckoutWizard() { export function CheckoutWizard() {
const { isAuthenticated } = useAuthSession();
const { cartItem, currentStep, setCurrentStep, registrationComplete } = useCheckoutStore(); const { cartItem, currentStep, setCurrentStep, registrationComplete } = useCheckoutStore();
useEffect(() => {
if ((isAuthenticated || registrationComplete) && currentStep === "account") {
setCurrentStep("address");
}
}, [currentStep, isAuthenticated, registrationComplete, setCurrentStep]);
// Redirect if no cart // Redirect if no cart
if (!cartItem) { if (!cartItem) {
return <EmptyCartRedirect />; return <EmptyCartRedirect />;
@ -50,10 +59,8 @@ export function CheckoutWizard() {
}; };
// Determine effective step (skip account if already authenticated) // Determine effective step (skip account if already authenticated)
const effectiveStep = registrationComplete && currentStep === "account" ? "address" : currentStep;
const renderStep = () => { const renderStep = () => {
switch (effectiveStep) { switch (currentStep) {
case "account": case "account":
return <AccountStep />; return <AccountStep />;
case "address": case "address":
@ -71,7 +78,7 @@ export function CheckoutWizard() {
<div className="max-w-5xl mx-auto"> <div className="max-w-5xl mx-auto">
{/* Progress indicator */} {/* Progress indicator */}
<CheckoutProgress <CheckoutProgress
currentStep={effectiveStep} currentStep={currentStep}
completedSteps={getCompletedSteps()} completedSteps={getCompletedSteps()}
onStepClick={handleStepClick} onStepClick={handleStepClick}
/> />

View File

@ -8,7 +8,7 @@ import { Button } from "@/components/atoms/button";
/** /**
* EmptyCartRedirect - Shown when checkout is accessed without a cart * EmptyCartRedirect - Shown when checkout is accessed without a cart
* *
* Redirects to catalog after a short delay, or user can click to go immediately. * Redirects to shop after a short delay, or user can click to go immediately.
*/ */
export function EmptyCartRedirect() { export function EmptyCartRedirect() {
const router = useRouter(); const router = useRouter();
@ -29,13 +29,13 @@ export function EmptyCartRedirect() {
</div> </div>
<h2 className="text-xl font-semibold text-foreground mb-2">Your cart is empty</h2> <h2 className="text-xl font-semibold text-foreground mb-2">Your cart is empty</h2>
<p className="text-muted-foreground mb-6"> <p className="text-muted-foreground mb-6">
Browse our catalog 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="/shop" className="w-full">
Browse Catalog Browse Services
</Button> </Button>
<p className="text-xs text-muted-foreground mt-4"> <p className="text-xs text-muted-foreground mt-4">
Redirecting to catalog in a few seconds... Redirecting to the shop in a few seconds...
</p> </p>
</div> </div>
</div> </div>

View File

@ -84,10 +84,10 @@ export function OrderConfirmation() {
{/* Actions */} {/* Actions */}
<div className="flex flex-col sm:flex-row gap-4 justify-center"> <div className="flex flex-col sm:flex-row gap-4 justify-center">
<Button as="a" href="/dashboard" className="sm:w-auto"> <Button as="a" href="/account" className="sm:w-auto">
Go to Dashboard Go to Dashboard
</Button> </Button>
<Button as="a" href="/orders" variant="outline" className="sm:w-auto"> <Button as="a" href="/account/orders" variant="outline" className="sm:w-auto">
View Orders View Orders
</Button> </Button>
</div> </div>
@ -95,7 +95,7 @@ export function OrderConfirmation() {
{/* Support Link */} {/* Support Link */}
<p className="text-sm text-muted-foreground mt-8"> <p className="text-sm text-muted-foreground mt-8">
Have questions?{" "} Have questions?{" "}
<Link href="/support" className="text-primary hover:underline"> <Link href="/contact" className="text-primary hover:underline">
Contact Support Contact Support
</Link> </Link>
</p> </p>

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useCallback } from "react"; import { useEffect, 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";
@ -8,6 +8,7 @@ import { FormField } from "@/components/molecules/FormField/FormField";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { UserIcon, ArrowRightIcon } from "@heroicons/react/24/outline"; import { UserIcon, ArrowRightIcon } from "@heroicons/react/24/outline";
import { useZodForm } from "@/hooks/useZodForm"; import { useZodForm } from "@/hooks/useZodForm";
import { useAuthSession, useAuthStore } from "@/features/auth/services/auth.store";
import { import {
emailSchema, emailSchema,
passwordSchema, passwordSchema,
@ -39,6 +40,7 @@ type AccountFormData = z.infer<typeof accountFormSchema>;
* Allows new customers to enter their info or existing customers to sign in. * Allows new customers to enter their info or existing customers to sign in.
*/ */
export function AccountStep() { export function AccountStep() {
const { isAuthenticated } = useAuthSession();
const { const {
guestInfo, guestInfo,
updateGuestInfo, updateGuestInfo,
@ -77,9 +79,13 @@ export function AccountStep() {
onSubmit: handleSubmit, onSubmit: handleSubmit,
}); });
// If already registered, skip to address useEffect(() => {
if (registrationComplete) { if (isAuthenticated || registrationComplete) {
setCurrentStep("address"); setCurrentStep("address");
}
}, [isAuthenticated, registrationComplete, setCurrentStep]);
if (isAuthenticated || registrationComplete) {
return null; return null;
} }
@ -262,6 +268,7 @@ function SignInForm({
}) { }) {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const login = useAuthStore(state => state.login);
const handleSubmit = useCallback( const handleSubmit = useCallback(
async (data: { email: string; password: string }) => { async (data: { email: string; password: string }) => {
@ -269,20 +276,11 @@ function SignInForm({
setError(null); setError(null);
try { try {
const response = await fetch("/api/auth/login", { await login(data);
method: "POST", const userId = useAuthStore.getState().user?.id;
headers: { "Content-Type": "application/json" }, if (userId) {
body: JSON.stringify(data), setRegistrationComplete(userId);
credentials: "include",
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error?.message || "Invalid email or password");
} }
const result = await response.json();
setRegistrationComplete(result.user?.id || result.id || "");
onSuccess(); onSuccess();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Login failed"); setError(err instanceof Error ? err.message : "Login failed");
@ -290,7 +288,7 @@ function SignInForm({
setIsLoading(false); setIsLoading(false);
} }
}, },
[onSuccess, setRegistrationComplete] [login, onSuccess, setRegistrationComplete]
); );
const form = useZodForm<{ email: string; password: string }>({ const form = useZodForm<{ email: string; password: string }>({

View File

@ -2,12 +2,15 @@
import { useState, useCallback } from "react"; import { useState, useCallback } from "react";
import { useCheckoutStore } from "../../stores/checkout.store"; import { useCheckoutStore } from "../../stores/checkout.store";
import { useAuthSession, useAuthStore } from "@/features/auth/services/auth.store";
import { Button, Input } from "@/components/atoms"; import { Button, Input } from "@/components/atoms";
import { FormField } from "@/components/molecules/FormField/FormField"; import { FormField } from "@/components/molecules/FormField/FormField";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { MapPinIcon, ArrowLeftIcon, ArrowRightIcon } from "@heroicons/react/24/outline"; import { MapPinIcon, ArrowLeftIcon, ArrowRightIcon } from "@heroicons/react/24/outline";
import { addressFormSchema, type AddressFormData } from "@customer-portal/domain/customer"; import { addressFormSchema, type AddressFormData } from "@customer-portal/domain/customer";
import { useZodForm } from "@/hooks/useZodForm"; import { useZodForm } from "@/hooks/useZodForm";
import { apiClient } from "@/lib/api";
import { checkoutRegisterResponseSchema } from "@customer-portal/domain/checkout";
/** /**
* AddressStep - Second step in checkout * AddressStep - Second step in checkout
@ -15,6 +18,8 @@ import { useZodForm } from "@/hooks/useZodForm";
* Collects service/shipping address and triggers registration for new users. * Collects service/shipping address and triggers registration for new users.
*/ */
export function AddressStep() { export function AddressStep() {
const { isAuthenticated } = useAuthSession();
const refreshUser = useAuthStore(state => state.refreshUser);
const { const {
address, address,
setAddress, setAddress,
@ -33,12 +38,18 @@ export function AddressStep() {
setAddress(data); setAddress(data);
// If not yet registered, trigger registration // If not yet registered, trigger registration
if (!registrationComplete && guestInfo) { const hasGuestInfo =
Boolean(guestInfo?.email) &&
Boolean(guestInfo?.firstName) &&
Boolean(guestInfo?.lastName) &&
Boolean(guestInfo?.phone) &&
Boolean(guestInfo?.phoneCountryCode) &&
Boolean(guestInfo?.password);
if (!isAuthenticated && !registrationComplete && hasGuestInfo && guestInfo) {
try { try {
const response = await fetch("/api/checkout/register", { const response = await apiClient.POST("/api/checkout/register", {
method: "POST", body: {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: guestInfo.email, email: guestInfo.email,
firstName: guestInfo.firstName, firstName: guestInfo.firstName,
lastName: guestInfo.lastName, lastName: guestInfo.lastName,
@ -47,17 +58,12 @@ export function AddressStep() {
password: guestInfo.password, password: guestInfo.password,
address: data, address: data,
acceptTerms: true, acceptTerms: true,
}), },
credentials: "include",
}); });
if (!response.ok) { const result = checkoutRegisterResponseSchema.parse(response.data);
const errorData = await response.json();
throw new Error(errorData.error?.message || errorData.message || "Registration failed");
}
const result = await response.json();
setRegistrationComplete(result.user.id); setRegistrationComplete(result.user.id);
await refreshUser();
} catch (error) { } catch (error) {
setRegistrationError(error instanceof Error ? error.message : "Registration failed"); setRegistrationError(error instanceof Error ? error.message : "Registration failed");
return; return;
@ -66,7 +72,15 @@ export function AddressStep() {
setCurrentStep("payment"); setCurrentStep("payment");
}, },
[guestInfo, registrationComplete, setAddress, setCurrentStep, setRegistrationComplete] [
guestInfo,
isAuthenticated,
refreshUser,
registrationComplete,
setAddress,
setCurrentStep,
setRegistrationComplete,
]
); );
const form = useZodForm<AddressFormData>({ const form = useZodForm<AddressFormData>({

View File

@ -2,8 +2,11 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { useCheckoutStore } from "../../stores/checkout.store"; import { useCheckoutStore } from "../../stores/checkout.store";
import { useAuthSession } from "@/features/auth/services/auth.store";
import { Button } from "@/components/atoms/button"; import { Button } from "@/components/atoms/button";
import { Spinner } from "@/components/atoms"; import { Spinner } from "@/components/atoms";
import { apiClient } from "@/lib/api";
import { ssoLinkResponseSchema } from "@customer-portal/domain/auth";
import { import {
CreditCardIcon, CreditCardIcon,
ArrowLeftIcon, ArrowLeftIcon,
@ -18,6 +21,7 @@ import {
* Opens WHMCS SSO to add payment method and polls for completion. * Opens WHMCS SSO to add payment method and polls for completion.
*/ */
export function PaymentStep() { export function PaymentStep() {
const { isAuthenticated } = useAuthSession();
const { setPaymentVerified, paymentMethodVerified, setCurrentStep, registrationComplete } = const { setPaymentVerified, paymentMethodVerified, setCurrentStep, registrationComplete } =
useCheckoutStore(); useCheckoutStore();
const [isWaiting, setIsWaiting] = useState(false); const [isWaiting, setIsWaiting] = useState(false);
@ -27,11 +31,12 @@ export function PaymentStep() {
lastFour?: string; lastFour?: string;
} | null>(null); } | null>(null);
const canCheckPayment = isAuthenticated || registrationComplete;
// Poll for payment method // Poll for payment method
const checkPaymentMethod = useCallback(async () => { const checkPaymentMethod = useCallback(async () => {
if (!registrationComplete) { if (!canCheckPayment) {
// Need to be registered first - show message setError("Please complete account setup first");
setError("Please complete registration first");
return false; return false;
} }
@ -63,7 +68,7 @@ export function PaymentStep() {
console.error("Error checking payment methods:", err); console.error("Error checking payment methods:", err);
return false; return false;
} }
}, [registrationComplete, setPaymentVerified]); }, [canCheckPayment, setPaymentVerified]);
// Check on mount and when returning focus // Check on mount and when returning focus
useEffect(() => { useEffect(() => {
@ -97,7 +102,7 @@ export function PaymentStep() {
}, [isWaiting, checkPaymentMethod]); }, [isWaiting, checkPaymentMethod]);
const handleAddPayment = async () => { const handleAddPayment = async () => {
if (!registrationComplete) { if (!canCheckPayment) {
setError("Please complete account setup first"); setError("Please complete account setup first");
return; return;
} }
@ -107,19 +112,11 @@ export function PaymentStep() {
try { try {
// Get SSO link for payment methods // Get SSO link for payment methods
const response = await fetch("/api/auth/sso-link", { const response = await apiClient.POST("/api/auth/sso-link", {
method: "POST", body: { destination: "index.php?rp=/account/paymentmethods" },
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ destination: "paymentmethods" }),
credentials: "include",
}); });
const data = ssoLinkResponseSchema.parse(response.data);
if (!response.ok) { const url = data.url;
throw new Error("Failed to get payment portal link");
}
const data = await response.json();
const url = data.data?.url ?? data.url;
if (url) { if (url) {
window.open(url, "_blank", "noopener,noreferrer"); window.open(url, "_blank", "noopener,noreferrer");
@ -199,10 +196,10 @@ export function PaymentStep() {
We'll open our secure payment portal where you can add your credit card or other We'll open our secure payment portal where you can add your credit card or other
payment method. payment method.
</p> </p>
<Button onClick={handleAddPayment} disabled={!registrationComplete}> <Button onClick={handleAddPayment} disabled={!canCheckPayment}>
Add Payment Method Add Payment Method
</Button> </Button>
{!registrationComplete && ( {!canCheckPayment && (
<p className="text-sm text-warning mt-2"> <p className="text-sm text-warning mt-2">
You need to complete registration first You need to complete registration first
</p> </p>

View File

@ -3,6 +3,8 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useCheckoutStore } from "../../stores/checkout.store"; import { useCheckoutStore } from "../../stores/checkout.store";
import { useAuthSession } from "@/features/auth/services/auth.store";
import { ordersService } from "@/features/orders/services/orders.service";
import { Button } from "@/components/atoms/button"; import { Button } from "@/components/atoms/button";
import { import {
ShieldCheckIcon, ShieldCheckIcon,
@ -21,8 +23,16 @@ import {
*/ */
export function ReviewStep() { export function ReviewStep() {
const router = useRouter(); const router = useRouter();
const { cartItem, guestInfo, address, paymentMethodVerified, setCurrentStep, clear } = const { user } = useAuthSession();
useCheckoutStore(); const {
cartItem,
guestInfo,
address,
paymentMethodVerified,
checkoutSessionId,
setCurrentStep,
clear,
} = useCheckoutStore();
const [termsAccepted, setTermsAccepted] = useState(false); const [termsAccepted, setTermsAccepted] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
@ -43,31 +53,17 @@ export function ReviewStep() {
setError(null); setError(null);
try { try {
// Submit order via API if (!checkoutSessionId) {
const response = await fetch("/api/orders", { throw new Error("Checkout session expired. Please restart checkout from the shop.");
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
orderType: cartItem.orderType,
skus: [cartItem.planSku, ...cartItem.addonSkus],
configuration: cartItem.configuration,
}),
credentials: "include",
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error?.message || "Failed to submit order");
} }
const result = await response.json(); const result = await ordersService.createOrderFromCheckoutSession(checkoutSessionId);
const orderId = result.data?.orderId ?? result.orderId;
// Clear checkout state // Clear checkout state
clear(); clear();
// Redirect to confirmation // Redirect to confirmation
router.push(`/order/complete${orderId ? `?orderId=${orderId}` : ""}`); router.push(`/order/complete?orderId=${encodeURIComponent(result.sfOrderId)}`);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to submit order"); setError(err instanceof Error ? err.message : "Failed to submit order");
setIsSubmitting(false); setIsSubmitting(false);
@ -106,9 +102,9 @@ export function ReviewStep() {
</Button> </Button>
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{guestInfo?.firstName} {guestInfo?.lastName} {guestInfo?.firstName || user?.firstname} {guestInfo?.lastName || user?.lastname}
</p> </p>
<p className="text-sm text-muted-foreground">{guestInfo?.email}</p> <p className="text-sm text-muted-foreground">{guestInfo?.email || user?.email}</p>
</div> </div>
{/* Address */} {/* Address */}

View File

@ -1,229 +0,0 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { logger } from "@/lib/logger";
import { ordersService } from "@/features/orders/services/orders.service";
import { checkoutService } from "@/features/checkout/services/checkout.service";
import { ACTIVE_INTERNET_SUBSCRIPTION_WARNING } from "@/features/checkout/constants";
import { usePaymentMethods } from "@/features/billing/hooks/useBilling";
import { usePaymentRefresh } from "@/features/billing/hooks/usePaymentRefresh";
import {
createLoadingState,
createSuccessState,
createErrorState,
} from "@customer-portal/domain/toolkit";
import type { AsyncState } from "@customer-portal/domain/toolkit";
import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscriptions";
import {
ORDER_TYPE,
orderWithSkuValidationSchema,
prepareOrderFromCart,
type CheckoutCart,
} from "@customer-portal/domain/orders";
import { CheckoutParamsService } from "@/features/checkout/services/checkout-params.service";
import { useAuthSession, useAuthStore } from "@/features/auth/services/auth.store";
import { ZodError } from "zod";
// Use domain Address type
import type { Address } from "@customer-portal/domain/customer";
export function useCheckout() {
const params = useSearchParams();
const router = useRouter();
const { isAuthenticated } = useAuthSession();
const [submitting, setSubmitting] = useState(false);
const [addressConfirmed, setAddressConfirmed] = useState(false);
const [checkoutState, setCheckoutState] = useState<AsyncState<CheckoutCart>>({
status: "loading",
});
// Load active subscriptions to enforce business rules client-side before submission
const { data: activeSubs } = useActiveSubscriptions();
const hasActiveInternetSubscription = useMemo(() => {
if (!Array.isArray(activeSubs)) return false;
return activeSubs.some(
subscription =>
String(subscription.groupName || subscription.productName || "")
.toLowerCase()
.includes("internet") && String(subscription.status || "").toLowerCase() === "active"
);
}, [activeSubs]);
const [activeInternetWarning, setActiveInternetWarning] = useState<string | null>(null);
const {
data: paymentMethods,
isLoading: paymentMethodsLoading,
error: paymentMethodsError,
refetch: refetchPaymentMethods,
} = usePaymentMethods();
const paymentRefresh = usePaymentRefresh({
refetch: refetchPaymentMethods,
attachFocusListeners: true,
});
const paramsKey = params.toString();
const checkoutSnapshot = CheckoutParamsService.buildSnapshot(new URLSearchParams(paramsKey));
const { orderType, warnings } = checkoutSnapshot;
const lastWarningSignature = useRef<string | null>(null);
useEffect(() => {
if (warnings.length === 0) {
return;
}
const signature = warnings.join("|");
if (signature === lastWarningSignature.current) {
return;
}
lastWarningSignature.current = signature;
warnings.forEach(message => {
logger.warn("Checkout parameter warning", { message });
});
}, [warnings]);
useEffect(() => {
if (orderType !== ORDER_TYPE.INTERNET || !hasActiveInternetSubscription) {
setActiveInternetWarning(null);
return;
}
setActiveInternetWarning(ACTIVE_INTERNET_SUBSCRIPTION_WARNING);
}, [orderType, hasActiveInternetSubscription]);
useEffect(() => {
// Wait for authentication before building cart
if (!isAuthenticated) {
return;
}
let mounted = true;
void (async () => {
const snapshot = CheckoutParamsService.buildSnapshot(new URLSearchParams(paramsKey));
const {
orderType: snapshotOrderType,
selections,
configuration,
planReference: snapshotPlan,
} = snapshot;
try {
setCheckoutState(createLoadingState());
if (!snapshotPlan) {
throw new Error("No plan selected. Please go back and select a plan.");
}
// Build cart using BFF service
const cart = await checkoutService.buildCart(snapshotOrderType, selections, configuration);
if (!mounted) return;
setCheckoutState(createSuccessState(cart));
} catch (error) {
if (mounted) {
const reason = error instanceof Error ? error.message : "Failed to load checkout data";
setCheckoutState(createErrorState(new Error(reason)));
}
}
})();
return () => {
mounted = false;
};
}, [isAuthenticated, paramsKey]);
const handleSubmitOrder = useCallback(async () => {
try {
setSubmitting(true);
if (checkoutState.status !== "success") {
throw new Error("Checkout data not loaded");
}
const cart = checkoutState.data;
// Debug logging to check cart contents
console.log("[DEBUG] Cart data:", cart);
console.log("[DEBUG] Cart items:", cart.items);
// Validate cart before submission
await checkoutService.validateCart(cart);
// Use domain helper to prepare order data
// This encapsulates SKU extraction and payload formatting
const orderData = prepareOrderFromCart(cart, orderType);
console.log("[DEBUG] Extracted SKUs from cart:", orderData.skus);
const currentUserId = useAuthStore.getState().user?.id;
if (currentUserId) {
try {
orderWithSkuValidationSchema.parse({
...orderData,
userId: currentUserId,
});
} catch (validationError) {
if (validationError instanceof ZodError) {
const firstIssue = validationError.issues.at(0);
throw new Error(firstIssue?.message || "Order contains invalid data");
}
throw validationError;
}
}
const response = await ordersService.createOrder(orderData);
router.push(`/orders/${response.sfOrderId}?status=success`);
} catch (error) {
let errorMessage = "Order submission failed";
if (error instanceof Error) errorMessage = error.message;
setCheckoutState(createErrorState(new Error(errorMessage)));
} finally {
setSubmitting(false);
}
}, [checkoutState, orderType, router]);
const confirmAddress = useCallback((address?: Address) => {
setAddressConfirmed(true);
void address;
}, []);
const markAddressIncomplete = useCallback(() => {
setAddressConfirmed(false);
}, []);
const navigateBackToConfigure = useCallback(() => {
// State is already persisted in Zustand store
// Just need to restore params and navigate
const urlParams = new URLSearchParams(paramsKey);
urlParams.delete("type"); // Remove type param as it's not needed
const configureUrl =
orderType === ORDER_TYPE.INTERNET
? `/shop/internet/configure?${urlParams.toString()}`
: `/shop/sim/configure?${urlParams.toString()}`;
router.push(configureUrl);
}, [orderType, paramsKey, router]);
return {
checkoutState,
submitting,
orderType,
addressConfirmed,
paymentMethods,
paymentMethodsLoading,
paymentMethodsError,
paymentRefresh,
confirmAddress,
markAddressIncomplete,
handleSubmitOrder,
navigateBackToConfigure,
activeInternetWarning,
} as const;
}

View File

@ -1,110 +0,0 @@
/**
* Checkout API Service
*
* Handles API calls for checkout flow.
*/
import type { CartItem } from "@customer-portal/domain/checkout";
import type { AddressFormData } from "@customer-portal/domain/customer";
interface RegisterForCheckoutParams {
guestInfo: {
email: string;
firstName: string;
lastName: string;
phone: string;
phoneCountryCode: string;
password: string;
};
address: AddressFormData;
}
interface CheckoutRegisterResult {
success: boolean;
user: {
id: string;
email: string;
firstname: string;
lastname: string;
};
session: {
expiresAt: string;
refreshExpiresAt: string;
};
sfAccountNumber?: string;
}
export const checkoutApiService = {
/**
* Register a new user during checkout
*/
async registerForCheckout(params: RegisterForCheckoutParams): Promise<CheckoutRegisterResult> {
const response = await fetch("/api/checkout/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: params.guestInfo.email,
firstName: params.guestInfo.firstName,
lastName: params.guestInfo.lastName,
phone: params.guestInfo.phone,
phoneCountryCode: params.guestInfo.phoneCountryCode,
password: params.guestInfo.password,
address: params.address,
acceptTerms: true,
}),
credentials: "include",
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error?.message || errorData.message || "Registration failed");
}
return response.json();
},
/**
* Check if current user has a valid payment method
*/
async getPaymentStatus(): Promise<{ hasPaymentMethod: boolean }> {
try {
const response = await fetch("/api/checkout/payment-status", {
credentials: "include",
});
if (!response.ok) {
return { hasPaymentMethod: false };
}
return response.json();
} catch {
return { hasPaymentMethod: false };
}
},
/**
* Submit order
*/
async submitOrder(cartItem: CartItem): Promise<{ orderId?: string }> {
const response = await fetch("/api/orders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
orderType: cartItem.orderType,
skus: [cartItem.planSku, ...cartItem.addonSkus],
configuration: cartItem.configuration,
}),
credentials: "include",
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error?.message || "Failed to submit order");
}
const result = await response.json();
return {
orderId: result.data?.orderId ?? result.orderId,
};
},
};

View File

@ -7,6 +7,15 @@ import type {
} from "@customer-portal/domain/orders"; } from "@customer-portal/domain/orders";
import type { ApiSuccessResponse } from "@customer-portal/domain/common"; import type { ApiSuccessResponse } from "@customer-portal/domain/common";
type CheckoutCartSummary = { items: CheckoutCart["items"]; totals: CheckoutCart["totals"] };
type CheckoutSessionResponse = {
sessionId: string;
expiresAt: string;
orderType: OrderTypeValue;
cart: CheckoutCartSummary;
};
export const checkoutService = { export const checkoutService = {
/** /**
* Build checkout cart from order type and selections * Build checkout cart from order type and selections
@ -31,6 +40,40 @@ export const checkoutService = {
return wrappedResponse.data; return wrappedResponse.data;
}, },
async createSession(
orderType: OrderTypeValue,
selections: OrderSelections,
configuration?: OrderConfigurations
): Promise<CheckoutSessionResponse> {
const response = await apiClient.POST<ApiSuccessResponse<CheckoutSessionResponse>>(
"/api/checkout/session",
{
body: { orderType, selections, configuration },
}
);
const wrappedResponse = getDataOrThrow(response, "Failed to create checkout session");
if (!wrappedResponse.success) {
throw new Error("Failed to create checkout session");
}
return wrappedResponse.data;
},
async getSession(sessionId: string): Promise<CheckoutSessionResponse> {
const response = await apiClient.GET<ApiSuccessResponse<CheckoutSessionResponse>>(
"/api/checkout/session/{sessionId}",
{
params: { path: { sessionId } },
}
);
const wrappedResponse = getDataOrThrow(response, "Failed to load checkout session");
if (!wrappedResponse.success) {
throw new Error("Failed to load checkout session");
}
return wrappedResponse.data;
},
/** /**
* Validate checkout cart * Validate checkout cart
*/ */

View File

@ -13,6 +13,9 @@ import type { AddressFormData } from "@customer-portal/domain/customer";
interface CheckoutState { interface CheckoutState {
// Cart data // Cart data
cartItem: CartItem | null; cartItem: CartItem | null;
cartParamsSignature: string | null;
checkoutSessionId: string | null;
checkoutSessionExpiresAt: string | null;
// Guest info (pre-registration) // Guest info (pre-registration)
guestInfo: Partial<GuestInfo> | null; guestInfo: Partial<GuestInfo> | null;
@ -37,6 +40,8 @@ interface CheckoutState {
interface CheckoutActions { interface CheckoutActions {
// Cart actions // Cart actions
setCartItem: (item: CartItem) => void; setCartItem: (item: CartItem) => void;
setCartItemFromParams: (item: CartItem, signature: string) => void;
setCheckoutSession: (session: { id: string; expiresAt: string }) => void;
clearCart: () => void; clearCart: () => void;
// Guest info actions // Guest info actions
@ -71,6 +76,9 @@ const STEP_ORDER: CheckoutStep[] = ["account", "address", "payment", "review"];
const initialState: CheckoutState = { const initialState: CheckoutState = {
cartItem: null, cartItem: null,
cartParamsSignature: null,
checkoutSessionId: null,
checkoutSessionExpiresAt: null,
guestInfo: null, guestInfo: null,
address: null, address: null,
registrationComplete: false, registrationComplete: false,
@ -92,9 +100,43 @@ export const useCheckoutStore = create<CheckoutStore>()(
cartUpdatedAt: Date.now(), cartUpdatedAt: Date.now(),
}), }),
setCartItemFromParams: (item: CartItem, signature: string) => {
const { cartParamsSignature, cartItem } = get();
const signatureChanged = cartParamsSignature !== signature;
const hasExistingCart = cartItem !== null || cartParamsSignature !== null;
if (signatureChanged && hasExistingCart) {
set(initialState);
}
if (!signatureChanged && cartItem) {
// Allow refreshing cart totals without resetting progress
set({
cartItem: item,
cartUpdatedAt: Date.now(),
});
return;
}
set({
cartItem: item,
cartParamsSignature: signature,
cartUpdatedAt: Date.now(),
});
},
setCheckoutSession: session =>
set({
checkoutSessionId: session.id,
checkoutSessionExpiresAt: session.expiresAt,
}),
clearCart: () => clearCart: () =>
set({ set({
cartItem: null, cartItem: null,
cartParamsSignature: null,
checkoutSessionId: null,
checkoutSessionExpiresAt: null,
cartUpdatedAt: null, cartUpdatedAt: null,
}), }),
@ -171,7 +213,16 @@ export const useCheckoutStore = create<CheckoutStore>()(
storage: createJSONStorage(() => localStorage), storage: createJSONStorage(() => localStorage),
partialize: state => ({ partialize: state => ({
// Persist only essential data // Persist only essential data
cartItem: state.cartItem, cartItem: state.cartItem
? {
...state.cartItem,
// Avoid persisting potentially sensitive configuration details.
configuration: {},
}
: null,
cartParamsSignature: state.cartParamsSignature,
checkoutSessionId: state.checkoutSessionId,
checkoutSessionExpiresAt: state.checkoutSessionExpiresAt,
guestInfo: state.guestInfo, guestInfo: state.guestInfo,
address: state.address, address: state.address,
currentStep: state.currentStep, currentStep: state.currentStep,

View File

@ -1,369 +0,0 @@
"use client";
import { useCheckout } from "@/features/checkout/hooks/useCheckout";
import { PageLayout } from "@/components/templates/PageLayout";
import { SubCard } from "@/components/molecules/SubCard/SubCard";
import { Button } from "@/components/atoms/button";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { PageAsync } from "@/components/molecules/AsyncBlock/AsyncBlock";
import { InlineToast } from "@/components/atoms/inline-toast";
import { StatusPill } from "@/components/atoms/status-pill";
import { AddressConfirmation } from "@/features/catalog/components/base/AddressConfirmation";
import { isLoading, isError, isSuccess } from "@customer-portal/domain/toolkit";
import { ShieldCheckIcon, CreditCardIcon } from "@heroicons/react/24/outline";
import type { PaymentMethod } from "@customer-portal/domain/payments";
export function CheckoutContainer() {
const {
checkoutState,
submitting,
orderType,
addressConfirmed,
paymentMethods,
paymentMethodsLoading,
paymentMethodsError,
paymentRefresh,
confirmAddress,
markAddressIncomplete,
handleSubmitOrder,
navigateBackToConfigure,
activeInternetWarning,
} = useCheckout();
if (isLoading(checkoutState)) {
return (
<PageLayout
title="Submit Order"
description="Loading order details"
icon={<ShieldCheckIcon className="h-6 w-6" />}
>
<PageAsync isLoading loadingText="Loading order submission...">
<></>
</PageAsync>
</PageLayout>
);
}
if (isError(checkoutState)) {
return (
<PageLayout
title="Submit Order"
description="Error loading order submission"
icon={<ShieldCheckIcon className="h-6 w-6" />}
>
<div className="py-6">
<AlertBanner variant="error" title="Unable to load checkout" elevated>
<div className="flex items-center justify-between">
<span>{checkoutState.error.message}</span>
<Button variant="link" onClick={navigateBackToConfigure}>
Go Back
</Button>
</div>
</AlertBanner>
</div>
</PageLayout>
);
}
if (!isSuccess(checkoutState)) {
return (
<PageLayout
title="Submit Order"
description="Checkout data not available"
icon={<ShieldCheckIcon className="h-6 w-6" />}
>
<div className="py-6">
<AlertBanner variant="error" title="Checkout Error" elevated>
<div className="flex items-center justify-between">
<span>Checkout data is not available</span>
<Button variant="link" onClick={navigateBackToConfigure}>
Go Back
</Button>
</div>
</AlertBanner>
</div>
</PageLayout>
);
}
const { items, totals } = checkoutState.data;
const paymentMethodList = paymentMethods?.paymentMethods ?? [];
const defaultPaymentMethod =
paymentMethodList.find(method => method.isDefault) ?? paymentMethodList[0] ?? null;
const paymentMethodDisplay = defaultPaymentMethod
? buildPaymentMethodDisplay(defaultPaymentMethod)
: null;
return (
<PageLayout
title="Checkout"
description="Verify your address, review totals, and submit your order"
icon={<ShieldCheckIcon className="h-6 w-6" />}
>
<div className="max-w-2xl mx-auto space-y-8">
<InlineToast
visible={paymentRefresh.toast.visible}
text={paymentRefresh.toast.text}
tone={paymentRefresh.toast.tone}
/>
{activeInternetWarning && (
<AlertBanner variant="warning" title="Existing Internet Subscription" elevated>
<span className="text-sm text-foreground/80">{activeInternetWarning}</span>
</AlertBanner>
)}
<div className="bg-card border border-border rounded-2xl p-6 md:p-7 shadow-[var(--cp-shadow-1)]">
<div className="flex items-center gap-3 mb-6">
<ShieldCheckIcon className="w-6 h-6 text-primary" />
<h2 className="text-lg font-semibold text-foreground">Confirm Details</h2>
</div>
<div className="space-y-5">
<SubCard>
<AddressConfirmation
embedded
onAddressConfirmed={confirmAddress}
onAddressIncomplete={markAddressIncomplete}
orderType={orderType}
/>
</SubCard>
<SubCard
title="Billing & Payment"
icon={<CreditCardIcon className="w-5 h-5 text-primary" />}
right={
paymentMethods && paymentMethods.paymentMethods.length > 0 ? (
<StatusPill label="Verified" variant="success" />
) : undefined
}
>
{paymentMethodsLoading ? (
<div className="text-sm text-muted-foreground">Checking payment methods...</div>
) : paymentMethodsError ? (
<AlertBanner
variant="warning"
title="Unable to verify payment methods"
size="sm"
elevated
>
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
onClick={() => void paymentRefresh.triggerRefresh()}
>
Check Again
</Button>
<Button as="a" href="/billing/payments" size="sm">
Add Payment Method
</Button>
</div>
</AlertBanner>
) : paymentMethodList.length > 0 ? (
<div className="space-y-3">
{paymentMethodDisplay ? (
<div className="rounded-xl border border-border bg-card p-4 shadow-[var(--cp-shadow-1)] transition-shadow duration-200 hover:shadow-[var(--cp-shadow-2)]">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-primary">
Default payment method
</p>
<p className="mt-1 text-sm font-semibold text-foreground">
{paymentMethodDisplay.title}
</p>
{paymentMethodDisplay.subtitle ? (
<p className="mt-1 text-xs text-muted-foreground">
{paymentMethodDisplay.subtitle}
</p>
) : null}
</div>
<Button
as="a"
href="/billing/payments"
variant="link"
size="sm"
className="self-start whitespace-nowrap"
>
Manage billing & payments
</Button>
</div>
</div>
) : null}
<p className="text-xs text-muted-foreground">
We securely charge your saved payment method after the order is approved. Need
to make changes? Visit Billing & Payments.
</p>
</div>
) : (
<AlertBanner variant="error" title="No payment method on file" size="sm" elevated>
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
onClick={() => void paymentRefresh.triggerRefresh()}
>
Check Again
</Button>
<Button as="a" href="/billing/payments" size="sm">
Add Payment Method
</Button>
</div>
</AlertBanner>
)}
</SubCard>
</div>
</div>
<div className="bg-card border border-border rounded-2xl p-6 md:p-7 text-center shadow-[var(--cp-shadow-1)]">
<div className="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center mx-auto mb-4 shadow-sm border border-primary/20">
<ShieldCheckIcon className="w-8 h-8 text-primary" />
</div>
<h2 className="text-2xl font-bold text-foreground mb-2">Review & Submit</h2>
<p className="text-muted-foreground mb-4 max-w-xl mx-auto">
Youre almost done. Confirm your details above, then submit your order. Well review and
notify you when everything is ready.
</p>
<div className="bg-muted/50 rounded-lg p-4 border border-border text-left max-w-2xl mx-auto">
<h3 className="font-semibold text-foreground mb-2">What to expect</h3>
<div className="text-sm text-muted-foreground space-y-1">
<p> Our team reviews your order and schedules setup if needed</p>
<p> We may contact you to confirm details or availability</p>
<p> We only charge your card after the order is approved</p>
<p> Youll receive confirmation and next steps by email</p>
</div>
</div>
<div className="mt-4 bg-card rounded-lg p-4 border border-border max-w-2xl mx-auto shadow-[var(--cp-shadow-1)]">
<div className="flex justify-between items-center">
<span className="font-medium text-muted-foreground">Estimated Total</span>
<div className="text-right">
<div className="text-xl font-bold text-foreground">
¥{totals.monthlyTotal.toLocaleString()}/mo
</div>
{totals.oneTimeTotal > 0 && (
<div className="text-sm text-warning font-medium">
+ ¥{totals.oneTimeTotal.toLocaleString()} one-time
</div>
)}
</div>
</div>
</div>
</div>
<div className="flex gap-4">
<Button
type="button"
variant="ghost"
className="flex-1 py-4 text-muted-foreground hover:text-foreground"
onClick={navigateBackToConfigure}
>
Back to Configuration
</Button>
<Button
type="button"
className="flex-1 py-4 text-lg"
onClick={() => {
void handleSubmitOrder();
}}
disabled={
submitting ||
items.length === 0 ||
!addressConfirmed ||
paymentMethodsLoading ||
!paymentMethods ||
paymentMethods.paymentMethods.length === 0
}
isLoading={submitting}
loadingText="Submitting…"
>
{!addressConfirmed
? "Confirm Installation Address"
: paymentMethodsLoading
? "Verifying Payment Method…"
: !paymentMethods || paymentMethods.paymentMethods.length === 0
? "Add Payment Method to Continue"
: "Submit Order"}
</Button>
</div>
</div>
</PageLayout>
);
}
function buildPaymentMethodDisplay(method: PaymentMethod): { title: string; subtitle?: string } {
const descriptor =
method.cardType?.trim() ||
method.bankName?.trim() ||
method.description?.trim() ||
method.gatewayName?.trim() ||
"Saved payment method";
const trimmedLastFour =
typeof method.cardLastFour === "string" && method.cardLastFour.trim().length > 0
? method.cardLastFour.trim().slice(-4)
: null;
const headline =
trimmedLastFour && method.type?.toLowerCase().includes("card")
? `${descriptor} · •••• ${trimmedLastFour}`
: descriptor;
const details = new Set<string>();
if (method.bankName && !headline.toLowerCase().includes(method.bankName.trim().toLowerCase())) {
details.add(method.bankName.trim());
}
const expiry = normalizeExpiryLabel(method.expiryDate);
if (expiry) {
details.add(`Exp ${expiry}`);
}
if (!trimmedLastFour && method.cardLastFour && method.cardLastFour.trim().length > 0) {
details.add(`Ends ${method.cardLastFour.trim().slice(-4)}`);
}
if (method.type?.toLowerCase().includes("bank") && method.description?.trim()) {
details.add(method.description.trim());
}
const subtitle = details.size > 0 ? Array.from(details).join(" · ") : undefined;
return { title: headline, subtitle };
}
function normalizeExpiryLabel(expiry?: string | null): string | null {
if (!expiry) return null;
const value = expiry.trim();
if (!value) return null;
if (/^\d{4}-\d{2}$/.test(value)) {
const [year, month] = value.split("-");
return `${month}/${year.slice(-2)}`;
}
if (/^\d{2}\/\d{4}$/.test(value)) {
const [month, year] = value.split("/");
return `${month}/${year.slice(-2)}`;
}
if (/^\d{2}\/\d{2}$/.test(value)) {
return value;
}
const digits = value.replace(/\D/g, "");
if (digits.length === 6) {
const year = digits.slice(2, 4);
const month = digits.slice(4, 6);
return `${month}/${year}`;
}
if (digits.length === 4) {
const month = digits.slice(0, 2);
const year = digits.slice(2, 4);
return `${month}/${year}`;
}
return value;
}
export default CheckoutContainer;

View File

@ -135,7 +135,7 @@ export function QuickStats({
icon={ServerIcon} icon={ServerIcon}
label="Active Services" label="Active Services"
value={activeSubscriptions} value={activeSubscriptions}
href="/subscriptions" href="/account/services"
tone="primary" tone="primary"
emptyText="No active services" emptyText="No active services"
/> />
@ -143,7 +143,7 @@ export function QuickStats({
icon={ChatBubbleLeftRightIcon} icon={ChatBubbleLeftRightIcon}
label="Open Support Cases" label="Open Support Cases"
value={openCases} value={openCases}
href="/support/cases" href="/account/support"
tone={openCases > 0 ? "warning" : "info"} tone={openCases > 0 ? "warning" : "info"}
emptyText="No open cases" emptyText="No open cases"
/> />
@ -152,7 +152,7 @@ export function QuickStats({
icon={ClipboardDocumentListIcon} icon={ClipboardDocumentListIcon}
label="Recent Orders" label="Recent Orders"
value={recentOrders} value={recentOrders}
href="/orders" href="/account/orders"
tone="success" tone="success"
emptyText="No recent orders" emptyText="No recent orders"
/> />

View File

@ -52,18 +52,18 @@ function AllCaughtUp() {
{/* Quick action cards */} {/* Quick action cards */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mt-8 max-w-lg mx-auto"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mt-8 max-w-lg mx-auto">
<Link <Link
href="/catalog" href="/shop"
className="group flex flex-col items-center gap-2 p-4 rounded-xl bg-surface/80 backdrop-blur-sm border border-border/60 hover:border-primary/40 hover:shadow-lg transition-all" className="group flex flex-col items-center gap-2 p-4 rounded-xl bg-surface/80 backdrop-blur-sm border border-border/60 hover:border-primary/40 hover:shadow-lg transition-all"
> >
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center group-hover:bg-primary/20 transition-colors"> <div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center group-hover:bg-primary/20 transition-colors">
<Squares2X2Icon className="h-5 w-5 text-primary" /> <Squares2X2Icon className="h-5 w-5 text-primary" />
</div> </div>
<span className="text-sm font-medium text-foreground">Browse Catalog</span> <span className="text-sm font-medium text-foreground">Browse Services</span>
<ArrowRightIcon className="h-4 w-4 text-muted-foreground group-hover:text-primary group-hover:translate-x-0.5 transition-all" /> <ArrowRightIcon className="h-4 w-4 text-muted-foreground group-hover:text-primary group-hover:translate-x-0.5 transition-all" />
</Link> </Link>
<Link <Link
href="/billing/invoices" href="/account/billing/invoices"
className="group flex flex-col items-center gap-2 p-4 rounded-xl bg-surface/80 backdrop-blur-sm border border-border/60 hover:border-info/40 hover:shadow-lg transition-all" className="group flex flex-col items-center gap-2 p-4 rounded-xl bg-surface/80 backdrop-blur-sm border border-border/60 hover:border-info/40 hover:shadow-lg transition-all"
> >
<div className="w-10 h-10 rounded-lg bg-info/10 flex items-center justify-center group-hover:bg-info/20 transition-colors"> <div className="w-10 h-10 rounded-lg bg-info/10 flex items-center justify-center group-hover:bg-info/20 transition-colors">
@ -74,7 +74,7 @@ function AllCaughtUp() {
</Link> </Link>
<Link <Link
href="/support" href="/account/support"
className="group flex flex-col items-center gap-2 p-4 rounded-xl bg-surface/80 backdrop-blur-sm border border-border/60 hover:border-warning/40 hover:shadow-lg transition-all" className="group flex flex-col items-center gap-2 p-4 rounded-xl bg-surface/80 backdrop-blur-sm border border-border/60 hover:border-warning/40 hover:shadow-lg transition-all"
> >
<div className="w-10 h-10 rounded-lg bg-warning/10 flex items-center justify-center group-hover:bg-warning/20 transition-colors"> <div className="w-10 h-10 rounded-lg bg-warning/10 flex items-center justify-center group-hover:bg-warning/20 transition-colors">

View File

@ -82,7 +82,7 @@ function computeTasks({
title: isOverdue ? "Pay overdue invoice" : "Pay upcoming invoice", title: isOverdue ? "Pay overdue invoice" : "Pay upcoming invoice",
description: `Invoice #${summary.nextInvoice.id} · ${formatCurrency(summary.nextInvoice.amount, { currency: summary.nextInvoice.currency })} · ${dueText}`, description: `Invoice #${summary.nextInvoice.id} · ${formatCurrency(summary.nextInvoice.amount, { currency: summary.nextInvoice.currency })} · ${dueText}`,
actionLabel: "Pay now", actionLabel: "Pay now",
detailHref: `/billing/invoices/${summary.nextInvoice.id}`, detailHref: `/account/billing/invoices/${summary.nextInvoice.id}`,
requiresSsoAction: true, requiresSsoAction: true,
tone: "critical", tone: "critical",
icon: ExclamationCircleIcon, icon: ExclamationCircleIcon,
@ -103,7 +103,7 @@ function computeTasks({
title: "Add a payment method", title: "Add a payment method",
description: "Required to place orders and process invoices", description: "Required to place orders and process invoices",
actionLabel: "Add method", actionLabel: "Add method",
detailHref: "/billing/payments", detailHref: "/account/billing/payments",
requiresSsoAction: true, requiresSsoAction: true,
tone: "warning", tone: "warning",
icon: CreditCardIcon, icon: CreditCardIcon,
@ -135,7 +135,7 @@ function computeTasks({
title: "Order in progress", title: "Order in progress",
description: `${order.orderType || "Your"} order is ${statusText}`, description: `${order.orderType || "Your"} order is ${statusText}`,
actionLabel: "View details", actionLabel: "View details",
detailHref: `/orders/${order.id}`, detailHref: `/account/orders/${order.id}`,
tone: "info", tone: "info",
icon: ClockIcon, icon: ClockIcon,
metadata: { orderId: order.id }, metadata: { orderId: order.id },
@ -151,8 +151,8 @@ function computeTasks({
type: "onboarding", type: "onboarding",
title: "Start your first service", title: "Start your first service",
description: "Browse our catalog and subscribe to internet, SIM, or VPN", description: "Browse our catalog and subscribe to internet, SIM, or VPN",
actionLabel: "Browse catalog", actionLabel: "Browse services",
detailHref: "/catalog", detailHref: "/shop",
tone: "neutral", tone: "neutral",
icon: SparklesIcon, icon: SparklesIcon,
}); });

View File

@ -38,12 +38,12 @@ export function getActivityNavigationPath(activity: Activity): string | null {
switch (activity.type) { switch (activity.type) {
case "invoice_created": case "invoice_created":
case "invoice_paid": case "invoice_paid":
return `/billing/invoices/${activity.relatedId}`; return `/account/billing/invoices/${activity.relatedId}`;
case "service_activated": case "service_activated":
return `/subscriptions/${activity.relatedId}`; return `/account/services/${activity.relatedId}`;
case "case_created": case "case_created":
case "case_closed": case "case_closed":
return `/support/cases/${activity.relatedId}`; return `/account/support/${activity.relatedId}`;
default: default:
return null; return null;
} }

View File

@ -27,7 +27,7 @@ export function PublicLandingView() {
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold tracking-tight text-foreground"> <h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold tracking-tight text-foreground">
Customer Portal Account Portal
</h1> </h1>
<p className="text-lg sm:text-xl text-muted-foreground max-w-2xl mx-auto leading-relaxed"> <p className="text-lg sm:text-xl text-muted-foreground max-w-2xl mx-auto leading-relaxed">
Manage your services, billing, and support in one place. Manage your services, billing, and support in one place.
@ -54,7 +54,7 @@ export function PublicLandingView() {
href="/shop" href="/shop"
className="inline-flex items-center justify-center gap-2 rounded-xl px-8 py-4 text-base font-semibold bg-primary text-primary-foreground hover:bg-primary-hover shadow-lg shadow-primary/30 hover:shadow-xl hover:shadow-primary/40 transition-all whitespace-nowrap" className="inline-flex items-center justify-center gap-2 rounded-xl px-8 py-4 text-base font-semibold bg-primary text-primary-foreground hover:bg-primary-hover shadow-lg shadow-primary/30 hover:shadow-xl hover:shadow-primary/40 transition-all whitespace-nowrap"
> >
View Catalog Shop Services
<ArrowRightIcon className="h-5 w-5" /> <ArrowRightIcon className="h-5 w-5" />
</Link> </Link>
</div> </div>

Some files were not shown because too many files have changed in this diff Show More