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 { WhmcsService } from "@bff/integrations/whmcs/whmcs.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 { AuthTokenService } from "../../token/token.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 whmcsService: WhmcsService,
private readonly salesforceService: SalesforceService,
private readonly salesforceAccountService: SalesforceAccountService,
private readonly configService: ConfigService,
private readonly prisma: PrismaService,
private readonly auditService: AuditService,
@ -66,14 +68,30 @@ export class SignupWorkflowService {
async validateSignup(validateData: ValidateSignupRequest, request?: Request) {
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 {
const accountSnapshot = await this.getAccountSnapshot(sfNumber);
const accountSnapshot = await this.getAccountSnapshot(normalizedCustomerNumber);
if (!accountSnapshot) {
await this.auditService.logAuthEvent(
AuditAction.SIGNUP,
undefined,
{ sfNumber, reason: "SF number not found" },
{ sfNumber: normalizedCustomerNumber, reason: "SF number not found" },
request,
false,
"Customer number not found in Salesforce"
@ -118,7 +136,7 @@ export class SignupWorkflowService {
await this.auditService.logAuthEvent(
AuditAction.SIGNUP,
undefined,
{ sfNumber, sfAccountId: accountSnapshot.id, step: "validation" },
{ sfNumber: normalizedCustomerNumber, sfAccountId: accountSnapshot.id, step: "validation" },
request,
true
);
@ -136,7 +154,7 @@ export class SignupWorkflowService {
await this.auditService.logAuthEvent(
AuditAction.SIGNUP,
undefined,
{ sfNumber, error: getErrorMessage(error) },
{ sfNumber: normalizedCustomerNumber, error: getErrorMessage(error) },
request,
false,
getErrorMessage(error)
@ -189,17 +207,87 @@ export class SignupWorkflowService {
const passwordHash = await argon2.hash(password);
try {
const accountSnapshot = await this.getAccountSnapshot(sfNumber);
if (!accountSnapshot) {
throw new BadRequestException(
`Salesforce account not found for Customer Number: ${sfNumber}`
);
}
const normalizedCustomerNumber = this.normalizeCustomerNumber(sfNumber);
let accountSnapshot: SignupAccountSnapshot;
let customerNumberForWhmcs: string | null = normalizedCustomerNumber;
if (accountSnapshot.WH_Account__c && accountSnapshot.WH_Account__c.trim() !== "") {
throw new ConflictException(
"You already have an account. Please use the login page to access your existing account."
);
if (normalizedCustomerNumber) {
const resolved = await this.getAccountSnapshot(normalizedCustomerNumber);
if (!resolved) {
throw new BadRequestException(
`Salesforce account not found for Customer Number: ${normalizedCustomerNumber}`
);
}
if (resolved.WH_Account__c && resolved.WH_Account__c.trim() !== "") {
throw new ConflictException(
"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 };
@ -232,7 +320,9 @@ export class SignupWorkflowService {
const nationalityFieldId = this.configService.get<string>("WHMCS_NATIONALITY_FIELD_ID");
const customfieldsMap: Record<string, string> = {};
if (customerNumberFieldId) customfieldsMap[customerNumberFieldId] = sfNumber;
if (customerNumberFieldId && customerNumberForWhmcs) {
customfieldsMap[customerNumberFieldId] = customerNumberForWhmcs;
}
if (dobFieldId && dateOfBirth) customfieldsMap[dobFieldId] = dateOfBirth;
if (genderFieldId && gender) customfieldsMap[genderFieldId] = gender;
if (nationalityFieldId && nationality) customfieldsMap[nationalityFieldId] = nationality;
@ -253,7 +343,12 @@ export class SignupWorkflowService {
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({
firstname: firstName,
@ -399,6 +494,7 @@ export class SignupWorkflowService {
async signupPreflight(signupData: SignupRequest) {
const { email, sfNumber } = signupData;
const normalizedEmail = email.toLowerCase().trim();
const normalizedCustomerNumber = this.normalizeCustomerNumber(sfNumber);
const result: {
ok: boolean;
@ -440,7 +536,59 @@ export class SignupWorkflowService {
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) {
result.nextAction = "fix_input";
result.messages.push("Customer number not found in Salesforce");
@ -494,7 +642,9 @@ export class SignupWorkflowService {
return result;
}
private async getAccountSnapshot(sfNumber: string): Promise<SignupAccountSnapshot | null> {
private async getAccountSnapshot(
sfNumber?: string | null
): Promise<SignupAccountSnapshot | null> {
const normalized = this.normalizeCustomerNumber(sfNumber);
if (!normalized) {
return null;
@ -519,7 +669,7 @@ export class SignupWorkflowService {
return resolved;
}
private normalizeCustomerNumber(sfNumber: string): string | null {
private normalizeCustomerNumber(sfNumber?: string | null): string | null {
if (typeof sfNumber !== "string") {
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 { ZodValidationPipe } from "nestjs-zod";
import { Public } from "@bff/modules/auth/decorators/public.decorator.js";
import { CheckoutService } from "../services/checkout.service.js";
import { CheckoutSessionService } from "../services/checkout-session.service.js";
import {
checkoutItemSchema,
checkoutCartSchema,
checkoutBuildCartRequestSchema,
checkoutBuildCartResponseSchema,
checkoutTotalsSchema,
} from "@customer-portal/domain/orders";
import type { CheckoutCart, CheckoutBuildCartRequest } from "@customer-portal/domain/orders";
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";
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")
@Public() // Cart building and validation can be done without authentication
export class CheckoutController {
constructor(
private readonly checkoutService: CheckoutService,
private readonly checkoutSessions: CheckoutSessionService,
@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")
@UsePipes(new ZodValidationPipe(checkoutCartSchema))
validateCart(@Body() cart: CheckoutCart) {

View File

@ -29,12 +29,21 @@ import { Observable } from "rxjs";
import { OrderEventsService } from "./services/order-events.service.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 { 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")
@UseGuards(RateLimitGuard)
export class OrdersController {
constructor(
private orderOrchestrator: OrderOrchestrator,
private readonly checkoutService: CheckoutService,
private readonly checkoutSessions: CheckoutSessionService,
private readonly orderEvents: OrderEventsService,
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")
@UseGuards(SalesforceReadThrottleGuard)
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 { PaymentValidatorService } from "./services/payment-validator.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 { OrdersCacheService } from "./services/orders-cache.service.js";
@ -54,6 +55,7 @@ import { OrderFieldConfigModule } from "./config/order-field-config.module.js";
OrderOrchestrator,
OrdersCacheService,
CheckoutService,
CheckoutSessionService,
// Order fulfillment services (modular)
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";
export default function PublicContactPage() {
export default function ContactPage() {
return <PublicContactView />;
}

View File

@ -4,8 +4,8 @@
* 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() {
return <CheckoutWizard />;
return <CheckoutEntry />;
}

View File

@ -4,8 +4,13 @@
* 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 }) {
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 { LoadingCard, Skeleton } from "@/components/atoms/loading-skeleton";
export default function InvoiceDetailLoading() {
export default function AccountInvoiceDetailLoading() {
return (
<RouteLoading
icon={<CreditCardIcon />}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,10 +1,12 @@
import type { ReactNode } from "react";
import { AppShell } from "@/components/organisms";
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 (
<AppShell>
<AccountRouteGuard />
<AccountEventsListener />
{children}
</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";
export default function OrderDetailPage() {
export default function AccountOrderDetailPage() {
return <OrderDetailContainer />;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -67,9 +67,10 @@ export function AppShell({ children }: AppShellProps) {
useEffect(() => {
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
useEffect(() => {
@ -97,10 +98,10 @@ export function AppShell({ children }: AppShellProps) {
useEffect(() => {
setExpandedItems(prev => {
const next = new Set(prev);
if (pathname.startsWith("/subscriptions")) next.add("Subscriptions");
if (pathname.startsWith("/billing")) next.add("Billing");
if (pathname.startsWith("/support")) next.add("Support");
if (pathname.startsWith("/account")) next.add("Account");
if (pathname.startsWith("/account/services")) next.add("My Services");
if (pathname.startsWith("/account/billing")) next.add("Billing");
if (pathname.startsWith("/account/support")) next.add("Support");
if (pathname.startsWith("/account/settings")) next.add("Settings");
const result = Array.from(next);
// Avoid state update if unchanged
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">
<Link
href="/support"
href="/account/support"
prefetch
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"
@ -49,7 +49,7 @@ export const Header = memo(function Header({ onMenuClick, user, profileReady }:
</Link>
<Link
href="/account"
href="/account/settings"
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"
>

View File

@ -26,33 +26,33 @@ export interface NavigationItem {
}
export const baseNavigation: NavigationItem[] = [
{ name: "Dashboard", href: "/dashboard", icon: HomeIcon },
{ name: "Orders", href: "/orders", icon: ClipboardDocumentListIcon },
{ name: "Dashboard", href: "/account", icon: HomeIcon },
{ name: "Orders", href: "/account/orders", icon: ClipboardDocumentListIcon },
{
name: "Billing",
icon: CreditCardIcon,
children: [
{ name: "Invoices", href: "/billing/invoices" },
{ name: "Payment Methods", href: "/billing/payments" },
{ name: "Invoices", href: "/account/billing/invoices" },
{ name: "Payment Methods", href: "/account/billing/payments" },
],
},
{
name: "Subscriptions",
name: "My Services",
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",
icon: ChatBubbleLeftRightIcon,
children: [
{ name: "Cases", href: "/support/cases" },
{ name: "New Case", href: "/support/new" },
{ name: "Cases", href: "/account/support" },
{ name: "New Case", href: "/account/support/new" },
],
},
{
name: "Account",
href: "/account",
name: "Settings",
href: "/account/settings",
icon: UserIcon,
},
{ name: "Log out", href: "#", icon: ArrowRightStartOnRectangleIcon, isLogout: true },
@ -64,17 +64,17 @@ export function computeNavigation(activeSubscriptions?: Subscription[]): Navigat
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) {
const dynamicChildren = (activeSubscriptions || []).map(sub => ({
name: truncate(sub.productName || `Subscription ${sub.id}`, 28),
href: `/subscriptions/${sub.id}`,
href: `/account/services/${sub.id}`,
tooltip: sub.productName || `Subscription ${sub.id}`,
}));
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.
*/
"use client";
import type { ReactNode } from "react";
import { useEffect } from "react";
import Link from "next/link";
import { Logo } from "@/components/atoms/logo";
import { useAuthStore } from "@/features/auth/services/auth.store";
export interface CatalogShellProps {
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) {
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 (
<div className="min-h-screen flex flex-col bg-background text-foreground">
{/* Subtle background pattern */}
@ -34,39 +83,11 @@ export function CatalogShell({ children }: CatalogShellProps) {
Assist Solutions
</span>
<span className="block text-xs text-muted-foreground leading-tight truncate">
Customer Portal
Account Portal
</span>
</span>
</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 */}
<div className="flex items-center gap-2">
<Link
@ -75,16 +96,27 @@ export function CatalogShell({ children }: CatalogShellProps) {
>
Support
</Link>
<Link
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"
>
Sign in
</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
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"
>
Sign in
</Link>
)}
</div>
</div>
</header>
<CatalogNav />
<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">
{children}

View File

@ -1,2 +1,2 @@
export { CatalogShell } from "./CatalogShell";
export { CatalogNav, CatalogShell } 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 { useEffect } from "react";
import Link from "next/link";
import { Logo } from "@/components/atoms/logo";
import { useAuthStore } from "@/features/auth/services/auth.store";
export interface PublicShellProps {
children: ReactNode;
}
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 (
<div className="min-h-screen flex flex-col bg-background text-foreground">
{/* Subtle background pattern */}
@ -26,7 +46,7 @@ export function PublicShell({ children }: PublicShellProps) {
Assist Solutions
</span>
<span className="block text-xs text-muted-foreground leading-tight truncate">
Customer Portal
Account Portal
</span>
</span>
</Link>
@ -44,12 +64,21 @@ export function PublicShell({ children }: PublicShellProps) {
>
Support
</Link>
<Link
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"
>
Sign in
</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
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"
>
Sign in
</Link>
)}
</nav>
</div>
</header>

View File

@ -27,7 +27,7 @@ import { ReviewStep } from "./steps/ReviewStep";
* - phoneCountryCode: Separate field for country code input
* - 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"),
phoneCountryCode: z.string().regex(/^\+\d{1,4}$/, "Enter a valid country code (e.g., +81)"),
address: addressFormSchema,
@ -75,16 +75,7 @@ const STEPS = [
] as const;
const STEP_FIELD_KEYS: Record<(typeof STEPS)[number]["key"], Array<keyof SignupFormData>> = {
account: [
"sfNumber",
"firstName",
"lastName",
"email",
"phone",
"phoneCountryCode",
"dateOfBirth",
"gender",
],
account: ["firstName", "lastName", "email", "phone", "phoneCountryCode", "dateOfBirth", "gender"],
address: ["address"],
password: ["password", "confirmPassword"],
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> = {
account: signupFormBaseSchema.pick({
sfNumber: true,
firstName: true,
lastName: true,
email: true,
@ -130,7 +120,6 @@ export function SignupForm({ onSuccess, onError, className = "" }: SignupFormPro
const form = useZodForm<SignupFormData>({
schema: signupFormSchema,
initialValues: {
sfNumber: "",
firstName: "",
lastName: "",
email: "",

View File

@ -1,5 +1,5 @@
/**
* Account Step - Customer number and contact info
* Account Step - Contact info
*/
"use client";
@ -10,7 +10,6 @@ import { FormField } from "@/components/molecules/FormField/FormField";
interface AccountStepProps {
form: {
values: {
sfNumber: string;
firstName: string;
lastName: string;
email: string;
@ -33,24 +32,6 @@ export function AccountStep({ form }: AccountStepProps) {
return (
<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 */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="First Name" error={getError("firstName")} required>
@ -61,6 +42,7 @@ export function AccountStep({ form }: AccountStepProps) {
onBlur={() => setTouchedField("firstName")}
placeholder="Taro"
autoComplete="given-name"
autoFocus
/>
</FormField>
<FormField label="Last Name" error={getError("lastName")} required>

View File

@ -68,7 +68,6 @@ interface ReviewStepProps {
email: string;
phone: string;
phoneCountryCode: string;
sfNumber: string;
company?: string;
dateOfBirth?: string;
gender?: "male" | "female" | "other";
@ -116,10 +115,6 @@ export function ReviewStep({ form }: ReviewStepProps) {
Account Summary
</h4>
<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">
<dt className="text-muted-foreground">Name</dt>
<dd className="text-foreground font-medium">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -91,7 +91,7 @@ export function InvoiceDetailContainer() {
variant="page"
/>
<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
</Link>
</div>
@ -105,8 +105,8 @@ export function InvoiceDetailContainer() {
title={`Invoice #${invoice.id}`}
description="Invoice details and actions"
breadcrumbs={[
{ label: "Billing", href: "/billing/invoices" },
{ label: "Invoices", href: "/billing/invoices" },
{ label: "Billing", href: "/account/billing/invoices" },
{ label: "Invoices", href: "/account/billing/invoices" },
{ label: `#${invoice.id}` },
]}
>

View File

@ -20,7 +20,7 @@ interface InternetPlanCardProps {
installations: InternetInstallationCatalogItem[];
disabled?: boolean;
disabledReason?: string;
/** Override the default configure href (default: /catalog/internet/configure?plan=...) */
/** Override the default configure href (default: /shop/internet/configure?plan=...) */
configureHref?: string;
}
@ -205,7 +205,7 @@ export function InternetPlanCard({
const { resetInternetConfig, setInternetConfig } = useCatalogStore.getState();
resetInternetConfig();
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);
}}
>

View File

@ -237,7 +237,7 @@ function PlanHeader({ plan }: { plan: InternetPlanCatalogItem }) {
<div className="text-center mb-8 animate-in fade-in duration-300">
<Button
as="a"
href="/catalog/internet"
href="/shop/internet"
variant="ghost"
size="sm"
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" />
<h2 className="text-xl font-semibold text-foreground mb-2">Plan Not Found</h2>
<p className="text-muted-foreground mb-4">The selected plan could not be found</p>
<a href="/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
</a>
</div>
@ -185,7 +185,7 @@ export function SimConfigureView({
icon={<DevicePhoneMobileIcon className="h-6 w-6" />}
>
<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">
<div className="flex justify-between items-start">

View File

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

View File

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

View File

@ -89,7 +89,7 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
// Redirect if no plan selected
if (!effectivePlanSku && !configState.planSku) {
router.push("/catalog/sim");
router.push("/shop/sim");
}
}, [
configState.planSku,

View File

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

View File

@ -68,7 +68,7 @@ export function InternetPlansContainer() {
>
<AsyncBlock isLoading={false} error={error}>
<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 */}
<div className="text-center mb-12">
@ -112,7 +112,7 @@ export function InternetPlansContainer() {
icon={<WifiIcon className="h-6 w-6" />}
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
<CatalogBackLink href="/catalog" label="Back to Services" />
<CatalogBackLink href="/shop" label="Back to Services" />
<CatalogHero
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
for a different residence, please{" "}
<a
href="/support/new"
href="/account/support/new"
className="underline text-primary hover:text-primary-hover font-medium transition-colors"
>
contact us
@ -197,7 +197,7 @@ export function InternetPlansContainer() {
We couldn&apos;t find any internet plans available for your location at this time.
</p>
<CatalogBackLink
href="/catalog"
href="/shop"
label="Back to Services"
align="center"
className="mt-0 mb-0"

View File

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

View File

@ -39,7 +39,7 @@ export function PublicSimPlansView() {
if (isLoading) {
return (
<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">
<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>
<Button
as="a"
href="/catalog"
href="/shop"
className="mt-4"
leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
>
@ -96,7 +96,7 @@ export function PublicSimPlansView() {
return (
<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
title="Choose Your SIM Plan"

View File

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

View File

@ -45,7 +45,7 @@ export function SimPlansContainer() {
icon={<DevicePhoneMobileIcon className="h-6 w-6" />}
>
<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 */}
<div className="text-center mb-12">
@ -110,7 +110,7 @@ export function SimPlansContainer() {
<div className="text-red-600 text-sm mt-1">{errorMessage}</div>
<Button
as="a"
href="/catalog"
href="/shop"
className="mt-4"
leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
>
@ -140,7 +140,7 @@ export function SimPlansContainer() {
icon={<DevicePhoneMobileIcon className="h-6 w-6" />}
>
<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
title="Choose Your SIM Plan"

View File

@ -24,7 +24,7 @@ export function VpnPlansView() {
icon={<ShieldCheckIcon className="h-6 w-6" />}
>
<div className="max-w-6xl mx-auto px-4">
<CatalogBackLink href="/catalog" label="Back to Services" />
<CatalogBackLink href="/shop" label="Back to Services" />
<AsyncBlock
isLoading={isLoading}
@ -52,7 +52,7 @@ export function VpnPlansView() {
icon={<ShieldCheckIcon className="h-6 w-6" />}
>
<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
title="SonixNet VPN Router Service"
@ -89,7 +89,7 @@ export function VpnPlansView() {
We couldn&apos;t find any VPN plans available at this time.
</p>
<CatalogBackLink
href="/catalog"
href="/shop"
label="Back to Services"
align="center"
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>
<p className="text-xs text-muted-foreground mt-6">
If this problem persists, please{" "}
<Link href="/support/contact" className="text-primary hover:underline">
<Link href="/contact" className="text-primary hover:underline">
contact support
</Link>
.

View File

@ -1,9 +1,11 @@
"use client";
import type { ReactNode } from "react";
import { useEffect } from "react";
import Link from "next/link";
import { Logo } from "@/components/atoms/logo";
import { ShieldCheckIcon } from "@heroicons/react/24/outline";
import { useAuthStore } from "@/features/auth/services/auth.store";
interface CheckoutShellProps {
children: ReactNode;
@ -19,6 +21,15 @@ interface CheckoutShellProps {
* - Clean, focused design
*/
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 (
<div className="min-h-screen flex flex-col bg-background text-foreground">
{/* Subtle background pattern */}
@ -51,7 +62,7 @@ export function CheckoutShell({ children }: CheckoutShellProps) {
<span>Secure Checkout</span>
</div>
<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"
>
Need Help?

View File

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

View File

@ -8,7 +8,7 @@ import { Button } from "@/components/atoms/button";
/**
* 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() {
const router = useRouter();
@ -29,13 +29,13 @@ export function EmptyCartRedirect() {
</div>
<h2 className="text-xl font-semibold text-foreground mb-2">Your cart is empty</h2>
<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>
<Button as="a" href="/shop" className="w-full">
Browse Catalog
Browse Services
</Button>
<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>
</div>
</div>

View File

@ -84,10 +84,10 @@ export function OrderConfirmation() {
{/* Actions */}
<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
</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
</Button>
</div>
@ -95,7 +95,7 @@ export function OrderConfirmation() {
{/* Support Link */}
<p className="text-sm text-muted-foreground mt-8">
Have questions?{" "}
<Link href="/support" className="text-primary hover:underline">
<Link href="/contact" className="text-primary hover:underline">
Contact Support
</Link>
</p>

View File

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

View File

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

View File

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

View File

@ -3,6 +3,8 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
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 {
ShieldCheckIcon,
@ -21,8 +23,16 @@ import {
*/
export function ReviewStep() {
const router = useRouter();
const { cartItem, guestInfo, address, paymentMethodVerified, setCurrentStep, clear } =
useCheckoutStore();
const { user } = useAuthSession();
const {
cartItem,
guestInfo,
address,
paymentMethodVerified,
checkoutSessionId,
setCurrentStep,
clear,
} = useCheckoutStore();
const [termsAccepted, setTermsAccepted] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
@ -43,31 +53,17 @@ export function ReviewStep() {
setError(null);
try {
// Submit order via API
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");
if (!checkoutSessionId) {
throw new Error("Checkout session expired. Please restart checkout from the shop.");
}
const result = await response.json();
const orderId = result.data?.orderId ?? result.orderId;
const result = await ordersService.createOrderFromCheckoutSession(checkoutSessionId);
// Clear checkout state
clear();
// Redirect to confirmation
router.push(`/order/complete${orderId ? `?orderId=${orderId}` : ""}`);
router.push(`/order/complete?orderId=${encodeURIComponent(result.sfOrderId)}`);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to submit order");
setIsSubmitting(false);
@ -106,9 +102,9 @@ export function ReviewStep() {
</Button>
</div>
<p className="text-sm text-muted-foreground">
{guestInfo?.firstName} {guestInfo?.lastName}
{guestInfo?.firstName || user?.firstname} {guestInfo?.lastName || user?.lastname}
</p>
<p className="text-sm text-muted-foreground">{guestInfo?.email}</p>
<p className="text-sm text-muted-foreground">{guestInfo?.email || user?.email}</p>
</div>
{/* 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";
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 = {
/**
* Build checkout cart from order type and selections
@ -31,6 +40,40 @@ export const checkoutService = {
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
*/

View File

@ -13,6 +13,9 @@ import type { AddressFormData } from "@customer-portal/domain/customer";
interface CheckoutState {
// Cart data
cartItem: CartItem | null;
cartParamsSignature: string | null;
checkoutSessionId: string | null;
checkoutSessionExpiresAt: string | null;
// Guest info (pre-registration)
guestInfo: Partial<GuestInfo> | null;
@ -37,6 +40,8 @@ interface CheckoutState {
interface CheckoutActions {
// Cart actions
setCartItem: (item: CartItem) => void;
setCartItemFromParams: (item: CartItem, signature: string) => void;
setCheckoutSession: (session: { id: string; expiresAt: string }) => void;
clearCart: () => void;
// Guest info actions
@ -71,6 +76,9 @@ const STEP_ORDER: CheckoutStep[] = ["account", "address", "payment", "review"];
const initialState: CheckoutState = {
cartItem: null,
cartParamsSignature: null,
checkoutSessionId: null,
checkoutSessionExpiresAt: null,
guestInfo: null,
address: null,
registrationComplete: false,
@ -92,9 +100,43 @@ export const useCheckoutStore = create<CheckoutStore>()(
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: () =>
set({
cartItem: null,
cartParamsSignature: null,
checkoutSessionId: null,
checkoutSessionExpiresAt: null,
cartUpdatedAt: null,
}),
@ -171,7 +213,16 @@ export const useCheckoutStore = create<CheckoutStore>()(
storage: createJSONStorage(() => localStorage),
partialize: state => ({
// 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,
address: state.address,
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}
label="Active Services"
value={activeSubscriptions}
href="/subscriptions"
href="/account/services"
tone="primary"
emptyText="No active services"
/>
@ -143,7 +143,7 @@ export function QuickStats({
icon={ChatBubbleLeftRightIcon}
label="Open Support Cases"
value={openCases}
href="/support/cases"
href="/account/support"
tone={openCases > 0 ? "warning" : "info"}
emptyText="No open cases"
/>
@ -152,7 +152,7 @@ export function QuickStats({
icon={ClipboardDocumentListIcon}
label="Recent Orders"
value={recentOrders}
href="/orders"
href="/account/orders"
tone="success"
emptyText="No recent orders"
/>

View File

@ -52,18 +52,18 @@ function AllCaughtUp() {
{/* Quick action cards */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mt-8 max-w-lg mx-auto">
<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"
>
<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" />
</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" />
</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"
>
<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
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"
>
<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",
description: `Invoice #${summary.nextInvoice.id} · ${formatCurrency(summary.nextInvoice.amount, { currency: summary.nextInvoice.currency })} · ${dueText}`,
actionLabel: "Pay now",
detailHref: `/billing/invoices/${summary.nextInvoice.id}`,
detailHref: `/account/billing/invoices/${summary.nextInvoice.id}`,
requiresSsoAction: true,
tone: "critical",
icon: ExclamationCircleIcon,
@ -103,7 +103,7 @@ function computeTasks({
title: "Add a payment method",
description: "Required to place orders and process invoices",
actionLabel: "Add method",
detailHref: "/billing/payments",
detailHref: "/account/billing/payments",
requiresSsoAction: true,
tone: "warning",
icon: CreditCardIcon,
@ -135,7 +135,7 @@ function computeTasks({
title: "Order in progress",
description: `${order.orderType || "Your"} order is ${statusText}`,
actionLabel: "View details",
detailHref: `/orders/${order.id}`,
detailHref: `/account/orders/${order.id}`,
tone: "info",
icon: ClockIcon,
metadata: { orderId: order.id },
@ -151,8 +151,8 @@ function computeTasks({
type: "onboarding",
title: "Start your first service",
description: "Browse our catalog and subscribe to internet, SIM, or VPN",
actionLabel: "Browse catalog",
detailHref: "/catalog",
actionLabel: "Browse services",
detailHref: "/shop",
tone: "neutral",
icon: SparklesIcon,
});

View File

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

View File

@ -27,7 +27,7 @@ export function PublicLandingView() {
</div>
<div className="space-y-4">
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold tracking-tight text-foreground">
Customer Portal
Account Portal
</h1>
<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.
@ -54,7 +54,7 @@ export function PublicLandingView() {
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"
>
View Catalog
Shop Services
<ArrowRightIcon className="h-5 w-5" />
</Link>
</div>

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