diff --git a/.cursor/plans/restructure_to_account_portal_efdb4b10.plan.md b/.cursor/plans/restructure_to_account_portal_efdb4b10.plan.md new file mode 100644 index 00000000..9fea44ba --- /dev/null +++ b/.cursor/plans/restructure_to_account_portal_efdb4b10.plan.md @@ -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 ( +
+
+ +
+ ... +
+ ); +} +``` + +### 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 {children}; +} +``` + +### 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 ( + <> + + {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** diff --git a/apps/bff/src/modules/auth/infra/workflows/workflows/signup-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/workflows/signup-workflow.service.ts index bce6f5f2..91b2744d 100644 --- a/apps/bff/src/modules/auth/infra/workflows/workflows/signup-workflow.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/workflows/signup-workflow.service.ts @@ -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("WHMCS_NATIONALITY_FIELD_ID"); const customfieldsMap: Record = {}; - 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 { + private async getAccountSnapshot( + sfNumber?: string | null + ): Promise { 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; } diff --git a/apps/bff/src/modules/orders/controllers/checkout.controller.ts b/apps/bff/src/modules/orders/controllers/checkout.controller.ts index d5ee4423..773a8761 100644 --- a/apps/bff/src/modules/orders/controllers/checkout.controller.ts +++ b/apps/bff/src/modules/orders/controllers/checkout.controller.ts @@ -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) { diff --git a/apps/bff/src/modules/orders/orders.controller.ts b/apps/bff/src/modules/orders/orders.controller.ts index 8350e3de..54883b84 100644 --- a/apps/bff/src/modules/orders/orders.controller.ts +++ b/apps/bff/src/modules/orders/orders.controller.ts @@ -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) { diff --git a/apps/bff/src/modules/orders/orders.module.ts b/apps/bff/src/modules/orders/orders.module.ts index e865c8a1..e9028f4b 100644 --- a/apps/bff/src/modules/orders/orders.module.ts +++ b/apps/bff/src/modules/orders/orders.module.ts @@ -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, diff --git a/apps/portal/src/app/(authenticated)/account/page.tsx b/apps/portal/src/app/(authenticated)/account/page.tsx deleted file mode 100644 index ed663a5b..00000000 --- a/apps/portal/src/app/(authenticated)/account/page.tsx +++ /dev/null @@ -1,7 +0,0 @@ -"use client"; - -import ProfileContainer from "@/features/account/views/ProfileContainer"; - -export default function AccountPage() { - return ; -} diff --git a/apps/portal/src/app/(authenticated)/catalog/internet/configure/page.tsx b/apps/portal/src/app/(authenticated)/catalog/internet/configure/page.tsx deleted file mode 100644 index 6029868b..00000000 --- a/apps/portal/src/app/(authenticated)/catalog/internet/configure/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import InternetConfigureContainer from "@/features/catalog/views/InternetConfigure"; - -export default function InternetConfigurePage() { - return ; -} diff --git a/apps/portal/src/app/(authenticated)/catalog/internet/page.tsx b/apps/portal/src/app/(authenticated)/catalog/internet/page.tsx deleted file mode 100644 index c2181889..00000000 --- a/apps/portal/src/app/(authenticated)/catalog/internet/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { InternetPlansContainer } from "@/features/catalog/views/InternetPlans"; - -export default function InternetPlansPage() { - return ; -} diff --git a/apps/portal/src/app/(authenticated)/catalog/loading.tsx b/apps/portal/src/app/(authenticated)/catalog/loading.tsx deleted file mode 100644 index 388d7699..00000000 --- a/apps/portal/src/app/(authenticated)/catalog/loading.tsx +++ /dev/null @@ -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 ( - } - title="Catalog" - description="Loading catalog..." - mode="content" - > -
- {Array.from({ length: 6 }).map((_, i) => ( - - ))} -
-
- ); -} diff --git a/apps/portal/src/app/(authenticated)/catalog/page.tsx b/apps/portal/src/app/(authenticated)/catalog/page.tsx deleted file mode 100644 index 970a2c73..00000000 --- a/apps/portal/src/app/(authenticated)/catalog/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import CatalogHomeView from "@/features/catalog/views/CatalogHome"; - -export default function CatalogPage() { - return ; -} diff --git a/apps/portal/src/app/(authenticated)/catalog/sim/configure/page.tsx b/apps/portal/src/app/(authenticated)/catalog/sim/configure/page.tsx deleted file mode 100644 index d36bdbbb..00000000 --- a/apps/portal/src/app/(authenticated)/catalog/sim/configure/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import SimConfigureContainer from "@/features/catalog/views/SimConfigure"; - -export default function SimConfigurePage() { - return ; -} diff --git a/apps/portal/src/app/(authenticated)/catalog/sim/page.tsx b/apps/portal/src/app/(authenticated)/catalog/sim/page.tsx deleted file mode 100644 index a04d19d3..00000000 --- a/apps/portal/src/app/(authenticated)/catalog/sim/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import SimPlansView from "@/features/catalog/views/SimPlans"; - -export default function SimCatalogPage() { - return ; -} diff --git a/apps/portal/src/app/(authenticated)/catalog/vpn/page.tsx b/apps/portal/src/app/(authenticated)/catalog/vpn/page.tsx deleted file mode 100644 index 1486c847..00000000 --- a/apps/portal/src/app/(authenticated)/catalog/vpn/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import VpnPlansView from "@/features/catalog/views/VpnPlans"; - -export default function VpnCatalogPage() { - return ; -} diff --git a/apps/portal/src/app/(authenticated)/checkout/loading.tsx b/apps/portal/src/app/(authenticated)/checkout/loading.tsx deleted file mode 100644 index 2a272cfb..00000000 --- a/apps/portal/src/app/(authenticated)/checkout/loading.tsx +++ /dev/null @@ -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 ( - } - title="Checkout" - description="Verifying details and preparing your order..." - mode="content" - > -
- - -
- - -
-
-
- ); -} diff --git a/apps/portal/src/app/(authenticated)/checkout/page.tsx b/apps/portal/src/app/(authenticated)/checkout/page.tsx deleted file mode 100644 index dab56689..00000000 --- a/apps/portal/src/app/(authenticated)/checkout/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import CheckoutContainer from "@/features/checkout/views/CheckoutContainer"; - -export default function CheckoutPage() { - return ; -} diff --git a/apps/portal/src/app/(authenticated)/dashboard/loading.tsx b/apps/portal/src/app/(authenticated)/dashboard/loading.tsx deleted file mode 100644 index 72cc930e..00000000 --- a/apps/portal/src/app/(authenticated)/dashboard/loading.tsx +++ /dev/null @@ -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 ( - } - title="Dashboard" - description="Loading your overview..." - mode="content" - > -
- -
-
- - -
-
- - -
-
-
-
- ); -} diff --git a/apps/portal/src/app/(authenticated)/orders/[id]/loading.tsx b/apps/portal/src/app/(authenticated)/orders/[id]/loading.tsx deleted file mode 100644 index 9645aded..00000000 --- a/apps/portal/src/app/(authenticated)/orders/[id]/loading.tsx +++ /dev/null @@ -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 ( - } - title="Order Details" - description="Loading order details..." - mode="content" - > -
- -
- -
-
- {/* Header Section */} -
-
- {/* Left: Title & Status */} -
-
-
-
-
-
-
- - {/* Right: Pricing Section */} -
-
-
-
-
-
-
-
-
-
-
-
- - {/* Body Section */} -
-
- {/* Order Items Section */} -
-
-
- {/* Item 1 */} -
-
-
-
-
-
-
-
-
-
-
-
-
-
- - {/* Item 2 */} -
-
-
-
-
-
-
-
-
-
-
-
-
-
- - {/* Item 3 */} -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- - ); -} diff --git a/apps/portal/src/app/(authenticated)/support/page.tsx b/apps/portal/src/app/(authenticated)/support/page.tsx deleted file mode 100644 index 5148a44b..00000000 --- a/apps/portal/src/app/(authenticated)/support/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { SupportHomeView } from "@/features/support"; -import { AgentforceWidget } from "@/components"; - -export default function SupportPage() { - return ( - <> - - - - ); -} - diff --git a/apps/portal/src/app/(public)/help/contact/page.tsx b/apps/portal/src/app/(public)/contact/page.tsx similarity index 80% rename from apps/portal/src/app/(public)/help/contact/page.tsx rename to apps/portal/src/app/(public)/contact/page.tsx index ee30d4b2..cea3eeee 100644 --- a/apps/portal/src/app/(public)/help/contact/page.tsx +++ b/apps/portal/src/app/(public)/contact/page.tsx @@ -6,6 +6,6 @@ import { PublicContactView } from "@/features/support/views/PublicContactView"; -export default function PublicContactPage() { +export default function ContactPage() { return ; } diff --git a/apps/portal/src/app/(public)/order/page.tsx b/apps/portal/src/app/(public)/order/page.tsx index a71f9c27..817f5c48 100644 --- a/apps/portal/src/app/(public)/order/page.tsx +++ b/apps/portal/src/app/(public)/order/page.tsx @@ -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 ; + return ; } diff --git a/apps/portal/src/app/(public)/shop/layout.tsx b/apps/portal/src/app/(public)/shop/layout.tsx index 878df033..490f02b7 100644 --- a/apps/portal/src/app/(public)/shop/layout.tsx +++ b/apps/portal/src/app/(public)/shop/layout.tsx @@ -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 {children}; + return ( + <> + + {children} + + ); } diff --git a/apps/portal/src/app/account/AccountRouteGuard.tsx b/apps/portal/src/app/account/AccountRouteGuard.tsx new file mode 100644 index 00000000..89c9f0c2 --- /dev/null +++ b/apps/portal/src/app/account/AccountRouteGuard.tsx @@ -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; +} diff --git a/apps/portal/src/app/(authenticated)/billing/invoices/[id]/loading.tsx b/apps/portal/src/app/account/billing/invoices/[id]/loading.tsx similarity index 95% rename from apps/portal/src/app/(authenticated)/billing/invoices/[id]/loading.tsx rename to apps/portal/src/app/account/billing/invoices/[id]/loading.tsx index f9a8afc7..ae7cfe86 100644 --- a/apps/portal/src/app/(authenticated)/billing/invoices/[id]/loading.tsx +++ b/apps/portal/src/app/account/billing/invoices/[id]/loading.tsx @@ -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 ( } diff --git a/apps/portal/src/app/(authenticated)/billing/invoices/[id]/page.tsx b/apps/portal/src/app/account/billing/invoices/[id]/page.tsx similarity index 68% rename from apps/portal/src/app/(authenticated)/billing/invoices/[id]/page.tsx rename to apps/portal/src/app/account/billing/invoices/[id]/page.tsx index 96b28667..2b8a005e 100644 --- a/apps/portal/src/app/(authenticated)/billing/invoices/[id]/page.tsx +++ b/apps/portal/src/app/account/billing/invoices/[id]/page.tsx @@ -1,5 +1,5 @@ import InvoiceDetailContainer from "@/features/billing/views/InvoiceDetail"; -export default function InvoiceDetailPage() { +export default function AccountInvoiceDetailPage() { return ; } diff --git a/apps/portal/src/app/(authenticated)/billing/invoices/loading.tsx b/apps/portal/src/app/account/billing/invoices/loading.tsx similarity index 89% rename from apps/portal/src/app/(authenticated)/billing/invoices/loading.tsx rename to apps/portal/src/app/account/billing/invoices/loading.tsx index 09027e5a..15bb93e0 100644 --- a/apps/portal/src/app/(authenticated)/billing/invoices/loading.tsx +++ b/apps/portal/src/app/account/billing/invoices/loading.tsx @@ -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 ( } diff --git a/apps/portal/src/app/(authenticated)/billing/invoices/page.tsx b/apps/portal/src/app/account/billing/invoices/page.tsx similarity index 70% rename from apps/portal/src/app/(authenticated)/billing/invoices/page.tsx rename to apps/portal/src/app/account/billing/invoices/page.tsx index 3544a515..2d60ed00 100644 --- a/apps/portal/src/app/(authenticated)/billing/invoices/page.tsx +++ b/apps/portal/src/app/account/billing/invoices/page.tsx @@ -1,5 +1,5 @@ import InvoicesListContainer from "@/features/billing/views/InvoicesList"; -export default function InvoicesPage() { +export default function AccountInvoicesPage() { return ; } diff --git a/apps/portal/src/app/(authenticated)/billing/payments/loading.tsx b/apps/portal/src/app/account/billing/payments/loading.tsx similarity index 90% rename from apps/portal/src/app/(authenticated)/billing/payments/loading.tsx rename to apps/portal/src/app/account/billing/payments/loading.tsx index 6b364fb7..048a66a1 100644 --- a/apps/portal/src/app/(authenticated)/billing/payments/loading.tsx +++ b/apps/portal/src/app/account/billing/payments/loading.tsx @@ -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 ( } diff --git a/apps/portal/src/app/(authenticated)/billing/payments/page.tsx b/apps/portal/src/app/account/billing/payments/page.tsx similarity index 68% rename from apps/portal/src/app/(authenticated)/billing/payments/page.tsx rename to apps/portal/src/app/account/billing/payments/page.tsx index b3ab158e..64d907eb 100644 --- a/apps/portal/src/app/(authenticated)/billing/payments/page.tsx +++ b/apps/portal/src/app/account/billing/payments/page.tsx @@ -1,5 +1,5 @@ import PaymentMethodsContainer from "@/features/billing/views/PaymentMethods"; -export default function PaymentMethodsPage() { +export default function AccountPaymentMethodsPage() { return ; } diff --git a/apps/portal/src/app/(authenticated)/layout.tsx b/apps/portal/src/app/account/layout.tsx similarity index 63% rename from apps/portal/src/app/(authenticated)/layout.tsx rename to apps/portal/src/app/account/layout.tsx index 5f6a0e8e..7275d838 100644 --- a/apps/portal/src/app/(authenticated)/layout.tsx +++ b/apps/portal/src/app/account/layout.tsx @@ -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 ( + {children} diff --git a/apps/portal/src/app/account/orders/[id]/loading.tsx b/apps/portal/src/app/account/orders/[id]/loading.tsx new file mode 100644 index 00000000..261bf654 --- /dev/null +++ b/apps/portal/src/app/account/orders/[id]/loading.tsx @@ -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 ( + } + title="Order Details" + description="Loading order details..." + mode="content" + > +
+ +
+ +
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+ {Array.from({ length: 3 }).map((_, idx) => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))} +
+
+
+
+
+
+ + ); +} diff --git a/apps/portal/src/app/(authenticated)/orders/[id]/page.tsx b/apps/portal/src/app/account/orders/[id]/page.tsx similarity index 68% rename from apps/portal/src/app/(authenticated)/orders/[id]/page.tsx rename to apps/portal/src/app/account/orders/[id]/page.tsx index c9f0394c..8582797c 100644 --- a/apps/portal/src/app/(authenticated)/orders/[id]/page.tsx +++ b/apps/portal/src/app/account/orders/[id]/page.tsx @@ -1,5 +1,5 @@ import OrderDetailContainer from "@/features/orders/views/OrderDetail"; -export default function OrderDetailPage() { +export default function AccountOrderDetailPage() { return ; } diff --git a/apps/portal/src/app/(authenticated)/orders/loading.tsx b/apps/portal/src/app/account/orders/loading.tsx similarity index 88% rename from apps/portal/src/app/(authenticated)/orders/loading.tsx rename to apps/portal/src/app/account/orders/loading.tsx index 5fadc1da..4fe46114 100644 --- a/apps/portal/src/app/(authenticated)/orders/loading.tsx +++ b/apps/portal/src/app/account/orders/loading.tsx @@ -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 ( } - title="My Orders" + title="Orders" description="View and track all your orders" mode="content" > diff --git a/apps/portal/src/app/(authenticated)/orders/page.tsx b/apps/portal/src/app/account/orders/page.tsx similarity index 69% rename from apps/portal/src/app/(authenticated)/orders/page.tsx rename to apps/portal/src/app/account/orders/page.tsx index dfda86db..730abc91 100644 --- a/apps/portal/src/app/(authenticated)/orders/page.tsx +++ b/apps/portal/src/app/account/orders/page.tsx @@ -1,5 +1,5 @@ import OrdersListContainer from "@/features/orders/views/OrdersList"; -export default function OrdersPage() { +export default function AccountOrdersPage() { return ; } diff --git a/apps/portal/src/app/(authenticated)/dashboard/page.tsx b/apps/portal/src/app/account/page.tsx similarity index 63% rename from apps/portal/src/app/(authenticated)/dashboard/page.tsx rename to apps/portal/src/app/account/page.tsx index 0871d6d4..d55872f4 100644 --- a/apps/portal/src/app/(authenticated)/dashboard/page.tsx +++ b/apps/portal/src/app/account/page.tsx @@ -1,5 +1,5 @@ import { DashboardView } from "@/features/dashboard"; -export default function DashboardPage() { +export default function AccountDashboardPage() { return ; } diff --git a/apps/portal/src/app/(authenticated)/subscriptions/[id]/loading.tsx b/apps/portal/src/app/account/services/[id]/loading.tsx similarity index 76% rename from apps/portal/src/app/(authenticated)/subscriptions/[id]/loading.tsx rename to apps/portal/src/app/account/services/[id]/loading.tsx index cf53e385..70d2b15f 100644 --- a/apps/portal/src/app/(authenticated)/subscriptions/[id]/loading.tsx +++ b/apps/portal/src/app/account/services/[id]/loading.tsx @@ -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 ( } - title="Subscription" - description="Subscription details" + title="Service" + description="Service details" mode="content" >
diff --git a/apps/portal/src/app/(authenticated)/subscriptions/[id]/page.tsx b/apps/portal/src/app/account/services/[id]/page.tsx similarity index 72% rename from apps/portal/src/app/(authenticated)/subscriptions/[id]/page.tsx rename to apps/portal/src/app/account/services/[id]/page.tsx index 4073bbde..2324febc 100644 --- a/apps/portal/src/app/(authenticated)/subscriptions/[id]/page.tsx +++ b/apps/portal/src/app/account/services/[id]/page.tsx @@ -1,5 +1,5 @@ import SubscriptionDetailContainer from "@/features/subscriptions/views/SubscriptionDetail"; -export default function SubscriptionDetailPage() { +export default function AccountServiceDetailPage() { return ; } diff --git a/apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/call-history/page.tsx b/apps/portal/src/app/account/services/[id]/sim/call-history/page.tsx similarity index 70% rename from apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/call-history/page.tsx rename to apps/portal/src/app/account/services/[id]/sim/call-history/page.tsx index efa22ee9..16e52603 100644 --- a/apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/call-history/page.tsx +++ b/apps/portal/src/app/account/services/[id]/sim/call-history/page.tsx @@ -1,6 +1,5 @@ import SimCallHistoryContainer from "@/features/subscriptions/views/SimCallHistory"; -export default function SimCallHistoryPage() { +export default function AccountSimCallHistoryPage() { return ; } - diff --git a/apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/cancel/page.tsx b/apps/portal/src/app/account/services/[id]/sim/cancel/page.tsx similarity index 69% rename from apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/cancel/page.tsx rename to apps/portal/src/app/account/services/[id]/sim/cancel/page.tsx index c103af27..f9aaf9a4 100644 --- a/apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/cancel/page.tsx +++ b/apps/portal/src/app/account/services/[id]/sim/cancel/page.tsx @@ -1,5 +1,5 @@ import SimCancelContainer from "@/features/subscriptions/views/SimCancel"; -export default function SimCancelPage() { +export default function AccountSimCancelPage() { return ; } diff --git a/apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/change-plan/page.tsx b/apps/portal/src/app/account/services/[id]/sim/change-plan/page.tsx similarity index 69% rename from apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/change-plan/page.tsx rename to apps/portal/src/app/account/services/[id]/sim/change-plan/page.tsx index 4ad4d81d..8ff1da30 100644 --- a/apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/change-plan/page.tsx +++ b/apps/portal/src/app/account/services/[id]/sim/change-plan/page.tsx @@ -1,5 +1,5 @@ import SimChangePlanContainer from "@/features/subscriptions/views/SimChangePlan"; -export default function SimChangePlanPage() { +export default function AccountSimChangePlanPage() { return ; } diff --git a/apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/reissue/page.tsx b/apps/portal/src/app/account/services/[id]/sim/reissue/page.tsx similarity index 69% rename from apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/reissue/page.tsx rename to apps/portal/src/app/account/services/[id]/sim/reissue/page.tsx index e99470f2..1936a048 100644 --- a/apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/reissue/page.tsx +++ b/apps/portal/src/app/account/services/[id]/sim/reissue/page.tsx @@ -1,5 +1,5 @@ import SimReissueContainer from "@/features/subscriptions/views/SimReissue"; -export default function SimReissuePage() { +export default function AccountSimReissuePage() { return ; } diff --git a/apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/top-up/page.tsx b/apps/portal/src/app/account/services/[id]/sim/top-up/page.tsx similarity index 69% rename from apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/top-up/page.tsx rename to apps/portal/src/app/account/services/[id]/sim/top-up/page.tsx index 0c7da26a..89629c2e 100644 --- a/apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/top-up/page.tsx +++ b/apps/portal/src/app/account/services/[id]/sim/top-up/page.tsx @@ -1,5 +1,5 @@ import SimTopUpContainer from "@/features/subscriptions/views/SimTopUp"; -export default function SimTopUpPage() { +export default function AccountSimTopUpPage() { return ; } diff --git a/apps/portal/src/app/(authenticated)/subscriptions/loading.tsx b/apps/portal/src/app/account/services/loading.tsx similarity index 72% rename from apps/portal/src/app/(authenticated)/subscriptions/loading.tsx rename to apps/portal/src/app/account/services/loading.tsx index 82717262..3db64007 100644 --- a/apps/portal/src/app/(authenticated)/subscriptions/loading.tsx +++ b/apps/portal/src/app/account/services/loading.tsx @@ -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 ( } - title="Subscriptions" - description="View and manage your subscriptions" + title="Services" + description="View and manage your services" mode="content" > diff --git a/apps/portal/src/app/(authenticated)/subscriptions/page.tsx b/apps/portal/src/app/account/services/page.tsx similarity index 73% rename from apps/portal/src/app/(authenticated)/subscriptions/page.tsx rename to apps/portal/src/app/account/services/page.tsx index c6bef54d..13e6e5bb 100644 --- a/apps/portal/src/app/(authenticated)/subscriptions/page.tsx +++ b/apps/portal/src/app/account/services/page.tsx @@ -1,5 +1,5 @@ import SubscriptionsListContainer from "@/features/subscriptions/views/SubscriptionsList"; -export default function SubscriptionsPage() { +export default function AccountServicesPage() { return ; } diff --git a/apps/portal/src/app/(authenticated)/account/loading.tsx b/apps/portal/src/app/account/settings/loading.tsx similarity index 94% rename from apps/portal/src/app/(authenticated)/account/loading.tsx rename to apps/portal/src/app/account/settings/loading.tsx index 2e925894..77d7c570 100644 --- a/apps/portal/src/app/(authenticated)/account/loading.tsx +++ b/apps/portal/src/app/account/settings/loading.tsx @@ -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 ( } - title="Account" + title="Settings" description="Loading your profile..." mode="content" > diff --git a/apps/portal/src/app/(authenticated)/account/profile/page.tsx b/apps/portal/src/app/account/settings/page.tsx similarity index 69% rename from apps/portal/src/app/(authenticated)/account/profile/page.tsx rename to apps/portal/src/app/account/settings/page.tsx index b97286f7..ff9565c5 100644 --- a/apps/portal/src/app/(authenticated)/account/profile/page.tsx +++ b/apps/portal/src/app/account/settings/page.tsx @@ -1,5 +1,5 @@ import ProfileContainer from "@/features/account/views/ProfileContainer"; -export default function ProfilePage() { +export default function AccountSettingsPage() { return ; } diff --git a/apps/portal/src/app/(authenticated)/support/cases/[id]/page.tsx b/apps/portal/src/app/account/support/[id]/page.tsx similarity index 70% rename from apps/portal/src/app/(authenticated)/support/cases/[id]/page.tsx rename to apps/portal/src/app/account/support/[id]/page.tsx index 0bcc3feb..bece2764 100644 --- a/apps/portal/src/app/(authenticated)/support/cases/[id]/page.tsx +++ b/apps/portal/src/app/account/support/[id]/page.tsx @@ -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 ; } - diff --git a/apps/portal/src/app/(authenticated)/support/cases/loading.tsx b/apps/portal/src/app/account/support/loading.tsx similarity index 90% rename from apps/portal/src/app/(authenticated)/support/cases/loading.tsx rename to apps/portal/src/app/account/support/loading.tsx index 3c933597..833b5692 100644 --- a/apps/portal/src/app/(authenticated)/support/cases/loading.tsx +++ b/apps/portal/src/app/account/support/loading.tsx @@ -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 ( } diff --git a/apps/portal/src/app/(authenticated)/support/new/loading.tsx b/apps/portal/src/app/account/support/new/loading.tsx similarity index 94% rename from apps/portal/src/app/(authenticated)/support/new/loading.tsx rename to apps/portal/src/app/account/support/new/loading.tsx index 6da32d5c..bad05834 100644 --- a/apps/portal/src/app/(authenticated)/support/new/loading.tsx +++ b/apps/portal/src/app/account/support/new/loading.tsx @@ -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 ( } diff --git a/apps/portal/src/app/(authenticated)/support/new/page.tsx b/apps/portal/src/app/account/support/new/page.tsx similarity index 63% rename from apps/portal/src/app/(authenticated)/support/new/page.tsx rename to apps/portal/src/app/account/support/new/page.tsx index 65c960da..730dceac 100644 --- a/apps/portal/src/app/(authenticated)/support/new/page.tsx +++ b/apps/portal/src/app/account/support/new/page.tsx @@ -1,5 +1,5 @@ import { NewSupportCaseView } from "@/features/support"; -export default function NewSupportCasePage() { +export default function AccountNewSupportCasePage() { return ; } diff --git a/apps/portal/src/app/(authenticated)/support/cases/page.tsx b/apps/portal/src/app/account/support/page.tsx similarity index 65% rename from apps/portal/src/app/(authenticated)/support/cases/page.tsx rename to apps/portal/src/app/account/support/page.tsx index 54a27c29..41ef7fa3 100644 --- a/apps/portal/src/app/(authenticated)/support/cases/page.tsx +++ b/apps/portal/src/app/account/support/page.tsx @@ -1,5 +1,5 @@ import { SupportCasesView } from "@/features/support"; -export default function SupportCasesPage() { +export default function AccountSupportPage() { return ; } diff --git a/apps/portal/src/components/organisms/AppShell/AppShell.tsx b/apps/portal/src/components/organisms/AppShell/AppShell.tsx index eb3c2a03..4c04191f 100644 --- a/apps/portal/src/components/organisms/AppShell/AppShell.tsx +++ b/apps/portal/src/components/organisms/AppShell/AppShell.tsx @@ -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; diff --git a/apps/portal/src/components/organisms/AppShell/Header.tsx b/apps/portal/src/components/organisms/AppShell/Header.tsx index 4f1f1d30..e058caff 100644 --- a/apps/portal/src/components/organisms/AppShell/Header.tsx +++ b/apps/portal/src/components/organisms/AppShell/Header.tsx @@ -39,7 +39,7 @@ export const Header = memo(function Header({ onMenuClick, user, profileReady }:
diff --git a/apps/portal/src/components/organisms/AppShell/navigation.ts b/apps/portal/src/components/organisms/AppShell/navigation.ts index a63a4f0c..1f7663e7 100644 --- a/apps/portal/src/components/organisms/AppShell/navigation.ts +++ b/apps/portal/src/components/organisms/AppShell/navigation.ts @@ -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], }; } diff --git a/apps/portal/src/components/templates/CatalogShell/CatalogShell.tsx b/apps/portal/src/components/templates/CatalogShell/CatalogShell.tsx index e4e6eec1..cdfbe46b 100644 --- a/apps/portal/src/components/templates/CatalogShell/CatalogShell.tsx +++ b/apps/portal/src/components/templates/CatalogShell/CatalogShell.tsx @@ -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 ( +
+
+ +
+
+ ); +} + 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 (
{/* Subtle background pattern */} @@ -34,39 +83,11 @@ export function CatalogShell({ children }: CatalogShellProps) { Assist Solutions - Customer Portal + Account Portal - {/* Catalog Navigation */} - - {/* Right side actions */}
Support - - Sign in - + {isAuthenticated ? ( + + My Account + + ) : ( + + Sign in + + )}
+ +
{children} diff --git a/apps/portal/src/components/templates/CatalogShell/index.ts b/apps/portal/src/components/templates/CatalogShell/index.ts index a38391c3..4abbb86e 100644 --- a/apps/portal/src/components/templates/CatalogShell/index.ts +++ b/apps/portal/src/components/templates/CatalogShell/index.ts @@ -1,2 +1,2 @@ -export { CatalogShell } from "./CatalogShell"; +export { CatalogNav, CatalogShell } from "./CatalogShell"; export type { CatalogShellProps } from "./CatalogShell"; diff --git a/apps/portal/src/components/templates/PublicShell/PublicShell.tsx b/apps/portal/src/components/templates/PublicShell/PublicShell.tsx index c1e2161e..a85bd1a1 100644 --- a/apps/portal/src/components/templates/PublicShell/PublicShell.tsx +++ b/apps/portal/src/components/templates/PublicShell/PublicShell.tsx @@ -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 (
{/* Subtle background pattern */} @@ -26,7 +46,7 @@ export function PublicShell({ children }: PublicShellProps) { Assist Solutions - Customer Portal + Account Portal @@ -44,12 +64,21 @@ export function PublicShell({ children }: PublicShellProps) { > Support - - Sign in - + {isAuthenticated ? ( + + My Account + + ) : ( + + Sign in + + )}
diff --git a/apps/portal/src/features/auth/components/SignupForm/SignupForm.tsx b/apps/portal/src/features/auth/components/SignupForm/SignupForm.tsx index bc6486ce..8efc594b 100644 --- a/apps/portal/src/features/auth/components/SignupForm/SignupForm.tsx +++ b/apps/portal/src/features/auth/components/SignupForm/SignupForm.tsx @@ -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> = { - 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 = { 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({ schema: signupFormSchema, initialValues: { - sfNumber: "", firstName: "", lastName: "", email: "", diff --git a/apps/portal/src/features/auth/components/SignupForm/steps/AccountStep.tsx b/apps/portal/src/features/auth/components/SignupForm/steps/AccountStep.tsx index b5b2ffc9..9fb9af15 100644 --- a/apps/portal/src/features/auth/components/SignupForm/steps/AccountStep.tsx +++ b/apps/portal/src/features/auth/components/SignupForm/steps/AccountStep.tsx @@ -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 (
- {/* Customer Number - Highlighted */} -
- - setValue("sfNumber", e.target.value)} - onBlur={() => setTouchedField("sfNumber")} - placeholder="e.g., AST-123456" - autoFocus - /> - -
- {/* Name Fields */}
@@ -61,6 +42,7 @@ export function AccountStep({ form }: AccountStepProps) { onBlur={() => setTouchedField("firstName")} placeholder="Taro" autoComplete="given-name" + autoFocus /> diff --git a/apps/portal/src/features/auth/components/SignupForm/steps/ReviewStep.tsx b/apps/portal/src/features/auth/components/SignupForm/steps/ReviewStep.tsx index 9dcff4f4..2811b7ad 100644 --- a/apps/portal/src/features/auth/components/SignupForm/steps/ReviewStep.tsx +++ b/apps/portal/src/features/auth/components/SignupForm/steps/ReviewStep.tsx @@ -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
-
-
Customer Number
-
{values.sfNumber}
-
Name
diff --git a/apps/portal/src/features/auth/utils/route-protection.ts b/apps/portal/src/features/auth/utils/route-protection.ts index 33408eac..7988c717 100644 --- a/apps/portal/src/features/auth/utils/route-protection.ts +++ b/apps/portal/src/features/auth/utils/route-protection.ts @@ -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; } diff --git a/apps/portal/src/features/auth/views/LinkWhmcsView.tsx b/apps/portal/src/features/auth/views/LinkWhmcsView.tsx index 8d63a4e7..c92620c4 100644 --- a/apps/portal/src/features/auth/views/LinkWhmcsView.tsx +++ b/apps/portal/src/features/auth/views/LinkWhmcsView.tsx @@ -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() {

Need help?{" "} - + Contact support

diff --git a/apps/portal/src/features/auth/views/SetPasswordView.tsx b/apps/portal/src/features/auth/views/SetPasswordView.tsx index 43519b5a..0d78864e 100644 --- a/apps/portal/src/features/auth/views/SetPasswordView.tsx +++ b/apps/portal/src/features/auth/views/SetPasswordView.tsx @@ -20,7 +20,7 @@ function SetPasswordContent() { const handlePasswordSetSuccess = () => { // Redirect to dashboard after successful password setup - router.push("/dashboard"); + router.push("/account"); }; if (!email) { diff --git a/apps/portal/src/features/billing/components/BillingSummary/BillingSummary.tsx b/apps/portal/src/features/billing/components/BillingSummary/BillingSummary.tsx index e446e857..ec19e8c7 100644 --- a/apps/portal/src/features/billing/components/BillingSummary/BillingSummary.tsx +++ b/apps/portal/src/features/billing/components/BillingSummary/BillingSummary.tsx @@ -91,7 +91,7 @@ const BillingSummary = forwardRef(
{!compact && ( View All @@ -158,7 +158,7 @@ const BillingSummary = forwardRef( {compact && (
View All Invoices diff --git a/apps/portal/src/features/billing/components/InvoiceDetail/InvoiceItems.tsx b/apps/portal/src/features/billing/components/InvoiceDetail/InvoiceItems.tsx index 4ebe2837..aba4a124 100644 --- a/apps/portal/src/features/billing/components/InvoiceDetail/InvoiceItems.tsx +++ b/apps/portal/src/features/billing/components/InvoiceDetail/InvoiceItems.tsx @@ -104,7 +104,7 @@ export function InvoiceItems({ items = [], currency }: InvoiceItemsProps) { if (isLinked) { return ( - + {itemContent} ); diff --git a/apps/portal/src/features/billing/components/InvoiceItemRow.tsx b/apps/portal/src/features/billing/components/InvoiceItemRow.tsx index fe32456e..76939ed6 100644 --- a/apps/portal/src/features/billing/components/InvoiceItemRow.tsx +++ b/apps/portal/src/features/billing/components/InvoiceItemRow.tsx @@ -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} >
- + ← Back to invoices
@@ -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}` }, ]} > diff --git a/apps/portal/src/features/catalog/components/internet/InternetPlanCard.tsx b/apps/portal/src/features/catalog/components/internet/InternetPlanCard.tsx index 72ab73c1..69533ce3 100644 --- a/apps/portal/src/features/catalog/components/internet/InternetPlanCard.tsx +++ b/apps/portal/src/features/catalog/components/internet/InternetPlanCard.tsx @@ -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); }} > diff --git a/apps/portal/src/features/catalog/components/internet/configure/InternetConfigureContainer.tsx b/apps/portal/src/features/catalog/components/internet/configure/InternetConfigureContainer.tsx index 335bbfc3..beb02db7 100644 --- a/apps/portal/src/features/catalog/components/internet/configure/InternetConfigureContainer.tsx +++ b/apps/portal/src/features/catalog/components/internet/configure/InternetConfigureContainer.tsx @@ -237,7 +237,7 @@ function PlanHeader({ plan }: { plan: InternetPlanCatalogItem }) {
@@ -185,7 +185,7 @@ export function SimConfigureView({ icon={} >
- +
diff --git a/apps/portal/src/features/catalog/components/vpn/VpnPlanCard.tsx b/apps/portal/src/features/catalog/components/vpn/VpnPlanCard.tsx index bcea8cdc..eb25aae1 100644 --- a/apps/portal/src/features/catalog/components/vpn/VpnPlanCard.tsx +++ b/apps/portal/src/features/catalog/components/vpn/VpnPlanCard.tsx @@ -32,11 +32,11 @@ export function VpnPlanCard({ plan }: VpnPlanCardProps) {
diff --git a/apps/portal/src/features/catalog/hooks/useInternetConfigure.ts b/apps/portal/src/features/catalog/hooks/useInternetConfigure.ts index a4f8c725..b98438b1 100644 --- a/apps/portal/src/features/catalog/hooks/useInternetConfigure.ts +++ b/apps/portal/src/features/catalog/hooks/useInternetConfigure.ts @@ -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]); diff --git a/apps/portal/src/features/catalog/hooks/useSimConfigure.ts b/apps/portal/src/features/catalog/hooks/useSimConfigure.ts index 6980d91c..f694d96f 100644 --- a/apps/portal/src/features/catalog/hooks/useSimConfigure.ts +++ b/apps/portal/src/features/catalog/hooks/useSimConfigure.ts @@ -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, diff --git a/apps/portal/src/features/catalog/views/CatalogHome.tsx b/apps/portal/src/features/catalog/views/CatalogHome.tsx index f787861c..c148fcef 100644 --- a/apps/portal/src/features/catalog/views/CatalogHome.tsx +++ b/apps/portal/src/features/catalog/views/CatalogHome.tsx @@ -45,7 +45,7 @@ export function CatalogHomeView() { "Multiple access modes", "Professional installation", ]} - href="/catalog/internet" + href="/shop/internet" color="blue" />
diff --git a/apps/portal/src/features/catalog/views/InternetPlans.tsx b/apps/portal/src/features/catalog/views/InternetPlans.tsx index 4095c17d..1d7b9b54 100644 --- a/apps/portal/src/features/catalog/views/InternetPlans.tsx +++ b/apps/portal/src/features/catalog/views/InternetPlans.tsx @@ -68,7 +68,7 @@ export function InternetPlansContainer() { >
- + {/* Title + eligibility */}
@@ -112,7 +112,7 @@ export function InternetPlansContainer() { icon={} >
- + contact us @@ -197,7 +197,7 @@ export function InternetPlansContainer() { We couldn't find any internet plans available for your location at this time.

- +
@@ -72,7 +72,7 @@ export function PublicInternetPlansView() { if (error) { return (
- + {error instanceof Error ? error.message : "An unexpected error occurred"} @@ -82,7 +82,7 @@ export function PublicInternetPlansView() { return (
- + - +
@@ -72,7 +72,7 @@ export function PublicSimPlansView() {
{errorMessage}
+ + Contact support + +
+
+ +
+ ); + } + + if (!paramsKey && !checkoutSessionId) { + return ; + } + + return ; +} diff --git a/apps/portal/src/features/checkout/components/CheckoutErrorBoundary.tsx b/apps/portal/src/features/checkout/components/CheckoutErrorBoundary.tsx index e828bf42..0b31edd9 100644 --- a/apps/portal/src/features/checkout/components/CheckoutErrorBoundary.tsx +++ b/apps/portal/src/features/checkout/components/CheckoutErrorBoundary.tsx @@ -58,7 +58,7 @@ export class CheckoutErrorBoundary extends Component {

If this problem persists, please{" "} - + contact support . diff --git a/apps/portal/src/features/checkout/components/CheckoutShell.tsx b/apps/portal/src/features/checkout/components/CheckoutShell.tsx index fe5216ac..7bbced29 100644 --- a/apps/portal/src/features/checkout/components/CheckoutShell.tsx +++ b/apps/portal/src/features/checkout/components/CheckoutShell.tsx @@ -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 (

{/* Subtle background pattern */} @@ -51,7 +62,7 @@ export function CheckoutShell({ children }: CheckoutShellProps) { Secure Checkout
Need Help? diff --git a/apps/portal/src/features/checkout/components/CheckoutWizard.tsx b/apps/portal/src/features/checkout/components/CheckoutWizard.tsx index f8a117c8..b7b9d5ac 100644 --- a/apps/portal/src/features/checkout/components/CheckoutWizard.tsx +++ b/apps/portal/src/features/checkout/components/CheckoutWizard.tsx @@ -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 ; @@ -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 ; case "address": @@ -71,7 +78,7 @@ export function CheckoutWizard() {
{/* Progress indicator */} diff --git a/apps/portal/src/features/checkout/components/EmptyCartRedirect.tsx b/apps/portal/src/features/checkout/components/EmptyCartRedirect.tsx index 9ceee79e..d8de2f68 100644 --- a/apps/portal/src/features/checkout/components/EmptyCartRedirect.tsx +++ b/apps/portal/src/features/checkout/components/EmptyCartRedirect.tsx @@ -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() {

Your cart is empty

- Browse our catalog to find the perfect plan for your needs. + Browse our services to find the perfect plan for your needs.

- Redirecting to catalog in a few seconds... + Redirecting to the shop in a few seconds...

diff --git a/apps/portal/src/features/checkout/components/OrderConfirmation.tsx b/apps/portal/src/features/checkout/components/OrderConfirmation.tsx index bccb76e5..144ff0d7 100644 --- a/apps/portal/src/features/checkout/components/OrderConfirmation.tsx +++ b/apps/portal/src/features/checkout/components/OrderConfirmation.tsx @@ -84,10 +84,10 @@ export function OrderConfirmation() { {/* Actions */}
- -
@@ -95,7 +95,7 @@ export function OrderConfirmation() { {/* Support Link */}

Have questions?{" "} - + Contact Support

diff --git a/apps/portal/src/features/checkout/components/steps/AccountStep.tsx b/apps/portal/src/features/checkout/components/steps/AccountStep.tsx index 20111f68..a106122d 100644 --- a/apps/portal/src/features/checkout/components/steps/AccountStep.tsx +++ b/apps/portal/src/features/checkout/components/steps/AccountStep.tsx @@ -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; * 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(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 }>({ diff --git a/apps/portal/src/features/checkout/components/steps/AddressStep.tsx b/apps/portal/src/features/checkout/components/steps/AddressStep.tsx index 8211f007..64fab094 100644 --- a/apps/portal/src/features/checkout/components/steps/AddressStep.tsx +++ b/apps/portal/src/features/checkout/components/steps/AddressStep.tsx @@ -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({ diff --git a/apps/portal/src/features/checkout/components/steps/PaymentStep.tsx b/apps/portal/src/features/checkout/components/steps/PaymentStep.tsx index 7b8f7b8b..50e4bc4b 100644 --- a/apps/portal/src/features/checkout/components/steps/PaymentStep.tsx +++ b/apps/portal/src/features/checkout/components/steps/PaymentStep.tsx @@ -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.

- - {!registrationComplete && ( + {!canCheckPayment && (

You need to complete registration first

diff --git a/apps/portal/src/features/checkout/components/steps/ReviewStep.tsx b/apps/portal/src/features/checkout/components/steps/ReviewStep.tsx index 6e4bfb67..4f3921fc 100644 --- a/apps/portal/src/features/checkout/components/steps/ReviewStep.tsx +++ b/apps/portal/src/features/checkout/components/steps/ReviewStep.tsx @@ -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() {

- {guestInfo?.firstName} {guestInfo?.lastName} + {guestInfo?.firstName || user?.firstname} {guestInfo?.lastName || user?.lastname}

-

{guestInfo?.email}

+

{guestInfo?.email || user?.email}

{/* Address */} diff --git a/apps/portal/src/features/checkout/hooks/useCheckout.ts b/apps/portal/src/features/checkout/hooks/useCheckout.ts deleted file mode 100644 index 7ce706c1..00000000 --- a/apps/portal/src/features/checkout/hooks/useCheckout.ts +++ /dev/null @@ -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>({ - 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(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(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; -} diff --git a/apps/portal/src/features/checkout/services/checkout-api.service.ts b/apps/portal/src/features/checkout/services/checkout-api.service.ts deleted file mode 100644 index 0fbd7246..00000000 --- a/apps/portal/src/features/checkout/services/checkout-api.service.ts +++ /dev/null @@ -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 { - 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, - }; - }, -}; diff --git a/apps/portal/src/features/checkout/services/checkout.service.ts b/apps/portal/src/features/checkout/services/checkout.service.ts index b24f50ac..9c01b37d 100644 --- a/apps/portal/src/features/checkout/services/checkout.service.ts +++ b/apps/portal/src/features/checkout/services/checkout.service.ts @@ -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 { + const response = await apiClient.POST>( + "/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 { + const response = await apiClient.GET>( + "/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 */ diff --git a/apps/portal/src/features/checkout/stores/checkout.store.ts b/apps/portal/src/features/checkout/stores/checkout.store.ts index ae36689f..e5c9b6e2 100644 --- a/apps/portal/src/features/checkout/stores/checkout.store.ts +++ b/apps/portal/src/features/checkout/stores/checkout.store.ts @@ -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 | 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()( 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()( 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, diff --git a/apps/portal/src/features/checkout/views/CheckoutContainer.tsx b/apps/portal/src/features/checkout/views/CheckoutContainer.tsx deleted file mode 100644 index fbda24b7..00000000 --- a/apps/portal/src/features/checkout/views/CheckoutContainer.tsx +++ /dev/null @@ -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 ( - } - > - - <> - - - ); - } - - if (isError(checkoutState)) { - return ( - } - > -
- -
- {checkoutState.error.message} - -
-
-
-
- ); - } - - if (!isSuccess(checkoutState)) { - return ( - } - > -
- -
- Checkout data is not available - -
-
-
-
- ); - } - - 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 ( - } - > -
- - - {activeInternetWarning && ( - - {activeInternetWarning} - - )} - -
-
- -

Confirm Details

-
-
- - - - - } - right={ - paymentMethods && paymentMethods.paymentMethods.length > 0 ? ( - - ) : undefined - } - > - {paymentMethodsLoading ? ( -
Checking payment methods...
- ) : paymentMethodsError ? ( - -
- - -
-
- ) : paymentMethodList.length > 0 ? ( -
- {paymentMethodDisplay ? ( -
-
-
-

- Default payment method -

-

- {paymentMethodDisplay.title} -

- {paymentMethodDisplay.subtitle ? ( -

- {paymentMethodDisplay.subtitle} -

- ) : null} -
- -
-
- ) : null} -

- We securely charge your saved payment method after the order is approved. Need - to make changes? Visit Billing & Payments. -

-
- ) : ( - -
- - -
-
- )} -
-
-
- -
-
- -
-

Review & Submit

-

- You’re almost done. Confirm your details above, then submit your order. We’ll review and - notify you when everything is ready. -

-
-

What to expect

-
-

• Our team reviews your order and schedules setup if needed

-

• We may contact you to confirm details or availability

-

• We only charge your card after the order is approved

-

• You’ll receive confirmation and next steps by email

-
-
- -
-
- Estimated Total -
-
- ¥{totals.monthlyTotal.toLocaleString()}/mo -
- {totals.oneTimeTotal > 0 && ( -
- + ¥{totals.oneTimeTotal.toLocaleString()} one-time -
- )} -
-
-
-
- -
- - -
-
-
- ); -} - -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(); - - 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; diff --git a/apps/portal/src/features/dashboard/components/QuickStats.tsx b/apps/portal/src/features/dashboard/components/QuickStats.tsx index 5f6bfe68..1a9deab3 100644 --- a/apps/portal/src/features/dashboard/components/QuickStats.tsx +++ b/apps/portal/src/features/dashboard/components/QuickStats.tsx @@ -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" /> diff --git a/apps/portal/src/features/dashboard/components/TaskList.tsx b/apps/portal/src/features/dashboard/components/TaskList.tsx index 36edbc6d..b9971344 100644 --- a/apps/portal/src/features/dashboard/components/TaskList.tsx +++ b/apps/portal/src/features/dashboard/components/TaskList.tsx @@ -52,18 +52,18 @@ function AllCaughtUp() { {/* Quick action cards */}
- Browse Catalog + Browse Services
@@ -74,7 +74,7 @@ function AllCaughtUp() {
diff --git a/apps/portal/src/features/dashboard/hooks/useDashboardTasks.ts b/apps/portal/src/features/dashboard/hooks/useDashboardTasks.ts index 5569eb61..8c33fb37 100644 --- a/apps/portal/src/features/dashboard/hooks/useDashboardTasks.ts +++ b/apps/portal/src/features/dashboard/hooks/useDashboardTasks.ts @@ -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, }); diff --git a/apps/portal/src/features/dashboard/utils/dashboard.utils.ts b/apps/portal/src/features/dashboard/utils/dashboard.utils.ts index 3900aabd..d27c499d 100644 --- a/apps/portal/src/features/dashboard/utils/dashboard.utils.ts +++ b/apps/portal/src/features/dashboard/utils/dashboard.utils.ts @@ -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; } diff --git a/apps/portal/src/features/landing-page/views/PublicLandingView.tsx b/apps/portal/src/features/landing-page/views/PublicLandingView.tsx index 99394cb0..7fb25fb9 100644 --- a/apps/portal/src/features/landing-page/views/PublicLandingView.tsx +++ b/apps/portal/src/features/landing-page/views/PublicLandingView.tsx @@ -27,7 +27,7 @@ export function PublicLandingView() {

- Customer Portal + Account Portal

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

diff --git a/apps/portal/src/features/orders/services/orders.service.ts b/apps/portal/src/features/orders/services/orders.service.ts index 17416ae5..7704549a 100644 --- a/apps/portal/src/features/orders/services/orders.service.ts +++ b/apps/portal/src/features/orders/services/orders.service.ts @@ -30,18 +30,27 @@ async function createOrder(payload: CreateOrderRequest): Promise<{ sfOrderId: st ); return { sfOrderId: parsed.data.sfOrderId }; } catch (error) { - log.error( - "Order creation failed", - error instanceof Error ? error : undefined, - { - orderType: body.orderType, - skuCount: body.skus.length, - } - ); + log.error("Order creation failed", error instanceof Error ? error : undefined, { + orderType: body.orderType, + skuCount: body.skus.length, + }); throw error; } } +async function createOrderFromCheckoutSession( + checkoutSessionId: string +): Promise<{ sfOrderId: string }> { + const response = await apiClient.POST("/api/orders/from-checkout-session", { + body: { checkoutSessionId }, + }); + + const parsed = assertSuccess<{ sfOrderId: string; status: string; message: string }>( + response.data as DomainApiResponse<{ sfOrderId: string; status: string; message: string }> + ); + return { sfOrderId: parsed.data.sfOrderId }; +} + async function getMyOrders(): Promise { const response = await apiClient.GET("/api/orders/user"); const data = Array.isArray(response.data) ? response.data : []; @@ -68,6 +77,7 @@ async function getOrderById( export const ordersService = { createOrder, + createOrderFromCheckoutSession, getMyOrders, getOrderById, } as const; diff --git a/apps/portal/src/features/orders/views/OrderDetail.tsx b/apps/portal/src/features/orders/views/OrderDetail.tsx index 70adf445..afc39a2d 100644 --- a/apps/portal/src/features/orders/views/OrderDetail.tsx +++ b/apps/portal/src/features/orders/views/OrderDetail.tsx @@ -275,7 +275,7 @@ export function OrderDetailContainer() { title={data ? `${data.orderType} Service Order` : "Order Details"} description={data ? `Order #${orderNumber}` : "Loading order details..."} breadcrumbs={[ - { label: "Orders", href: "/orders" }, + { label: "Orders", href: "/account/orders" }, { label: data ? `Order #${orderNumber}` : "Order Details" }, ]} > diff --git a/apps/portal/src/features/orders/views/OrdersList.tsx b/apps/portal/src/features/orders/views/OrdersList.tsx index d34cb331..f6681c92 100644 --- a/apps/portal/src/features/orders/views/OrdersList.tsx +++ b/apps/portal/src/features/orders/views/OrdersList.tsx @@ -90,7 +90,7 @@ export function OrdersListContainer() { icon={} title="No orders yet" description="You haven't placed any orders yet." - action={{ label: "Browse Catalog", onClick: () => router.push("/catalog") }} + action={{ label: "Browse Services", onClick: () => router.push("/shop") }} /> ) : ( @@ -99,7 +99,7 @@ export function OrdersListContainer() { router.push(`/orders/${order.id}`)} + onClick={() => router.push(`/account/orders/${order.id}`)} /> ))}
diff --git a/apps/portal/src/features/sim-management/components/SimActions.tsx b/apps/portal/src/features/sim-management/components/SimActions.tsx index c4dacb40..d421d254 100644 --- a/apps/portal/src/features/sim-management/components/SimActions.tsx +++ b/apps/portal/src/features/sim-management/components/SimActions.tsx @@ -149,7 +149,7 @@ export function SimActions({ onClick={() => { setActiveInfo("topup"); try { - router.push(`/subscriptions/${subscriptionId}/sim/top-up`); + router.push(`/account/services/${subscriptionId}/sim/top-up`); } catch { setShowTopUpModal(true); } @@ -177,7 +177,7 @@ export function SimActions({ onClick={() => { setActiveInfo("changePlan"); try { - router.push(`/subscriptions/${subscriptionId}/sim/change-plan`); + router.push(`/account/services/${subscriptionId}/sim/change-plan`); } catch { setShowChangePlanModal(true); } @@ -236,7 +236,7 @@ export function SimActions({ onClick={() => { setActiveInfo("cancel"); try { - router.push(`/subscriptions/${subscriptionId}/sim/cancel`); + router.push(`/account/services/${subscriptionId}/sim/cancel`); } catch { // Fallback to inline confirmation modal if navigation is unavailable setShowCancelConfirm(true); diff --git a/apps/portal/src/features/sim-management/components/SimManagementSection.tsx b/apps/portal/src/features/sim-management/components/SimManagementSection.tsx index ae748958..fc3ba9d2 100644 --- a/apps/portal/src/features/sim-management/components/SimManagementSection.tsx +++ b/apps/portal/src/features/sim-management/components/SimManagementSection.tsx @@ -56,13 +56,13 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro const [error, setError] = useState(null); // Navigation handlers - const navigateToTopUp = () => router.push(`/subscriptions/${subscriptionId}/sim/top-up`); + const navigateToTopUp = () => router.push(`/account/services/${subscriptionId}/sim/top-up`); const navigateToChangePlan = () => - router.push(`/subscriptions/${subscriptionId}/sim/change-plan`); - const navigateToReissue = () => router.push(`/subscriptions/${subscriptionId}/sim/reissue`); - const navigateToCancel = () => router.push(`/subscriptions/${subscriptionId}/sim/cancel`); + router.push(`/account/services/${subscriptionId}/sim/change-plan`); + const navigateToReissue = () => router.push(`/account/services/${subscriptionId}/sim/reissue`); + const navigateToCancel = () => router.push(`/account/services/${subscriptionId}/sim/cancel`); const navigateToCallHistory = () => - router.push(`/subscriptions/${subscriptionId}/sim/call-history`); + router.push(`/account/services/${subscriptionId}/sim/call-history`); // Fetch subscription data const { data: subscription } = useSubscription(subscriptionId); diff --git a/apps/portal/src/features/subscriptions/components/SubscriptionTable/SubscriptionTable.tsx b/apps/portal/src/features/subscriptions/components/SubscriptionTable/SubscriptionTable.tsx index ba0fe934..61251db1 100644 --- a/apps/portal/src/features/subscriptions/components/SubscriptionTable/SubscriptionTable.tsx +++ b/apps/portal/src/features/subscriptions/components/SubscriptionTable/SubscriptionTable.tsx @@ -101,7 +101,7 @@ export function SubscriptionTable({ if (onSubscriptionClick) { onSubscriptionClick(subscription); } else { - router.push(`/subscriptions/${subscription.id}`); + router.push(`/account/services/${subscription.id}`); } }, [onSubscriptionClick, router] diff --git a/apps/portal/src/features/subscriptions/containers/SimCancel.tsx b/apps/portal/src/features/subscriptions/containers/SimCancel.tsx index f96ba73f..fa2b2ab3 100644 --- a/apps/portal/src/features/subscriptions/containers/SimCancel.tsx +++ b/apps/portal/src/features/subscriptions/containers/SimCancel.tsx @@ -108,7 +108,7 @@ export function SimCancelContainer() { try { await simActionsService.cancel(subscriptionId, { scheduledAt: runDate }); setMessage("Cancellation request submitted. You will receive a confirmation email."); - setTimeout(() => router.push(`/subscriptions/${subscriptionId}#sim-management`), 1500); + setTimeout(() => router.push(`/account/services/${subscriptionId}#sim-management`), 1500); } catch (e: unknown) { setError(e instanceof Error ? e.message : "Failed to submit cancellation"); } finally { @@ -120,7 +120,7 @@ export function SimCancelContainer() {
← Back to SIM Management diff --git a/apps/portal/src/features/subscriptions/views/SimCallHistory.tsx b/apps/portal/src/features/subscriptions/views/SimCallHistory.tsx index 01017416..5bc0cfc4 100644 --- a/apps/portal/src/features/subscriptions/views/SimCallHistory.tsx +++ b/apps/portal/src/features/subscriptions/views/SimCallHistory.tsx @@ -138,7 +138,7 @@ export function SimCallHistoryContainer() {
← Back to SIM Management diff --git a/apps/portal/src/features/subscriptions/views/SimCancel.tsx b/apps/portal/src/features/subscriptions/views/SimCancel.tsx index c64bc5b8..61780fa6 100644 --- a/apps/portal/src/features/subscriptions/views/SimCancel.tsx +++ b/apps/portal/src/features/subscriptions/views/SimCancel.tsx @@ -102,7 +102,7 @@ export function SimCancelContainer() { comments: comments.trim() || undefined, }); setMessage("Cancellation request submitted. You will receive a confirmation email."); - setTimeout(() => router.push(`/subscriptions/${subscriptionId}#sim-management`), 2000); + setTimeout(() => router.push(`/account/services/${subscriptionId}#sim-management`), 2000); } catch (e: unknown) { setError( process.env.NODE_ENV === "development" @@ -125,8 +125,8 @@ export function SimCancelContainer() { title="Cancel SIM" description="Cancel your SIM subscription" breadcrumbs={[ - { label: "Subscriptions", href: "/subscriptions" }, - { label: "SIM Management", href: `/subscriptions/${subscriptionId}#sim-management` }, + { label: "Services", href: "/account/services" }, + { label: "SIM Management", href: `/account/services/${subscriptionId}#sim-management` }, { label: "Cancel SIM" }, ]} loading={loadingPreview} @@ -136,7 +136,7 @@ export function SimCancelContainer() {
← Back to SIM Management diff --git a/apps/portal/src/features/subscriptions/views/SimChangePlan.tsx b/apps/portal/src/features/subscriptions/views/SimChangePlan.tsx index e2f2d5f8..ca7096e2 100644 --- a/apps/portal/src/features/subscriptions/views/SimChangePlan.tsx +++ b/apps/portal/src/features/subscriptions/views/SimChangePlan.tsx @@ -89,7 +89,7 @@ export function SimChangePlanContainer() {
← Back to SIM Management @@ -241,7 +241,7 @@ export function SimChangePlanContainer() { } > diff --git a/apps/portal/src/features/support/views/NewSupportCaseView.tsx b/apps/portal/src/features/support/views/NewSupportCaseView.tsx index 12032ca6..991cb24d 100644 --- a/apps/portal/src/features/support/views/NewSupportCaseView.tsx +++ b/apps/portal/src/features/support/views/NewSupportCaseView.tsx @@ -38,7 +38,7 @@ export function NewSupportCaseView() { priority: formData.priority, }); - router.push("/support/cases?created=true"); + router.push("/account/support?created=true"); } catch (err) { setError( process.env.NODE_ENV === "development" @@ -70,7 +70,7 @@ export function NewSupportCaseView() { icon={} title="Create Support Case" description="Get help from our support team" - breadcrumbs={[{ label: "Support", href: "/support" }, { label: "Create Case" }]} + breadcrumbs={[{ label: "Support", href: "/account/support" }, { label: "Create Case" }]} > {/* AI Chat Suggestion */} @@ -178,7 +178,7 @@ export function NewSupportCaseView() { {/* Actions */}
Cancel diff --git a/apps/portal/src/features/support/views/PublicSupportView.tsx b/apps/portal/src/features/support/views/PublicSupportView.tsx index 0ce58281..49545f17 100644 --- a/apps/portal/src/features/support/views/PublicSupportView.tsx +++ b/apps/portal/src/features/support/views/PublicSupportView.tsx @@ -61,7 +61,7 @@ export function PublicSupportView() { {/* Contact Options */}
@@ -125,7 +125,7 @@ export function PublicSupportView() { Sign in {" "} - to access your dashboard and support tickets. + to access your dashboard and support cases.

diff --git a/apps/portal/src/features/support/views/SupportCaseDetailView.tsx b/apps/portal/src/features/support/views/SupportCaseDetailView.tsx index b3200f86..ffad545d 100644 --- a/apps/portal/src/features/support/views/SupportCaseDetailView.tsx +++ b/apps/portal/src/features/support/views/SupportCaseDetailView.tsx @@ -30,8 +30,8 @@ export function SupportCaseDetailView({ caseId }: SupportCaseDetailViewProps) { icon={} title="Case Not Found" breadcrumbs={[ - { label: "Support", href: "/support" }, - { label: "Cases", href: "/support/cases" }, + { label: "Support", href: "/account/support" }, + { label: "Cases", href: "/account/support" }, { label: "Not Found" }, ]} > @@ -52,14 +52,14 @@ export function SupportCaseDetailView({ caseId }: SupportCaseDetailViewProps) { error={pageError} onRetry={() => void refetch()} breadcrumbs={[ - { label: "Support", href: "/support" }, - { label: "Cases", href: "/support/cases" }, + { label: "Support", href: "/account/support" }, + { label: "Cases", href: "/account/support" }, { label: supportCase ? `#${supportCase.caseNumber}` : "..." }, ]} actions={ } @@ -183,7 +183,7 @@ export function SupportCasesView() { {cases.map(supportCase => (
router.push(`/support/cases/${supportCase.id}`)} + onClick={() => router.push(`/account/support/${supportCase.id}`)} className="flex items-center gap-4 p-4 hover:bg-muted cursor-pointer transition-colors group" > {/* Status Icon */} @@ -239,7 +239,7 @@ export function SupportCasesView() { description="You haven't created any support cases yet. Need help? Create a new case." action={{ label: "Create Case", - onClick: () => router.push("/support/new"), + onClick: () => router.push("/account/support/new"), }} /> diff --git a/apps/portal/src/features/support/views/SupportHomeView.tsx b/apps/portal/src/features/support/views/SupportHomeView.tsx index f82c0c49..39389817 100644 --- a/apps/portal/src/features/support/views/SupportHomeView.tsx +++ b/apps/portal/src/features/support/views/SupportHomeView.tsx @@ -65,7 +65,7 @@ export function SupportHomeView() {

Our team typically responds within 24 hours.

-
@@ -93,7 +93,7 @@ export function SupportHomeView() {
{summary.total > 0 && ( View all @@ -107,7 +107,7 @@ export function SupportHomeView() { {recentCases.map(supportCase => (
router.push(`/support/cases/${supportCase.id}`)} + onClick={() => router.push(`/account/support/${supportCase.id}`)} className="flex items-center gap-4 p-4 hover:bg-muted cursor-pointer transition-colors group" >
{getCaseStatusIcon(supportCase.status)}
@@ -139,7 +139,7 @@ export function SupportHomeView() { description="Need help? Start a chat with our AI assistant or create a support case." action={{ label: "Create Case", - onClick: () => router.push("/support/new"), + onClick: () => router.push("/account/support/new"), }} /> diff --git a/packages/domain/auth/forms.ts b/packages/domain/auth/forms.ts index 053a2dfb..55bf2ee7 100644 --- a/packages/domain/auth/forms.ts +++ b/packages/domain/auth/forms.ts @@ -64,7 +64,7 @@ export function getPasswordStrengthDisplay(strength: number): { export const MIGRATION_TRANSFER_ITEMS = [ "All active services", "Billing history", - "Support tickets", + "Support cases", "Account details", ] as const; diff --git a/packages/domain/auth/schema.ts b/packages/domain/auth/schema.ts index b5a4d50a..9c31f90a 100644 --- a/packages/domain/auth/schema.ts +++ b/packages/domain/auth/schema.ts @@ -41,7 +41,7 @@ export const signupInputSchema = z.object({ lastName: nameSchema, company: z.string().optional(), phone: phoneSchema, - sfNumber: z.string().min(6, "Customer number must be at least 6 characters"), + sfNumber: z.string().trim().min(6, "Customer number must be at least 6 characters").optional(), address: addressSchema.optional(), nationality: z.string().optional(), dateOfBirth: isoDateOnlySchema.optional(), @@ -85,7 +85,7 @@ export const linkWhmcsRequestSchema = z.object({ }); export const validateSignupRequestSchema = z.object({ - sfNumber: z.string().min(1, "Customer number is required"), + sfNumber: z.string().trim().min(1, "Customer number is required").optional(), }); /**