Implement Account Portal Restructuring and Component Updates
- Introduced a new plan for restructuring the account portal, resulting in over 60 file operations including the addition of new components and the deletion of outdated routes. - Updated various components to enhance user experience, including the addition of AccountRouteGuard and AccountDashboardPage. - Refactored routing paths to align with the new account structure, ensuring seamless navigation across billing, orders, and support sections. - Enhanced the checkout process by integrating new components and improving error handling for better user feedback. - Updated public-facing features to streamline contact and support functionalities, improving accessibility for users.
This commit is contained in:
parent
3fe74b72dd
commit
963e30e817
390
.cursor/plans/restructure_to_account_portal_efdb4b10.plan.md
Normal file
390
.cursor/plans/restructure_to_account_portal_efdb4b10.plan.md
Normal file
@ -0,0 +1,390 @@
|
|||||||
|
---
|
||||||
|
name: Restructure to Account Portal
|
||||||
|
overview: Restructure the app to have public pages under (public)/ and all authenticated portal pages under /account/*, with auth-aware headers in public shells.
|
||||||
|
todos:
|
||||||
|
- id: auth-aware-public-shell
|
||||||
|
content: "Make PublicShell auth-aware: show 'My Account' for logged-in users, 'Sign in' for guests"
|
||||||
|
status: pending
|
||||||
|
- id: auth-aware-catalog-shell
|
||||||
|
content: Make CatalogShell auth-aware with same pattern
|
||||||
|
status: pending
|
||||||
|
- id: create-account-layout
|
||||||
|
content: Create account/layout.tsx with AppShell and auth guard redirect
|
||||||
|
status: pending
|
||||||
|
- id: move-dashboard-to-account
|
||||||
|
content: Move dashboard page to account/page.tsx
|
||||||
|
status: pending
|
||||||
|
- id: move-billing-to-account
|
||||||
|
content: Move billing pages to account/billing/*
|
||||||
|
status: pending
|
||||||
|
- id: move-subscriptions-to-services
|
||||||
|
content: Move subscriptions to account/services/*
|
||||||
|
status: pending
|
||||||
|
- id: move-orders-to-account
|
||||||
|
content: Move orders to account/orders/*
|
||||||
|
status: pending
|
||||||
|
- id: move-support-to-account
|
||||||
|
content: Move support cases to account/support/*
|
||||||
|
status: pending
|
||||||
|
- id: move-profile-to-settings
|
||||||
|
content: Move account/profile to account/settings/*
|
||||||
|
status: pending
|
||||||
|
- id: fix-shop-double-header
|
||||||
|
content: Fix shop layout to not create double header - add CatalogNav only
|
||||||
|
status: pending
|
||||||
|
- id: create-contact-route
|
||||||
|
content: Create (public)/contact/page.tsx for contact form
|
||||||
|
status: pending
|
||||||
|
- id: update-navigation
|
||||||
|
content: Update AppShell navigation.ts with /account/* paths
|
||||||
|
status: pending
|
||||||
|
- id: update-catalog-links
|
||||||
|
content: Replace all /catalog links with /shop
|
||||||
|
status: pending
|
||||||
|
- id: update-portal-links
|
||||||
|
content: Replace all old portal links with /account/* paths
|
||||||
|
status: pending
|
||||||
|
- id: remove-sfnumber
|
||||||
|
content: Remove sfNumber from domain schema and signup components
|
||||||
|
status: pending
|
||||||
|
- id: delete-old-authenticated
|
||||||
|
content: Delete (authenticated)/ directory after migration
|
||||||
|
status: pending
|
||||||
|
- id: rebuild-test
|
||||||
|
content: Rebuild domain package and test all routes
|
||||||
|
status: pending
|
||||||
|
---
|
||||||
|
|
||||||
|
# Restructure Portal to /account/\* Architecture
|
||||||
|
|
||||||
|
## Target Architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph public ["(public)/ - Public Pages"]
|
||||||
|
P1["/"] --> Home["Homepage"]
|
||||||
|
P2["/auth/*"] --> Auth["Login, Signup, etc"]
|
||||||
|
P3["/shop/*"] --> Shop["Product Catalog"]
|
||||||
|
P4["/help"] --> Help["FAQ & Knowledge Base"]
|
||||||
|
P5["/contact"] --> Contact["Contact Form"]
|
||||||
|
P6["/order/*"] --> Order["Checkout Flow"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph account ["/account/* - My Portal"]
|
||||||
|
A1["/account"] --> Dashboard["Dashboard"]
|
||||||
|
A2["/account/billing"] --> Billing["Invoices & Payments"]
|
||||||
|
A3["/account/services"] --> Services["My Subscriptions"]
|
||||||
|
A4["/account/orders"] --> Orders["Order History"]
|
||||||
|
A5["/account/support"] --> Support["My Tickets"]
|
||||||
|
A6["/account/settings"] --> Settings["Profile Settings"]
|
||||||
|
end
|
||||||
|
|
||||||
|
public -.->|"Auth-aware header"| account
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Make Shells Auth-Aware
|
||||||
|
|
||||||
|
### 1.1 Update PublicShell
|
||||||
|
|
||||||
|
**File:** `apps/portal/src/components/templates/PublicShell/PublicShell.tsx`
|
||||||
|
|
||||||
|
Add auth detection to header navigation:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
"use client";
|
||||||
|
import { useAuthStore } from "@/features/auth/services/auth.store";
|
||||||
|
|
||||||
|
export function PublicShell({ children }: PublicShellProps) {
|
||||||
|
const { isAuthenticated } = useAuthStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen...">
|
||||||
|
<header>
|
||||||
|
<nav>
|
||||||
|
<Link href="/shop">Services</Link>
|
||||||
|
<Link href="/help">Support</Link>
|
||||||
|
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<Link href="/account" className="primary-button">
|
||||||
|
My Account
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Link href="/auth/login" className="primary-button">
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 Update CatalogShell
|
||||||
|
|
||||||
|
**File:** `apps/portal/src/components/templates/CatalogShell/CatalogShell.tsx`
|
||||||
|
|
||||||
|
Same auth-aware pattern - show "My Account" or "Sign in" based on auth state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Create /account Route Structure
|
||||||
|
|
||||||
|
### 2.1 Create Account Layout with Auth Guard
|
||||||
|
|
||||||
|
**File:** `apps/portal/src/app/account/layout.tsx` (NEW)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { AppShell } from "@/components/organisms/AppShell";
|
||||||
|
|
||||||
|
export default async function AccountLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const hasAuthToken = cookieStore.has("access_token");
|
||||||
|
|
||||||
|
if (!hasAuthToken) {
|
||||||
|
redirect("/auth/login?redirect=/account");
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AppShell>{children}</AppShell>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Create Account Pages
|
||||||
|
|
||||||
|
Move and rename pages:
|
||||||
|
|
||||||
|
| Current Path | New Path | New File |
|
||||||
|
|
||||||
|
|--------------|----------|----------|
|
||||||
|
|
||||||
|
| `(authenticated)/dashboard/page.tsx` | `/account` | `account/page.tsx` |
|
||||||
|
|
||||||
|
| `(authenticated)/billing/*` | `/account/billing/*` | `account/billing/*` |
|
||||||
|
|
||||||
|
| `(authenticated)/subscriptions/*` | `/account/services/*` | `account/services/*` |
|
||||||
|
|
||||||
|
| `(authenticated)/orders/*` | `/account/orders/*` | `account/orders/*` |
|
||||||
|
|
||||||
|
| `(authenticated)/support/*` | `/account/support/*` | `account/support/*` |
|
||||||
|
|
||||||
|
| `(authenticated)/account/*` | `/account/settings/*` | `account/settings/*` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Update Navigation
|
||||||
|
|
||||||
|
### 3.1 Update AppShell Navigation
|
||||||
|
|
||||||
|
**File:** `apps/portal/src/components/organisms/AppShell/navigation.ts`
|
||||||
|
|
||||||
|
Update all paths to use `/account/*`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const baseNavigation: NavigationItem[] = [
|
||||||
|
{ name: "Dashboard", href: "/account", icon: HomeIcon },
|
||||||
|
{ name: "Orders", href: "/account/orders", icon: ClipboardDocumentListIcon },
|
||||||
|
{
|
||||||
|
name: "Billing",
|
||||||
|
icon: CreditCardIcon,
|
||||||
|
children: [
|
||||||
|
{ name: "Invoices", href: "/account/billing/invoices" },
|
||||||
|
{ name: "Payment Methods", href: "/account/billing/payments" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "My Services",
|
||||||
|
icon: ServerIcon,
|
||||||
|
children: [{ name: "All Services", href: "/account/services" }],
|
||||||
|
},
|
||||||
|
{ name: "Shop", href: "/shop", icon: Squares2X2Icon }, // Links to public shop
|
||||||
|
{
|
||||||
|
name: "Support",
|
||||||
|
icon: ChatBubbleLeftRightIcon,
|
||||||
|
children: [
|
||||||
|
{ name: "My Tickets", href: "/account/support" },
|
||||||
|
{ name: "New Ticket", href: "/account/support/new" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ name: "Settings", href: "/account/settings", icon: UserIcon },
|
||||||
|
{ name: "Log out", href: "#", icon: ArrowRightStartOnRectangleIcon, isLogout: true },
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: Fix Public Routes
|
||||||
|
|
||||||
|
### 4.1 Fix Double Header in Shop
|
||||||
|
|
||||||
|
Remove the nested shell issue by having CatalogShell NOT render a full page wrapper, or by not nesting it under PublicShell.
|
||||||
|
|
||||||
|
**Option A:** Move shop out of (public) to its own route with CatalogShell only
|
||||||
|
|
||||||
|
**Option B:** Have (public)/shop/layout.tsx return just children with catalog nav (no shell)
|
||||||
|
|
||||||
|
Recommended: **Option B** - Keep shop under (public) but have shop layout add only catalog navigation, not a full shell.
|
||||||
|
|
||||||
|
**File:** `apps/portal/src/app/(public)/shop/layout.tsx`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { CatalogNav } from "@/components/templates/CatalogShell";
|
||||||
|
|
||||||
|
export default function ShopLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
// Don't wrap with another shell - parent (public) layout already has PublicShell
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CatalogNav />
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**File:** `apps/portal/src/components/templates/CatalogShell/CatalogShell.tsx`
|
||||||
|
|
||||||
|
Split into two exports:
|
||||||
|
|
||||||
|
- `CatalogShell` - full shell (if ever needed standalone)
|
||||||
|
- `CatalogNav` - just the navigation bar
|
||||||
|
|
||||||
|
### 4.2 Create /contact Route
|
||||||
|
|
||||||
|
**File:** `apps/portal/src/app/(public)/contact/page.tsx` (NEW)
|
||||||
|
|
||||||
|
Move content from `(public)/help/contact/` to `(public)/contact/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: Delete Old Routes
|
||||||
|
|
||||||
|
### 5.1 Delete (authenticated) Directory
|
||||||
|
|
||||||
|
After moving all content to /account/:
|
||||||
|
|
||||||
|
- Delete entire `apps/portal/src/app/(authenticated)/` directory
|
||||||
|
|
||||||
|
### 5.2 Clean Up Unused Files
|
||||||
|
|
||||||
|
- Delete `(public)/help/contact/` (moved to /contact)
|
||||||
|
- Keep `(public)/help/page.tsx` for FAQ
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Update All Internal Links
|
||||||
|
|
||||||
|
### 6.1 Update /catalog to /shop Links
|
||||||
|
|
||||||
|
Replace in feature components (11 files, 27 occurrences):
|
||||||
|
|
||||||
|
```
|
||||||
|
/catalog → /shop
|
||||||
|
/catalog/internet → /shop/internet
|
||||||
|
/catalog/sim → /shop/sim
|
||||||
|
/catalog/vpn → /shop/vpn
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Update Dashboard/Portal Links
|
||||||
|
|
||||||
|
Replace throughout codebase:
|
||||||
|
|
||||||
|
```
|
||||||
|
/dashboard → /account
|
||||||
|
/billing → /account/billing
|
||||||
|
/subscriptions → /account/services
|
||||||
|
/orders → /account/orders
|
||||||
|
/support/cases → /account/support
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7: Remove sfNumber from Signup
|
||||||
|
|
||||||
|
### 7.1 Update Domain Schema
|
||||||
|
|
||||||
|
**File:** `packages/domain/auth/schema.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Line 44: Remove required sfNumber
|
||||||
|
// Before:
|
||||||
|
sfNumber: z.string().min(6, "Customer number must be at least 6 characters"),
|
||||||
|
|
||||||
|
// After:
|
||||||
|
sfNumber: z.string().optional(),
|
||||||
|
```
|
||||||
|
|
||||||
|
Also update `validateSignupRequestSchema` to not require sfNumber.
|
||||||
|
|
||||||
|
### 7.2 Update SignupForm Components
|
||||||
|
|
||||||
|
- `SignupForm.tsx` - Remove sfNumber from initialValues and validation
|
||||||
|
- `AccountStep.tsx` - Remove Customer Number form field
|
||||||
|
- `ReviewStep.tsx` - Remove Customer Number display
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 8: Rebuild and Test
|
||||||
|
|
||||||
|
### 8.1 Rebuild Domain Package
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @customer-portal/domain build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 Test Matrix
|
||||||
|
|
||||||
|
| Scenario | URL | Expected |
|
||||||
|
|
||||||
|
|----------|-----|----------|
|
||||||
|
|
||||||
|
| Public homepage | `/` | PublicShell, homepage content |
|
||||||
|
|
||||||
|
| Public shop | `/shop` | CatalogShell (auth-aware), products |
|
||||||
|
|
||||||
|
| Auth user in shop | `/shop` | "My Account" button, personalized pricing |
|
||||||
|
|
||||||
|
| Public help | `/help` | FAQ content |
|
||||||
|
|
||||||
|
| Public contact | `/contact` | Contact form, prefills if logged in |
|
||||||
|
|
||||||
|
| Login | `/auth/login` | Login form |
|
||||||
|
|
||||||
|
| Signup | `/auth/signup` | No sfNumber field |
|
||||||
|
|
||||||
|
| Account dashboard | `/account` | AppShell, dashboard (redirect if not auth) |
|
||||||
|
|
||||||
|
| My services | `/account/services` | Subscriptions list |
|
||||||
|
|
||||||
|
| My tickets | `/account/support` | Support cases |
|
||||||
|
|
||||||
|
| Checkout | `/order` | CheckoutShell, wizard |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Summary
|
||||||
|
|
||||||
|
| Category | Action | Count |
|
||||||
|
|
||||||
|
|----------|--------|-------|
|
||||||
|
|
||||||
|
| New account/ routes | Create | ~15 files |
|
||||||
|
|
||||||
|
| Shell components | Modify | 2 (PublicShell, CatalogShell) |
|
||||||
|
|
||||||
|
| Shop layout | Modify | 1 |
|
||||||
|
|
||||||
|
| Navigation | Modify | 1 |
|
||||||
|
|
||||||
|
| Link updates | Modify | ~20 files |
|
||||||
|
|
||||||
|
| Domain schema | Modify | 1 |
|
||||||
|
|
||||||
|
| Signup components | Modify | 3 |
|
||||||
|
|
||||||
|
| Delete old routes | Delete | ~20 files |
|
||||||
|
|
||||||
|
**Total: ~60+ file operations**
|
||||||
@ -14,6 +14,7 @@ import { UsersFacade } from "@bff/modules/users/application/users.facade.js";
|
|||||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||||
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js";
|
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js";
|
||||||
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service.js";
|
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service.js";
|
||||||
|
import { SalesforceAccountService } from "@bff/integrations/salesforce/services/salesforce-account.service.js";
|
||||||
import { PrismaService } from "@bff/infra/database/prisma.service.js";
|
import { PrismaService } from "@bff/infra/database/prisma.service.js";
|
||||||
import { AuthTokenService } from "../../token/token.service.js";
|
import { AuthTokenService } from "../../token/token.service.js";
|
||||||
import { AuthRateLimitService } from "../../rate-limiting/auth-rate-limit.service.js";
|
import { AuthRateLimitService } from "../../rate-limiting/auth-rate-limit.service.js";
|
||||||
@ -55,6 +56,7 @@ export class SignupWorkflowService {
|
|||||||
private readonly mappingsService: MappingsService,
|
private readonly mappingsService: MappingsService,
|
||||||
private readonly whmcsService: WhmcsService,
|
private readonly whmcsService: WhmcsService,
|
||||||
private readonly salesforceService: SalesforceService,
|
private readonly salesforceService: SalesforceService,
|
||||||
|
private readonly salesforceAccountService: SalesforceAccountService,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly auditService: AuditService,
|
private readonly auditService: AuditService,
|
||||||
@ -66,14 +68,30 @@ export class SignupWorkflowService {
|
|||||||
|
|
||||||
async validateSignup(validateData: ValidateSignupRequest, request?: Request) {
|
async validateSignup(validateData: ValidateSignupRequest, request?: Request) {
|
||||||
const { sfNumber } = validateData;
|
const { sfNumber } = validateData;
|
||||||
|
const normalizedCustomerNumber = this.normalizeCustomerNumber(sfNumber);
|
||||||
|
|
||||||
|
if (!normalizedCustomerNumber) {
|
||||||
|
await this.auditService.logAuthEvent(
|
||||||
|
AuditAction.SIGNUP,
|
||||||
|
undefined,
|
||||||
|
{ sfNumber: sfNumber ?? null, reason: "no_customer_number_provided" },
|
||||||
|
request,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
message: "Customer number is not required for signup",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const accountSnapshot = await this.getAccountSnapshot(sfNumber);
|
const accountSnapshot = await this.getAccountSnapshot(normalizedCustomerNumber);
|
||||||
if (!accountSnapshot) {
|
if (!accountSnapshot) {
|
||||||
await this.auditService.logAuthEvent(
|
await this.auditService.logAuthEvent(
|
||||||
AuditAction.SIGNUP,
|
AuditAction.SIGNUP,
|
||||||
undefined,
|
undefined,
|
||||||
{ sfNumber, reason: "SF number not found" },
|
{ sfNumber: normalizedCustomerNumber, reason: "SF number not found" },
|
||||||
request,
|
request,
|
||||||
false,
|
false,
|
||||||
"Customer number not found in Salesforce"
|
"Customer number not found in Salesforce"
|
||||||
@ -118,7 +136,7 @@ export class SignupWorkflowService {
|
|||||||
await this.auditService.logAuthEvent(
|
await this.auditService.logAuthEvent(
|
||||||
AuditAction.SIGNUP,
|
AuditAction.SIGNUP,
|
||||||
undefined,
|
undefined,
|
||||||
{ sfNumber, sfAccountId: accountSnapshot.id, step: "validation" },
|
{ sfNumber: normalizedCustomerNumber, sfAccountId: accountSnapshot.id, step: "validation" },
|
||||||
request,
|
request,
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
@ -136,7 +154,7 @@ export class SignupWorkflowService {
|
|||||||
await this.auditService.logAuthEvent(
|
await this.auditService.logAuthEvent(
|
||||||
AuditAction.SIGNUP,
|
AuditAction.SIGNUP,
|
||||||
undefined,
|
undefined,
|
||||||
{ sfNumber, error: getErrorMessage(error) },
|
{ sfNumber: normalizedCustomerNumber, error: getErrorMessage(error) },
|
||||||
request,
|
request,
|
||||||
false,
|
false,
|
||||||
getErrorMessage(error)
|
getErrorMessage(error)
|
||||||
@ -189,17 +207,87 @@ export class SignupWorkflowService {
|
|||||||
const passwordHash = await argon2.hash(password);
|
const passwordHash = await argon2.hash(password);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const accountSnapshot = await this.getAccountSnapshot(sfNumber);
|
const normalizedCustomerNumber = this.normalizeCustomerNumber(sfNumber);
|
||||||
if (!accountSnapshot) {
|
let accountSnapshot: SignupAccountSnapshot;
|
||||||
throw new BadRequestException(
|
let customerNumberForWhmcs: string | null = normalizedCustomerNumber;
|
||||||
`Salesforce account not found for Customer Number: ${sfNumber}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (accountSnapshot.WH_Account__c && accountSnapshot.WH_Account__c.trim() !== "") {
|
if (normalizedCustomerNumber) {
|
||||||
throw new ConflictException(
|
const resolved = await this.getAccountSnapshot(normalizedCustomerNumber);
|
||||||
"You already have an account. Please use the login page to access your existing account."
|
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 };
|
let whmcsClient: { clientId: number };
|
||||||
@ -232,7 +320,9 @@ export class SignupWorkflowService {
|
|||||||
const nationalityFieldId = this.configService.get<string>("WHMCS_NATIONALITY_FIELD_ID");
|
const nationalityFieldId = this.configService.get<string>("WHMCS_NATIONALITY_FIELD_ID");
|
||||||
|
|
||||||
const customfieldsMap: Record<string, string> = {};
|
const customfieldsMap: Record<string, string> = {};
|
||||||
if (customerNumberFieldId) customfieldsMap[customerNumberFieldId] = sfNumber;
|
if (customerNumberFieldId && customerNumberForWhmcs) {
|
||||||
|
customfieldsMap[customerNumberFieldId] = customerNumberForWhmcs;
|
||||||
|
}
|
||||||
if (dobFieldId && dateOfBirth) customfieldsMap[dobFieldId] = dateOfBirth;
|
if (dobFieldId && dateOfBirth) customfieldsMap[dobFieldId] = dateOfBirth;
|
||||||
if (genderFieldId && gender) customfieldsMap[genderFieldId] = gender;
|
if (genderFieldId && gender) customfieldsMap[genderFieldId] = gender;
|
||||||
if (nationalityFieldId && nationality) customfieldsMap[nationalityFieldId] = nationality;
|
if (nationalityFieldId && nationality) customfieldsMap[nationalityFieldId] = nationality;
|
||||||
@ -253,7 +343,12 @@ export class SignupWorkflowService {
|
|||||||
throw new BadRequestException("Phone number is required for billing account creation");
|
throw new BadRequestException("Phone number is required for billing account creation");
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log("Creating WHMCS client", { email, firstName, lastName, sfNumber });
|
this.logger.log("Creating WHMCS client", {
|
||||||
|
email,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
sfNumber: customerNumberForWhmcs,
|
||||||
|
});
|
||||||
|
|
||||||
whmcsClient = await this.whmcsService.addClient({
|
whmcsClient = await this.whmcsService.addClient({
|
||||||
firstname: firstName,
|
firstname: firstName,
|
||||||
@ -399,6 +494,7 @@ export class SignupWorkflowService {
|
|||||||
async signupPreflight(signupData: SignupRequest) {
|
async signupPreflight(signupData: SignupRequest) {
|
||||||
const { email, sfNumber } = signupData;
|
const { email, sfNumber } = signupData;
|
||||||
const normalizedEmail = email.toLowerCase().trim();
|
const normalizedEmail = email.toLowerCase().trim();
|
||||||
|
const normalizedCustomerNumber = this.normalizeCustomerNumber(sfNumber);
|
||||||
|
|
||||||
const result: {
|
const result: {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
@ -440,7 +536,59 @@ export class SignupWorkflowService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
const accountSnapshot = await this.getAccountSnapshot(sfNumber);
|
if (!normalizedCustomerNumber) {
|
||||||
|
try {
|
||||||
|
const client = await this.whmcsService.getClientDetailsByEmail(normalizedEmail);
|
||||||
|
if (client) {
|
||||||
|
result.whmcs.clientExists = true;
|
||||||
|
result.whmcs.clientId = client.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mapped = await this.mappingsService.findByWhmcsClientId(client.id);
|
||||||
|
if (mapped) {
|
||||||
|
result.nextAction = "login";
|
||||||
|
result.messages.push("This billing account is already linked. Please sign in.");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore; treat as unmapped
|
||||||
|
}
|
||||||
|
|
||||||
|
result.nextAction = "link_whmcs";
|
||||||
|
result.messages.push(
|
||||||
|
"We found an existing billing account for this email. Please transfer your account."
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!(err instanceof NotFoundException)) {
|
||||||
|
this.logger.warn("WHMCS preflight check failed", { error: getErrorMessage(err) });
|
||||||
|
result.messages.push("Unable to verify billing system. Please try again later.");
|
||||||
|
result.nextAction = "blocked";
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existingSf = await this.salesforceAccountService.findByEmail(normalizedEmail);
|
||||||
|
if (existingSf) {
|
||||||
|
result.nextAction = "blocked";
|
||||||
|
result.messages.push(
|
||||||
|
"We found an existing customer record for this email. Please transfer your account or contact support."
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
} catch (sfErr) {
|
||||||
|
this.logger.warn("Salesforce preflight check failed", { error: getErrorMessage(sfErr) });
|
||||||
|
}
|
||||||
|
|
||||||
|
result.canProceed = true;
|
||||||
|
result.nextAction = "proceed_signup";
|
||||||
|
result.messages.push("All checks passed. Ready to create your account.");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountSnapshot = await this.getAccountSnapshot(normalizedCustomerNumber);
|
||||||
if (!accountSnapshot) {
|
if (!accountSnapshot) {
|
||||||
result.nextAction = "fix_input";
|
result.nextAction = "fix_input";
|
||||||
result.messages.push("Customer number not found in Salesforce");
|
result.messages.push("Customer number not found in Salesforce");
|
||||||
@ -494,7 +642,9 @@ export class SignupWorkflowService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getAccountSnapshot(sfNumber: string): Promise<SignupAccountSnapshot | null> {
|
private async getAccountSnapshot(
|
||||||
|
sfNumber?: string | null
|
||||||
|
): Promise<SignupAccountSnapshot | null> {
|
||||||
const normalized = this.normalizeCustomerNumber(sfNumber);
|
const normalized = this.normalizeCustomerNumber(sfNumber);
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
return null;
|
return null;
|
||||||
@ -519,7 +669,7 @@ export class SignupWorkflowService {
|
|||||||
return resolved;
|
return resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeCustomerNumber(sfNumber: string): string | null {
|
private normalizeCustomerNumber(sfNumber?: string | null): string | null {
|
||||||
if (typeof sfNumber !== "string") {
|
if (typeof sfNumber !== "string") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,25 @@
|
|||||||
import { Body, Controller, Post, Request, UsePipes, Inject, UseGuards } from "@nestjs/common";
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Request,
|
||||||
|
UseGuards,
|
||||||
|
UsePipes,
|
||||||
|
Inject,
|
||||||
|
} from "@nestjs/common";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { ZodValidationPipe } from "nestjs-zod";
|
import { ZodValidationPipe } from "nestjs-zod";
|
||||||
import { Public } from "@bff/modules/auth/decorators/public.decorator.js";
|
import { Public } from "@bff/modules/auth/decorators/public.decorator.js";
|
||||||
import { CheckoutService } from "../services/checkout.service.js";
|
import { CheckoutService } from "../services/checkout.service.js";
|
||||||
|
import { CheckoutSessionService } from "../services/checkout-session.service.js";
|
||||||
import {
|
import {
|
||||||
|
checkoutItemSchema,
|
||||||
checkoutCartSchema,
|
checkoutCartSchema,
|
||||||
checkoutBuildCartRequestSchema,
|
checkoutBuildCartRequestSchema,
|
||||||
checkoutBuildCartResponseSchema,
|
checkoutBuildCartResponseSchema,
|
||||||
|
checkoutTotalsSchema,
|
||||||
} from "@customer-portal/domain/orders";
|
} from "@customer-portal/domain/orders";
|
||||||
import type { CheckoutCart, CheckoutBuildCartRequest } from "@customer-portal/domain/orders";
|
import type { CheckoutCart, CheckoutBuildCartRequest } from "@customer-portal/domain/orders";
|
||||||
import { apiSuccessResponseSchema } from "@customer-portal/domain/common";
|
import { apiSuccessResponseSchema } from "@customer-portal/domain/common";
|
||||||
@ -15,12 +28,28 @@ import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
|
|||||||
import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js";
|
import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js";
|
||||||
|
|
||||||
const validateCartResponseSchema = apiSuccessResponseSchema(z.object({ valid: z.boolean() }));
|
const validateCartResponseSchema = apiSuccessResponseSchema(z.object({ valid: z.boolean() }));
|
||||||
|
const checkoutSessionIdParamSchema = z.object({ sessionId: z.string().uuid() });
|
||||||
|
|
||||||
|
const checkoutCartSummarySchema = z.object({
|
||||||
|
items: z.array(checkoutItemSchema),
|
||||||
|
totals: checkoutTotalsSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
const checkoutSessionResponseSchema = apiSuccessResponseSchema(
|
||||||
|
z.object({
|
||||||
|
sessionId: z.string().uuid(),
|
||||||
|
expiresAt: z.string(),
|
||||||
|
orderType: z.enum(["Internet", "SIM", "VPN", "Other"]),
|
||||||
|
cart: checkoutCartSummarySchema,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
@Controller("checkout")
|
@Controller("checkout")
|
||||||
@Public() // Cart building and validation can be done without authentication
|
@Public() // Cart building and validation can be done without authentication
|
||||||
export class CheckoutController {
|
export class CheckoutController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly checkoutService: CheckoutService,
|
private readonly checkoutService: CheckoutService,
|
||||||
|
private readonly checkoutSessions: CheckoutSessionService,
|
||||||
@Inject(Logger) private readonly logger: Logger
|
@Inject(Logger) private readonly logger: Logger
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -55,6 +84,61 @@ export class CheckoutController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a short-lived checkout session to avoid trusting client-side state.
|
||||||
|
* This returns a cart summary (items + totals) and stores the full request+cart server-side.
|
||||||
|
*/
|
||||||
|
@Post("session")
|
||||||
|
@UseGuards(SalesforceReadThrottleGuard)
|
||||||
|
@UsePipes(new ZodValidationPipe(checkoutBuildCartRequestSchema))
|
||||||
|
async createSession(@Request() req: RequestWithUser, @Body() body: CheckoutBuildCartRequest) {
|
||||||
|
this.logger.log("Creating checkout session", {
|
||||||
|
userId: req.user?.id,
|
||||||
|
orderType: body.orderType,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cart = await this.checkoutService.buildCart(
|
||||||
|
body.orderType,
|
||||||
|
body.selections,
|
||||||
|
body.configuration,
|
||||||
|
req.user?.id
|
||||||
|
);
|
||||||
|
|
||||||
|
const session = await this.checkoutSessions.createSession(body, cart);
|
||||||
|
|
||||||
|
return checkoutSessionResponseSchema.parse({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
sessionId: session.sessionId,
|
||||||
|
expiresAt: session.expiresAt,
|
||||||
|
orderType: body.orderType,
|
||||||
|
cart: {
|
||||||
|
items: cart.items,
|
||||||
|
totals: cart.totals,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("session/:sessionId")
|
||||||
|
@UseGuards(SalesforceReadThrottleGuard)
|
||||||
|
@UsePipes(new ZodValidationPipe(checkoutSessionIdParamSchema))
|
||||||
|
async getSession(@Param() params: { sessionId: string }) {
|
||||||
|
const session = await this.checkoutSessions.getSession(params.sessionId);
|
||||||
|
return checkoutSessionResponseSchema.parse({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
sessionId: params.sessionId,
|
||||||
|
expiresAt: session.expiresAt,
|
||||||
|
orderType: session.request.orderType,
|
||||||
|
cart: {
|
||||||
|
items: session.cart.items,
|
||||||
|
totals: session.cart.totals,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@Post("validate")
|
@Post("validate")
|
||||||
@UsePipes(new ZodValidationPipe(checkoutCartSchema))
|
@UsePipes(new ZodValidationPipe(checkoutCartSchema))
|
||||||
validateCart(@Body() cart: CheckoutCart) {
|
validateCart(@Body() cart: CheckoutCart) {
|
||||||
|
|||||||
@ -29,12 +29,21 @@ import { Observable } from "rxjs";
|
|||||||
import { OrderEventsService } from "./services/order-events.service.js";
|
import { OrderEventsService } from "./services/order-events.service.js";
|
||||||
import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js";
|
import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js";
|
||||||
import { SalesforceWriteThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-write-throttle.guard.js";
|
import { SalesforceWriteThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-write-throttle.guard.js";
|
||||||
|
import { CheckoutService } from "./services/checkout.service.js";
|
||||||
|
import { CheckoutSessionService } from "./services/checkout-session.service.js";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const checkoutSessionCreateOrderSchema = z.object({
|
||||||
|
checkoutSessionId: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
@Controller("orders")
|
@Controller("orders")
|
||||||
@UseGuards(RateLimitGuard)
|
@UseGuards(RateLimitGuard)
|
||||||
export class OrdersController {
|
export class OrdersController {
|
||||||
constructor(
|
constructor(
|
||||||
private orderOrchestrator: OrderOrchestrator,
|
private orderOrchestrator: OrderOrchestrator,
|
||||||
|
private readonly checkoutService: CheckoutService,
|
||||||
|
private readonly checkoutSessions: CheckoutSessionService,
|
||||||
private readonly orderEvents: OrderEventsService,
|
private readonly orderEvents: OrderEventsService,
|
||||||
private readonly logger: Logger
|
private readonly logger: Logger
|
||||||
) {}
|
) {}
|
||||||
@ -71,6 +80,58 @@ export class OrdersController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post("from-checkout-session")
|
||||||
|
@UseGuards(SalesforceWriteThrottleGuard)
|
||||||
|
@RateLimit({ limit: 5, ttl: 60 }) // 5 order creations per minute
|
||||||
|
@UsePipes(new ZodValidationPipe(checkoutSessionCreateOrderSchema))
|
||||||
|
async createFromCheckoutSession(
|
||||||
|
@Request() req: RequestWithUser,
|
||||||
|
@Body() body: { checkoutSessionId: string }
|
||||||
|
) {
|
||||||
|
this.logger.log(
|
||||||
|
{
|
||||||
|
userId: req.user?.id,
|
||||||
|
checkoutSessionId: body.checkoutSessionId,
|
||||||
|
},
|
||||||
|
"Order creation from checkout session request received"
|
||||||
|
);
|
||||||
|
|
||||||
|
const session = await this.checkoutSessions.getSession(body.checkoutSessionId);
|
||||||
|
|
||||||
|
const cart = await this.checkoutService.buildCart(
|
||||||
|
session.request.orderType,
|
||||||
|
session.request.selections,
|
||||||
|
session.request.configuration,
|
||||||
|
req.user?.id
|
||||||
|
);
|
||||||
|
|
||||||
|
const uniqueSkus = Array.from(
|
||||||
|
new Set(
|
||||||
|
cart.items
|
||||||
|
.map(item => item.sku)
|
||||||
|
.filter((sku): sku is string => typeof sku === "string" && sku.trim().length > 0)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (uniqueSkus.length === 0) {
|
||||||
|
throw new NotFoundException("Checkout session contains no items");
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderBody: CreateOrderRequest = {
|
||||||
|
orderType: session.request.orderType,
|
||||||
|
skus: uniqueSkus,
|
||||||
|
...(Object.keys(cart.configuration ?? {}).length > 0
|
||||||
|
? { configurations: cart.configuration }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await this.orderOrchestrator.createOrder(req.user.id, orderBody);
|
||||||
|
|
||||||
|
await this.checkoutSessions.deleteSession(body.checkoutSessionId);
|
||||||
|
|
||||||
|
return this.createOrderResponseSchema.parse({ success: true, data: result });
|
||||||
|
}
|
||||||
|
|
||||||
@Get("user")
|
@Get("user")
|
||||||
@UseGuards(SalesforceReadThrottleGuard)
|
@UseGuards(SalesforceReadThrottleGuard)
|
||||||
async getUserOrders(@Request() req: RequestWithUser) {
|
async getUserOrders(@Request() req: RequestWithUser) {
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import { OrderPricebookService } from "./services/order-pricebook.service.js";
|
|||||||
import { OrderOrchestrator } from "./services/order-orchestrator.service.js";
|
import { OrderOrchestrator } from "./services/order-orchestrator.service.js";
|
||||||
import { PaymentValidatorService } from "./services/payment-validator.service.js";
|
import { PaymentValidatorService } from "./services/payment-validator.service.js";
|
||||||
import { CheckoutService } from "./services/checkout.service.js";
|
import { CheckoutService } from "./services/checkout.service.js";
|
||||||
|
import { CheckoutSessionService } from "./services/checkout-session.service.js";
|
||||||
import { OrderEventsService } from "./services/order-events.service.js";
|
import { OrderEventsService } from "./services/order-events.service.js";
|
||||||
import { OrdersCacheService } from "./services/orders-cache.service.js";
|
import { OrdersCacheService } from "./services/orders-cache.service.js";
|
||||||
|
|
||||||
@ -54,6 +55,7 @@ import { OrderFieldConfigModule } from "./config/order-field-config.module.js";
|
|||||||
OrderOrchestrator,
|
OrderOrchestrator,
|
||||||
OrdersCacheService,
|
OrdersCacheService,
|
||||||
CheckoutService,
|
CheckoutService,
|
||||||
|
CheckoutSessionService,
|
||||||
|
|
||||||
// Order fulfillment services (modular)
|
// Order fulfillment services (modular)
|
||||||
OrderFulfillmentValidator,
|
OrderFulfillmentValidator,
|
||||||
|
|||||||
@ -1,7 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import ProfileContainer from "@/features/account/views/ProfileContainer";
|
|
||||||
|
|
||||||
export default function AccountPage() {
|
|
||||||
return <ProfileContainer />;
|
|
||||||
}
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
import InternetConfigureContainer from "@/features/catalog/views/InternetConfigure";
|
|
||||||
|
|
||||||
export default function InternetConfigurePage() {
|
|
||||||
return <InternetConfigureContainer />;
|
|
||||||
}
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
import { InternetPlansContainer } from "@/features/catalog/views/InternetPlans";
|
|
||||||
|
|
||||||
export default function InternetPlansPage() {
|
|
||||||
return <InternetPlansContainer />;
|
|
||||||
}
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
import { RouteLoading } from "@/components/molecules/RouteLoading";
|
|
||||||
import { Squares2X2Icon } from "@heroicons/react/24/outline";
|
|
||||||
import { LoadingCard } from "@/components/atoms/loading-skeleton";
|
|
||||||
|
|
||||||
export default function CatalogLoading() {
|
|
||||||
return (
|
|
||||||
<RouteLoading
|
|
||||||
icon={<Squares2X2Icon />}
|
|
||||||
title="Catalog"
|
|
||||||
description="Loading catalog..."
|
|
||||||
mode="content"
|
|
||||||
>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
|
||||||
<LoadingCard key={i} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</RouteLoading>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
import CatalogHomeView from "@/features/catalog/views/CatalogHome";
|
|
||||||
|
|
||||||
export default function CatalogPage() {
|
|
||||||
return <CatalogHomeView />;
|
|
||||||
}
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
import SimConfigureContainer from "@/features/catalog/views/SimConfigure";
|
|
||||||
|
|
||||||
export default function SimConfigurePage() {
|
|
||||||
return <SimConfigureContainer />;
|
|
||||||
}
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
import SimPlansView from "@/features/catalog/views/SimPlans";
|
|
||||||
|
|
||||||
export default function SimCatalogPage() {
|
|
||||||
return <SimPlansView />;
|
|
||||||
}
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
import VpnPlansView from "@/features/catalog/views/VpnPlans";
|
|
||||||
|
|
||||||
export default function VpnCatalogPage() {
|
|
||||||
return <VpnPlansView />;
|
|
||||||
}
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
import { RouteLoading } from "@/components/molecules/RouteLoading";
|
|
||||||
import { ShieldCheckIcon } from "@heroicons/react/24/outline";
|
|
||||||
import { LoadingCard, Skeleton } from "@/components/atoms/loading-skeleton";
|
|
||||||
|
|
||||||
export default function CheckoutLoading() {
|
|
||||||
return (
|
|
||||||
<RouteLoading
|
|
||||||
icon={<ShieldCheckIcon />}
|
|
||||||
title="Checkout"
|
|
||||||
description="Verifying details and preparing your order..."
|
|
||||||
mode="content"
|
|
||||||
>
|
|
||||||
<div className="max-w-2xl mx-auto space-y-6">
|
|
||||||
<LoadingCard />
|
|
||||||
<LoadingCard />
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<Skeleton className="h-10 w-full" />
|
|
||||||
<Skeleton className="h-10 w-full" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</RouteLoading>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
import CheckoutContainer from "@/features/checkout/views/CheckoutContainer";
|
|
||||||
|
|
||||||
export default function CheckoutPage() {
|
|
||||||
return <CheckoutContainer />;
|
|
||||||
}
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
import { RouteLoading } from "@/components/molecules/RouteLoading";
|
|
||||||
import { HomeIcon } from "@heroicons/react/24/outline";
|
|
||||||
import { LoadingStats, LoadingCard } from "@/components/atoms/loading-skeleton";
|
|
||||||
|
|
||||||
export default function DashboardLoading() {
|
|
||||||
return (
|
|
||||||
<RouteLoading
|
|
||||||
icon={<HomeIcon />}
|
|
||||||
title="Dashboard"
|
|
||||||
description="Loading your overview..."
|
|
||||||
mode="content"
|
|
||||||
>
|
|
||||||
<div className="space-y-6">
|
|
||||||
<LoadingStats count={4} />
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
||||||
<div className="lg:col-span-2 space-y-6">
|
|
||||||
<LoadingCard />
|
|
||||||
<LoadingCard />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-6">
|
|
||||||
<LoadingCard />
|
|
||||||
<LoadingCard />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</RouteLoading>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,116 +0,0 @@
|
|||||||
import { RouteLoading } from "@/components/molecules/RouteLoading";
|
|
||||||
import { ClipboardDocumentCheckIcon, ArrowLeftIcon } from "@heroicons/react/24/outline";
|
|
||||||
import { Button } from "@/components/atoms/button";
|
|
||||||
|
|
||||||
export default function OrderDetailLoading() {
|
|
||||||
return (
|
|
||||||
<RouteLoading
|
|
||||||
icon={<ClipboardDocumentCheckIcon />}
|
|
||||||
title="Order Details"
|
|
||||||
description="Loading order details..."
|
|
||||||
mode="content"
|
|
||||||
>
|
|
||||||
<div className="mb-6">
|
|
||||||
<Button
|
|
||||||
as="a"
|
|
||||||
href="/orders"
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
leftIcon={<ArrowLeftIcon className="h-4 w-4" />}
|
|
||||||
className="text-gray-600 hover:text-gray-900"
|
|
||||||
>
|
|
||||||
Back to orders
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="animate-pulse">
|
|
||||||
<div className="rounded-3xl border border-slate-200 bg-white shadow-sm">
|
|
||||||
{/* Header Section */}
|
|
||||||
<div className="border-b border-slate-200 bg-gradient-to-br from-white to-slate-50 px-6 py-6 sm:px-8">
|
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
|
||||||
{/* Left: Title & Status */}
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="h-8 w-48 rounded bg-slate-200" />
|
|
||||||
<div className="h-6 w-24 rounded-full bg-slate-200" />
|
|
||||||
</div>
|
|
||||||
<div className="h-4 w-64 rounded bg-slate-100" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right: Pricing Section */}
|
|
||||||
<div className="flex items-start gap-6 sm:gap-8">
|
|
||||||
<div className="text-right space-y-2">
|
|
||||||
<div className="h-3 w-16 rounded bg-slate-100" />
|
|
||||||
<div className="h-9 w-24 rounded bg-slate-200" />
|
|
||||||
</div>
|
|
||||||
<div className="text-right space-y-2">
|
|
||||||
<div className="h-3 w-16 rounded bg-slate-100" />
|
|
||||||
<div className="h-9 w-24 rounded bg-slate-200" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Body Section */}
|
|
||||||
<div className="px-6 py-6 sm:px-8">
|
|
||||||
<div className="flex flex-col gap-6">
|
|
||||||
{/* Order Items Section */}
|
|
||||||
<div>
|
|
||||||
<div className="mb-2 h-3 w-32 rounded bg-slate-200" />
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Item 1 */}
|
|
||||||
<div className="flex flex-col gap-3 rounded-xl border border-slate-200 bg-white px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div className="flex flex-1 items-start gap-3">
|
|
||||||
<div className="h-6 w-6 rounded-lg bg-slate-100 flex-shrink-0" />
|
|
||||||
<div className="flex flex-1 items-baseline justify-between gap-4">
|
|
||||||
<div className="min-w-0 flex-1 space-y-2">
|
|
||||||
<div className="h-5 w-3/4 rounded bg-slate-200" />
|
|
||||||
<div className="h-3 w-24 rounded bg-slate-100" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-end gap-1">
|
|
||||||
<div className="h-6 w-28 rounded bg-slate-200" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Item 2 */}
|
|
||||||
<div className="flex flex-col gap-3 rounded-xl border border-slate-200 bg-white px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div className="flex flex-1 items-start gap-3">
|
|
||||||
<div className="h-6 w-6 rounded-lg bg-slate-100 flex-shrink-0" />
|
|
||||||
<div className="flex flex-1 items-baseline justify-between gap-4">
|
|
||||||
<div className="min-w-0 flex-1 space-y-2">
|
|
||||||
<div className="h-5 w-2/3 rounded bg-slate-200" />
|
|
||||||
<div className="h-3 w-20 rounded bg-slate-100" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-end gap-1">
|
|
||||||
<div className="h-6 w-24 rounded bg-slate-200" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Item 3 */}
|
|
||||||
<div className="flex flex-col gap-3 rounded-xl border border-slate-200 bg-white px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div className="flex flex-1 items-start gap-3">
|
|
||||||
<div className="h-6 w-6 rounded-lg bg-slate-100 flex-shrink-0" />
|
|
||||||
<div className="flex flex-1 items-baseline justify-between gap-4">
|
|
||||||
<div className="min-w-0 flex-1 space-y-2">
|
|
||||||
<div className="h-5 w-1/2 rounded bg-slate-200" />
|
|
||||||
<div className="h-3 w-16 rounded bg-slate-100" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-end gap-1">
|
|
||||||
<div className="h-6 w-20 rounded bg-slate-200" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</RouteLoading>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
import { SupportHomeView } from "@/features/support";
|
|
||||||
import { AgentforceWidget } from "@/components";
|
|
||||||
|
|
||||||
export default function SupportPage() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<SupportHomeView />
|
|
||||||
<AgentforceWidget />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -6,6 +6,6 @@
|
|||||||
|
|
||||||
import { PublicContactView } from "@/features/support/views/PublicContactView";
|
import { PublicContactView } from "@/features/support/views/PublicContactView";
|
||||||
|
|
||||||
export default function PublicContactPage() {
|
export default function ContactPage() {
|
||||||
return <PublicContactView />;
|
return <PublicContactView />;
|
||||||
}
|
}
|
||||||
@ -4,8 +4,8 @@
|
|||||||
* Multi-step checkout wizard for completing orders.
|
* Multi-step checkout wizard for completing orders.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { CheckoutWizard } from "@/features/checkout/components/CheckoutWizard";
|
import { CheckoutEntry } from "@/features/checkout/components/CheckoutEntry";
|
||||||
|
|
||||||
export default function CheckoutPage() {
|
export default function CheckoutPage() {
|
||||||
return <CheckoutWizard />;
|
return <CheckoutEntry />;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,8 +4,13 @@
|
|||||||
* Layout for public catalog pages with catalog-specific navigation.
|
* Layout for public catalog pages with catalog-specific navigation.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { CatalogShell } from "@/components/templates";
|
import { CatalogNav } from "@/components/templates/CatalogShell";
|
||||||
|
|
||||||
export default function CatalogLayout({ children }: { children: React.ReactNode }) {
|
export default function CatalogLayout({ children }: { children: React.ReactNode }) {
|
||||||
return <CatalogShell>{children}</CatalogShell>;
|
return (
|
||||||
|
<>
|
||||||
|
<CatalogNav />
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
31
apps/portal/src/app/account/AccountRouteGuard.tsx
Normal file
31
apps/portal/src/app/account/AccountRouteGuard.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
import { useAuthStore } from "@/features/auth/services/auth.store";
|
||||||
|
|
||||||
|
export function AccountRouteGuard() {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const isAuthenticated = useAuthStore(state => state.isAuthenticated);
|
||||||
|
const hasCheckedAuth = useAuthStore(state => state.hasCheckedAuth);
|
||||||
|
const loading = useAuthStore(state => state.loading);
|
||||||
|
const checkAuth = useAuthStore(state => state.checkAuth);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasCheckedAuth) {
|
||||||
|
void checkAuth();
|
||||||
|
}
|
||||||
|
}, [checkAuth, hasCheckedAuth]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasCheckedAuth || loading || isAuthenticated) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const destination = pathname || "/account";
|
||||||
|
router.replace(`/auth/login?redirect=${encodeURIComponent(destination)}`);
|
||||||
|
}, [hasCheckedAuth, isAuthenticated, loading, pathname, router]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@ -2,7 +2,7 @@ import { RouteLoading } from "@/components/molecules/RouteLoading";
|
|||||||
import { CreditCardIcon } from "@heroicons/react/24/outline";
|
import { CreditCardIcon } from "@heroicons/react/24/outline";
|
||||||
import { LoadingCard, Skeleton } from "@/components/atoms/loading-skeleton";
|
import { LoadingCard, Skeleton } from "@/components/atoms/loading-skeleton";
|
||||||
|
|
||||||
export default function InvoiceDetailLoading() {
|
export default function AccountInvoiceDetailLoading() {
|
||||||
return (
|
return (
|
||||||
<RouteLoading
|
<RouteLoading
|
||||||
icon={<CreditCardIcon />}
|
icon={<CreditCardIcon />}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import InvoiceDetailContainer from "@/features/billing/views/InvoiceDetail";
|
import InvoiceDetailContainer from "@/features/billing/views/InvoiceDetail";
|
||||||
|
|
||||||
export default function InvoiceDetailPage() {
|
export default function AccountInvoiceDetailPage() {
|
||||||
return <InvoiceDetailContainer />;
|
return <InvoiceDetailContainer />;
|
||||||
}
|
}
|
||||||
@ -2,7 +2,7 @@ import { RouteLoading } from "@/components/molecules/RouteLoading";
|
|||||||
import { CreditCardIcon } from "@heroicons/react/24/outline";
|
import { CreditCardIcon } from "@heroicons/react/24/outline";
|
||||||
import { LoadingTable } from "@/components/atoms/loading-skeleton";
|
import { LoadingTable } from "@/components/atoms/loading-skeleton";
|
||||||
|
|
||||||
export default function InvoicesLoading() {
|
export default function AccountInvoicesLoading() {
|
||||||
return (
|
return (
|
||||||
<RouteLoading
|
<RouteLoading
|
||||||
icon={<CreditCardIcon />}
|
icon={<CreditCardIcon />}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import InvoicesListContainer from "@/features/billing/views/InvoicesList";
|
import InvoicesListContainer from "@/features/billing/views/InvoicesList";
|
||||||
|
|
||||||
export default function InvoicesPage() {
|
export default function AccountInvoicesPage() {
|
||||||
return <InvoicesListContainer />;
|
return <InvoicesListContainer />;
|
||||||
}
|
}
|
||||||
@ -2,7 +2,7 @@ import { RouteLoading } from "@/components/molecules/RouteLoading";
|
|||||||
import { CreditCardIcon } from "@heroicons/react/24/outline";
|
import { CreditCardIcon } from "@heroicons/react/24/outline";
|
||||||
import { LoadingCard } from "@/components/atoms/loading-skeleton";
|
import { LoadingCard } from "@/components/atoms/loading-skeleton";
|
||||||
|
|
||||||
export default function PaymentsLoading() {
|
export default function AccountPaymentsLoading() {
|
||||||
return (
|
return (
|
||||||
<RouteLoading
|
<RouteLoading
|
||||||
icon={<CreditCardIcon />}
|
icon={<CreditCardIcon />}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import PaymentMethodsContainer from "@/features/billing/views/PaymentMethods";
|
import PaymentMethodsContainer from "@/features/billing/views/PaymentMethods";
|
||||||
|
|
||||||
export default function PaymentMethodsPage() {
|
export default function AccountPaymentMethodsPage() {
|
||||||
return <PaymentMethodsContainer />;
|
return <PaymentMethodsContainer />;
|
||||||
}
|
}
|
||||||
@ -1,10 +1,12 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { AppShell } from "@/components/organisms";
|
import { AppShell } from "@/components/organisms";
|
||||||
import { AccountEventsListener } from "@/features/realtime/components/AccountEventsListener";
|
import { AccountEventsListener } from "@/features/realtime/components/AccountEventsListener";
|
||||||
|
import { AccountRouteGuard } from "./AccountRouteGuard";
|
||||||
|
|
||||||
export default function PortalLayout({ children }: { children: ReactNode }) {
|
export default function AccountLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<AppShell>
|
<AppShell>
|
||||||
|
<AccountRouteGuard />
|
||||||
<AccountEventsListener />
|
<AccountEventsListener />
|
||||||
{children}
|
{children}
|
||||||
</AppShell>
|
</AppShell>
|
||||||
83
apps/portal/src/app/account/orders/[id]/loading.tsx
Normal file
83
apps/portal/src/app/account/orders/[id]/loading.tsx
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import { RouteLoading } from "@/components/molecules/RouteLoading";
|
||||||
|
import { ClipboardDocumentCheckIcon, ArrowLeftIcon } from "@heroicons/react/24/outline";
|
||||||
|
import { Button } from "@/components/atoms/button";
|
||||||
|
|
||||||
|
export default function AccountOrderDetailLoading() {
|
||||||
|
return (
|
||||||
|
<RouteLoading
|
||||||
|
icon={<ClipboardDocumentCheckIcon />}
|
||||||
|
title="Order Details"
|
||||||
|
description="Loading order details..."
|
||||||
|
mode="content"
|
||||||
|
>
|
||||||
|
<div className="mb-6">
|
||||||
|
<Button
|
||||||
|
as="a"
|
||||||
|
href="/account/orders"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
leftIcon={<ArrowLeftIcon className="h-4 w-4" />}
|
||||||
|
className="text-gray-600 hover:text-gray-900"
|
||||||
|
>
|
||||||
|
Back to orders
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="animate-pulse">
|
||||||
|
<div className="rounded-3xl border border-slate-200 bg-white shadow-sm">
|
||||||
|
<div className="border-b border-slate-200 bg-gradient-to-br from-white to-slate-50 px-6 py-6 sm:px-8">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-8 w-48 rounded bg-slate-200" />
|
||||||
|
<div className="h-6 w-24 rounded-full bg-slate-200" />
|
||||||
|
</div>
|
||||||
|
<div className="h-4 w-64 rounded bg-slate-100" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-6 sm:gap-8">
|
||||||
|
<div className="text-right space-y-2">
|
||||||
|
<div className="h-3 w-16 rounded bg-slate-100" />
|
||||||
|
<div className="h-9 w-24 rounded bg-slate-200" />
|
||||||
|
</div>
|
||||||
|
<div className="text-right space-y-2">
|
||||||
|
<div className="h-3 w-16 rounded bg-slate-100" />
|
||||||
|
<div className="h-9 w-24 rounded bg-slate-200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 py-6 sm:px-8">
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 h-3 w-32 rounded bg-slate-200" />
|
||||||
|
<div className="space-y-4">
|
||||||
|
{Array.from({ length: 3 }).map((_, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="flex flex-col gap-3 rounded-xl border border-slate-200 bg-white px-4 py-4 sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
>
|
||||||
|
<div className="flex flex-1 items-start gap-3">
|
||||||
|
<div className="h-6 w-6 rounded-lg bg-slate-100 flex-shrink-0" />
|
||||||
|
<div className="flex flex-1 items-baseline justify-between gap-4">
|
||||||
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
|
<div className="h-5 w-3/4 rounded bg-slate-200" />
|
||||||
|
<div className="h-3 w-24 rounded bg-slate-100" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-1">
|
||||||
|
<div className="h-6 w-28 rounded bg-slate-200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</RouteLoading>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import OrderDetailContainer from "@/features/orders/views/OrderDetail";
|
import OrderDetailContainer from "@/features/orders/views/OrderDetail";
|
||||||
|
|
||||||
export default function OrderDetailPage() {
|
export default function AccountOrderDetailPage() {
|
||||||
return <OrderDetailContainer />;
|
return <OrderDetailContainer />;
|
||||||
}
|
}
|
||||||
@ -2,11 +2,11 @@ import { RouteLoading } from "@/components/molecules/RouteLoading";
|
|||||||
import { ClipboardDocumentListIcon } from "@heroicons/react/24/outline";
|
import { ClipboardDocumentListIcon } from "@heroicons/react/24/outline";
|
||||||
import { OrderCardSkeleton } from "@/features/orders/components/OrderCardSkeleton";
|
import { OrderCardSkeleton } from "@/features/orders/components/OrderCardSkeleton";
|
||||||
|
|
||||||
export default function OrdersLoading() {
|
export default function AccountOrdersLoading() {
|
||||||
return (
|
return (
|
||||||
<RouteLoading
|
<RouteLoading
|
||||||
icon={<ClipboardDocumentListIcon />}
|
icon={<ClipboardDocumentListIcon />}
|
||||||
title="My Orders"
|
title="Orders"
|
||||||
description="View and track all your orders"
|
description="View and track all your orders"
|
||||||
mode="content"
|
mode="content"
|
||||||
>
|
>
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import OrdersListContainer from "@/features/orders/views/OrdersList";
|
import OrdersListContainer from "@/features/orders/views/OrdersList";
|
||||||
|
|
||||||
export default function OrdersPage() {
|
export default function AccountOrdersPage() {
|
||||||
return <OrdersListContainer />;
|
return <OrdersListContainer />;
|
||||||
}
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { DashboardView } from "@/features/dashboard";
|
import { DashboardView } from "@/features/dashboard";
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function AccountDashboardPage() {
|
||||||
return <DashboardView />;
|
return <DashboardView />;
|
||||||
}
|
}
|
||||||
@ -2,12 +2,12 @@ import { RouteLoading } from "@/components/molecules/RouteLoading";
|
|||||||
import { ServerIcon } from "@heroicons/react/24/outline";
|
import { ServerIcon } from "@heroicons/react/24/outline";
|
||||||
import { LoadingCard } from "@/components/atoms/loading-skeleton";
|
import { LoadingCard } from "@/components/atoms/loading-skeleton";
|
||||||
|
|
||||||
export default function SubscriptionDetailLoading() {
|
export default function AccountServiceDetailLoading() {
|
||||||
return (
|
return (
|
||||||
<RouteLoading
|
<RouteLoading
|
||||||
icon={<ServerIcon />}
|
icon={<ServerIcon />}
|
||||||
title="Subscription"
|
title="Service"
|
||||||
description="Subscription details"
|
description="Service details"
|
||||||
mode="content"
|
mode="content"
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import SubscriptionDetailContainer from "@/features/subscriptions/views/SubscriptionDetail";
|
import SubscriptionDetailContainer from "@/features/subscriptions/views/SubscriptionDetail";
|
||||||
|
|
||||||
export default function SubscriptionDetailPage() {
|
export default function AccountServiceDetailPage() {
|
||||||
return <SubscriptionDetailContainer />;
|
return <SubscriptionDetailContainer />;
|
||||||
}
|
}
|
||||||
@ -1,6 +1,5 @@
|
|||||||
import SimCallHistoryContainer from "@/features/subscriptions/views/SimCallHistory";
|
import SimCallHistoryContainer from "@/features/subscriptions/views/SimCallHistory";
|
||||||
|
|
||||||
export default function SimCallHistoryPage() {
|
export default function AccountSimCallHistoryPage() {
|
||||||
return <SimCallHistoryContainer />;
|
return <SimCallHistoryContainer />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import SimCancelContainer from "@/features/subscriptions/views/SimCancel";
|
import SimCancelContainer from "@/features/subscriptions/views/SimCancel";
|
||||||
|
|
||||||
export default function SimCancelPage() {
|
export default function AccountSimCancelPage() {
|
||||||
return <SimCancelContainer />;
|
return <SimCancelContainer />;
|
||||||
}
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import SimChangePlanContainer from "@/features/subscriptions/views/SimChangePlan";
|
import SimChangePlanContainer from "@/features/subscriptions/views/SimChangePlan";
|
||||||
|
|
||||||
export default function SimChangePlanPage() {
|
export default function AccountSimChangePlanPage() {
|
||||||
return <SimChangePlanContainer />;
|
return <SimChangePlanContainer />;
|
||||||
}
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import SimReissueContainer from "@/features/subscriptions/views/SimReissue";
|
import SimReissueContainer from "@/features/subscriptions/views/SimReissue";
|
||||||
|
|
||||||
export default function SimReissuePage() {
|
export default function AccountSimReissuePage() {
|
||||||
return <SimReissueContainer />;
|
return <SimReissueContainer />;
|
||||||
}
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import SimTopUpContainer from "@/features/subscriptions/views/SimTopUp";
|
import SimTopUpContainer from "@/features/subscriptions/views/SimTopUp";
|
||||||
|
|
||||||
export default function SimTopUpPage() {
|
export default function AccountSimTopUpPage() {
|
||||||
return <SimTopUpContainer />;
|
return <SimTopUpContainer />;
|
||||||
}
|
}
|
||||||
@ -2,12 +2,12 @@ import { RouteLoading } from "@/components/molecules/RouteLoading";
|
|||||||
import { ServerIcon } from "@heroicons/react/24/outline";
|
import { ServerIcon } from "@heroicons/react/24/outline";
|
||||||
import { LoadingTable } from "@/components/atoms/loading-skeleton";
|
import { LoadingTable } from "@/components/atoms/loading-skeleton";
|
||||||
|
|
||||||
export default function SubscriptionsLoading() {
|
export default function AccountServicesLoading() {
|
||||||
return (
|
return (
|
||||||
<RouteLoading
|
<RouteLoading
|
||||||
icon={<ServerIcon />}
|
icon={<ServerIcon />}
|
||||||
title="Subscriptions"
|
title="Services"
|
||||||
description="View and manage your subscriptions"
|
description="View and manage your services"
|
||||||
mode="content"
|
mode="content"
|
||||||
>
|
>
|
||||||
<LoadingTable rows={6} columns={5} />
|
<LoadingTable rows={6} columns={5} />
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import SubscriptionsListContainer from "@/features/subscriptions/views/SubscriptionsList";
|
import SubscriptionsListContainer from "@/features/subscriptions/views/SubscriptionsList";
|
||||||
|
|
||||||
export default function SubscriptionsPage() {
|
export default function AccountServicesPage() {
|
||||||
return <SubscriptionsListContainer />;
|
return <SubscriptionsListContainer />;
|
||||||
}
|
}
|
||||||
@ -2,11 +2,11 @@ import { RouteLoading } from "@/components/molecules/RouteLoading";
|
|||||||
import { UserIcon } from "@heroicons/react/24/outline";
|
import { UserIcon } from "@heroicons/react/24/outline";
|
||||||
import { LoadingCard, Skeleton } from "@/components/atoms/loading-skeleton";
|
import { LoadingCard, Skeleton } from "@/components/atoms/loading-skeleton";
|
||||||
|
|
||||||
export default function AccountLoading() {
|
export default function AccountSettingsLoading() {
|
||||||
return (
|
return (
|
||||||
<RouteLoading
|
<RouteLoading
|
||||||
icon={<UserIcon />}
|
icon={<UserIcon />}
|
||||||
title="Account"
|
title="Settings"
|
||||||
description="Loading your profile..."
|
description="Loading your profile..."
|
||||||
mode="content"
|
mode="content"
|
||||||
>
|
>
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import ProfileContainer from "@/features/account/views/ProfileContainer";
|
import ProfileContainer from "@/features/account/views/ProfileContainer";
|
||||||
|
|
||||||
export default function ProfilePage() {
|
export default function AccountSettingsPage() {
|
||||||
return <ProfileContainer />;
|
return <ProfileContainer />;
|
||||||
}
|
}
|
||||||
@ -4,8 +4,7 @@ interface PageProps {
|
|||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function SupportCaseDetailPage({ params }: PageProps) {
|
export default async function AccountSupportCaseDetailPage({ params }: PageProps) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
return <SupportCaseDetailView caseId={id} />;
|
return <SupportCaseDetailView caseId={id} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2,7 +2,7 @@ import { RouteLoading } from "@/components/molecules/RouteLoading";
|
|||||||
import { ChatBubbleLeftRightIcon } from "@heroicons/react/24/outline";
|
import { ChatBubbleLeftRightIcon } from "@heroicons/react/24/outline";
|
||||||
import { LoadingTable } from "@/components/atoms/loading-skeleton";
|
import { LoadingTable } from "@/components/atoms/loading-skeleton";
|
||||||
|
|
||||||
export default function SupportCasesLoading() {
|
export default function AccountSupportLoading() {
|
||||||
return (
|
return (
|
||||||
<RouteLoading
|
<RouteLoading
|
||||||
icon={<ChatBubbleLeftRightIcon />}
|
icon={<ChatBubbleLeftRightIcon />}
|
||||||
@ -2,7 +2,7 @@ import { RouteLoading } from "@/components/molecules/RouteLoading";
|
|||||||
import { PencilSquareIcon } from "@heroicons/react/24/outline";
|
import { PencilSquareIcon } from "@heroicons/react/24/outline";
|
||||||
import { LoadingCard, Skeleton } from "@/components/atoms/loading-skeleton";
|
import { LoadingCard, Skeleton } from "@/components/atoms/loading-skeleton";
|
||||||
|
|
||||||
export default function NewSupportLoading() {
|
export default function AccountSupportNewLoading() {
|
||||||
return (
|
return (
|
||||||
<RouteLoading
|
<RouteLoading
|
||||||
icon={<PencilSquareIcon />}
|
icon={<PencilSquareIcon />}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { NewSupportCaseView } from "@/features/support";
|
import { NewSupportCaseView } from "@/features/support";
|
||||||
|
|
||||||
export default function NewSupportCasePage() {
|
export default function AccountNewSupportCasePage() {
|
||||||
return <NewSupportCaseView />;
|
return <NewSupportCaseView />;
|
||||||
}
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { SupportCasesView } from "@/features/support";
|
import { SupportCasesView } from "@/features/support";
|
||||||
|
|
||||||
export default function SupportCasesPage() {
|
export default function AccountSupportPage() {
|
||||||
return <SupportCasesView />;
|
return <SupportCasesView />;
|
||||||
}
|
}
|
||||||
@ -67,9 +67,10 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasCheckedAuth && !isAuthenticated && !loading) {
|
if (hasCheckedAuth && !isAuthenticated && !loading) {
|
||||||
router.push("/auth/login");
|
const destination = pathname || "/account";
|
||||||
|
router.push(`/auth/login?redirect=${encodeURIComponent(destination)}`);
|
||||||
}
|
}
|
||||||
}, [hasCheckedAuth, isAuthenticated, loading, router]);
|
}, [hasCheckedAuth, isAuthenticated, loading, pathname, router]);
|
||||||
|
|
||||||
// Hydrate full profile once after auth so header name is consistent across pages
|
// Hydrate full profile once after auth so header name is consistent across pages
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -97,10 +98,10 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setExpandedItems(prev => {
|
setExpandedItems(prev => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
if (pathname.startsWith("/subscriptions")) next.add("Subscriptions");
|
if (pathname.startsWith("/account/services")) next.add("My Services");
|
||||||
if (pathname.startsWith("/billing")) next.add("Billing");
|
if (pathname.startsWith("/account/billing")) next.add("Billing");
|
||||||
if (pathname.startsWith("/support")) next.add("Support");
|
if (pathname.startsWith("/account/support")) next.add("Support");
|
||||||
if (pathname.startsWith("/account")) next.add("Account");
|
if (pathname.startsWith("/account/settings")) next.add("Settings");
|
||||||
const result = Array.from(next);
|
const result = Array.from(next);
|
||||||
// Avoid state update if unchanged
|
// Avoid state update if unchanged
|
||||||
if (result.length === prev.length && result.every(v => prev.includes(v))) return prev;
|
if (result.length === prev.length && result.every(v => prev.includes(v))) return prev;
|
||||||
|
|||||||
@ -39,7 +39,7 @@ export const Header = memo(function Header({ onMenuClick, user, profileReady }:
|
|||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Link
|
<Link
|
||||||
href="/support"
|
href="/account/support"
|
||||||
prefetch
|
prefetch
|
||||||
aria-label="Help"
|
aria-label="Help"
|
||||||
className="hidden sm:inline-flex p-2.5 rounded-xl text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-all duration-200"
|
className="hidden sm:inline-flex p-2.5 rounded-xl text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-all duration-200"
|
||||||
@ -49,7 +49,7 @@ export const Header = memo(function Header({ onMenuClick, user, profileReady }:
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="/account"
|
href="/account/settings"
|
||||||
prefetch
|
prefetch
|
||||||
className="group flex items-center gap-2.5 px-3 py-1.5 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/60 rounded-xl transition-all duration-200"
|
className="group flex items-center gap-2.5 px-3 py-1.5 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/60 rounded-xl transition-all duration-200"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -26,33 +26,33 @@ export interface NavigationItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const baseNavigation: NavigationItem[] = [
|
export const baseNavigation: NavigationItem[] = [
|
||||||
{ name: "Dashboard", href: "/dashboard", icon: HomeIcon },
|
{ name: "Dashboard", href: "/account", icon: HomeIcon },
|
||||||
{ name: "Orders", href: "/orders", icon: ClipboardDocumentListIcon },
|
{ name: "Orders", href: "/account/orders", icon: ClipboardDocumentListIcon },
|
||||||
{
|
{
|
||||||
name: "Billing",
|
name: "Billing",
|
||||||
icon: CreditCardIcon,
|
icon: CreditCardIcon,
|
||||||
children: [
|
children: [
|
||||||
{ name: "Invoices", href: "/billing/invoices" },
|
{ name: "Invoices", href: "/account/billing/invoices" },
|
||||||
{ name: "Payment Methods", href: "/billing/payments" },
|
{ name: "Payment Methods", href: "/account/billing/payments" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Subscriptions",
|
name: "My Services",
|
||||||
icon: ServerIcon,
|
icon: ServerIcon,
|
||||||
children: [{ name: "All Subscriptions", href: "/subscriptions" }],
|
children: [{ name: "All Services", href: "/account/services" }],
|
||||||
},
|
},
|
||||||
{ name: "Catalog", href: "/catalog", icon: Squares2X2Icon },
|
{ name: "Shop", href: "/shop", icon: Squares2X2Icon },
|
||||||
{
|
{
|
||||||
name: "Support",
|
name: "Support",
|
||||||
icon: ChatBubbleLeftRightIcon,
|
icon: ChatBubbleLeftRightIcon,
|
||||||
children: [
|
children: [
|
||||||
{ name: "Cases", href: "/support/cases" },
|
{ name: "Cases", href: "/account/support" },
|
||||||
{ name: "New Case", href: "/support/new" },
|
{ name: "New Case", href: "/account/support/new" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Account",
|
name: "Settings",
|
||||||
href: "/account",
|
href: "/account/settings",
|
||||||
icon: UserIcon,
|
icon: UserIcon,
|
||||||
},
|
},
|
||||||
{ name: "Log out", href: "#", icon: ArrowRightStartOnRectangleIcon, isLogout: true },
|
{ name: "Log out", href: "#", icon: ArrowRightStartOnRectangleIcon, isLogout: true },
|
||||||
@ -64,17 +64,17 @@ export function computeNavigation(activeSubscriptions?: Subscription[]): Navigat
|
|||||||
children: item.children ? [...item.children] : undefined,
|
children: item.children ? [...item.children] : undefined,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const subIdx = nav.findIndex(n => n.name === "Subscriptions");
|
const subIdx = nav.findIndex(n => n.name === "My Services");
|
||||||
if (subIdx >= 0) {
|
if (subIdx >= 0) {
|
||||||
const dynamicChildren = (activeSubscriptions || []).map(sub => ({
|
const dynamicChildren = (activeSubscriptions || []).map(sub => ({
|
||||||
name: truncate(sub.productName || `Subscription ${sub.id}`, 28),
|
name: truncate(sub.productName || `Subscription ${sub.id}`, 28),
|
||||||
href: `/subscriptions/${sub.id}`,
|
href: `/account/services/${sub.id}`,
|
||||||
tooltip: sub.productName || `Subscription ${sub.id}`,
|
tooltip: sub.productName || `Subscription ${sub.id}`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
nav[subIdx] = {
|
nav[subIdx] = {
|
||||||
...nav[subIdx],
|
...nav[subIdx],
|
||||||
children: [{ name: "All Subscriptions", href: "/subscriptions" }, ...dynamicChildren],
|
children: [{ name: "All Services", href: "/account/services" }, ...dynamicChildren],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,15 +5,64 @@
|
|||||||
* Extends the PublicShell with catalog navigation tabs.
|
* Extends the PublicShell with catalog navigation tabs.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { useEffect } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Logo } from "@/components/atoms/logo";
|
import { Logo } from "@/components/atoms/logo";
|
||||||
|
import { useAuthStore } from "@/features/auth/services/auth.store";
|
||||||
|
|
||||||
export interface CatalogShellProps {
|
export interface CatalogShellProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function CatalogNav() {
|
||||||
|
return (
|
||||||
|
<div className="border-b border-border/50 bg-background/60 backdrop-blur-xl">
|
||||||
|
<div className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-page-padding)] py-2">
|
||||||
|
<nav className="flex items-center gap-1 overflow-x-auto">
|
||||||
|
<Link
|
||||||
|
href="/shop"
|
||||||
|
className="whitespace-nowrap inline-flex items-center rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
All Services
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/shop/internet"
|
||||||
|
className="whitespace-nowrap inline-flex items-center rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
Internet
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/shop/sim"
|
||||||
|
className="whitespace-nowrap inline-flex items-center rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
SIM
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/shop/vpn"
|
||||||
|
className="whitespace-nowrap inline-flex items-center rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
VPN
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function CatalogShell({ children }: CatalogShellProps) {
|
export function CatalogShell({ children }: CatalogShellProps) {
|
||||||
|
const isAuthenticated = useAuthStore(state => state.isAuthenticated);
|
||||||
|
const hasCheckedAuth = useAuthStore(state => state.hasCheckedAuth);
|
||||||
|
const checkAuth = useAuthStore(state => state.checkAuth);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasCheckedAuth) {
|
||||||
|
void checkAuth();
|
||||||
|
}
|
||||||
|
}, [checkAuth, hasCheckedAuth]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col bg-background text-foreground">
|
<div className="min-h-screen flex flex-col bg-background text-foreground">
|
||||||
{/* Subtle background pattern */}
|
{/* Subtle background pattern */}
|
||||||
@ -34,39 +83,11 @@ export function CatalogShell({ children }: CatalogShellProps) {
|
|||||||
Assist Solutions
|
Assist Solutions
|
||||||
</span>
|
</span>
|
||||||
<span className="block text-xs text-muted-foreground leading-tight truncate">
|
<span className="block text-xs text-muted-foreground leading-tight truncate">
|
||||||
Customer Portal
|
Account Portal
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Catalog Navigation */}
|
|
||||||
<nav className="hidden md:flex items-center gap-1">
|
|
||||||
<Link
|
|
||||||
href="/shop"
|
|
||||||
className="inline-flex items-center rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
|
|
||||||
>
|
|
||||||
All Services
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/shop/internet"
|
|
||||||
className="inline-flex items-center rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
|
|
||||||
>
|
|
||||||
Internet
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/shop/sim"
|
|
||||||
className="inline-flex items-center rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
|
|
||||||
>
|
|
||||||
SIM
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/shop/vpn"
|
|
||||||
className="inline-flex items-center rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
|
|
||||||
>
|
|
||||||
VPN
|
|
||||||
</Link>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* Right side actions */}
|
{/* Right side actions */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Link
|
<Link
|
||||||
@ -75,16 +96,27 @@ export function CatalogShell({ children }: CatalogShellProps) {
|
|||||||
>
|
>
|
||||||
Support
|
Support
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
{isAuthenticated ? (
|
||||||
href="/auth/login"
|
<Link
|
||||||
className="inline-flex items-center rounded-lg px-4 py-2 text-sm font-medium bg-primary text-primary-foreground hover:bg-primary-hover shadow-sm shadow-primary/20 transition-all hover:shadow-md hover:shadow-primary/30"
|
href="/account"
|
||||||
>
|
className="inline-flex items-center rounded-lg px-4 py-2 text-sm font-medium bg-primary text-primary-foreground hover:bg-primary-hover shadow-sm shadow-primary/20 transition-all hover:shadow-md hover:shadow-primary/30"
|
||||||
Sign in
|
>
|
||||||
</Link>
|
My Account
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
href="/auth/login"
|
||||||
|
className="inline-flex items-center rounded-lg px-4 py-2 text-sm font-medium bg-primary text-primary-foreground hover:bg-primary-hover shadow-sm shadow-primary/20 transition-all hover:shadow-md hover:shadow-primary/30"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<CatalogNav />
|
||||||
|
|
||||||
<main className="flex-1">
|
<main className="flex-1">
|
||||||
<div className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-page-padding)] py-8 sm:py-12">
|
<div className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-page-padding)] py-8 sm:py-12">
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@ -1,2 +1,2 @@
|
|||||||
export { CatalogShell } from "./CatalogShell";
|
export { CatalogNav, CatalogShell } from "./CatalogShell";
|
||||||
export type { CatalogShellProps } from "./CatalogShell";
|
export type { CatalogShellProps } from "./CatalogShell";
|
||||||
|
|||||||
@ -1,12 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* PublicShell
|
||||||
|
*
|
||||||
|
* Shared shell for public-facing pages with an auth-aware header.
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { useEffect } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Logo } from "@/components/atoms/logo";
|
import { Logo } from "@/components/atoms/logo";
|
||||||
|
import { useAuthStore } from "@/features/auth/services/auth.store";
|
||||||
|
|
||||||
export interface PublicShellProps {
|
export interface PublicShellProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PublicShell({ children }: PublicShellProps) {
|
export function PublicShell({ children }: PublicShellProps) {
|
||||||
|
const isAuthenticated = useAuthStore(state => state.isAuthenticated);
|
||||||
|
const hasCheckedAuth = useAuthStore(state => state.hasCheckedAuth);
|
||||||
|
const checkAuth = useAuthStore(state => state.checkAuth);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasCheckedAuth) {
|
||||||
|
void checkAuth();
|
||||||
|
}
|
||||||
|
}, [checkAuth, hasCheckedAuth]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col bg-background text-foreground">
|
<div className="min-h-screen flex flex-col bg-background text-foreground">
|
||||||
{/* Subtle background pattern */}
|
{/* Subtle background pattern */}
|
||||||
@ -26,7 +46,7 @@ export function PublicShell({ children }: PublicShellProps) {
|
|||||||
Assist Solutions
|
Assist Solutions
|
||||||
</span>
|
</span>
|
||||||
<span className="block text-xs text-muted-foreground leading-tight truncate">
|
<span className="block text-xs text-muted-foreground leading-tight truncate">
|
||||||
Customer Portal
|
Account Portal
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
@ -44,12 +64,21 @@ export function PublicShell({ children }: PublicShellProps) {
|
|||||||
>
|
>
|
||||||
Support
|
Support
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
{isAuthenticated ? (
|
||||||
href="/auth/login"
|
<Link
|
||||||
className="inline-flex items-center rounded-lg px-4 py-2 text-sm font-medium bg-primary text-primary-foreground hover:bg-primary-hover shadow-sm shadow-primary/20 transition-all hover:shadow-md hover:shadow-primary/30"
|
href="/account"
|
||||||
>
|
className="inline-flex items-center rounded-lg px-4 py-2 text-sm font-medium bg-primary text-primary-foreground hover:bg-primary-hover shadow-sm shadow-primary/20 transition-all hover:shadow-md hover:shadow-primary/30"
|
||||||
Sign in
|
>
|
||||||
</Link>
|
My Account
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
href="/auth/login"
|
||||||
|
className="inline-flex items-center rounded-lg px-4 py-2 text-sm font-medium bg-primary text-primary-foreground hover:bg-primary-hover shadow-sm shadow-primary/20 transition-all hover:shadow-md hover:shadow-primary/30"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@ -27,7 +27,7 @@ import { ReviewStep } from "./steps/ReviewStep";
|
|||||||
* - phoneCountryCode: Separate field for country code input
|
* - phoneCountryCode: Separate field for country code input
|
||||||
* - address: Required addressFormSchema (domain schema makes it optional)
|
* - address: Required addressFormSchema (domain schema makes it optional)
|
||||||
*/
|
*/
|
||||||
const signupFormBaseSchema = signupInputSchema.extend({
|
const signupFormBaseSchema = signupInputSchema.omit({ sfNumber: true }).extend({
|
||||||
confirmPassword: z.string().min(1, "Please confirm your password"),
|
confirmPassword: z.string().min(1, "Please confirm your password"),
|
||||||
phoneCountryCode: z.string().regex(/^\+\d{1,4}$/, "Enter a valid country code (e.g., +81)"),
|
phoneCountryCode: z.string().regex(/^\+\d{1,4}$/, "Enter a valid country code (e.g., +81)"),
|
||||||
address: addressFormSchema,
|
address: addressFormSchema,
|
||||||
@ -75,16 +75,7 @@ const STEPS = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const STEP_FIELD_KEYS: Record<(typeof STEPS)[number]["key"], Array<keyof SignupFormData>> = {
|
const STEP_FIELD_KEYS: Record<(typeof STEPS)[number]["key"], Array<keyof SignupFormData>> = {
|
||||||
account: [
|
account: ["firstName", "lastName", "email", "phone", "phoneCountryCode", "dateOfBirth", "gender"],
|
||||||
"sfNumber",
|
|
||||||
"firstName",
|
|
||||||
"lastName",
|
|
||||||
"email",
|
|
||||||
"phone",
|
|
||||||
"phoneCountryCode",
|
|
||||||
"dateOfBirth",
|
|
||||||
"gender",
|
|
||||||
],
|
|
||||||
address: ["address"],
|
address: ["address"],
|
||||||
password: ["password", "confirmPassword"],
|
password: ["password", "confirmPassword"],
|
||||||
review: ["acceptTerms"],
|
review: ["acceptTerms"],
|
||||||
@ -92,7 +83,6 @@ const STEP_FIELD_KEYS: Record<(typeof STEPS)[number]["key"], Array<keyof SignupF
|
|||||||
|
|
||||||
const STEP_VALIDATION_SCHEMAS: Record<(typeof STEPS)[number]["key"], z.ZodTypeAny | undefined> = {
|
const STEP_VALIDATION_SCHEMAS: Record<(typeof STEPS)[number]["key"], z.ZodTypeAny | undefined> = {
|
||||||
account: signupFormBaseSchema.pick({
|
account: signupFormBaseSchema.pick({
|
||||||
sfNumber: true,
|
|
||||||
firstName: true,
|
firstName: true,
|
||||||
lastName: true,
|
lastName: true,
|
||||||
email: true,
|
email: true,
|
||||||
@ -130,7 +120,6 @@ export function SignupForm({ onSuccess, onError, className = "" }: SignupFormPro
|
|||||||
const form = useZodForm<SignupFormData>({
|
const form = useZodForm<SignupFormData>({
|
||||||
schema: signupFormSchema,
|
schema: signupFormSchema,
|
||||||
initialValues: {
|
initialValues: {
|
||||||
sfNumber: "",
|
|
||||||
firstName: "",
|
firstName: "",
|
||||||
lastName: "",
|
lastName: "",
|
||||||
email: "",
|
email: "",
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Account Step - Customer number and contact info
|
* Account Step - Contact info
|
||||||
*/
|
*/
|
||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
@ -10,7 +10,6 @@ import { FormField } from "@/components/molecules/FormField/FormField";
|
|||||||
interface AccountStepProps {
|
interface AccountStepProps {
|
||||||
form: {
|
form: {
|
||||||
values: {
|
values: {
|
||||||
sfNumber: string;
|
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
email: string;
|
email: string;
|
||||||
@ -33,24 +32,6 @@ export function AccountStep({ form }: AccountStepProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{/* Customer Number - Highlighted */}
|
|
||||||
<div className="bg-info-soft border border-info/25 rounded-xl p-4">
|
|
||||||
<FormField
|
|
||||||
label="Customer Number"
|
|
||||||
error={getError("sfNumber")}
|
|
||||||
required
|
|
||||||
helperText="Your Assist Solutions customer number"
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
value={values.sfNumber}
|
|
||||||
onChange={e => setValue("sfNumber", e.target.value)}
|
|
||||||
onBlur={() => setTouchedField("sfNumber")}
|
|
||||||
placeholder="e.g., AST-123456"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Name Fields */}
|
{/* Name Fields */}
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<FormField label="First Name" error={getError("firstName")} required>
|
<FormField label="First Name" error={getError("firstName")} required>
|
||||||
@ -61,6 +42,7 @@ export function AccountStep({ form }: AccountStepProps) {
|
|||||||
onBlur={() => setTouchedField("firstName")}
|
onBlur={() => setTouchedField("firstName")}
|
||||||
placeholder="Taro"
|
placeholder="Taro"
|
||||||
autoComplete="given-name"
|
autoComplete="given-name"
|
||||||
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Last Name" error={getError("lastName")} required>
|
<FormField label="Last Name" error={getError("lastName")} required>
|
||||||
|
|||||||
@ -68,7 +68,6 @@ interface ReviewStepProps {
|
|||||||
email: string;
|
email: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
phoneCountryCode: string;
|
phoneCountryCode: string;
|
||||||
sfNumber: string;
|
|
||||||
company?: string;
|
company?: string;
|
||||||
dateOfBirth?: string;
|
dateOfBirth?: string;
|
||||||
gender?: "male" | "female" | "other";
|
gender?: "male" | "female" | "other";
|
||||||
@ -116,10 +115,6 @@ export function ReviewStep({ form }: ReviewStepProps) {
|
|||||||
Account Summary
|
Account Summary
|
||||||
</h4>
|
</h4>
|
||||||
<dl className="space-y-3 text-sm">
|
<dl className="space-y-3 text-sm">
|
||||||
<div className="flex justify-between py-2 border-b border-border/60">
|
|
||||||
<dt className="text-muted-foreground">Customer Number</dt>
|
|
||||||
<dd className="text-foreground font-medium">{values.sfNumber}</dd>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between py-2 border-b border-border/60">
|
<div className="flex justify-between py-2 border-b border-border/60">
|
||||||
<dt className="text-muted-foreground">Name</dt>
|
<dt className="text-muted-foreground">Name</dt>
|
||||||
<dd className="text-foreground font-medium">
|
<dd className="text-foreground font-medium">
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import type { ReadonlyURLSearchParams } from "next/navigation";
|
import type { ReadonlyURLSearchParams } from "next/navigation";
|
||||||
|
|
||||||
export function getPostLoginRedirect(searchParams: ReadonlyURLSearchParams): string {
|
export function getPostLoginRedirect(searchParams: ReadonlyURLSearchParams): string {
|
||||||
const dest = searchParams.get("redirect") || "/dashboard";
|
const dest = searchParams.get("redirect") || "/account";
|
||||||
// prevent open redirects
|
// prevent open redirects
|
||||||
if (dest.startsWith("http://") || dest.startsWith("https://")) return "/dashboard";
|
if (dest.startsWith("http://") || dest.startsWith("https://")) return "/account";
|
||||||
return dest;
|
return dest;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -44,7 +44,7 @@ export function LinkWhmcsView() {
|
|||||||
if (result.needsPasswordSet) {
|
if (result.needsPasswordSet) {
|
||||||
router.push(`/auth/set-password?email=${encodeURIComponent(result.user.email)}`);
|
router.push(`/auth/set-password?email=${encodeURIComponent(result.user.email)}`);
|
||||||
} else {
|
} else {
|
||||||
router.push("/dashboard");
|
router.push("/account");
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -83,7 +83,7 @@ export function LinkWhmcsView() {
|
|||||||
|
|
||||||
<p className="text-center text-sm text-muted-foreground">
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
Need help?{" "}
|
Need help?{" "}
|
||||||
<Link href="/support" className="text-primary hover:underline">
|
<Link href="/contact" className="text-primary hover:underline">
|
||||||
Contact support
|
Contact support
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -20,7 +20,7 @@ function SetPasswordContent() {
|
|||||||
|
|
||||||
const handlePasswordSetSuccess = () => {
|
const handlePasswordSetSuccess = () => {
|
||||||
// Redirect to dashboard after successful password setup
|
// Redirect to dashboard after successful password setup
|
||||||
router.push("/dashboard");
|
router.push("/account");
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!email) {
|
if (!email) {
|
||||||
|
|||||||
@ -91,7 +91,7 @@ const BillingSummary = forwardRef<HTMLDivElement, BillingSummaryProps>(
|
|||||||
</div>
|
</div>
|
||||||
{!compact && (
|
{!compact && (
|
||||||
<Link
|
<Link
|
||||||
href="/billing/invoices"
|
href="/account/billing/invoices"
|
||||||
className="inline-flex items-center text-sm text-primary hover:text-primary-hover font-medium"
|
className="inline-flex items-center text-sm text-primary hover:text-primary-hover font-medium"
|
||||||
>
|
>
|
||||||
View All
|
View All
|
||||||
@ -158,7 +158,7 @@ const BillingSummary = forwardRef<HTMLDivElement, BillingSummaryProps>(
|
|||||||
{compact && (
|
{compact && (
|
||||||
<div className="mt-4 pt-4 border-t border-border">
|
<div className="mt-4 pt-4 border-t border-border">
|
||||||
<Link
|
<Link
|
||||||
href="/billing/invoices"
|
href="/account/billing/invoices"
|
||||||
className="inline-flex items-center text-sm text-primary hover:text-primary-hover font-medium"
|
className="inline-flex items-center text-sm text-primary hover:text-primary-hover font-medium"
|
||||||
>
|
>
|
||||||
View All Invoices
|
View All Invoices
|
||||||
|
|||||||
@ -104,7 +104,7 @@ export function InvoiceItems({ items = [], currency }: InvoiceItemsProps) {
|
|||||||
|
|
||||||
if (isLinked) {
|
if (isLinked) {
|
||||||
return (
|
return (
|
||||||
<Link key={item.id} href={`/subscriptions/${item.serviceId}`} className="block">
|
<Link key={item.id} href={`/account/services/${item.serviceId}`} className="block">
|
||||||
{itemContent}
|
{itemContent}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -25,7 +25,7 @@ export function InvoiceItemRow({
|
|||||||
? "border-blue-200 bg-blue-50 hover:bg-blue-100 cursor-pointer hover:shadow-sm"
|
? "border-blue-200 bg-blue-50 hover:bg-blue-100 cursor-pointer hover:shadow-sm"
|
||||||
: "border-gray-200 bg-gray-50"
|
: "border-gray-200 bg-gray-50"
|
||||||
}`}
|
}`}
|
||||||
onClick={serviceId ? () => router.push(`/subscriptions/${serviceId}`) : undefined}
|
onClick={serviceId ? () => router.push(`/account/services/${serviceId}`) : undefined}
|
||||||
>
|
>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -65,7 +65,7 @@ export function InvoiceTable({
|
|||||||
if (onInvoiceClick) {
|
if (onInvoiceClick) {
|
||||||
onInvoiceClick(invoice);
|
onInvoiceClick(invoice);
|
||||||
} else {
|
} else {
|
||||||
router.push(`/billing/invoices/${invoice.id}`);
|
router.push(`/account/billing/invoices/${invoice.id}`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onInvoiceClick, router]
|
[onInvoiceClick, router]
|
||||||
|
|||||||
@ -91,7 +91,7 @@ export function InvoiceDetailContainer() {
|
|||||||
variant="page"
|
variant="page"
|
||||||
/>
|
/>
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<Link href="/billing/invoices" className="text-primary font-medium">
|
<Link href="/account/billing/invoices" className="text-primary font-medium">
|
||||||
← Back to invoices
|
← Back to invoices
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@ -105,8 +105,8 @@ export function InvoiceDetailContainer() {
|
|||||||
title={`Invoice #${invoice.id}`}
|
title={`Invoice #${invoice.id}`}
|
||||||
description="Invoice details and actions"
|
description="Invoice details and actions"
|
||||||
breadcrumbs={[
|
breadcrumbs={[
|
||||||
{ label: "Billing", href: "/billing/invoices" },
|
{ label: "Billing", href: "/account/billing/invoices" },
|
||||||
{ label: "Invoices", href: "/billing/invoices" },
|
{ label: "Invoices", href: "/account/billing/invoices" },
|
||||||
{ label: `#${invoice.id}` },
|
{ label: `#${invoice.id}` },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -20,7 +20,7 @@ interface InternetPlanCardProps {
|
|||||||
installations: InternetInstallationCatalogItem[];
|
installations: InternetInstallationCatalogItem[];
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
disabledReason?: string;
|
disabledReason?: string;
|
||||||
/** Override the default configure href (default: /catalog/internet/configure?plan=...) */
|
/** Override the default configure href (default: /shop/internet/configure?plan=...) */
|
||||||
configureHref?: string;
|
configureHref?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -205,7 +205,7 @@ export function InternetPlanCard({
|
|||||||
const { resetInternetConfig, setInternetConfig } = useCatalogStore.getState();
|
const { resetInternetConfig, setInternetConfig } = useCatalogStore.getState();
|
||||||
resetInternetConfig();
|
resetInternetConfig();
|
||||||
setInternetConfig({ planSku: plan.sku, currentStep: 1 });
|
setInternetConfig({ planSku: plan.sku, currentStep: 1 });
|
||||||
const href = configureHref ?? `/catalog/internet/configure?plan=${plan.sku}`;
|
const href = configureHref ?? `/shop/internet/configure?plan=${plan.sku}`;
|
||||||
router.push(href);
|
router.push(href);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -237,7 +237,7 @@ function PlanHeader({ plan }: { plan: InternetPlanCatalogItem }) {
|
|||||||
<div className="text-center mb-8 animate-in fade-in duration-300">
|
<div className="text-center mb-8 animate-in fade-in duration-300">
|
||||||
<Button
|
<Button
|
||||||
as="a"
|
as="a"
|
||||||
href="/catalog/internet"
|
href="/shop/internet"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
|
leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
|
||||||
|
|||||||
@ -161,7 +161,7 @@ export function SimConfigureView({
|
|||||||
<ExclamationTriangleIcon className="h-12 w-12 mx-auto text-danger mb-4" />
|
<ExclamationTriangleIcon className="h-12 w-12 mx-auto text-danger mb-4" />
|
||||||
<h2 className="text-xl font-semibold text-foreground mb-2">Plan Not Found</h2>
|
<h2 className="text-xl font-semibold text-foreground mb-2">Plan Not Found</h2>
|
||||||
<p className="text-muted-foreground mb-4">The selected plan could not be found</p>
|
<p className="text-muted-foreground mb-4">The selected plan could not be found</p>
|
||||||
<a href="/catalog/sim" className="text-primary hover:text-primary-hover font-medium">
|
<a href="/shop/sim" className="text-primary hover:text-primary-hover font-medium">
|
||||||
← Return to SIM Plans
|
← Return to SIM Plans
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@ -185,7 +185,7 @@ export function SimConfigureView({
|
|||||||
icon={<DevicePhoneMobileIcon className="h-6 w-6" />}
|
icon={<DevicePhoneMobileIcon className="h-6 w-6" />}
|
||||||
>
|
>
|
||||||
<div className="max-w-4xl mx-auto space-y-8">
|
<div className="max-w-4xl mx-auto space-y-8">
|
||||||
<CatalogBackLink href="/catalog/sim" label="Back to SIM Plans" />
|
<CatalogBackLink href="/shop/sim" label="Back to SIM Plans" />
|
||||||
|
|
||||||
<AnimatedCard variant="static" className="p-6">
|
<AnimatedCard variant="static" className="p-6">
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
|
|||||||
@ -32,11 +32,11 @@ export function VpnPlanCard({ plan }: VpnPlanCardProps) {
|
|||||||
<div className="mt-auto">
|
<div className="mt-auto">
|
||||||
<Button
|
<Button
|
||||||
as="a"
|
as="a"
|
||||||
href={`/catalog/vpn/configure?plan=${plan.sku}`}
|
href={`/order?type=vpn&planSku=${encodeURIComponent(plan.sku)}`}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
rightIcon={<ArrowRightIcon className="w-4 h-4" />}
|
rightIcon={<ArrowRightIcon className="w-4 h-4" />}
|
||||||
>
|
>
|
||||||
Configure Plan
|
Continue to Checkout
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
|
|||||||
@ -75,7 +75,7 @@ export function useInternetConfigure(): UseInternetConfigureResult {
|
|||||||
|
|
||||||
// Redirect if no plan selected
|
// Redirect if no plan selected
|
||||||
if (!urlPlanSku && !configState.planSku) {
|
if (!urlPlanSku && !configState.planSku) {
|
||||||
router.push("/catalog/internet");
|
router.push("/shop/internet");
|
||||||
}
|
}
|
||||||
}, [configState.planSku, paramsSignature, restoreFromParams, router, setConfig, urlPlanSku]);
|
}, [configState.planSku, paramsSignature, restoreFromParams, router, setConfig, urlPlanSku]);
|
||||||
|
|
||||||
|
|||||||
@ -89,7 +89,7 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
|
|||||||
|
|
||||||
// Redirect if no plan selected
|
// Redirect if no plan selected
|
||||||
if (!effectivePlanSku && !configState.planSku) {
|
if (!effectivePlanSku && !configState.planSku) {
|
||||||
router.push("/catalog/sim");
|
router.push("/shop/sim");
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
configState.planSku,
|
configState.planSku,
|
||||||
|
|||||||
@ -45,7 +45,7 @@ export function CatalogHomeView() {
|
|||||||
"Multiple access modes",
|
"Multiple access modes",
|
||||||
"Professional installation",
|
"Professional installation",
|
||||||
]}
|
]}
|
||||||
href="/catalog/internet"
|
href="/shop/internet"
|
||||||
color="blue"
|
color="blue"
|
||||||
/>
|
/>
|
||||||
<ServiceHeroCard
|
<ServiceHeroCard
|
||||||
@ -58,7 +58,7 @@ export function CatalogHomeView() {
|
|||||||
"Family discounts",
|
"Family discounts",
|
||||||
"Multiple data options",
|
"Multiple data options",
|
||||||
]}
|
]}
|
||||||
href="/catalog/sim"
|
href="/shop/sim"
|
||||||
color="green"
|
color="green"
|
||||||
/>
|
/>
|
||||||
<ServiceHeroCard
|
<ServiceHeroCard
|
||||||
@ -71,7 +71,7 @@ export function CatalogHomeView() {
|
|||||||
"Business & personal",
|
"Business & personal",
|
||||||
"24/7 connectivity",
|
"24/7 connectivity",
|
||||||
]}
|
]}
|
||||||
href="/catalog/vpn"
|
href="/shop/vpn"
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -68,7 +68,7 @@ export function InternetPlansContainer() {
|
|||||||
>
|
>
|
||||||
<AsyncBlock isLoading={false} error={error}>
|
<AsyncBlock isLoading={false} error={error}>
|
||||||
<div className="max-w-6xl mx-auto px-4">
|
<div className="max-w-6xl mx-auto px-4">
|
||||||
<CatalogBackLink href="/catalog" label="Back to Services" />
|
<CatalogBackLink href="/shop" label="Back to Services" />
|
||||||
|
|
||||||
{/* Title + eligibility */}
|
{/* Title + eligibility */}
|
||||||
<div className="text-center mb-12">
|
<div className="text-center mb-12">
|
||||||
@ -112,7 +112,7 @@ export function InternetPlansContainer() {
|
|||||||
icon={<WifiIcon className="h-6 w-6" />}
|
icon={<WifiIcon className="h-6 w-6" />}
|
||||||
>
|
>
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
|
||||||
<CatalogBackLink href="/catalog" label="Back to Services" />
|
<CatalogBackLink href="/shop" label="Back to Services" />
|
||||||
|
|
||||||
<CatalogHero
|
<CatalogHero
|
||||||
title="Choose Your Internet Plan"
|
title="Choose Your Internet Plan"
|
||||||
@ -143,7 +143,7 @@ export function InternetPlansContainer() {
|
|||||||
You already have an Internet subscription with us. If you want another subscription
|
You already have an Internet subscription with us. If you want another subscription
|
||||||
for a different residence, please{" "}
|
for a different residence, please{" "}
|
||||||
<a
|
<a
|
||||||
href="/support/new"
|
href="/account/support/new"
|
||||||
className="underline text-primary hover:text-primary-hover font-medium transition-colors"
|
className="underline text-primary hover:text-primary-hover font-medium transition-colors"
|
||||||
>
|
>
|
||||||
contact us
|
contact us
|
||||||
@ -197,7 +197,7 @@ export function InternetPlansContainer() {
|
|||||||
We couldn't find any internet plans available for your location at this time.
|
We couldn't find any internet plans available for your location at this time.
|
||||||
</p>
|
</p>
|
||||||
<CatalogBackLink
|
<CatalogBackLink
|
||||||
href="/catalog"
|
href="/shop"
|
||||||
label="Back to Services"
|
label="Back to Services"
|
||||||
align="center"
|
align="center"
|
||||||
className="mt-0 mb-0"
|
className="mt-0 mb-0"
|
||||||
|
|||||||
@ -46,7 +46,7 @@ export function PublicInternetPlansView() {
|
|||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
|
||||||
<CatalogBackLink href="/catalog" label="Back to Services" />
|
<CatalogBackLink href="/shop" label="Back to Services" />
|
||||||
|
|
||||||
<div className="text-center mb-12">
|
<div className="text-center mb-12">
|
||||||
<Skeleton className="h-10 w-96 mx-auto mb-4" />
|
<Skeleton className="h-10 w-96 mx-auto mb-4" />
|
||||||
@ -72,7 +72,7 @@ export function PublicInternetPlansView() {
|
|||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
|
||||||
<CatalogBackLink href="/catalog" label="Back to Services" />
|
<CatalogBackLink href="/shop" label="Back to Services" />
|
||||||
<AlertBanner variant="error" title="Failed to load plans">
|
<AlertBanner variant="error" title="Failed to load plans">
|
||||||
{error instanceof Error ? error.message : "An unexpected error occurred"}
|
{error instanceof Error ? error.message : "An unexpected error occurred"}
|
||||||
</AlertBanner>
|
</AlertBanner>
|
||||||
@ -82,7 +82,7 @@ export function PublicInternetPlansView() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
|
||||||
<CatalogBackLink href="/catalog" label="Back to Services" />
|
<CatalogBackLink href="/shop" label="Back to Services" />
|
||||||
|
|
||||||
<CatalogHero
|
<CatalogHero
|
||||||
title="Choose Your Internet Plan"
|
title="Choose Your Internet Plan"
|
||||||
@ -143,7 +143,7 @@ export function PublicInternetPlansView() {
|
|||||||
We couldn't find any internet plans available at this time.
|
We couldn't find any internet plans available at this time.
|
||||||
</p>
|
</p>
|
||||||
<CatalogBackLink
|
<CatalogBackLink
|
||||||
href="/catalog"
|
href="/shop"
|
||||||
label="Back to Services"
|
label="Back to Services"
|
||||||
align="center"
|
align="center"
|
||||||
className="mt-0 mb-0"
|
className="mt-0 mb-0"
|
||||||
|
|||||||
@ -39,7 +39,7 @@ export function PublicSimPlansView() {
|
|||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl mx-auto px-4">
|
<div className="max-w-6xl mx-auto px-4">
|
||||||
<CatalogBackLink href="/catalog" label="Back to Services" />
|
<CatalogBackLink href="/shop" label="Back to Services" />
|
||||||
|
|
||||||
<div className="text-center mb-12">
|
<div className="text-center mb-12">
|
||||||
<Skeleton className="h-10 w-80 mx-auto mb-4" />
|
<Skeleton className="h-10 w-80 mx-auto mb-4" />
|
||||||
@ -72,7 +72,7 @@ export function PublicSimPlansView() {
|
|||||||
<div className="text-destructive/80 text-sm mt-1">{errorMessage}</div>
|
<div className="text-destructive/80 text-sm mt-1">{errorMessage}</div>
|
||||||
<Button
|
<Button
|
||||||
as="a"
|
as="a"
|
||||||
href="/catalog"
|
href="/shop"
|
||||||
className="mt-4"
|
className="mt-4"
|
||||||
leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
|
leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
|
||||||
>
|
>
|
||||||
@ -96,7 +96,7 @@ export function PublicSimPlansView() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl mx-auto px-4 pb-16">
|
<div className="max-w-6xl mx-auto px-4 pb-16">
|
||||||
<CatalogBackLink href="/catalog" label="Back to Services" />
|
<CatalogBackLink href="/shop" label="Back to Services" />
|
||||||
|
|
||||||
<CatalogHero
|
<CatalogHero
|
||||||
title="Choose Your SIM Plan"
|
title="Choose Your SIM Plan"
|
||||||
|
|||||||
@ -22,7 +22,7 @@ export function PublicVpnPlansView() {
|
|||||||
if (isLoading || error) {
|
if (isLoading || error) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl mx-auto px-4">
|
<div className="max-w-6xl mx-auto px-4">
|
||||||
<CatalogBackLink href="/catalog" label="Back to Services" />
|
<CatalogBackLink href="/shop" label="Back to Services" />
|
||||||
|
|
||||||
<AsyncBlock
|
<AsyncBlock
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
@ -42,7 +42,7 @@ export function PublicVpnPlansView() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl mx-auto px-4 pb-16">
|
<div className="max-w-6xl mx-auto px-4 pb-16">
|
||||||
<CatalogBackLink href="/catalog" label="Back to Services" />
|
<CatalogBackLink href="/shop" label="Back to Services" />
|
||||||
|
|
||||||
<CatalogHero
|
<CatalogHero
|
||||||
title="SonixNet VPN Router Service"
|
title="SonixNet VPN Router Service"
|
||||||
@ -75,7 +75,7 @@ export function PublicVpnPlansView() {
|
|||||||
We couldn't find any VPN plans available at this time.
|
We couldn't find any VPN plans available at this time.
|
||||||
</p>
|
</p>
|
||||||
<CatalogBackLink
|
<CatalogBackLink
|
||||||
href="/catalog"
|
href="/shop"
|
||||||
label="Back to Services"
|
label="Back to Services"
|
||||||
align="center"
|
align="center"
|
||||||
className="mt-4 mb-0"
|
className="mt-4 mb-0"
|
||||||
|
|||||||
@ -45,7 +45,7 @@ export function SimPlansContainer() {
|
|||||||
icon={<DevicePhoneMobileIcon className="h-6 w-6" />}
|
icon={<DevicePhoneMobileIcon className="h-6 w-6" />}
|
||||||
>
|
>
|
||||||
<div className="max-w-6xl mx-auto px-4">
|
<div className="max-w-6xl mx-auto px-4">
|
||||||
<CatalogBackLink href="/catalog" label="Back to Services" />
|
<CatalogBackLink href="/shop" label="Back to Services" />
|
||||||
|
|
||||||
{/* Title block */}
|
{/* Title block */}
|
||||||
<div className="text-center mb-12">
|
<div className="text-center mb-12">
|
||||||
@ -110,7 +110,7 @@ export function SimPlansContainer() {
|
|||||||
<div className="text-red-600 text-sm mt-1">{errorMessage}</div>
|
<div className="text-red-600 text-sm mt-1">{errorMessage}</div>
|
||||||
<Button
|
<Button
|
||||||
as="a"
|
as="a"
|
||||||
href="/catalog"
|
href="/shop"
|
||||||
className="mt-4"
|
className="mt-4"
|
||||||
leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
|
leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
|
||||||
>
|
>
|
||||||
@ -140,7 +140,7 @@ export function SimPlansContainer() {
|
|||||||
icon={<DevicePhoneMobileIcon className="h-6 w-6" />}
|
icon={<DevicePhoneMobileIcon className="h-6 w-6" />}
|
||||||
>
|
>
|
||||||
<div className="max-w-6xl mx-auto px-4 pb-16">
|
<div className="max-w-6xl mx-auto px-4 pb-16">
|
||||||
<CatalogBackLink href="/catalog" label="Back to Services" />
|
<CatalogBackLink href="/shop" label="Back to Services" />
|
||||||
|
|
||||||
<CatalogHero
|
<CatalogHero
|
||||||
title="Choose Your SIM Plan"
|
title="Choose Your SIM Plan"
|
||||||
|
|||||||
@ -24,7 +24,7 @@ export function VpnPlansView() {
|
|||||||
icon={<ShieldCheckIcon className="h-6 w-6" />}
|
icon={<ShieldCheckIcon className="h-6 w-6" />}
|
||||||
>
|
>
|
||||||
<div className="max-w-6xl mx-auto px-4">
|
<div className="max-w-6xl mx-auto px-4">
|
||||||
<CatalogBackLink href="/catalog" label="Back to Services" />
|
<CatalogBackLink href="/shop" label="Back to Services" />
|
||||||
|
|
||||||
<AsyncBlock
|
<AsyncBlock
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
@ -52,7 +52,7 @@ export function VpnPlansView() {
|
|||||||
icon={<ShieldCheckIcon className="h-6 w-6" />}
|
icon={<ShieldCheckIcon className="h-6 w-6" />}
|
||||||
>
|
>
|
||||||
<div className="max-w-6xl mx-auto px-4 pb-16">
|
<div className="max-w-6xl mx-auto px-4 pb-16">
|
||||||
<CatalogBackLink href="/catalog" label="Back to Services" />
|
<CatalogBackLink href="/shop" label="Back to Services" />
|
||||||
|
|
||||||
<CatalogHero
|
<CatalogHero
|
||||||
title="SonixNet VPN Router Service"
|
title="SonixNet VPN Router Service"
|
||||||
@ -89,7 +89,7 @@ export function VpnPlansView() {
|
|||||||
We couldn't find any VPN plans available at this time.
|
We couldn't find any VPN plans available at this time.
|
||||||
</p>
|
</p>
|
||||||
<CatalogBackLink
|
<CatalogBackLink
|
||||||
href="/catalog"
|
href="/shop"
|
||||||
label="Back to Services"
|
label="Back to Services"
|
||||||
align="center"
|
align="center"
|
||||||
className="mt-4 mb-0"
|
className="mt-4 mb-0"
|
||||||
|
|||||||
209
apps/portal/src/features/checkout/components/CheckoutEntry.tsx
Normal file
209
apps/portal/src/features/checkout/components/CheckoutEntry.tsx
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import type { CartItem, OrderType as CheckoutOrderType } from "@customer-portal/domain/checkout";
|
||||||
|
import type { CheckoutCart, OrderTypeValue } from "@customer-portal/domain/orders";
|
||||||
|
import { ORDER_TYPE } from "@customer-portal/domain/orders";
|
||||||
|
import { checkoutService } from "@/features/checkout/services/checkout.service";
|
||||||
|
import { CheckoutParamsService } from "@/features/checkout/services/checkout-params.service";
|
||||||
|
import { useCheckoutStore } from "@/features/checkout/stores/checkout.store";
|
||||||
|
import { CheckoutWizard } from "@/features/checkout/components/CheckoutWizard";
|
||||||
|
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||||
|
import { Button } from "@/components/atoms/button";
|
||||||
|
import { Spinner } from "@/components/atoms";
|
||||||
|
import { EmptyCartRedirect } from "@/features/checkout/components/EmptyCartRedirect";
|
||||||
|
|
||||||
|
const signatureFromSearchParams = (params: URLSearchParams): string => {
|
||||||
|
const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b));
|
||||||
|
return entries.map(([key, value]) => `${key}=${value}`).join("&");
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapOrderTypeToCheckout = (orderType: OrderTypeValue): CheckoutOrderType => {
|
||||||
|
switch (orderType) {
|
||||||
|
case ORDER_TYPE.SIM:
|
||||||
|
return "SIM";
|
||||||
|
case ORDER_TYPE.VPN:
|
||||||
|
return "VPN";
|
||||||
|
case ORDER_TYPE.INTERNET:
|
||||||
|
case ORDER_TYPE.OTHER:
|
||||||
|
default:
|
||||||
|
return "INTERNET";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
type CheckoutCartSummary = { items: CheckoutCart["items"]; totals: CheckoutCart["totals"] };
|
||||||
|
|
||||||
|
const cartItemFromCheckoutCart = (
|
||||||
|
cart: CheckoutCartSummary,
|
||||||
|
orderType: OrderTypeValue
|
||||||
|
): CartItem => {
|
||||||
|
const planItem = cart.items.find(item => item.itemType === "plan") ?? cart.items[0];
|
||||||
|
const planSku = planItem?.sku;
|
||||||
|
if (!planSku) {
|
||||||
|
throw new Error("Checkout cart did not include a plan. Please re-select your plan.");
|
||||||
|
}
|
||||||
|
const addonSkus = Array.from(
|
||||||
|
new Set(cart.items.map(item => item.sku).filter(sku => sku && sku !== planSku))
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
orderType: mapOrderTypeToCheckout(orderType),
|
||||||
|
planSku,
|
||||||
|
planName: planItem?.name ?? planSku,
|
||||||
|
addonSkus,
|
||||||
|
configuration: {},
|
||||||
|
pricing: {
|
||||||
|
monthlyTotal: cart.totals.monthlyTotal,
|
||||||
|
oneTimeTotal: cart.totals.oneTimeTotal,
|
||||||
|
breakdown: cart.items.map(item => ({
|
||||||
|
label: item.name,
|
||||||
|
sku: item.sku,
|
||||||
|
monthlyPrice: item.monthlyPrice,
|
||||||
|
oneTimePrice: item.oneTimePrice,
|
||||||
|
quantity: item.quantity,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CheckoutEntry() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const paramsKey = useMemo(() => searchParams.toString(), [searchParams]);
|
||||||
|
const signature = useMemo(
|
||||||
|
() => signatureFromSearchParams(new URLSearchParams(paramsKey)),
|
||||||
|
[paramsKey]
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
cartItem,
|
||||||
|
cartParamsSignature,
|
||||||
|
checkoutSessionId,
|
||||||
|
setCartItem,
|
||||||
|
setCartItemFromParams,
|
||||||
|
setCheckoutSession,
|
||||||
|
isCartStale,
|
||||||
|
clear,
|
||||||
|
} = useCheckoutStore();
|
||||||
|
|
||||||
|
const [status, setStatus] = useState<"idle" | "loading" | "error">("idle");
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!paramsKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mounted = true;
|
||||||
|
setStatus("loading");
|
||||||
|
setErrorMessage(null);
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const snapshot = CheckoutParamsService.buildSnapshot(new URLSearchParams(paramsKey));
|
||||||
|
if (!snapshot.planReference) {
|
||||||
|
throw new Error("No plan selected. Please go back and select a plan.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await checkoutService.createSession(
|
||||||
|
snapshot.orderType,
|
||||||
|
snapshot.selections,
|
||||||
|
snapshot.configuration
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
const nextCartItem = cartItemFromCheckoutCart(session.cart, session.orderType);
|
||||||
|
setCartItemFromParams(nextCartItem, signature);
|
||||||
|
setCheckoutSession({ id: session.sessionId, expiresAt: session.expiresAt });
|
||||||
|
setStatus("idle");
|
||||||
|
} catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setStatus("error");
|
||||||
|
setErrorMessage(error instanceof Error ? error.message : "Failed to load checkout");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, [paramsKey, setCartItemFromParams, setCheckoutSession, signature]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (paramsKey) return;
|
||||||
|
|
||||||
|
if (isCartStale()) {
|
||||||
|
clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!checkoutSessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mounted = true;
|
||||||
|
setStatus("loading");
|
||||||
|
setErrorMessage(null);
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const session = await checkoutService.getSession(checkoutSessionId);
|
||||||
|
if (!mounted) return;
|
||||||
|
setCheckoutSession({ id: session.sessionId, expiresAt: session.expiresAt });
|
||||||
|
const nextCartItem = cartItemFromCheckoutCart(session.cart, session.orderType);
|
||||||
|
// Session-based entry: don't tie progress to URL params.
|
||||||
|
setCartItem(nextCartItem);
|
||||||
|
setStatus("idle");
|
||||||
|
} catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setStatus("error");
|
||||||
|
setErrorMessage(error instanceof Error ? error.message : "Failed to load checkout");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, [checkoutSessionId, clear, isCartStale, paramsKey, setCartItem, setCheckoutSession]);
|
||||||
|
|
||||||
|
const shouldWaitForCart =
|
||||||
|
(Boolean(paramsKey) && (!cartItem || cartParamsSignature !== signature)) ||
|
||||||
|
(!paramsKey && Boolean(checkoutSessionId) && !cartItem);
|
||||||
|
|
||||||
|
if (status === "loading" && shouldWaitForCart) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto py-12">
|
||||||
|
<div className="bg-card rounded-xl border border-border p-8 shadow-[var(--cp-shadow-1)] text-center">
|
||||||
|
<Spinner className="mx-auto mb-4" />
|
||||||
|
<p className="text-sm text-muted-foreground">Preparing your checkout…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "error") {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto py-8">
|
||||||
|
<AlertBanner variant="error" title="Unable to start checkout" elevated>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="text-sm text-foreground/80">{errorMessage}</div>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
|
<Button as="a" href="/shop" size="sm">
|
||||||
|
Back to Shop
|
||||||
|
</Button>
|
||||||
|
<Link href="/contact" className="text-sm text-primary hover:underline self-center">
|
||||||
|
Contact support
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AlertBanner>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!paramsKey && !checkoutSessionId) {
|
||||||
|
return <EmptyCartRedirect />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <CheckoutWizard />;
|
||||||
|
}
|
||||||
@ -58,7 +58,7 @@ export class CheckoutErrorBoundary extends Component<Props, State> {
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-6">
|
<p className="text-xs text-muted-foreground mt-6">
|
||||||
If this problem persists, please{" "}
|
If this problem persists, please{" "}
|
||||||
<Link href="/support/contact" className="text-primary hover:underline">
|
<Link href="/contact" className="text-primary hover:underline">
|
||||||
contact support
|
contact support
|
||||||
</Link>
|
</Link>
|
||||||
.
|
.
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { useEffect } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Logo } from "@/components/atoms/logo";
|
import { Logo } from "@/components/atoms/logo";
|
||||||
import { ShieldCheckIcon } from "@heroicons/react/24/outline";
|
import { ShieldCheckIcon } from "@heroicons/react/24/outline";
|
||||||
|
import { useAuthStore } from "@/features/auth/services/auth.store";
|
||||||
|
|
||||||
interface CheckoutShellProps {
|
interface CheckoutShellProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@ -19,6 +21,15 @@ interface CheckoutShellProps {
|
|||||||
* - Clean, focused design
|
* - Clean, focused design
|
||||||
*/
|
*/
|
||||||
export function CheckoutShell({ children }: CheckoutShellProps) {
|
export function CheckoutShell({ children }: CheckoutShellProps) {
|
||||||
|
const hasCheckedAuth = useAuthStore(state => state.hasCheckedAuth);
|
||||||
|
const checkAuth = useAuthStore(state => state.checkAuth);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasCheckedAuth) {
|
||||||
|
void checkAuth();
|
||||||
|
}
|
||||||
|
}, [checkAuth, hasCheckedAuth]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col bg-background text-foreground">
|
<div className="min-h-screen flex flex-col bg-background text-foreground">
|
||||||
{/* Subtle background pattern */}
|
{/* Subtle background pattern */}
|
||||||
@ -51,7 +62,7 @@ export function CheckoutShell({ children }: CheckoutShellProps) {
|
|||||||
<span>Secure Checkout</span>
|
<span>Secure Checkout</span>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
href="/support"
|
href="/help"
|
||||||
className="inline-flex items-center rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
|
className="inline-flex items-center rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
|
||||||
>
|
>
|
||||||
Need Help?
|
Need Help?
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
import { useCheckoutStore } from "../stores/checkout.store";
|
import { useCheckoutStore } from "../stores/checkout.store";
|
||||||
import { CheckoutProgress } from "./CheckoutProgress";
|
import { CheckoutProgress } from "./CheckoutProgress";
|
||||||
import { OrderSummaryCard } from "./OrderSummaryCard";
|
import { OrderSummaryCard } from "./OrderSummaryCard";
|
||||||
@ -9,6 +10,7 @@ import { AddressStep } from "./steps/AddressStep";
|
|||||||
import { PaymentStep } from "./steps/PaymentStep";
|
import { PaymentStep } from "./steps/PaymentStep";
|
||||||
import { ReviewStep } from "./steps/ReviewStep";
|
import { ReviewStep } from "./steps/ReviewStep";
|
||||||
import type { CheckoutStep } from "@customer-portal/domain/checkout";
|
import type { CheckoutStep } from "@customer-portal/domain/checkout";
|
||||||
|
import { useAuthSession } from "@/features/auth/services/auth.store";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CheckoutWizard - Main checkout flow orchestrator
|
* CheckoutWizard - Main checkout flow orchestrator
|
||||||
@ -17,8 +19,15 @@ import type { CheckoutStep } from "@customer-portal/domain/checkout";
|
|||||||
* appropriate content based on current step.
|
* appropriate content based on current step.
|
||||||
*/
|
*/
|
||||||
export function CheckoutWizard() {
|
export function CheckoutWizard() {
|
||||||
|
const { isAuthenticated } = useAuthSession();
|
||||||
const { cartItem, currentStep, setCurrentStep, registrationComplete } = useCheckoutStore();
|
const { cartItem, currentStep, setCurrentStep, registrationComplete } = useCheckoutStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if ((isAuthenticated || registrationComplete) && currentStep === "account") {
|
||||||
|
setCurrentStep("address");
|
||||||
|
}
|
||||||
|
}, [currentStep, isAuthenticated, registrationComplete, setCurrentStep]);
|
||||||
|
|
||||||
// Redirect if no cart
|
// Redirect if no cart
|
||||||
if (!cartItem) {
|
if (!cartItem) {
|
||||||
return <EmptyCartRedirect />;
|
return <EmptyCartRedirect />;
|
||||||
@ -50,10 +59,8 @@ export function CheckoutWizard() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Determine effective step (skip account if already authenticated)
|
// Determine effective step (skip account if already authenticated)
|
||||||
const effectiveStep = registrationComplete && currentStep === "account" ? "address" : currentStep;
|
|
||||||
|
|
||||||
const renderStep = () => {
|
const renderStep = () => {
|
||||||
switch (effectiveStep) {
|
switch (currentStep) {
|
||||||
case "account":
|
case "account":
|
||||||
return <AccountStep />;
|
return <AccountStep />;
|
||||||
case "address":
|
case "address":
|
||||||
@ -71,7 +78,7 @@ export function CheckoutWizard() {
|
|||||||
<div className="max-w-5xl mx-auto">
|
<div className="max-w-5xl mx-auto">
|
||||||
{/* Progress indicator */}
|
{/* Progress indicator */}
|
||||||
<CheckoutProgress
|
<CheckoutProgress
|
||||||
currentStep={effectiveStep}
|
currentStep={currentStep}
|
||||||
completedSteps={getCompletedSteps()}
|
completedSteps={getCompletedSteps()}
|
||||||
onStepClick={handleStepClick}
|
onStepClick={handleStepClick}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import { Button } from "@/components/atoms/button";
|
|||||||
/**
|
/**
|
||||||
* EmptyCartRedirect - Shown when checkout is accessed without a cart
|
* EmptyCartRedirect - Shown when checkout is accessed without a cart
|
||||||
*
|
*
|
||||||
* Redirects to catalog after a short delay, or user can click to go immediately.
|
* Redirects to shop after a short delay, or user can click to go immediately.
|
||||||
*/
|
*/
|
||||||
export function EmptyCartRedirect() {
|
export function EmptyCartRedirect() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -29,13 +29,13 @@ export function EmptyCartRedirect() {
|
|||||||
</div>
|
</div>
|
||||||
<h2 className="text-xl font-semibold text-foreground mb-2">Your cart is empty</h2>
|
<h2 className="text-xl font-semibold text-foreground mb-2">Your cart is empty</h2>
|
||||||
<p className="text-muted-foreground mb-6">
|
<p className="text-muted-foreground mb-6">
|
||||||
Browse our catalog to find the perfect plan for your needs.
|
Browse our services to find the perfect plan for your needs.
|
||||||
</p>
|
</p>
|
||||||
<Button as="a" href="/shop" className="w-full">
|
<Button as="a" href="/shop" className="w-full">
|
||||||
Browse Catalog
|
Browse Services
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-xs text-muted-foreground mt-4">
|
<p className="text-xs text-muted-foreground mt-4">
|
||||||
Redirecting to catalog in a few seconds...
|
Redirecting to the shop in a few seconds...
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -84,10 +84,10 @@ export function OrderConfirmation() {
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||||
<Button as="a" href="/dashboard" className="sm:w-auto">
|
<Button as="a" href="/account" className="sm:w-auto">
|
||||||
Go to Dashboard
|
Go to Dashboard
|
||||||
</Button>
|
</Button>
|
||||||
<Button as="a" href="/orders" variant="outline" className="sm:w-auto">
|
<Button as="a" href="/account/orders" variant="outline" className="sm:w-auto">
|
||||||
View Orders
|
View Orders
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -95,7 +95,7 @@ export function OrderConfirmation() {
|
|||||||
{/* Support Link */}
|
{/* Support Link */}
|
||||||
<p className="text-sm text-muted-foreground mt-8">
|
<p className="text-sm text-muted-foreground mt-8">
|
||||||
Have questions?{" "}
|
Have questions?{" "}
|
||||||
<Link href="/support" className="text-primary hover:underline">
|
<Link href="/contact" className="text-primary hover:underline">
|
||||||
Contact Support
|
Contact Support
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { useCheckoutStore } from "../../stores/checkout.store";
|
import { useCheckoutStore } from "../../stores/checkout.store";
|
||||||
import { Button, Input } from "@/components/atoms";
|
import { Button, Input } from "@/components/atoms";
|
||||||
@ -8,6 +8,7 @@ import { FormField } from "@/components/molecules/FormField/FormField";
|
|||||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||||
import { UserIcon, ArrowRightIcon } from "@heroicons/react/24/outline";
|
import { UserIcon, ArrowRightIcon } from "@heroicons/react/24/outline";
|
||||||
import { useZodForm } from "@/hooks/useZodForm";
|
import { useZodForm } from "@/hooks/useZodForm";
|
||||||
|
import { useAuthSession, useAuthStore } from "@/features/auth/services/auth.store";
|
||||||
import {
|
import {
|
||||||
emailSchema,
|
emailSchema,
|
||||||
passwordSchema,
|
passwordSchema,
|
||||||
@ -39,6 +40,7 @@ type AccountFormData = z.infer<typeof accountFormSchema>;
|
|||||||
* Allows new customers to enter their info or existing customers to sign in.
|
* Allows new customers to enter their info or existing customers to sign in.
|
||||||
*/
|
*/
|
||||||
export function AccountStep() {
|
export function AccountStep() {
|
||||||
|
const { isAuthenticated } = useAuthSession();
|
||||||
const {
|
const {
|
||||||
guestInfo,
|
guestInfo,
|
||||||
updateGuestInfo,
|
updateGuestInfo,
|
||||||
@ -77,9 +79,13 @@ export function AccountStep() {
|
|||||||
onSubmit: handleSubmit,
|
onSubmit: handleSubmit,
|
||||||
});
|
});
|
||||||
|
|
||||||
// If already registered, skip to address
|
useEffect(() => {
|
||||||
if (registrationComplete) {
|
if (isAuthenticated || registrationComplete) {
|
||||||
setCurrentStep("address");
|
setCurrentStep("address");
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, registrationComplete, setCurrentStep]);
|
||||||
|
|
||||||
|
if (isAuthenticated || registrationComplete) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -262,6 +268,7 @@ function SignInForm({
|
|||||||
}) {
|
}) {
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const login = useAuthStore(state => state.login);
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
async (data: { email: string; password: string }) => {
|
async (data: { email: string; password: string }) => {
|
||||||
@ -269,20 +276,11 @@ function SignInForm({
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/auth/login", {
|
await login(data);
|
||||||
method: "POST",
|
const userId = useAuthStore.getState().user?.id;
|
||||||
headers: { "Content-Type": "application/json" },
|
if (userId) {
|
||||||
body: JSON.stringify(data),
|
setRegistrationComplete(userId);
|
||||||
credentials: "include",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
throw new Error(errorData.error?.message || "Invalid email or password");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
setRegistrationComplete(result.user?.id || result.id || "");
|
|
||||||
onSuccess();
|
onSuccess();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Login failed");
|
setError(err instanceof Error ? err.message : "Login failed");
|
||||||
@ -290,7 +288,7 @@ function SignInForm({
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onSuccess, setRegistrationComplete]
|
[login, onSuccess, setRegistrationComplete]
|
||||||
);
|
);
|
||||||
|
|
||||||
const form = useZodForm<{ email: string; password: string }>({
|
const form = useZodForm<{ email: string; password: string }>({
|
||||||
|
|||||||
@ -2,12 +2,15 @@
|
|||||||
|
|
||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { useCheckoutStore } from "../../stores/checkout.store";
|
import { useCheckoutStore } from "../../stores/checkout.store";
|
||||||
|
import { useAuthSession, useAuthStore } from "@/features/auth/services/auth.store";
|
||||||
import { Button, Input } from "@/components/atoms";
|
import { Button, Input } from "@/components/atoms";
|
||||||
import { FormField } from "@/components/molecules/FormField/FormField";
|
import { FormField } from "@/components/molecules/FormField/FormField";
|
||||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||||
import { MapPinIcon, ArrowLeftIcon, ArrowRightIcon } from "@heroicons/react/24/outline";
|
import { MapPinIcon, ArrowLeftIcon, ArrowRightIcon } from "@heroicons/react/24/outline";
|
||||||
import { addressFormSchema, type AddressFormData } from "@customer-portal/domain/customer";
|
import { addressFormSchema, type AddressFormData } from "@customer-portal/domain/customer";
|
||||||
import { useZodForm } from "@/hooks/useZodForm";
|
import { useZodForm } from "@/hooks/useZodForm";
|
||||||
|
import { apiClient } from "@/lib/api";
|
||||||
|
import { checkoutRegisterResponseSchema } from "@customer-portal/domain/checkout";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AddressStep - Second step in checkout
|
* AddressStep - Second step in checkout
|
||||||
@ -15,6 +18,8 @@ import { useZodForm } from "@/hooks/useZodForm";
|
|||||||
* Collects service/shipping address and triggers registration for new users.
|
* Collects service/shipping address and triggers registration for new users.
|
||||||
*/
|
*/
|
||||||
export function AddressStep() {
|
export function AddressStep() {
|
||||||
|
const { isAuthenticated } = useAuthSession();
|
||||||
|
const refreshUser = useAuthStore(state => state.refreshUser);
|
||||||
const {
|
const {
|
||||||
address,
|
address,
|
||||||
setAddress,
|
setAddress,
|
||||||
@ -33,12 +38,18 @@ export function AddressStep() {
|
|||||||
setAddress(data);
|
setAddress(data);
|
||||||
|
|
||||||
// If not yet registered, trigger registration
|
// If not yet registered, trigger registration
|
||||||
if (!registrationComplete && guestInfo) {
|
const hasGuestInfo =
|
||||||
|
Boolean(guestInfo?.email) &&
|
||||||
|
Boolean(guestInfo?.firstName) &&
|
||||||
|
Boolean(guestInfo?.lastName) &&
|
||||||
|
Boolean(guestInfo?.phone) &&
|
||||||
|
Boolean(guestInfo?.phoneCountryCode) &&
|
||||||
|
Boolean(guestInfo?.password);
|
||||||
|
|
||||||
|
if (!isAuthenticated && !registrationComplete && hasGuestInfo && guestInfo) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/checkout/register", {
|
const response = await apiClient.POST("/api/checkout/register", {
|
||||||
method: "POST",
|
body: {
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
email: guestInfo.email,
|
email: guestInfo.email,
|
||||||
firstName: guestInfo.firstName,
|
firstName: guestInfo.firstName,
|
||||||
lastName: guestInfo.lastName,
|
lastName: guestInfo.lastName,
|
||||||
@ -47,17 +58,12 @@ export function AddressStep() {
|
|||||||
password: guestInfo.password,
|
password: guestInfo.password,
|
||||||
address: data,
|
address: data,
|
||||||
acceptTerms: true,
|
acceptTerms: true,
|
||||||
}),
|
},
|
||||||
credentials: "include",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
const result = checkoutRegisterResponseSchema.parse(response.data);
|
||||||
const errorData = await response.json();
|
|
||||||
throw new Error(errorData.error?.message || errorData.message || "Registration failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
setRegistrationComplete(result.user.id);
|
setRegistrationComplete(result.user.id);
|
||||||
|
await refreshUser();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setRegistrationError(error instanceof Error ? error.message : "Registration failed");
|
setRegistrationError(error instanceof Error ? error.message : "Registration failed");
|
||||||
return;
|
return;
|
||||||
@ -66,7 +72,15 @@ export function AddressStep() {
|
|||||||
|
|
||||||
setCurrentStep("payment");
|
setCurrentStep("payment");
|
||||||
},
|
},
|
||||||
[guestInfo, registrationComplete, setAddress, setCurrentStep, setRegistrationComplete]
|
[
|
||||||
|
guestInfo,
|
||||||
|
isAuthenticated,
|
||||||
|
refreshUser,
|
||||||
|
registrationComplete,
|
||||||
|
setAddress,
|
||||||
|
setCurrentStep,
|
||||||
|
setRegistrationComplete,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const form = useZodForm<AddressFormData>({
|
const form = useZodForm<AddressFormData>({
|
||||||
|
|||||||
@ -2,8 +2,11 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { useCheckoutStore } from "../../stores/checkout.store";
|
import { useCheckoutStore } from "../../stores/checkout.store";
|
||||||
|
import { useAuthSession } from "@/features/auth/services/auth.store";
|
||||||
import { Button } from "@/components/atoms/button";
|
import { Button } from "@/components/atoms/button";
|
||||||
import { Spinner } from "@/components/atoms";
|
import { Spinner } from "@/components/atoms";
|
||||||
|
import { apiClient } from "@/lib/api";
|
||||||
|
import { ssoLinkResponseSchema } from "@customer-portal/domain/auth";
|
||||||
import {
|
import {
|
||||||
CreditCardIcon,
|
CreditCardIcon,
|
||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
@ -18,6 +21,7 @@ import {
|
|||||||
* Opens WHMCS SSO to add payment method and polls for completion.
|
* Opens WHMCS SSO to add payment method and polls for completion.
|
||||||
*/
|
*/
|
||||||
export function PaymentStep() {
|
export function PaymentStep() {
|
||||||
|
const { isAuthenticated } = useAuthSession();
|
||||||
const { setPaymentVerified, paymentMethodVerified, setCurrentStep, registrationComplete } =
|
const { setPaymentVerified, paymentMethodVerified, setCurrentStep, registrationComplete } =
|
||||||
useCheckoutStore();
|
useCheckoutStore();
|
||||||
const [isWaiting, setIsWaiting] = useState(false);
|
const [isWaiting, setIsWaiting] = useState(false);
|
||||||
@ -27,11 +31,12 @@ export function PaymentStep() {
|
|||||||
lastFour?: string;
|
lastFour?: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
|
const canCheckPayment = isAuthenticated || registrationComplete;
|
||||||
|
|
||||||
// Poll for payment method
|
// Poll for payment method
|
||||||
const checkPaymentMethod = useCallback(async () => {
|
const checkPaymentMethod = useCallback(async () => {
|
||||||
if (!registrationComplete) {
|
if (!canCheckPayment) {
|
||||||
// Need to be registered first - show message
|
setError("Please complete account setup first");
|
||||||
setError("Please complete registration first");
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -63,7 +68,7 @@ export function PaymentStep() {
|
|||||||
console.error("Error checking payment methods:", err);
|
console.error("Error checking payment methods:", err);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, [registrationComplete, setPaymentVerified]);
|
}, [canCheckPayment, setPaymentVerified]);
|
||||||
|
|
||||||
// Check on mount and when returning focus
|
// Check on mount and when returning focus
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -97,7 +102,7 @@ export function PaymentStep() {
|
|||||||
}, [isWaiting, checkPaymentMethod]);
|
}, [isWaiting, checkPaymentMethod]);
|
||||||
|
|
||||||
const handleAddPayment = async () => {
|
const handleAddPayment = async () => {
|
||||||
if (!registrationComplete) {
|
if (!canCheckPayment) {
|
||||||
setError("Please complete account setup first");
|
setError("Please complete account setup first");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -107,19 +112,11 @@ export function PaymentStep() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Get SSO link for payment methods
|
// Get SSO link for payment methods
|
||||||
const response = await fetch("/api/auth/sso-link", {
|
const response = await apiClient.POST("/api/auth/sso-link", {
|
||||||
method: "POST",
|
body: { destination: "index.php?rp=/account/paymentmethods" },
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ destination: "paymentmethods" }),
|
|
||||||
credentials: "include",
|
|
||||||
});
|
});
|
||||||
|
const data = ssoLinkResponseSchema.parse(response.data);
|
||||||
if (!response.ok) {
|
const url = data.url;
|
||||||
throw new Error("Failed to get payment portal link");
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
const url = data.data?.url ?? data.url;
|
|
||||||
|
|
||||||
if (url) {
|
if (url) {
|
||||||
window.open(url, "_blank", "noopener,noreferrer");
|
window.open(url, "_blank", "noopener,noreferrer");
|
||||||
@ -199,10 +196,10 @@ export function PaymentStep() {
|
|||||||
We'll open our secure payment portal where you can add your credit card or other
|
We'll open our secure payment portal where you can add your credit card or other
|
||||||
payment method.
|
payment method.
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={handleAddPayment} disabled={!registrationComplete}>
|
<Button onClick={handleAddPayment} disabled={!canCheckPayment}>
|
||||||
Add Payment Method
|
Add Payment Method
|
||||||
</Button>
|
</Button>
|
||||||
{!registrationComplete && (
|
{!canCheckPayment && (
|
||||||
<p className="text-sm text-warning mt-2">
|
<p className="text-sm text-warning mt-2">
|
||||||
You need to complete registration first
|
You need to complete registration first
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -3,6 +3,8 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useCheckoutStore } from "../../stores/checkout.store";
|
import { useCheckoutStore } from "../../stores/checkout.store";
|
||||||
|
import { useAuthSession } from "@/features/auth/services/auth.store";
|
||||||
|
import { ordersService } from "@/features/orders/services/orders.service";
|
||||||
import { Button } from "@/components/atoms/button";
|
import { Button } from "@/components/atoms/button";
|
||||||
import {
|
import {
|
||||||
ShieldCheckIcon,
|
ShieldCheckIcon,
|
||||||
@ -21,8 +23,16 @@ import {
|
|||||||
*/
|
*/
|
||||||
export function ReviewStep() {
|
export function ReviewStep() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { cartItem, guestInfo, address, paymentMethodVerified, setCurrentStep, clear } =
|
const { user } = useAuthSession();
|
||||||
useCheckoutStore();
|
const {
|
||||||
|
cartItem,
|
||||||
|
guestInfo,
|
||||||
|
address,
|
||||||
|
paymentMethodVerified,
|
||||||
|
checkoutSessionId,
|
||||||
|
setCurrentStep,
|
||||||
|
clear,
|
||||||
|
} = useCheckoutStore();
|
||||||
|
|
||||||
const [termsAccepted, setTermsAccepted] = useState(false);
|
const [termsAccepted, setTermsAccepted] = useState(false);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
@ -43,31 +53,17 @@ export function ReviewStep() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Submit order via API
|
if (!checkoutSessionId) {
|
||||||
const response = await fetch("/api/orders", {
|
throw new Error("Checkout session expired. Please restart checkout from the shop.");
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
orderType: cartItem.orderType,
|
|
||||||
skus: [cartItem.planSku, ...cartItem.addonSkus],
|
|
||||||
configuration: cartItem.configuration,
|
|
||||||
}),
|
|
||||||
credentials: "include",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
throw new Error(errorData.error?.message || "Failed to submit order");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await ordersService.createOrderFromCheckoutSession(checkoutSessionId);
|
||||||
const orderId = result.data?.orderId ?? result.orderId;
|
|
||||||
|
|
||||||
// Clear checkout state
|
// Clear checkout state
|
||||||
clear();
|
clear();
|
||||||
|
|
||||||
// Redirect to confirmation
|
// Redirect to confirmation
|
||||||
router.push(`/order/complete${orderId ? `?orderId=${orderId}` : ""}`);
|
router.push(`/order/complete?orderId=${encodeURIComponent(result.sfOrderId)}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to submit order");
|
setError(err instanceof Error ? err.message : "Failed to submit order");
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
@ -106,9 +102,9 @@ export function ReviewStep() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{guestInfo?.firstName} {guestInfo?.lastName}
|
{guestInfo?.firstName || user?.firstname} {guestInfo?.lastName || user?.lastname}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground">{guestInfo?.email}</p>
|
<p className="text-sm text-muted-foreground">{guestInfo?.email || user?.email}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Address */}
|
{/* Address */}
|
||||||
|
|||||||
@ -1,229 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { useSearchParams, useRouter } from "next/navigation";
|
|
||||||
import { logger } from "@/lib/logger";
|
|
||||||
import { ordersService } from "@/features/orders/services/orders.service";
|
|
||||||
import { checkoutService } from "@/features/checkout/services/checkout.service";
|
|
||||||
import { ACTIVE_INTERNET_SUBSCRIPTION_WARNING } from "@/features/checkout/constants";
|
|
||||||
import { usePaymentMethods } from "@/features/billing/hooks/useBilling";
|
|
||||||
import { usePaymentRefresh } from "@/features/billing/hooks/usePaymentRefresh";
|
|
||||||
import {
|
|
||||||
createLoadingState,
|
|
||||||
createSuccessState,
|
|
||||||
createErrorState,
|
|
||||||
} from "@customer-portal/domain/toolkit";
|
|
||||||
import type { AsyncState } from "@customer-portal/domain/toolkit";
|
|
||||||
import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscriptions";
|
|
||||||
import {
|
|
||||||
ORDER_TYPE,
|
|
||||||
orderWithSkuValidationSchema,
|
|
||||||
prepareOrderFromCart,
|
|
||||||
type CheckoutCart,
|
|
||||||
} from "@customer-portal/domain/orders";
|
|
||||||
import { CheckoutParamsService } from "@/features/checkout/services/checkout-params.service";
|
|
||||||
import { useAuthSession, useAuthStore } from "@/features/auth/services/auth.store";
|
|
||||||
import { ZodError } from "zod";
|
|
||||||
|
|
||||||
// Use domain Address type
|
|
||||||
import type { Address } from "@customer-portal/domain/customer";
|
|
||||||
|
|
||||||
export function useCheckout() {
|
|
||||||
const params = useSearchParams();
|
|
||||||
const router = useRouter();
|
|
||||||
const { isAuthenticated } = useAuthSession();
|
|
||||||
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [addressConfirmed, setAddressConfirmed] = useState(false);
|
|
||||||
|
|
||||||
const [checkoutState, setCheckoutState] = useState<AsyncState<CheckoutCart>>({
|
|
||||||
status: "loading",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load active subscriptions to enforce business rules client-side before submission
|
|
||||||
const { data: activeSubs } = useActiveSubscriptions();
|
|
||||||
const hasActiveInternetSubscription = useMemo(() => {
|
|
||||||
if (!Array.isArray(activeSubs)) return false;
|
|
||||||
return activeSubs.some(
|
|
||||||
subscription =>
|
|
||||||
String(subscription.groupName || subscription.productName || "")
|
|
||||||
.toLowerCase()
|
|
||||||
.includes("internet") && String(subscription.status || "").toLowerCase() === "active"
|
|
||||||
);
|
|
||||||
}, [activeSubs]);
|
|
||||||
const [activeInternetWarning, setActiveInternetWarning] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: paymentMethods,
|
|
||||||
isLoading: paymentMethodsLoading,
|
|
||||||
error: paymentMethodsError,
|
|
||||||
refetch: refetchPaymentMethods,
|
|
||||||
} = usePaymentMethods();
|
|
||||||
|
|
||||||
const paymentRefresh = usePaymentRefresh({
|
|
||||||
refetch: refetchPaymentMethods,
|
|
||||||
attachFocusListeners: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const paramsKey = params.toString();
|
|
||||||
const checkoutSnapshot = CheckoutParamsService.buildSnapshot(new URLSearchParams(paramsKey));
|
|
||||||
const { orderType, warnings } = checkoutSnapshot;
|
|
||||||
|
|
||||||
const lastWarningSignature = useRef<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (warnings.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const signature = warnings.join("|");
|
|
||||||
if (signature === lastWarningSignature.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
lastWarningSignature.current = signature;
|
|
||||||
warnings.forEach(message => {
|
|
||||||
logger.warn("Checkout parameter warning", { message });
|
|
||||||
});
|
|
||||||
}, [warnings]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (orderType !== ORDER_TYPE.INTERNET || !hasActiveInternetSubscription) {
|
|
||||||
setActiveInternetWarning(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setActiveInternetWarning(ACTIVE_INTERNET_SUBSCRIPTION_WARNING);
|
|
||||||
}, [orderType, hasActiveInternetSubscription]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Wait for authentication before building cart
|
|
||||||
if (!isAuthenticated) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mounted = true;
|
|
||||||
|
|
||||||
void (async () => {
|
|
||||||
const snapshot = CheckoutParamsService.buildSnapshot(new URLSearchParams(paramsKey));
|
|
||||||
const {
|
|
||||||
orderType: snapshotOrderType,
|
|
||||||
selections,
|
|
||||||
configuration,
|
|
||||||
planReference: snapshotPlan,
|
|
||||||
} = snapshot;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setCheckoutState(createLoadingState());
|
|
||||||
|
|
||||||
if (!snapshotPlan) {
|
|
||||||
throw new Error("No plan selected. Please go back and select a plan.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build cart using BFF service
|
|
||||||
const cart = await checkoutService.buildCart(snapshotOrderType, selections, configuration);
|
|
||||||
|
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
setCheckoutState(createSuccessState(cart));
|
|
||||||
} catch (error) {
|
|
||||||
if (mounted) {
|
|
||||||
const reason = error instanceof Error ? error.message : "Failed to load checkout data";
|
|
||||||
setCheckoutState(createErrorState(new Error(reason)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
mounted = false;
|
|
||||||
};
|
|
||||||
}, [isAuthenticated, paramsKey]);
|
|
||||||
|
|
||||||
const handleSubmitOrder = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setSubmitting(true);
|
|
||||||
if (checkoutState.status !== "success") {
|
|
||||||
throw new Error("Checkout data not loaded");
|
|
||||||
}
|
|
||||||
|
|
||||||
const cart = checkoutState.data;
|
|
||||||
|
|
||||||
// Debug logging to check cart contents
|
|
||||||
console.log("[DEBUG] Cart data:", cart);
|
|
||||||
console.log("[DEBUG] Cart items:", cart.items);
|
|
||||||
|
|
||||||
// Validate cart before submission
|
|
||||||
await checkoutService.validateCart(cart);
|
|
||||||
|
|
||||||
// Use domain helper to prepare order data
|
|
||||||
// This encapsulates SKU extraction and payload formatting
|
|
||||||
const orderData = prepareOrderFromCart(cart, orderType);
|
|
||||||
|
|
||||||
console.log("[DEBUG] Extracted SKUs from cart:", orderData.skus);
|
|
||||||
|
|
||||||
const currentUserId = useAuthStore.getState().user?.id;
|
|
||||||
if (currentUserId) {
|
|
||||||
try {
|
|
||||||
orderWithSkuValidationSchema.parse({
|
|
||||||
...orderData,
|
|
||||||
userId: currentUserId,
|
|
||||||
});
|
|
||||||
} catch (validationError) {
|
|
||||||
if (validationError instanceof ZodError) {
|
|
||||||
const firstIssue = validationError.issues.at(0);
|
|
||||||
throw new Error(firstIssue?.message || "Order contains invalid data");
|
|
||||||
}
|
|
||||||
throw validationError;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await ordersService.createOrder(orderData);
|
|
||||||
router.push(`/orders/${response.sfOrderId}?status=success`);
|
|
||||||
} catch (error) {
|
|
||||||
let errorMessage = "Order submission failed";
|
|
||||||
if (error instanceof Error) errorMessage = error.message;
|
|
||||||
setCheckoutState(createErrorState(new Error(errorMessage)));
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
}, [checkoutState, orderType, router]);
|
|
||||||
|
|
||||||
const confirmAddress = useCallback((address?: Address) => {
|
|
||||||
setAddressConfirmed(true);
|
|
||||||
void address;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const markAddressIncomplete = useCallback(() => {
|
|
||||||
setAddressConfirmed(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const navigateBackToConfigure = useCallback(() => {
|
|
||||||
// State is already persisted in Zustand store
|
|
||||||
// Just need to restore params and navigate
|
|
||||||
const urlParams = new URLSearchParams(paramsKey);
|
|
||||||
urlParams.delete("type"); // Remove type param as it's not needed
|
|
||||||
|
|
||||||
const configureUrl =
|
|
||||||
orderType === ORDER_TYPE.INTERNET
|
|
||||||
? `/shop/internet/configure?${urlParams.toString()}`
|
|
||||||
: `/shop/sim/configure?${urlParams.toString()}`;
|
|
||||||
|
|
||||||
router.push(configureUrl);
|
|
||||||
}, [orderType, paramsKey, router]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
checkoutState,
|
|
||||||
submitting,
|
|
||||||
orderType,
|
|
||||||
addressConfirmed,
|
|
||||||
paymentMethods,
|
|
||||||
paymentMethodsLoading,
|
|
||||||
paymentMethodsError,
|
|
||||||
paymentRefresh,
|
|
||||||
confirmAddress,
|
|
||||||
markAddressIncomplete,
|
|
||||||
handleSubmitOrder,
|
|
||||||
navigateBackToConfigure,
|
|
||||||
activeInternetWarning,
|
|
||||||
} as const;
|
|
||||||
}
|
|
||||||
@ -1,110 +0,0 @@
|
|||||||
/**
|
|
||||||
* Checkout API Service
|
|
||||||
*
|
|
||||||
* Handles API calls for checkout flow.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { CartItem } from "@customer-portal/domain/checkout";
|
|
||||||
import type { AddressFormData } from "@customer-portal/domain/customer";
|
|
||||||
|
|
||||||
interface RegisterForCheckoutParams {
|
|
||||||
guestInfo: {
|
|
||||||
email: string;
|
|
||||||
firstName: string;
|
|
||||||
lastName: string;
|
|
||||||
phone: string;
|
|
||||||
phoneCountryCode: string;
|
|
||||||
password: string;
|
|
||||||
};
|
|
||||||
address: AddressFormData;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CheckoutRegisterResult {
|
|
||||||
success: boolean;
|
|
||||||
user: {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
firstname: string;
|
|
||||||
lastname: string;
|
|
||||||
};
|
|
||||||
session: {
|
|
||||||
expiresAt: string;
|
|
||||||
refreshExpiresAt: string;
|
|
||||||
};
|
|
||||||
sfAccountNumber?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const checkoutApiService = {
|
|
||||||
/**
|
|
||||||
* Register a new user during checkout
|
|
||||||
*/
|
|
||||||
async registerForCheckout(params: RegisterForCheckoutParams): Promise<CheckoutRegisterResult> {
|
|
||||||
const response = await fetch("/api/checkout/register", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
email: params.guestInfo.email,
|
|
||||||
firstName: params.guestInfo.firstName,
|
|
||||||
lastName: params.guestInfo.lastName,
|
|
||||||
phone: params.guestInfo.phone,
|
|
||||||
phoneCountryCode: params.guestInfo.phoneCountryCode,
|
|
||||||
password: params.guestInfo.password,
|
|
||||||
address: params.address,
|
|
||||||
acceptTerms: true,
|
|
||||||
}),
|
|
||||||
credentials: "include",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
throw new Error(errorData.error?.message || errorData.message || "Registration failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if current user has a valid payment method
|
|
||||||
*/
|
|
||||||
async getPaymentStatus(): Promise<{ hasPaymentMethod: boolean }> {
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/checkout/payment-status", {
|
|
||||||
credentials: "include",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
return { hasPaymentMethod: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
} catch {
|
|
||||||
return { hasPaymentMethod: false };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Submit order
|
|
||||||
*/
|
|
||||||
async submitOrder(cartItem: CartItem): Promise<{ orderId?: string }> {
|
|
||||||
const response = await fetch("/api/orders", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
orderType: cartItem.orderType,
|
|
||||||
skus: [cartItem.planSku, ...cartItem.addonSkus],
|
|
||||||
configuration: cartItem.configuration,
|
|
||||||
}),
|
|
||||||
credentials: "include",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
throw new Error(errorData.error?.message || "Failed to submit order");
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
return {
|
|
||||||
orderId: result.data?.orderId ?? result.orderId,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -7,6 +7,15 @@ import type {
|
|||||||
} from "@customer-portal/domain/orders";
|
} from "@customer-portal/domain/orders";
|
||||||
import type { ApiSuccessResponse } from "@customer-portal/domain/common";
|
import type { ApiSuccessResponse } from "@customer-portal/domain/common";
|
||||||
|
|
||||||
|
type CheckoutCartSummary = { items: CheckoutCart["items"]; totals: CheckoutCart["totals"] };
|
||||||
|
|
||||||
|
type CheckoutSessionResponse = {
|
||||||
|
sessionId: string;
|
||||||
|
expiresAt: string;
|
||||||
|
orderType: OrderTypeValue;
|
||||||
|
cart: CheckoutCartSummary;
|
||||||
|
};
|
||||||
|
|
||||||
export const checkoutService = {
|
export const checkoutService = {
|
||||||
/**
|
/**
|
||||||
* Build checkout cart from order type and selections
|
* Build checkout cart from order type and selections
|
||||||
@ -31,6 +40,40 @@ export const checkoutService = {
|
|||||||
return wrappedResponse.data;
|
return wrappedResponse.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async createSession(
|
||||||
|
orderType: OrderTypeValue,
|
||||||
|
selections: OrderSelections,
|
||||||
|
configuration?: OrderConfigurations
|
||||||
|
): Promise<CheckoutSessionResponse> {
|
||||||
|
const response = await apiClient.POST<ApiSuccessResponse<CheckoutSessionResponse>>(
|
||||||
|
"/api/checkout/session",
|
||||||
|
{
|
||||||
|
body: { orderType, selections, configuration },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const wrappedResponse = getDataOrThrow(response, "Failed to create checkout session");
|
||||||
|
if (!wrappedResponse.success) {
|
||||||
|
throw new Error("Failed to create checkout session");
|
||||||
|
}
|
||||||
|
return wrappedResponse.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getSession(sessionId: string): Promise<CheckoutSessionResponse> {
|
||||||
|
const response = await apiClient.GET<ApiSuccessResponse<CheckoutSessionResponse>>(
|
||||||
|
"/api/checkout/session/{sessionId}",
|
||||||
|
{
|
||||||
|
params: { path: { sessionId } },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const wrappedResponse = getDataOrThrow(response, "Failed to load checkout session");
|
||||||
|
if (!wrappedResponse.success) {
|
||||||
|
throw new Error("Failed to load checkout session");
|
||||||
|
}
|
||||||
|
return wrappedResponse.data;
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate checkout cart
|
* Validate checkout cart
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -13,6 +13,9 @@ import type { AddressFormData } from "@customer-portal/domain/customer";
|
|||||||
interface CheckoutState {
|
interface CheckoutState {
|
||||||
// Cart data
|
// Cart data
|
||||||
cartItem: CartItem | null;
|
cartItem: CartItem | null;
|
||||||
|
cartParamsSignature: string | null;
|
||||||
|
checkoutSessionId: string | null;
|
||||||
|
checkoutSessionExpiresAt: string | null;
|
||||||
|
|
||||||
// Guest info (pre-registration)
|
// Guest info (pre-registration)
|
||||||
guestInfo: Partial<GuestInfo> | null;
|
guestInfo: Partial<GuestInfo> | null;
|
||||||
@ -37,6 +40,8 @@ interface CheckoutState {
|
|||||||
interface CheckoutActions {
|
interface CheckoutActions {
|
||||||
// Cart actions
|
// Cart actions
|
||||||
setCartItem: (item: CartItem) => void;
|
setCartItem: (item: CartItem) => void;
|
||||||
|
setCartItemFromParams: (item: CartItem, signature: string) => void;
|
||||||
|
setCheckoutSession: (session: { id: string; expiresAt: string }) => void;
|
||||||
clearCart: () => void;
|
clearCart: () => void;
|
||||||
|
|
||||||
// Guest info actions
|
// Guest info actions
|
||||||
@ -71,6 +76,9 @@ const STEP_ORDER: CheckoutStep[] = ["account", "address", "payment", "review"];
|
|||||||
|
|
||||||
const initialState: CheckoutState = {
|
const initialState: CheckoutState = {
|
||||||
cartItem: null,
|
cartItem: null,
|
||||||
|
cartParamsSignature: null,
|
||||||
|
checkoutSessionId: null,
|
||||||
|
checkoutSessionExpiresAt: null,
|
||||||
guestInfo: null,
|
guestInfo: null,
|
||||||
address: null,
|
address: null,
|
||||||
registrationComplete: false,
|
registrationComplete: false,
|
||||||
@ -92,9 +100,43 @@ export const useCheckoutStore = create<CheckoutStore>()(
|
|||||||
cartUpdatedAt: Date.now(),
|
cartUpdatedAt: Date.now(),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
setCartItemFromParams: (item: CartItem, signature: string) => {
|
||||||
|
const { cartParamsSignature, cartItem } = get();
|
||||||
|
const signatureChanged = cartParamsSignature !== signature;
|
||||||
|
const hasExistingCart = cartItem !== null || cartParamsSignature !== null;
|
||||||
|
|
||||||
|
if (signatureChanged && hasExistingCart) {
|
||||||
|
set(initialState);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!signatureChanged && cartItem) {
|
||||||
|
// Allow refreshing cart totals without resetting progress
|
||||||
|
set({
|
||||||
|
cartItem: item,
|
||||||
|
cartUpdatedAt: Date.now(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
set({
|
||||||
|
cartItem: item,
|
||||||
|
cartParamsSignature: signature,
|
||||||
|
cartUpdatedAt: Date.now(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setCheckoutSession: session =>
|
||||||
|
set({
|
||||||
|
checkoutSessionId: session.id,
|
||||||
|
checkoutSessionExpiresAt: session.expiresAt,
|
||||||
|
}),
|
||||||
|
|
||||||
clearCart: () =>
|
clearCart: () =>
|
||||||
set({
|
set({
|
||||||
cartItem: null,
|
cartItem: null,
|
||||||
|
cartParamsSignature: null,
|
||||||
|
checkoutSessionId: null,
|
||||||
|
checkoutSessionExpiresAt: null,
|
||||||
cartUpdatedAt: null,
|
cartUpdatedAt: null,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@ -171,7 +213,16 @@ export const useCheckoutStore = create<CheckoutStore>()(
|
|||||||
storage: createJSONStorage(() => localStorage),
|
storage: createJSONStorage(() => localStorage),
|
||||||
partialize: state => ({
|
partialize: state => ({
|
||||||
// Persist only essential data
|
// Persist only essential data
|
||||||
cartItem: state.cartItem,
|
cartItem: state.cartItem
|
||||||
|
? {
|
||||||
|
...state.cartItem,
|
||||||
|
// Avoid persisting potentially sensitive configuration details.
|
||||||
|
configuration: {},
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
cartParamsSignature: state.cartParamsSignature,
|
||||||
|
checkoutSessionId: state.checkoutSessionId,
|
||||||
|
checkoutSessionExpiresAt: state.checkoutSessionExpiresAt,
|
||||||
guestInfo: state.guestInfo,
|
guestInfo: state.guestInfo,
|
||||||
address: state.address,
|
address: state.address,
|
||||||
currentStep: state.currentStep,
|
currentStep: state.currentStep,
|
||||||
|
|||||||
@ -1,369 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import { useCheckout } from "@/features/checkout/hooks/useCheckout";
|
|
||||||
import { PageLayout } from "@/components/templates/PageLayout";
|
|
||||||
import { SubCard } from "@/components/molecules/SubCard/SubCard";
|
|
||||||
import { Button } from "@/components/atoms/button";
|
|
||||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
|
||||||
import { PageAsync } from "@/components/molecules/AsyncBlock/AsyncBlock";
|
|
||||||
import { InlineToast } from "@/components/atoms/inline-toast";
|
|
||||||
import { StatusPill } from "@/components/atoms/status-pill";
|
|
||||||
import { AddressConfirmation } from "@/features/catalog/components/base/AddressConfirmation";
|
|
||||||
import { isLoading, isError, isSuccess } from "@customer-portal/domain/toolkit";
|
|
||||||
import { ShieldCheckIcon, CreditCardIcon } from "@heroicons/react/24/outline";
|
|
||||||
import type { PaymentMethod } from "@customer-portal/domain/payments";
|
|
||||||
|
|
||||||
export function CheckoutContainer() {
|
|
||||||
const {
|
|
||||||
checkoutState,
|
|
||||||
submitting,
|
|
||||||
orderType,
|
|
||||||
addressConfirmed,
|
|
||||||
paymentMethods,
|
|
||||||
paymentMethodsLoading,
|
|
||||||
paymentMethodsError,
|
|
||||||
paymentRefresh,
|
|
||||||
confirmAddress,
|
|
||||||
markAddressIncomplete,
|
|
||||||
handleSubmitOrder,
|
|
||||||
navigateBackToConfigure,
|
|
||||||
activeInternetWarning,
|
|
||||||
} = useCheckout();
|
|
||||||
|
|
||||||
if (isLoading(checkoutState)) {
|
|
||||||
return (
|
|
||||||
<PageLayout
|
|
||||||
title="Submit Order"
|
|
||||||
description="Loading order details"
|
|
||||||
icon={<ShieldCheckIcon className="h-6 w-6" />}
|
|
||||||
>
|
|
||||||
<PageAsync isLoading loadingText="Loading order submission...">
|
|
||||||
<></>
|
|
||||||
</PageAsync>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isError(checkoutState)) {
|
|
||||||
return (
|
|
||||||
<PageLayout
|
|
||||||
title="Submit Order"
|
|
||||||
description="Error loading order submission"
|
|
||||||
icon={<ShieldCheckIcon className="h-6 w-6" />}
|
|
||||||
>
|
|
||||||
<div className="py-6">
|
|
||||||
<AlertBanner variant="error" title="Unable to load checkout" elevated>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span>{checkoutState.error.message}</span>
|
|
||||||
<Button variant="link" onClick={navigateBackToConfigure}>
|
|
||||||
Go Back
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</AlertBanner>
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isSuccess(checkoutState)) {
|
|
||||||
return (
|
|
||||||
<PageLayout
|
|
||||||
title="Submit Order"
|
|
||||||
description="Checkout data not available"
|
|
||||||
icon={<ShieldCheckIcon className="h-6 w-6" />}
|
|
||||||
>
|
|
||||||
<div className="py-6">
|
|
||||||
<AlertBanner variant="error" title="Checkout Error" elevated>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span>Checkout data is not available</span>
|
|
||||||
<Button variant="link" onClick={navigateBackToConfigure}>
|
|
||||||
Go Back
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</AlertBanner>
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { items, totals } = checkoutState.data;
|
|
||||||
const paymentMethodList = paymentMethods?.paymentMethods ?? [];
|
|
||||||
const defaultPaymentMethod =
|
|
||||||
paymentMethodList.find(method => method.isDefault) ?? paymentMethodList[0] ?? null;
|
|
||||||
const paymentMethodDisplay = defaultPaymentMethod
|
|
||||||
? buildPaymentMethodDisplay(defaultPaymentMethod)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageLayout
|
|
||||||
title="Checkout"
|
|
||||||
description="Verify your address, review totals, and submit your order"
|
|
||||||
icon={<ShieldCheckIcon className="h-6 w-6" />}
|
|
||||||
>
|
|
||||||
<div className="max-w-2xl mx-auto space-y-8">
|
|
||||||
<InlineToast
|
|
||||||
visible={paymentRefresh.toast.visible}
|
|
||||||
text={paymentRefresh.toast.text}
|
|
||||||
tone={paymentRefresh.toast.tone}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{activeInternetWarning && (
|
|
||||||
<AlertBanner variant="warning" title="Existing Internet Subscription" elevated>
|
|
||||||
<span className="text-sm text-foreground/80">{activeInternetWarning}</span>
|
|
||||||
</AlertBanner>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="bg-card border border-border rounded-2xl p-6 md:p-7 shadow-[var(--cp-shadow-1)]">
|
|
||||||
<div className="flex items-center gap-3 mb-6">
|
|
||||||
<ShieldCheckIcon className="w-6 h-6 text-primary" />
|
|
||||||
<h2 className="text-lg font-semibold text-foreground">Confirm Details</h2>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-5">
|
|
||||||
<SubCard>
|
|
||||||
<AddressConfirmation
|
|
||||||
embedded
|
|
||||||
onAddressConfirmed={confirmAddress}
|
|
||||||
onAddressIncomplete={markAddressIncomplete}
|
|
||||||
orderType={orderType}
|
|
||||||
/>
|
|
||||||
</SubCard>
|
|
||||||
|
|
||||||
<SubCard
|
|
||||||
title="Billing & Payment"
|
|
||||||
icon={<CreditCardIcon className="w-5 h-5 text-primary" />}
|
|
||||||
right={
|
|
||||||
paymentMethods && paymentMethods.paymentMethods.length > 0 ? (
|
|
||||||
<StatusPill label="Verified" variant="success" />
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{paymentMethodsLoading ? (
|
|
||||||
<div className="text-sm text-muted-foreground">Checking payment methods...</div>
|
|
||||||
) : paymentMethodsError ? (
|
|
||||||
<AlertBanner
|
|
||||||
variant="warning"
|
|
||||||
title="Unable to verify payment methods"
|
|
||||||
size="sm"
|
|
||||||
elevated
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => void paymentRefresh.triggerRefresh()}
|
|
||||||
>
|
|
||||||
Check Again
|
|
||||||
</Button>
|
|
||||||
<Button as="a" href="/billing/payments" size="sm">
|
|
||||||
Add Payment Method
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</AlertBanner>
|
|
||||||
) : paymentMethodList.length > 0 ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{paymentMethodDisplay ? (
|
|
||||||
<div className="rounded-xl border border-border bg-card p-4 shadow-[var(--cp-shadow-1)] transition-shadow duration-200 hover:shadow-[var(--cp-shadow-2)]">
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-primary">
|
|
||||||
Default payment method
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-sm font-semibold text-foreground">
|
|
||||||
{paymentMethodDisplay.title}
|
|
||||||
</p>
|
|
||||||
{paymentMethodDisplay.subtitle ? (
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
{paymentMethodDisplay.subtitle}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
as="a"
|
|
||||||
href="/billing/payments"
|
|
||||||
variant="link"
|
|
||||||
size="sm"
|
|
||||||
className="self-start whitespace-nowrap"
|
|
||||||
>
|
|
||||||
Manage billing & payments
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
We securely charge your saved payment method after the order is approved. Need
|
|
||||||
to make changes? Visit Billing & Payments.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<AlertBanner variant="error" title="No payment method on file" size="sm" elevated>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => void paymentRefresh.triggerRefresh()}
|
|
||||||
>
|
|
||||||
Check Again
|
|
||||||
</Button>
|
|
||||||
<Button as="a" href="/billing/payments" size="sm">
|
|
||||||
Add Payment Method
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</AlertBanner>
|
|
||||||
)}
|
|
||||||
</SubCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-card border border-border rounded-2xl p-6 md:p-7 text-center shadow-[var(--cp-shadow-1)]">
|
|
||||||
<div className="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center mx-auto mb-4 shadow-sm border border-primary/20">
|
|
||||||
<ShieldCheckIcon className="w-8 h-8 text-primary" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-bold text-foreground mb-2">Review & Submit</h2>
|
|
||||||
<p className="text-muted-foreground mb-4 max-w-xl mx-auto">
|
|
||||||
You’re almost done. Confirm your details above, then submit your order. We’ll review and
|
|
||||||
notify you when everything is ready.
|
|
||||||
</p>
|
|
||||||
<div className="bg-muted/50 rounded-lg p-4 border border-border text-left max-w-2xl mx-auto">
|
|
||||||
<h3 className="font-semibold text-foreground mb-2">What to expect</h3>
|
|
||||||
<div className="text-sm text-muted-foreground space-y-1">
|
|
||||||
<p>• Our team reviews your order and schedules setup if needed</p>
|
|
||||||
<p>• We may contact you to confirm details or availability</p>
|
|
||||||
<p>• We only charge your card after the order is approved</p>
|
|
||||||
<p>• You’ll receive confirmation and next steps by email</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 bg-card rounded-lg p-4 border border-border max-w-2xl mx-auto shadow-[var(--cp-shadow-1)]">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="font-medium text-muted-foreground">Estimated Total</span>
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="text-xl font-bold text-foreground">
|
|
||||||
¥{totals.monthlyTotal.toLocaleString()}/mo
|
|
||||||
</div>
|
|
||||||
{totals.oneTimeTotal > 0 && (
|
|
||||||
<div className="text-sm text-warning font-medium">
|
|
||||||
+ ¥{totals.oneTimeTotal.toLocaleString()} one-time
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
className="flex-1 py-4 text-muted-foreground hover:text-foreground"
|
|
||||||
onClick={navigateBackToConfigure}
|
|
||||||
>
|
|
||||||
← Back to Configuration
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="flex-1 py-4 text-lg"
|
|
||||||
onClick={() => {
|
|
||||||
void handleSubmitOrder();
|
|
||||||
}}
|
|
||||||
disabled={
|
|
||||||
submitting ||
|
|
||||||
items.length === 0 ||
|
|
||||||
!addressConfirmed ||
|
|
||||||
paymentMethodsLoading ||
|
|
||||||
!paymentMethods ||
|
|
||||||
paymentMethods.paymentMethods.length === 0
|
|
||||||
}
|
|
||||||
isLoading={submitting}
|
|
||||||
loadingText="Submitting…"
|
|
||||||
>
|
|
||||||
{!addressConfirmed
|
|
||||||
? "Confirm Installation Address"
|
|
||||||
: paymentMethodsLoading
|
|
||||||
? "Verifying Payment Method…"
|
|
||||||
: !paymentMethods || paymentMethods.paymentMethods.length === 0
|
|
||||||
? "Add Payment Method to Continue"
|
|
||||||
: "Submit Order"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPaymentMethodDisplay(method: PaymentMethod): { title: string; subtitle?: string } {
|
|
||||||
const descriptor =
|
|
||||||
method.cardType?.trim() ||
|
|
||||||
method.bankName?.trim() ||
|
|
||||||
method.description?.trim() ||
|
|
||||||
method.gatewayName?.trim() ||
|
|
||||||
"Saved payment method";
|
|
||||||
|
|
||||||
const trimmedLastFour =
|
|
||||||
typeof method.cardLastFour === "string" && method.cardLastFour.trim().length > 0
|
|
||||||
? method.cardLastFour.trim().slice(-4)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const headline =
|
|
||||||
trimmedLastFour && method.type?.toLowerCase().includes("card")
|
|
||||||
? `${descriptor} · •••• ${trimmedLastFour}`
|
|
||||||
: descriptor;
|
|
||||||
|
|
||||||
const details = new Set<string>();
|
|
||||||
|
|
||||||
if (method.bankName && !headline.toLowerCase().includes(method.bankName.trim().toLowerCase())) {
|
|
||||||
details.add(method.bankName.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
const expiry = normalizeExpiryLabel(method.expiryDate);
|
|
||||||
if (expiry) {
|
|
||||||
details.add(`Exp ${expiry}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!trimmedLastFour && method.cardLastFour && method.cardLastFour.trim().length > 0) {
|
|
||||||
details.add(`Ends ${method.cardLastFour.trim().slice(-4)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (method.type?.toLowerCase().includes("bank") && method.description?.trim()) {
|
|
||||||
details.add(method.description.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
const subtitle = details.size > 0 ? Array.from(details).join(" · ") : undefined;
|
|
||||||
return { title: headline, subtitle };
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeExpiryLabel(expiry?: string | null): string | null {
|
|
||||||
if (!expiry) return null;
|
|
||||||
const value = expiry.trim();
|
|
||||||
if (!value) return null;
|
|
||||||
|
|
||||||
if (/^\d{4}-\d{2}$/.test(value)) {
|
|
||||||
const [year, month] = value.split("-");
|
|
||||||
return `${month}/${year.slice(-2)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (/^\d{2}\/\d{4}$/.test(value)) {
|
|
||||||
const [month, year] = value.split("/");
|
|
||||||
return `${month}/${year.slice(-2)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (/^\d{2}\/\d{2}$/.test(value)) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
const digits = value.replace(/\D/g, "");
|
|
||||||
|
|
||||||
if (digits.length === 6) {
|
|
||||||
const year = digits.slice(2, 4);
|
|
||||||
const month = digits.slice(4, 6);
|
|
||||||
return `${month}/${year}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (digits.length === 4) {
|
|
||||||
const month = digits.slice(0, 2);
|
|
||||||
const year = digits.slice(2, 4);
|
|
||||||
return `${month}/${year}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default CheckoutContainer;
|
|
||||||
@ -135,7 +135,7 @@ export function QuickStats({
|
|||||||
icon={ServerIcon}
|
icon={ServerIcon}
|
||||||
label="Active Services"
|
label="Active Services"
|
||||||
value={activeSubscriptions}
|
value={activeSubscriptions}
|
||||||
href="/subscriptions"
|
href="/account/services"
|
||||||
tone="primary"
|
tone="primary"
|
||||||
emptyText="No active services"
|
emptyText="No active services"
|
||||||
/>
|
/>
|
||||||
@ -143,7 +143,7 @@ export function QuickStats({
|
|||||||
icon={ChatBubbleLeftRightIcon}
|
icon={ChatBubbleLeftRightIcon}
|
||||||
label="Open Support Cases"
|
label="Open Support Cases"
|
||||||
value={openCases}
|
value={openCases}
|
||||||
href="/support/cases"
|
href="/account/support"
|
||||||
tone={openCases > 0 ? "warning" : "info"}
|
tone={openCases > 0 ? "warning" : "info"}
|
||||||
emptyText="No open cases"
|
emptyText="No open cases"
|
||||||
/>
|
/>
|
||||||
@ -152,7 +152,7 @@ export function QuickStats({
|
|||||||
icon={ClipboardDocumentListIcon}
|
icon={ClipboardDocumentListIcon}
|
||||||
label="Recent Orders"
|
label="Recent Orders"
|
||||||
value={recentOrders}
|
value={recentOrders}
|
||||||
href="/orders"
|
href="/account/orders"
|
||||||
tone="success"
|
tone="success"
|
||||||
emptyText="No recent orders"
|
emptyText="No recent orders"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -52,18 +52,18 @@ function AllCaughtUp() {
|
|||||||
{/* Quick action cards */}
|
{/* Quick action cards */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mt-8 max-w-lg mx-auto">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mt-8 max-w-lg mx-auto">
|
||||||
<Link
|
<Link
|
||||||
href="/catalog"
|
href="/shop"
|
||||||
className="group flex flex-col items-center gap-2 p-4 rounded-xl bg-surface/80 backdrop-blur-sm border border-border/60 hover:border-primary/40 hover:shadow-lg transition-all"
|
className="group flex flex-col items-center gap-2 p-4 rounded-xl bg-surface/80 backdrop-blur-sm border border-border/60 hover:border-primary/40 hover:shadow-lg transition-all"
|
||||||
>
|
>
|
||||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center group-hover:bg-primary/20 transition-colors">
|
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center group-hover:bg-primary/20 transition-colors">
|
||||||
<Squares2X2Icon className="h-5 w-5 text-primary" />
|
<Squares2X2Icon className="h-5 w-5 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium text-foreground">Browse Catalog</span>
|
<span className="text-sm font-medium text-foreground">Browse Services</span>
|
||||||
<ArrowRightIcon className="h-4 w-4 text-muted-foreground group-hover:text-primary group-hover:translate-x-0.5 transition-all" />
|
<ArrowRightIcon className="h-4 w-4 text-muted-foreground group-hover:text-primary group-hover:translate-x-0.5 transition-all" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="/billing/invoices"
|
href="/account/billing/invoices"
|
||||||
className="group flex flex-col items-center gap-2 p-4 rounded-xl bg-surface/80 backdrop-blur-sm border border-border/60 hover:border-info/40 hover:shadow-lg transition-all"
|
className="group flex flex-col items-center gap-2 p-4 rounded-xl bg-surface/80 backdrop-blur-sm border border-border/60 hover:border-info/40 hover:shadow-lg transition-all"
|
||||||
>
|
>
|
||||||
<div className="w-10 h-10 rounded-lg bg-info/10 flex items-center justify-center group-hover:bg-info/20 transition-colors">
|
<div className="w-10 h-10 rounded-lg bg-info/10 flex items-center justify-center group-hover:bg-info/20 transition-colors">
|
||||||
@ -74,7 +74,7 @@ function AllCaughtUp() {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="/support"
|
href="/account/support"
|
||||||
className="group flex flex-col items-center gap-2 p-4 rounded-xl bg-surface/80 backdrop-blur-sm border border-border/60 hover:border-warning/40 hover:shadow-lg transition-all"
|
className="group flex flex-col items-center gap-2 p-4 rounded-xl bg-surface/80 backdrop-blur-sm border border-border/60 hover:border-warning/40 hover:shadow-lg transition-all"
|
||||||
>
|
>
|
||||||
<div className="w-10 h-10 rounded-lg bg-warning/10 flex items-center justify-center group-hover:bg-warning/20 transition-colors">
|
<div className="w-10 h-10 rounded-lg bg-warning/10 flex items-center justify-center group-hover:bg-warning/20 transition-colors">
|
||||||
|
|||||||
@ -82,7 +82,7 @@ function computeTasks({
|
|||||||
title: isOverdue ? "Pay overdue invoice" : "Pay upcoming invoice",
|
title: isOverdue ? "Pay overdue invoice" : "Pay upcoming invoice",
|
||||||
description: `Invoice #${summary.nextInvoice.id} · ${formatCurrency(summary.nextInvoice.amount, { currency: summary.nextInvoice.currency })} · ${dueText}`,
|
description: `Invoice #${summary.nextInvoice.id} · ${formatCurrency(summary.nextInvoice.amount, { currency: summary.nextInvoice.currency })} · ${dueText}`,
|
||||||
actionLabel: "Pay now",
|
actionLabel: "Pay now",
|
||||||
detailHref: `/billing/invoices/${summary.nextInvoice.id}`,
|
detailHref: `/account/billing/invoices/${summary.nextInvoice.id}`,
|
||||||
requiresSsoAction: true,
|
requiresSsoAction: true,
|
||||||
tone: "critical",
|
tone: "critical",
|
||||||
icon: ExclamationCircleIcon,
|
icon: ExclamationCircleIcon,
|
||||||
@ -103,7 +103,7 @@ function computeTasks({
|
|||||||
title: "Add a payment method",
|
title: "Add a payment method",
|
||||||
description: "Required to place orders and process invoices",
|
description: "Required to place orders and process invoices",
|
||||||
actionLabel: "Add method",
|
actionLabel: "Add method",
|
||||||
detailHref: "/billing/payments",
|
detailHref: "/account/billing/payments",
|
||||||
requiresSsoAction: true,
|
requiresSsoAction: true,
|
||||||
tone: "warning",
|
tone: "warning",
|
||||||
icon: CreditCardIcon,
|
icon: CreditCardIcon,
|
||||||
@ -135,7 +135,7 @@ function computeTasks({
|
|||||||
title: "Order in progress",
|
title: "Order in progress",
|
||||||
description: `${order.orderType || "Your"} order is ${statusText}`,
|
description: `${order.orderType || "Your"} order is ${statusText}`,
|
||||||
actionLabel: "View details",
|
actionLabel: "View details",
|
||||||
detailHref: `/orders/${order.id}`,
|
detailHref: `/account/orders/${order.id}`,
|
||||||
tone: "info",
|
tone: "info",
|
||||||
icon: ClockIcon,
|
icon: ClockIcon,
|
||||||
metadata: { orderId: order.id },
|
metadata: { orderId: order.id },
|
||||||
@ -151,8 +151,8 @@ function computeTasks({
|
|||||||
type: "onboarding",
|
type: "onboarding",
|
||||||
title: "Start your first service",
|
title: "Start your first service",
|
||||||
description: "Browse our catalog and subscribe to internet, SIM, or VPN",
|
description: "Browse our catalog and subscribe to internet, SIM, or VPN",
|
||||||
actionLabel: "Browse catalog",
|
actionLabel: "Browse services",
|
||||||
detailHref: "/catalog",
|
detailHref: "/shop",
|
||||||
tone: "neutral",
|
tone: "neutral",
|
||||||
icon: SparklesIcon,
|
icon: SparklesIcon,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -38,12 +38,12 @@ export function getActivityNavigationPath(activity: Activity): string | null {
|
|||||||
switch (activity.type) {
|
switch (activity.type) {
|
||||||
case "invoice_created":
|
case "invoice_created":
|
||||||
case "invoice_paid":
|
case "invoice_paid":
|
||||||
return `/billing/invoices/${activity.relatedId}`;
|
return `/account/billing/invoices/${activity.relatedId}`;
|
||||||
case "service_activated":
|
case "service_activated":
|
||||||
return `/subscriptions/${activity.relatedId}`;
|
return `/account/services/${activity.relatedId}`;
|
||||||
case "case_created":
|
case "case_created":
|
||||||
case "case_closed":
|
case "case_closed":
|
||||||
return `/support/cases/${activity.relatedId}`;
|
return `/account/support/${activity.relatedId}`;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,7 +27,7 @@ export function PublicLandingView() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold tracking-tight text-foreground">
|
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold tracking-tight text-foreground">
|
||||||
Customer Portal
|
Account Portal
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg sm:text-xl text-muted-foreground max-w-2xl mx-auto leading-relaxed">
|
<p className="text-lg sm:text-xl text-muted-foreground max-w-2xl mx-auto leading-relaxed">
|
||||||
Manage your services, billing, and support in one place.
|
Manage your services, billing, and support in one place.
|
||||||
@ -54,7 +54,7 @@ export function PublicLandingView() {
|
|||||||
href="/shop"
|
href="/shop"
|
||||||
className="inline-flex items-center justify-center gap-2 rounded-xl px-8 py-4 text-base font-semibold bg-primary text-primary-foreground hover:bg-primary-hover shadow-lg shadow-primary/30 hover:shadow-xl hover:shadow-primary/40 transition-all whitespace-nowrap"
|
className="inline-flex items-center justify-center gap-2 rounded-xl px-8 py-4 text-base font-semibold bg-primary text-primary-foreground hover:bg-primary-hover shadow-lg shadow-primary/30 hover:shadow-xl hover:shadow-primary/40 transition-all whitespace-nowrap"
|
||||||
>
|
>
|
||||||
View Catalog
|
Shop Services
|
||||||
<ArrowRightIcon className="h-5 w-5" />
|
<ArrowRightIcon className="h-5 w-5" />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user