From 53676785575b3275a25678d334597dcf9129fe41 Mon Sep 17 00:00:00 2001 From: barsa Date: Wed, 17 Dec 2025 11:36:17 +0900 Subject: [PATCH 01/35] Refactor component styles and update color tokens for enhanced consistency - Updated various components to utilize the latest color tokens, improving visual coherence across the application. - Enhanced error and status messaging styles for better user feedback and clarity. - Streamlined layout components to align with the new design tokens, ensuring a unified look and feel. - Improved overall styling consistency by adopting standardized color usage in key components. --- docs/architecture/PUBLIC-CATALOG-TASKS.md | 419 +++++ .../PUBLIC-CATALOG-UNIFIED-CHECKOUT.md | 1421 +++++++++++++++++ 2 files changed, 1840 insertions(+) create mode 100644 docs/architecture/PUBLIC-CATALOG-TASKS.md create mode 100644 docs/architecture/PUBLIC-CATALOG-UNIFIED-CHECKOUT.md diff --git a/docs/architecture/PUBLIC-CATALOG-TASKS.md b/docs/architecture/PUBLIC-CATALOG-TASKS.md new file mode 100644 index 00000000..55cdb475 --- /dev/null +++ b/docs/architecture/PUBLIC-CATALOG-TASKS.md @@ -0,0 +1,419 @@ +# Public Catalog & Unified Checkout - Task Checklist + +> **Related**: See [PUBLIC-CATALOG-UNIFIED-CHECKOUT.md](./PUBLIC-CATALOG-UNIFIED-CHECKOUT.md) for full design document. + +## Quick Reference + +- **Total Effort**: ~7 weeks +- **Priority**: P0 (Critical), P1 (Important), P2 (Nice to have) +- **Status Legend**: ⬜ Todo, 🟡 In Progress, ✅ Done, ❌ Blocked +- **Branch**: `Homepage` + +### Current Progress + +``` +✅ Phase 0 (SF Permissions) → Phase 1 → Phase 2 → Phase 3 → Phase 4 → Phase 5 → Launch + ↑ + START HERE +``` + +### Critical Path + +Phase 4 (Salesforce Account Creation) remains the most complex phase, but **SF permissions are now verified** ✅ + +--- + +## Phase 0: Preparation (4 days) + +| # | Task | Priority | Effort | Status | Notes | +| --- | ---------------------------------------- | -------- | ------ | ------ | --------------------------- | +| 0.1 | Create feature branch `feature/homepage` | P0 | 1h | ✅ | Done - on Homepage branch | +| 0.2 | Document current catalog user flow | P0 | 2h | ⬜ | Screenshots + notes | +| 0.3 | Document current checkout user flow | P0 | 2h | ⬜ | Screenshots + notes | +| 0.4 | Set up feature flags infrastructure | P0 | 4h | ⬜ | Use env vars initially | +| 0.5 | Verify SF integration user permissions | P0 | 3h | ✅ | Done - permissions verified | +| 0.6 | Add "Portal Checkout" to SF picklist | P0 | 1h | ✅ | Done - picklist updated | +| 0.7 | Create test Salesforce sandbox accounts | P0 | 2h | ⬜ | For testing order flow | +| 0.8 | Create test WHMCS accounts | P0 | 2h | ⬜ | For payment testing | +| 0.9 | Document rollback/cleanup procedures | P0 | 2h | ⬜ | For orphaned records | + +### ✅ Salesforce Permissions - VERIFIED + +The following permissions have been confirmed: + +| Object | Permission | Status | +| ------------------------------- | ---------- | ------------------------------ | +| Account | CREATE | ✅ Verified | +| Account | UPDATE | ✅ Verified | +| Contact | CREATE | ✅ Verified | +| SF_Account_No\_\_c | READ/WRITE | ✅ Verified | +| WH_Account\_\_c | READ/WRITE | ✅ Verified | +| Portal_Status\_\_c | WRITE | ✅ Verified | +| Portal_Registration_Source\_\_c | WRITE | ✅ Verified (picklist updated) | + +--- + +## Phase 1: Public Catalog (5 days) + +### 1.1 Frontend - Routes & Layout + +| # | Task | Priority | Effort | Status | Notes | +| ------ | ----------------------------------------------------- | -------- | ------ | ------ | ----------------------------- | +| 1.1.1 | Create `CatalogLayout` component | P0 | 4h | ⬜ | Hybrid header (public + auth) | +| 1.1.2 | Create `CatalogHeader` component | P0 | 2h | ⬜ | Logo, nav, sign-in button | +| 1.1.3 | Create `(public)/catalog/layout.tsx` | P0 | 1h | ⬜ | Use CatalogLayout | +| 1.1.4 | Create `(public)/catalog/page.tsx` | P0 | 2h | ⬜ | Copy from authenticated | +| 1.1.5 | Create `(public)/catalog/internet/page.tsx` | P0 | 2h | ⬜ | Copy from authenticated | +| 1.1.6 | Create `(public)/catalog/internet/configure/page.tsx` | P0 | 2h | ⬜ | Copy from authenticated | +| 1.1.7 | Create `(public)/catalog/sim/page.tsx` | P0 | 2h | ⬜ | Copy from authenticated | +| 1.1.8 | Create `(public)/catalog/sim/configure/page.tsx` | P0 | 2h | ⬜ | Copy from authenticated | +| 1.1.9 | Create `(public)/catalog/vpn/page.tsx` | P0 | 1h | ⬜ | Copy from authenticated | +| 1.1.10 | Update internal links in catalog components | P0 | 2h | ⬜ | Ensure relative paths work | + +### 1.2 Backend - Public API + +| # | Task | Priority | Effort | Status | Notes | +| ----- | --------------------------------------------- | -------- | ------ | ------ | ----------------------- | +| 1.2.1 | Add `@Public()` to `CatalogController` | P0 | 0.5h | ⬜ | `catalog.controller.ts` | +| 1.2.2 | Update catalog services to handle null userId | P0 | 1h | ⬜ | Defensive coding | +| 1.2.3 | Add rate limiting to public catalog endpoints | P0 | 1h | ⬜ | Prevent abuse | +| 1.2.4 | Test catalog API without auth token | P0 | 1h | ⬜ | Manual + automated | + +### 1.3 Integration + +| # | Task | Priority | Effort | Status | Notes | +| ----- | -------------------------------------------- | -------- | ------ | ------ | ---------------------- | +| 1.3.1 | Update `catalogService` to work without auth | P0 | 1h | ⬜ | Frontend service | +| 1.3.2 | Test full catalog flow without login | P0 | 2h | ⬜ | All product types | +| 1.3.3 | Add "Proceed to Checkout" CTA | P0 | 2h | ⬜ | Redirects to /checkout | + +--- + +## Phase 2: Checkout Store (3 days) + +| # | Task | Priority | Effort | Status | Notes | +| --- | --------------------------------------- | -------- | ------ | ------ | ----------------------- | +| 2.1 | Define `CartItem` TypeScript interface | P0 | 1h | ⬜ | In domain package | +| 2.2 | Define `GuestInfo` TypeScript interface | P0 | 1h | ⬜ | In domain package | +| 2.3 | Create `checkout.store.ts` with Zustand | P0 | 2h | ⬜ | With persist middleware | +| 2.4 | Implement cart item serialization | P0 | 2h | ⬜ | Handle complex objects | +| 2.5 | Implement cart validation utilities | P0 | 2h | ⬜ | Zod schemas | +| 2.6 | Create `useCartRecovery` hook | P1 | 2h | ⬜ | Detect stale carts | +| 2.7 | Write unit tests for checkout store | P0 | 4h | ⬜ | Full coverage | +| 2.8 | Test localStorage persistence | P0 | 1h | ⬜ | Refresh, close browser | + +--- + +## Phase 3: Unified Checkout UI (8 days) + +### 3.1 Checkout Shell + +| # | Task | Priority | Effort | Status | Notes | +| ----- | ------------------------------------ | -------- | ------ | ------ | --------------- | +| 3.1.1 | Create `(public)/checkout/page.tsx` | P0 | 1h | ⬜ | Shell page | +| 3.1.2 | Create `CheckoutLayout` component | P0 | 2h | ⬜ | Minimal header | +| 3.1.3 | Create `CheckoutProgress` component | P0 | 2h | ⬜ | Step indicator | +| 3.1.4 | Create `CheckoutWizard` component | P0 | 3h | ⬜ | Step management | +| 3.1.5 | Create `OrderSummaryCard` component | P0 | 2h | ⬜ | Sidebar summary | +| 3.1.6 | Create `EmptyCartRedirect` component | P0 | 1h | ⬜ | Handle no cart | + +### 3.2 Account Step + +| # | Task | Priority | Effort | Status | Notes | +| ----- | ------------------------------------- | -------- | ------ | ------ | -------------------- | +| 3.2.1 | Create `AccountStep` component | P0 | 3h | ⬜ | Parent component | +| 3.2.2 | Create `GuestInfoForm` component | P0 | 3h | ⬜ | New customer form | +| 3.2.3 | Create `SignInForm` embedded variant | P0 | 2h | ⬜ | Reuse existing | +| 3.2.4 | Implement mode switching (new/signin) | P0 | 1h | ⬜ | Toggle between forms | +| 3.2.5 | Add form validation | P0 | 2h | ⬜ | Zod + useZodForm | + +### 3.3 Address Step + +| # | Task | Priority | Effort | Status | Notes | +| ----- | -------------------------------- | -------- | ------ | ------ | --------------- | +| 3.3.1 | Create `AddressStep` component | P0 | 2h | ⬜ | Wrapper | +| 3.3.2 | Integrate existing `AddressForm` | P0 | 2h | ⬜ | Reuse component | +| 3.3.3 | Add postal code auto-fill (JP) | P1 | 3h | ⬜ | External API | +| 3.3.4 | Add address validation | P0 | 2h | ⬜ | Required fields | + +### 3.4 Payment Step + +| # | Task | Priority | Effort | Status | Notes | +| ----- | --------------------------------------- | -------- | ------ | ------ | --------------- | +| 3.4.1 | Create `PaymentStep` component | P0 | 3h | ⬜ | Main component | +| 3.4.2 | Implement WHMCS SSO link opening | P0 | 2h | ⬜ | New tab | +| 3.4.3 | Create `usePaymentPolling` hook | P0 | 2h | ⬜ | Poll for method | +| 3.4.4 | Add focus listener for return detection | P0 | 1h | ⬜ | Window focus | +| 3.4.5 | Create `PaymentMethodDisplay` component | P0 | 1h | ⬜ | Show added card | +| 3.4.6 | Add loading/waiting states | P0 | 1h | ⬜ | UX polish | + +### 3.5 Review Step + +| # | Task | Priority | Effort | Status | Notes | +| ----- | ----------------------------------- | -------- | ------ | ------ | ------------ | +| 3.5.1 | Create `ReviewStep` component | P0 | 3h | ⬜ | Summary view | +| 3.5.2 | Create `OrderItemsReview` component | P0 | 2h | ⬜ | Cart items | +| 3.5.3 | Create `AddressReview` component | P0 | 1h | ⬜ | Show address | +| 3.5.4 | Create `PaymentReview` component | P0 | 1h | ⬜ | Show payment | +| 3.5.5 | Add terms acceptance checkbox | P0 | 0.5h | ⬜ | Required | +| 3.5.6 | Create submit order handler | P0 | 2h | ⬜ | API call | +| 3.5.7 | Add loading state during submission | P0 | 1h | ⬜ | UX | + +### 3.6 Confirmation + +| # | Task | Priority | Effort | Status | Notes | +| ----- | -------------------------------------------- | -------- | ------ | ------ | ------------- | +| 3.6.1 | Create `(public)/checkout/complete/page.tsx` | P0 | 2h | ⬜ | Success page | +| 3.6.2 | Create `OrderConfirmation` component | P0 | 2h | ⬜ | Order details | +| 3.6.3 | Add "Go to Dashboard" CTA | P0 | 0.5h | ⬜ | Navigation | +| 3.6.4 | Clear checkout store on completion | P0 | 0.5h | ⬜ | Cleanup | + +--- + +## Phase 4: Backend - Checkout Registration (7 days) + +> **CRITICAL**: Salesforce Account creation is SYNCHRONOUS and REQUIRED. +> Every new customer MUST have a Salesforce Account for business tracking. + +### 4.1 Domain Schema Changes + +| # | Task | Priority | Effort | Status | Notes | +| ----- | -------------------------------------------- | -------- | ------ | ------ | ---------------------- | +| 4.1.1 | Make `sfNumber` optional in signup schema | P0 | 1h | ⬜ | `packages/domain/auth` | +| 4.1.2 | Create `checkoutRegisterSchema` | P0 | 1h | ⬜ | New schema | +| 4.1.3 | Create `checkoutSubmitSchema` | P0 | 1h | ⬜ | Order from cart | +| 4.1.4 | Create `CreateSalesforceAccountRequest` type | P0 | 0.5h | ⬜ | SF Account payload | +| 4.1.5 | Create `CreateSalesforceContactRequest` type | P0 | 0.5h | ⬜ | SF Contact payload | +| 4.1.6 | Update type exports | P0 | 0.5h | ⬜ | Index files | +| 4.1.7 | Build domain package | P0 | 0.5h | ⬜ | Verify compilation | + +### 4.2 Salesforce Account Service Updates + +| # | Task | Priority | Effort | Status | Notes | +| ----- | ------------------------------------ | -------- | ------ | ------ | ---------------------- | +| 4.2.1 | Add `createAccount()` method | P0 | 3h | ⬜ | Creates SF Account | +| 4.2.2 | Add `createContact()` method | P0 | 2h | ⬜ | Creates SF Contact | +| 4.2.3 | Add `generateAccountNumber()` method | P0 | 2h | ⬜ | Auto-gen PNNNNNNN | +| 4.2.4 | Add `findByEmail()` method | P0 | 1h | ⬜ | Check for duplicates | +| 4.2.5 | Test SF Account creation in sandbox | P0 | 2h | ⬜ | Verify permissions | +| 4.2.6 | Test SF Contact creation in sandbox | P0 | 1h | ⬜ | Verify link to Account | +| 4.2.7 | Verify WH_Account\_\_c update works | P0 | 1h | ⬜ | Link SF → WHMCS | + +### 4.3 Checkout Service + +| # | Task | Priority | Effort | Status | Notes | +| ------ | ------------------------------------------ | -------- | ------ | ------ | ------------------- | +| 4.3.1 | Create `CheckoutService` class | P0 | 1h | ⬜ | Business logic | +| 4.3.2 | Implement `registerForCheckout()` - Step 1 | P0 | 1h | ⬜ | Create SF Account | +| 4.3.3 | Implement `registerForCheckout()` - Step 2 | P0 | 1h | ⬜ | Create SF Contact | +| 4.3.4 | Implement `registerForCheckout()` - Step 3 | P0 | 1h | ⬜ | Create WHMCS Client | +| 4.3.5 | Implement `registerForCheckout()` - Step 4 | P0 | 0.5h | ⬜ | Link SF to WHMCS | +| 4.3.6 | Implement `registerForCheckout()` - Step 5 | P0 | 1h | ⬜ | Create Portal User | +| 4.3.7 | Implement `registerForCheckout()` - Step 6 | P0 | 0.5h | ⬜ | Create ID Mapping | +| 4.3.8 | Implement `registerForCheckout()` - Step 7 | P0 | 0.5h | ⬜ | Generate tokens | +| 4.3.9 | Implement `rollbackRegistration()` method | P0 | 2h | ⬜ | Compensating txns | +| 4.3.10 | Implement `getPaymentStatus()` method | P0 | 1h | ⬜ | Check WHMCS | +| 4.3.11 | Add comprehensive logging | P0 | 1h | ⬜ | Debug each step | + +### 4.4 Checkout Controller + +| # | Task | Priority | Effort | Status | Notes | +| ----- | ------------------------------------------- | -------- | ------ | ------ | -------------- | +| 4.4.1 | Create `CheckoutController` class | P0 | 1h | ⬜ | New controller | +| 4.4.2 | Add `POST /checkout/register` endpoint | P0 | 1h | ⬜ | Public | +| 4.4.3 | Add `GET /checkout/payment-status` endpoint | P0 | 0.5h | ⬜ | Authenticated | +| 4.4.4 | Add `POST /checkout/submit` endpoint | P0 | 0.5h | ⬜ | Authenticated | +| 4.4.5 | Add rate limiting (5/min) | P0 | 0.5h | ⬜ | Prevent abuse | +| 4.4.6 | Create `CheckoutModule` | P0 | 0.5h | ⬜ | Nest module | +| 4.4.7 | Register in `AppModule` | P0 | 0.5h | ⬜ | Wire up | + +### 4.5 Testing + +| # | Task | Priority | Effort | Status | Notes | +| ----- | ------------------------------------ | -------- | ------ | ------ | -------------------- | +| 4.5.1 | Unit test: SF Account creation | P0 | 1h | ⬜ | Mock SF API | +| 4.5.2 | Unit test: SF Contact creation | P0 | 1h | ⬜ | Mock SF API | +| 4.5.3 | Unit test: Account number generation | P0 | 1h | ⬜ | Sequence logic | +| 4.5.4 | Unit test: Full registration flow | P0 | 2h | ⬜ | Happy path | +| 4.5.5 | Unit test: Rollback scenarios | P0 | 2h | ⬜ | Failure at each step | +| 4.5.6 | Integration test: SF sandbox | P0 | 2h | ⬜ | Real SF calls | +| 4.5.7 | Integration test: Full flow | P0 | 2h | ⬜ | SF + WHMCS + Portal | + +--- + +## Phase 5: Integration Testing (5 days) + +| # | Task | Priority | Effort | Status | Notes | +| ---- | ---------------------------------------- | -------- | ------ | ------ | ------------------- | +| 5.1 | Wire up checkout UI to register endpoint | P0 | 2h | ⬜ | API integration | +| 5.2 | Implement auto-login after registration | P0 | 2h | ⬜ | Token handling | +| 5.3 | Wire up order submission | P0 | 2h | ⬜ | API integration | +| 5.4 | E2E test: New customer full flow | P0 | 4h | ⬜ | Playwright | +| 5.5 | E2E test: Existing customer flow | P0 | 2h | ⬜ | Sign-in path | +| 5.6 | E2E test: Cart recovery | P1 | 2h | ⬜ | Abandon + resume | +| 5.7 | E2E test: Error scenarios | P0 | 3h | ⬜ | Network, validation | +| 5.8 | Performance test checkout | P1 | 2h | ⬜ | Load testing | +| 5.9 | Security review | P0 | 4h | ⬜ | Auth, CSRF, etc. | +| 5.10 | Fix bugs from testing | P0 | 8h | ⬜ | Buffer time | + +--- + +## Phase 6: Public Support (3 days) + +| # | Task | Priority | Effort | Status | Notes | +| --- | ------------------------------------------ | -------- | ------ | ------ | ------------ | +| 6.1 | Create `(public)/support/page.tsx` | P1 | 2h | ⬜ | FAQ landing | +| 6.2 | Create `PublicSupportView` component | P1 | 3h | ⬜ | Content | +| 6.3 | Create `(public)/support/contact/page.tsx` | P1 | 1h | ⬜ | Form page | +| 6.4 | Create `PublicContactForm` component | P1 | 3h | ⬜ | Form UI | +| 6.5 | Create `publicContactSchema` | P1 | 0.5h | ⬜ | Validation | +| 6.6 | Add `POST /support/contact` endpoint | P1 | 2h | ⬜ | Public API | +| 6.7 | Implement Salesforce Lead creation | P1 | 3h | ⬜ | Integration | +| 6.8 | Add rate limiting to contact endpoint | P1 | 0.5h | ⬜ | Prevent spam | +| 6.9 | Test public contact flow | P1 | 1h | ⬜ | Full flow | + +--- + +## Phase 7: Polish & Launch (4 days) + +### 7.1 Error Handling + +| # | Task | Priority | Effort | Status | Notes | +| ----- | ------------------------- | -------- | ------ | ------ | -------------------- | +| 7.1.1 | Review all error messages | P0 | 2h | ⬜ | User-friendly | +| 7.1.2 | Add error boundaries | P0 | 1h | ⬜ | React boundaries | +| 7.1.3 | Add retry mechanisms | P1 | 2h | ⬜ | Network errors | +| 7.1.4 | Add fallback UI states | P0 | 2h | ⬜ | Graceful degradation | + +### 7.2 UX Polish + +| # | Task | Priority | Effort | Status | Notes | +| ----- | ------------------------- | -------- | ------ | ------ | ----------------------- | +| 7.2.1 | Add loading skeletons | P0 | 2h | ⬜ | All async states | +| 7.2.2 | Add transition animations | P2 | 2h | ⬜ | Step transitions | +| 7.2.3 | Mobile responsive testing | P0 | 2h | ⬜ | All breakpoints | +| 7.2.4 | Accessibility audit | P1 | 2h | ⬜ | Screen reader, keyboard | + +### 7.3 Analytics & Monitoring + +| # | Task | Priority | Effort | Status | Notes | +| ----- | --------------------------- | -------- | ------ | ------ | ----------------- | +| 7.3.1 | Add funnel tracking events | P1 | 2h | ⬜ | Google Analytics | +| 7.3.2 | Add error tracking | P1 | 1h | ⬜ | Sentry or similar | +| 7.3.3 | Create conversion dashboard | P1 | 2h | ⬜ | Metrics | + +### 7.4 Documentation + +| # | Task | Priority | Effort | Status | Notes | +| ----- | ------------------------------ | -------- | ------ | ------ | ---------- | +| 7.4.1 | Update STRUCTURE.md | P1 | 1h | ⬜ | New routes | +| 7.4.2 | Update catalog-and-checkout.md | P1 | 2h | ⬜ | New flow | +| 7.4.3 | Create feature announcement | P1 | 1h | ⬜ | For users | + +### 7.5 Launch Prep + +| # | Task | Priority | Effort | Status | Notes | +| ----- | -------------------------------- | -------- | ------ | ------ | ---------------- | +| 7.5.1 | Configure feature flags for prod | P0 | 1h | ⬜ | Gradual rollout | +| 7.5.2 | Update production env vars | P0 | 0.5h | ⬜ | If needed | +| 7.5.3 | Create rollback procedure | P0 | 1h | ⬜ | Document steps | +| 7.5.4 | Prepare monitoring alerts | P0 | 1h | ⬜ | Error rate, etc. | +| 7.5.5 | Schedule launch window | P0 | 0.5h | ⬜ | With team | + +--- + +## Definition of Done + +Each task is considered **Done** when: + +- [ ] Code is written and follows project conventions +- [ ] Unit tests pass (if applicable) +- [ ] Code is reviewed and approved +- [ ] Feature works in staging environment +- [ ] No TypeScript errors +- [ ] No ESLint warnings +- [ ] Responsive design verified (if UI) + +--- + +## Dependencies Graph + +``` +Phase 0 ✅ (SF Permissions verified, branch created) + │ + ↓ +Phase 1 ──→ Phase 2 ──→ Phase 3 + │ + ↓ + Phase 4 (SF Account Creation) + │ + ↓ + Phase 5 (Integration Testing) + │ + ├──────────┬──────────┐ + ↓ │ ↓ + Phase 6 │ Phase 7 + │ │ │ + └──────────┴──────────┘ + ↓ + LAUNCH +``` + +### Phase Dependencies Detail + +| Phase | Depends On | Blocks | +| ------- | ------------------ | -------------------------------- | +| Phase 0 | - | All phases | +| Phase 1 | Phase 0 | Phase 3 (checkout needs catalog) | +| Phase 2 | Phase 0 | Phase 3 (checkout needs cart) | +| Phase 3 | Phase 1, 2 | Phase 5 | +| Phase 4 | Phase 0 (SF perms) | Phase 5 | +| Phase 5 | Phase 3, 4 | Phase 7 | +| Phase 6 | Phase 0 | Launch | +| Phase 7 | Phase 5 | Launch | + +--- + +## Risk Register + +| Risk | Likelihood | Impact | Mitigation | Status | +| ------------------------------------------- | ---------- | ------------ | ------------------------------------- | ------------ | +| **SF Account creation fails** | Medium | **Critical** | Retry logic, clear errors, monitoring | Open | +| ~~SF permission issues~~ | ~~Medium~~ | ~~Critical~~ | ~~Verify in Phase 0~~ | ✅ Mitigated | +| **Partial registration (orphaned records)** | Medium | High | Rollback logic, cleanup procedures | Open | +| SF API rate limits | Low | High | Monitor usage, batch if needed | Open | +| WHMCS SSO reliability | Medium | High | Add retry logic, clear error messages | Open | +| Performance regression | Low | Medium | Load test before launch | Open | +| Security vulnerability | Low | Critical | Security review, pen test | Open | +| Duplicate SF Accounts created | Low | Medium | Check by email before creating | Open | +| User confusion with new flow | Medium | Low | Clear UX, help text | Open | + +### Salesforce-Specific Risks + +| Risk | Mitigation | Status | +| -------------------------------------------- | ------------------------------------- | ----------- | +| ~~Integration user lacks CREATE on Account~~ | ~~Test in Phase 0~~ | ✅ Verified | +| Required fields missing in SF org | Document all fields, validate payload | Open | +| Customer number collision | Use SF auto-number or query for max | Open | +| SF sandbox differs from production | Test in production-like sandbox | Open | +| SF API timeout during registration | Add timeout handling, retry logic | Open | + +--- + +## Contacts + +| Role | Name | Responsibility | +| ------------ | ---- | ------------------------ | +| Tech Lead | TBD | Architecture decisions | +| Frontend Dev | TBD | UI implementation | +| Backend Dev | TBD | API implementation | +| QA | TBD | Testing | +| Product | TBD | Requirements, acceptance | + +--- + +## Changelog + +| Date | Change | Author | +| ---------- | ------------------------- | ------ | +| 2024-12-17 | Initial task list created | - | diff --git a/docs/architecture/PUBLIC-CATALOG-UNIFIED-CHECKOUT.md b/docs/architecture/PUBLIC-CATALOG-UNIFIED-CHECKOUT.md new file mode 100644 index 00000000..70d642fe --- /dev/null +++ b/docs/architecture/PUBLIC-CATALOG-UNIFIED-CHECKOUT.md @@ -0,0 +1,1421 @@ +# Public Catalog & Unified Checkout - Development Plan + +> **Status**: Planning +> **Created**: 2024-12-17 +> **Epic**: Transform Portal into Public-Facing Website with E-commerce Checkout + +## Executive Summary + +This document outlines the development plan to transform the customer portal from an authenticated-only application into a public-facing website where users can: + +1. **Browse catalog without authentication** +2. **Configure products without an account** +3. **Complete checkout with seamless account creation** +4. **Place orders in a single, unified flow** + +The goal is to eliminate friction in the customer acquisition funnel by making registration feel like a natural part of the ordering process, rather than a prerequisite. + +--- + +## Table of Contents + +1. [Current State Analysis](#current-state-analysis) +2. [Target Architecture](#target-architecture) +3. [User Journeys](#user-journeys) +4. [Technical Design](#technical-design) +5. [Development Phases](#development-phases) +6. [API Changes](#api-changes) +7. [Database Changes](#database-changes) +8. [Testing Strategy](#testing-strategy) +9. [Rollout Plan](#rollout-plan) +10. [Risks & Mitigations](#risks--mitigations) +11. [Success Metrics](#success-metrics) + +--- + +## Current State Analysis + +### Current Architecture + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ CURRENT FLOW │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. User visits portal │ +│ 2. Must sign up (requires sfNumber from Salesforce) │ +│ 3. Must add payment method (WHMCS SSO) │ +│ 4. Can browse catalog │ +│ 5. Can configure and order │ +│ │ +│ Problems: │ +│ • sfNumber requirement blocks new customers │ +│ • Registration is separate from ordering intent │ +│ • High friction = low conversion │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### Critical Integration Requirements + +**Salesforce is the source of truth** for customer data and must be created for every new customer: + +| System | Role | Requirement | +| -------------- | ------------------------------ | ------------------------------------------------------- | +| **Salesforce** | CRM, customer tracking, orders | Account + Contact MUST be created for every customer | +| **WHMCS** | Billing, invoices, payments | Client MUST be linked to SF Account via `WH_Account__c` | +| **Portal DB** | Authentication, session | User record links all systems via `id_mappings` | + +The relationship chain: + +``` +Salesforce Account (has SF_Account_No__c) + └── WH_Account__c → WHMCS Client ID + └── Portal User → id_mappings → whmcsClientId + sfAccountId +``` + +### Current Route Structure + +``` +apps/portal/src/app/ +├── (public)/ # Only auth pages +│ ├── auth/ +│ │ ├── login/ +│ │ ├── signup/ # Requires sfNumber +│ │ └── ... +│ └── page.tsx # Landing page +│ +└── (authenticated)/ # Everything else requires auth + ├── catalog/ # ← Should be public! + ├── checkout/ + ├── dashboard/ + ├── billing/ + ├── subscriptions/ + ├── orders/ + └── support/ +``` + +### Current Dependencies + +| Component | Current Behavior | Issue | +| --------------- | ------------------------------------------ | ---------------------------- | +| Catalog API | Works without auth (returns generic plans) | ✅ Ready | +| Catalog UI | Requires authentication | ❌ Needs change | +| Checkout | Requires auth + payment method | ❌ Needs redesign | +| Signup | Requires sfNumber | ❌ Needs to be optional | +| Payment Methods | WHMCS SSO only | ⚠️ Constraint to work around | + +--- + +## Target Architecture + +### New Route Structure + +``` +apps/portal/src/app/ +├── (public)/ +│ ├── page.tsx # Homepage/Landing +│ ├── layout.tsx # PublicShell +│ │ +│ ├── catalog/ # ★ PUBLIC CATALOG +│ │ ├── page.tsx # Catalog home +│ │ ├── layout.tsx # CatalogLayout +│ │ ├── internet/ +│ │ │ ├── page.tsx # Internet plans +│ │ │ └── configure/page.tsx # Configure internet +│ │ ├── sim/ +│ │ │ ├── page.tsx # SIM plans +│ │ │ └── configure/page.tsx # Configure SIM +│ │ └── vpn/ +│ │ └── page.tsx # VPN plans +│ │ +│ ├── checkout/ # ★ UNIFIED CHECKOUT +│ │ ├── page.tsx # Multi-step checkout +│ │ └── complete/page.tsx # Order confirmation +│ │ +│ ├── support/ # ★ PUBLIC SUPPORT +│ │ ├── page.tsx # FAQ/Help center +│ │ └── contact/page.tsx # Contact form +│ │ +│ └── auth/ +│ ├── login/page.tsx # Simplified login +│ └── forgot-password/page.tsx +│ +├── (authenticated)/ +│ ├── layout.tsx # AppShell +│ ├── dashboard/ # Customer dashboard +│ ├── orders/ # Order history +│ ├── subscriptions/ # Manage subscriptions +│ ├── billing/ # Invoices & payments +│ └── support/ +│ └── cases/ # Support case management +``` + +### Unified Checkout Flow + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ NEW FLOW │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ PUBLIC CATALOG │ +│ ┌────────────────────────────────────────────────────────────┐ │ +│ │ Browse Plans → Configure → "Proceed to Checkout" │ │ +│ │ (saves to localStorage) │ │ +│ └────────────────────────────────────────────────────────────┘ │ +│ ↓ │ +│ UNIFIED CHECKOUT (/checkout) │ +│ ┌────────────────────────────────────────────────────────────┐ │ +│ │ Step 1: Account │ │ +│ │ • "Already have account? Sign in" OR │ │ +│ │ • Collect: email, name, phone, password │ │ +│ ├────────────────────────────────────────────────────────────┤ │ +│ │ Step 2: Address │ │ +│ │ • Collect service/shipping address │ │ +│ │ (Account created in background after this step) │ │ +│ ├────────────────────────────────────────────────────────────┤ │ +│ │ Step 3: Payment │ │ +│ │ • Open WHMCS to add payment method │ │ +│ │ • Poll for completion, show confirmation │ │ +│ ├────────────────────────────────────────────────────────────┤ │ +│ │ Step 4: Review & Submit │ │ +│ │ • Order summary, T&C acceptance, submit │ │ +│ └────────────────────────────────────────────────────────────┘ │ +│ ↓ │ +│ ORDER CONFIRMATION → Redirect to /orders/[id] │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## User Journeys + +### Journey 1: New Customer - Internet Order + +```mermaid +journey + title New Customer Orders Internet + section Browse + Visit homepage: 5: Customer + View internet plans: 5: Customer + Select 10Gbps plan: 5: Customer + Configure addons: 4: Customer + section Checkout + Enter email & name: 4: Customer + Enter address: 4: Customer + Add payment (WHMCS): 3: Customer + Review order: 5: Customer + Submit order: 5: Customer + section Post-Order + View confirmation: 5: Customer + Access dashboard: 5: Customer +``` + +### Journey 2: Existing Customer - Quick Order + +```mermaid +journey + title Existing Customer Quick Order + section Browse + Visit catalog: 5: Customer + Select SIM plan: 5: Customer + Configure: 5: Customer + section Checkout + Click "Sign In": 5: Customer + Login: 4: Customer + Verify address: 5: Customer + Confirm payment: 5: Customer + Submit: 5: Customer +``` + +### Journey 3: Abandoned Cart Recovery + +```mermaid +journey + title Cart Recovery + section Initial + Configure product: 5: Customer + Start checkout: 4: Customer + Enter email: 4: Customer + Leave site: 1: Customer + section Recovery + Receive email: 3: Customer + Click link: 4: Customer + Resume checkout: 5: Customer + Complete order: 5: Customer +``` + +--- + +## Technical Design + +### 1. Checkout State Store + +```typescript +// Location: apps/portal/src/features/checkout/stores/checkout.store.ts + +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +interface CartItem { + orderType: "INTERNET" | "SIM" | "VPN"; + planSku: string; + planName: string; + addonSkus: string[]; + configuration: { + installationType?: string; + simType?: string; + activationType?: string; + mnpDetails?: MnpDetails; + [key: string]: unknown; + }; + pricing: { + monthlyTotal: number; + oneTimeTotal: number; + breakdown: PriceBreakdownItem[]; + }; +} + +interface GuestInfo { + email: string; + firstName: string; + lastName: string; + phone: string; + phoneCountryCode: string; + dateOfBirth?: string; + gender?: "male" | "female" | "other"; + password: string; +} + +interface CheckoutState { + // Cart data + cartItem: CartItem | null; + + // Guest info (pre-registration) + guestInfo: Partial | null; + + // Address + address: Address | null; + + // Registration state + registrationComplete: boolean; + userId: string | null; + + // Payment state + paymentMethodVerified: boolean; + + // Checkout step + currentStep: "account" | "address" | "payment" | "review"; + + // Actions + setCartItem: (item: CartItem) => void; + updateGuestInfo: (info: Partial) => void; + setAddress: (address: Address) => void; + setRegistrationComplete: (userId: string) => void; + setPaymentVerified: (verified: boolean) => void; + setCurrentStep: (step: CheckoutState["currentStep"]) => void; + clear: () => void; +} + +export const useCheckoutStore = create()( + persist( + (set, get) => ({ + cartItem: null, + guestInfo: null, + address: null, + registrationComplete: false, + userId: null, + paymentMethodVerified: false, + currentStep: "account", + + setCartItem: item => set({ cartItem: item }), + updateGuestInfo: info => + set(state => ({ + guestInfo: { ...state.guestInfo, ...info }, + })), + setAddress: address => set({ address }), + setRegistrationComplete: userId => + set({ + registrationComplete: true, + userId, + }), + setPaymentVerified: verified => set({ paymentMethodVerified: verified }), + setCurrentStep: step => set({ currentStep: step }), + clear: () => + set({ + cartItem: null, + guestInfo: null, + address: null, + registrationComplete: false, + userId: null, + paymentMethodVerified: false, + currentStep: "account", + }), + }), + { + name: "checkout-store", + version: 1, + } + ) +); +``` + +### 2. Checkout Page Component Structure + +```typescript +// Location: apps/portal/src/app/(public)/checkout/page.tsx + +export default function CheckoutPage() { + return ( + + + + ); +} + +// Location: apps/portal/src/features/checkout/components/CheckoutWizard.tsx + +export function CheckoutWizard() { + const { currentStep, cartItem } = useCheckoutStore(); + const { isAuthenticated } = useAuthSession(); + + // Redirect if no cart + if (!cartItem) { + return ; + } + + // Skip account step if already authenticated + const effectiveStep = isAuthenticated && currentStep === 'account' + ? 'address' + : currentStep; + + return ( +
+ + +
+
+ {effectiveStep === 'account' && } + {effectiveStep === 'address' && } + {effectiveStep === 'payment' && } + {effectiveStep === 'review' && } +
+ +
+ +
+
+
+ ); +} +``` + +### 3. Account Step with Sign-In Option + +```typescript +// Location: apps/portal/src/features/checkout/components/steps/AccountStep.tsx + +export function AccountStep() { + const [mode, setMode] = useState<'new' | 'signin'>('new'); + const { updateGuestInfo, setCurrentStep } = useCheckoutStore(); + + const handleContinue = async (data: GuestFormData) => { + updateGuestInfo(data); + setCurrentStep('address'); + }; + + const handleSignInSuccess = () => { + // User is now authenticated, skip to address + setCurrentStep('address'); + }; + + return ( +
+ {/* Sign-in prompt */} +
+
+
+

Already have an account?

+

+ Sign in to use your saved information +

+
+ +
+
+ + {mode === 'signin' ? ( + setMode('new')} + embedded + /> + ) : ( + <> +
+
+
+
+
+ + Or continue as new customer + +
+
+ + + + )} +
+ ); +} +``` + +### 4. Payment Step with WHMCS Integration + +```typescript +// Location: apps/portal/src/features/checkout/components/steps/PaymentStep.tsx + +export function PaymentStep() { + const { setPaymentVerified, setCurrentStep, registrationComplete } = useCheckoutStore(); + const { data: paymentMethods, refetch } = usePaymentMethods(); + const createSsoLink = useCreatePaymentMethodsSsoLink(); + const [isWaiting, setIsWaiting] = useState(false); + + // Poll for payment method after opening WHMCS + useEffect(() => { + if (!isWaiting) return; + + const interval = setInterval(async () => { + const result = await refetch(); + if (result.data?.paymentMethods?.length > 0) { + setPaymentVerified(true); + setIsWaiting(false); + } + }, 3000); + + return () => clearInterval(interval); + }, [isWaiting, refetch, setPaymentVerified]); + + // Focus listener for when user returns + useEffect(() => { + const handleFocus = () => { + if (isWaiting) { + void refetch(); + } + }; + window.addEventListener('focus', handleFocus); + return () => window.removeEventListener('focus', handleFocus); + }, [isWaiting, refetch]); + + const handleAddPayment = async () => { + const { url } = await createSsoLink.mutateAsync(); + window.open(url, '_blank'); + setIsWaiting(true); + }; + + const hasPaymentMethod = paymentMethods?.paymentMethods?.length > 0; + + return ( +
+
+
+ +

Payment Method

+
+ + {hasPaymentMethod ? ( +
+ +
+ + Payment method verified +
+
+ ) : ( +
+ + + {isWaiting ? ( + <> +

Waiting for payment method...

+

+ Complete the payment setup in the new tab, then return here. +

+ + + ) : ( + <> +

Add a payment method

+

+ We'll open our secure payment portal to add your card. +

+ + + )} +
+ )} +
+ +
+ + +
+
+ ); +} +``` + +### 5. New BFF Endpoints + +```typescript +// Location: apps/bff/src/modules/checkout/checkout.controller.ts + +@Controller("checkout") +export class CheckoutController { + constructor( + private readonly checkoutService: CheckoutService, + private readonly logger: Logger + ) {} + + /** + * Register a new user during checkout + * + * IMPORTANT: This creates accounts in ALL systems synchronously: + * 1. Salesforce Account + Contact (for CRM tracking) + * 2. WHMCS Client (for billing) + * 3. Portal User (for authentication) + * + * Returns auth tokens so user is immediately logged in + */ + @Post("register") + @Public() + @RateLimit({ limit: 5, ttl: 60 }) + @UsePipes(new ZodValidationPipe(checkoutRegisterSchema)) + async register(@Body() body: CheckoutRegisterRequest): Promise { + this.logger.log("Checkout registration request", { email: body.email }); + return this.checkoutService.registerForCheckout(body); + } + + /** + * Check if current user has valid payment method + * Used by checkout to gate the review step + */ + @Get("payment-status") + async getPaymentStatus(@Request() req: RequestWithUser): Promise { + return this.checkoutService.getPaymentStatus(req.user.id); + } + + /** + * Build cart preview from configuration + * Works for both authenticated and anonymous users + */ + @Post("cart") + @Public() + @UsePipes(new ZodValidationPipe(checkoutBuildCartRequestSchema)) + async buildCart( + @Request() req: RequestWithUser, + @Body() body: CheckoutBuildCartRequest + ): Promise { + const userId = req.user?.id; + return this.checkoutService.buildCart(body, userId); + } + + /** + * Submit order - requires authentication + */ + @Post("submit") + @UsePipes(new ZodValidationPipe(checkoutSubmitSchema)) + async submitOrder( + @Request() req: RequestWithUser, + @Body() body: CheckoutSubmitRequest + ): Promise { + return this.checkoutService.submitOrder(req.user.id, body); + } +} +``` + +### 6. Salesforce Account Creation Service + +**CRITICAL**: Every new customer MUST have a Salesforce Account for business tracking. + +```typescript +// Location: apps/bff/src/integrations/salesforce/services/salesforce-account.service.ts +// ADD these new methods to the existing service + +/** + * Create a new Salesforce Account for a new customer + * This is used when customer signs up through checkout (no existing sfNumber) + */ +async createAccount(data: CreateSalesforceAccountRequest): Promise<{ + accountId: string; + accountNumber: string; +}> { + this.logger.log('Creating new Salesforce Account', { email: data.email }); + + // Generate unique account number (SF_Account_No__c) + const accountNumber = await this.generateAccountNumber(); + + const accountPayload = { + Name: `${data.firstName} ${data.lastName}`, + SF_Account_No__c: accountNumber, + BillingStreet: data.address.address1, + BillingCity: data.address.city, + BillingState: data.address.state, + BillingPostalCode: data.address.postcode, + BillingCountry: data.address.country, + Phone: data.phone, + // Portal tracking fields + [this.portalStatusField]: 'Active', + [this.portalSourceField]: 'Portal Checkout', + // Record type for individual customers + RecordTypeId: this.configService.get('SF_PERSON_ACCOUNT_RECORD_TYPE_ID'), + }; + + try { + const result = await this.connection.sobject('Account').create(accountPayload); + + if (!result.id) { + throw new Error('Salesforce Account creation failed - no ID returned'); + } + + this.logger.log('Salesforce Account created', { + accountId: result.id, + accountNumber + }); + + return { + accountId: result.id, + accountNumber + }; + } catch (error) { + this.logger.error('Failed to create Salesforce Account', { + error: getErrorMessage(error), + email: data.email, + }); + throw new Error('Failed to create customer account in CRM'); + } +} + +/** + * Create a Contact associated with an Account + */ +async createContact(data: CreateSalesforceContactRequest): Promise<{ contactId: string }> { + this.logger.log('Creating Salesforce Contact', { + accountId: data.accountId, + email: data.email + }); + + const contactPayload = { + AccountId: data.accountId, + FirstName: data.firstName, + LastName: data.lastName, + Email: data.email, + Phone: data.phone, + MailingStreet: data.address.address1, + MailingCity: data.address.city, + MailingState: data.address.state, + MailingPostalCode: data.address.postcode, + MailingCountry: data.address.country, + }; + + try { + const result = await this.connection.sobject('Contact').create(contactPayload); + + if (!result.id) { + throw new Error('Salesforce Contact creation failed - no ID returned'); + } + + this.logger.log('Salesforce Contact created', { contactId: result.id }); + return { contactId: result.id }; + } catch (error) { + this.logger.error('Failed to create Salesforce Contact', { + error: getErrorMessage(error), + accountId: data.accountId, + }); + throw new Error('Failed to create customer contact in CRM'); + } +} + +/** + * Generate a unique customer number for new accounts + * Format: PNNNNNNN (P = Portal, 7 digits) + */ +private async generateAccountNumber(): Promise { + // Query for max existing portal account number + const result = await this.connection.query( + `SELECT SF_Account_No__c FROM Account + WHERE SF_Account_No__c LIKE 'P%' + ORDER BY SF_Account_No__c DESC LIMIT 1`, + { label: 'auth:getMaxAccountNumber' } + ); + + let nextNumber = 1000001; // Start from P1000001 + + if (result.totalSize > 0) { + const lastNumber = result.records[0]?.SF_Account_No__c; + if (lastNumber) { + const numPart = parseInt(lastNumber.substring(1), 10); + if (!isNaN(numPart)) { + nextNumber = numPart + 1; + } + } + } + + return `P${nextNumber}`; +} +``` + +### 7. Checkout Registration Service - Full Flow + +```typescript +// Location: apps/bff/src/modules/checkout/checkout.service.ts + +@Injectable() +export class CheckoutService { + constructor( + private readonly salesforceAccountService: SalesforceAccountService, + private readonly whmcsService: WhmcsService, + private readonly usersFacade: UsersFacade, + private readonly mappingsService: MappingsService, + private readonly tokenService: AuthTokenService, + private readonly prisma: PrismaService, + @Inject(Logger) private readonly logger: Logger + ) {} + + /** + * Register a new customer during checkout + * + * This is a critical workflow that creates accounts in ALL systems: + * + * 1. Create Salesforce Account (generates SF_Account_No__c) + * 2. Create Salesforce Contact (linked to Account) + * 3. Create WHMCS Client (for billing) + * 4. Update SF Account with WH_Account__c + * 5. Create Portal User + * 6. Create ID Mapping (links all systems) + * 7. Generate auth tokens + * + * If any step fails, we attempt rollback of previous steps. + */ + async registerForCheckout(data: CheckoutRegisterRequest): Promise { + this.logger.log("Starting checkout registration", { email: data.email }); + + // Track created resources for rollback + let sfAccountId: string | null = null; + let sfContactId: string | null = null; + let sfAccountNumber: string | null = null; + let whmcsClientId: number | null = null; + let portalUserId: string | null = null; + + try { + // Step 1: Create Salesforce Account + this.logger.log("Step 1: Creating Salesforce Account"); + const sfAccount = await this.salesforceAccountService.createAccount({ + firstName: data.firstName, + lastName: data.lastName, + email: data.email, + phone: this.formatPhone(data.phoneCountryCode, data.phone), + address: data.address, + }); + sfAccountId = sfAccount.accountId; + sfAccountNumber = sfAccount.accountNumber; + + // Step 2: Create Salesforce Contact + this.logger.log("Step 2: Creating Salesforce Contact"); + const sfContact = await this.salesforceAccountService.createContact({ + accountId: sfAccountId, + firstName: data.firstName, + lastName: data.lastName, + email: data.email, + phone: this.formatPhone(data.phoneCountryCode, data.phone), + address: data.address, + }); + sfContactId = sfContact.contactId; + + // Step 3: Create WHMCS Client + this.logger.log("Step 3: Creating WHMCS Client"); + const whmcsClient = await this.whmcsService.addClient({ + firstname: data.firstName, + lastname: data.lastName, + email: data.email, + phonenumber: this.formatPhone(data.phoneCountryCode, data.phone), + address1: data.address.address1, + address2: data.address.address2 || "", + city: data.address.city, + state: data.address.state, + postcode: data.address.postcode, + country: data.address.country, + password2: data.password, + customfields: this.buildWhmcsCustomFields(sfAccountNumber), + }); + whmcsClientId = whmcsClient.clientId; + + // Step 4: Update Salesforce Account with WHMCS ID + this.logger.log("Step 4: Linking Salesforce to WHMCS"); + await this.salesforceAccountService.updatePortalFields(sfAccountId, { + whmcsAccountId: whmcsClientId, + status: "Active", + source: "Portal Checkout", + }); + + // Step 5: Create Portal User (in transaction) + this.logger.log("Step 5: Creating Portal User"); + const user = await this.prisma.$transaction(async tx => { + const passwordHash = await argon2.hash(data.password); + + const newUser = await tx.user.create({ + data: { + email: data.email, + passwordHash, + isActive: true, + emailVerified: false, + }, + }); + + // Step 6: Create ID Mapping + await tx.idMapping.create({ + data: { + userId: newUser.id, + whmcsClientId: whmcsClientId!, + sfAccountId: sfAccountId!, + sfContactId: sfContactId!, + }, + }); + + return newUser; + }); + portalUserId = user.id; + + // Step 7: Generate auth tokens + this.logger.log("Step 6: Generating auth tokens"); + const tokens = await this.tokenService.generateTokenPair({ + sub: user.id, + email: user.email, + }); + + this.logger.log("Checkout registration completed successfully", { + userId: user.id, + sfAccountId, + sfAccountNumber, + whmcsClientId, + }); + + return { + success: true, + user: { + id: user.id, + email: user.email, + firstname: data.firstName, + lastname: data.lastName, + }, + session: { + expiresAt: tokens.accessTokenExpiresAt, + refreshExpiresAt: tokens.refreshTokenExpiresAt, + }, + sfAccountNumber, // Return so it can be shown to user + }; + } catch (error) { + this.logger.error("Checkout registration failed, initiating rollback", { + error: getErrorMessage(error), + sfAccountId, + whmcsClientId, + portalUserId, + }); + + // Rollback in reverse order + await this.rollbackRegistration({ + portalUserId, + whmcsClientId, + sfAccountId, + }); + + throw new BadRequestException("Registration failed. Please try again or contact support."); + } + } + + private async rollbackRegistration(resources: { + portalUserId: string | null; + whmcsClientId: number | null; + sfAccountId: string | null; + }) { + // Best-effort rollback - log failures but don't throw + + if (resources.portalUserId) { + try { + await this.prisma.user.delete({ where: { id: resources.portalUserId } }); + this.logger.log("Rollback: Deleted portal user", { userId: resources.portalUserId }); + } catch (e) { + this.logger.error("Rollback failed: Portal user", { error: getErrorMessage(e) }); + } + } + + if (resources.whmcsClientId) { + try { + await this.whmcsService.deleteClient(resources.whmcsClientId); + this.logger.log("Rollback: Deleted WHMCS client", { clientId: resources.whmcsClientId }); + } catch (e) { + this.logger.error("Rollback failed: WHMCS client", { error: getErrorMessage(e) }); + } + } + + // Note: We intentionally do NOT delete the Salesforce Account + // It's better to have an orphaned SF Account that can be cleaned up + // than to lose potential customer data + if (resources.sfAccountId) { + this.logger.warn("Salesforce Account not rolled back (intentional)", { + sfAccountId: resources.sfAccountId, + action: "Manual cleanup may be required", + }); + } + } + + private formatPhone(countryCode: string, phone: string): string { + const cc = countryCode.replace(/\D/g, ""); + const num = phone.replace(/\D/g, ""); + return `+${cc}.${num}`; + } + + private buildWhmcsCustomFields(sfAccountNumber: string): string { + const customerNumberFieldId = this.configService.get("WHMCS_CUSTOMER_NUMBER_FIELD_ID"); + if (!customerNumberFieldId) return ""; + + return `${customerNumberFieldId}|${sfAccountNumber}`; + } +} +``` + +--- + +## Development Phases + +### Phase 0: Preparation (3 days) + +| Task | Owner | Effort | Dependencies | +| ---------------------------------------- | ----- | ------ | ------------ | +| Create feature branch | Dev | 0.5d | - | +| Document current catalog/checkout flows | Dev | 0.5d | - | +| Set up feature flags for gradual rollout | Dev | 1d | - | +| Create test accounts for each scenario | QA | 1d | - | + +### Phase 1: Public Catalog (5 days) + +| Task | Description | Effort | Priority | +| ---------------------------------------------------------- | ------------------------------------ | ------ | -------- | +| **1.1** Create `CatalogLayout` component | Hybrid header for public/auth states | 1d | P0 | +| **1.2** Move catalog routes to `(public)/catalog/` | Copy and adapt existing pages | 1d | P0 | +| **1.3** Add `@Public()` decorator to BFF catalog endpoints | Make API publicly accessible | 0.5d | P0 | +| **1.4** Update catalog service for anonymous users | Handle missing userId gracefully | 0.5d | P0 | +| **1.5** Create "Proceed to Checkout" flow | Save config to store, redirect | 1d | P0 | +| **1.6** Test all catalog pages without auth | E2E testing | 1d | P0 | + +**Deliverable**: Users can browse and configure products without logging in. + +### Phase 2: Checkout Store & Cart (3 days) + +| Task | Description | Effort | Priority | +| ----------------------------------------- | ------------------------------------ | ------ | -------- | +| **2.1** Create checkout Zustand store | With localStorage persistence | 0.5d | P0 | +| **2.2** Implement cart item serialization | Save/restore configuration | 0.5d | P0 | +| **2.3** Add cart validation utilities | Ensure cart is valid before checkout | 0.5d | P0 | +| **2.4** Create cart recovery hook | Detect and restore abandoned carts | 0.5d | P1 | +| **2.5** Unit tests for store | Full coverage | 1d | P0 | + +**Deliverable**: Cart state persists across page reloads and sessions. + +### Phase 3: Unified Checkout UI (8 days) + +| Task | Description | Effort | Priority | +| ------------------------------------------ | --------------------------- | ------ | -------- | +| **3.1** Create `/checkout` page shell | Layout, progress indicator | 0.5d | P0 | +| **3.2** Build `CheckoutWizard` component | Step management, navigation | 1d | P0 | +| **3.3** Build `AccountStep` component | Sign-in + new account form | 1.5d | P0 | +| **3.4** Build `AddressStep` component | Reuse existing address form | 1d | P0 | +| **3.5** Build `PaymentStep` component | WHMCS SSO + polling | 1.5d | P0 | +| **3.6** Build `ReviewStep` component | Summary + terms + submit | 1d | P0 | +| **3.7** Build `OrderSummaryCard` component | Sidebar cart display | 0.5d | P0 | +| **3.8** Build order confirmation page | Success state, next steps | 1d | P0 | + +**Deliverable**: Complete checkout UI with all steps functional. + +### Phase 4: Backend - Checkout Registration (7 days) + +| Task | Description | Effort | Priority | +| --------------------------------------------------------- | -------------------------------------------------- | ------ | -------- | +| **4.1** Update domain schema - make sfNumber optional | Schema changes | 0.5d | P0 | +| **4.2** Add `createAccount()` to SalesforceAccountService | Create SF Account for new customers | 1d | P0 | +| **4.3** Add `createContact()` to SalesforceAccountService | Create SF Contact linked to Account | 0.5d | P0 | +| **4.4** Add `generateAccountNumber()` method | Auto-generate SF_Account_No\_\_c (PNNNNNNN format) | 0.5d | P0 | +| **4.5** Create `CheckoutService` in BFF | Main registration orchestration | 1d | P0 | +| **4.6** Implement `registerForCheckout()` method | Full 7-step registration flow | 1.5d | P0 | +| **4.7** Create `POST /checkout/register` endpoint | Public registration endpoint | 0.5d | P0 | +| **4.8** Implement rollback logic | Compensating transactions on failure | 1d | P0 | +| **4.9** Add comprehensive logging | Track each step for debugging | 0.5d | P0 | + +**Deliverable**: Users can create accounts during checkout with **synchronous** Salesforce Account + Contact + WHMCS Client creation. + +> **IMPORTANT**: Salesforce Account is created FIRST to ensure CRM tracking. The flow is: +> +> 1. Create SF Account (generates customer number) +> 2. Create SF Contact +> 3. Create WHMCS Client +> 4. Link SF Account to WHMCS (update WH_Account\_\_c) +> 5. Create Portal User + ID Mapping +> 6. Return auth tokens + +### Phase 5: Integration & Testing (5 days) + +| Task | Description | Effort | Priority | +| ------------------------------------------------------- | ------------------------- | ------ | -------- | +| **5.1** Integrate checkout UI with BFF endpoints | Wire up API calls | 1d | P0 | +| **5.2** Implement auth token handling post-registration | Auto-login after signup | 0.5d | P0 | +| **5.3** E2E tests - new customer flow | Full journey testing | 1d | P0 | +| **5.4** E2E tests - existing customer flow | Sign-in during checkout | 0.5d | P0 | +| **5.5** E2E tests - cart recovery | Abandoned cart scenarios | 0.5d | P1 | +| **5.6** Performance testing | Load testing checkout | 0.5d | P1 | +| **5.7** Security review | Auth, CSRF, rate limiting | 1d | P0 | + +**Deliverable**: Fully tested, production-ready checkout flow. + +### Phase 6: Public Support (3 days) + +| Task | Description | Effort | Priority | +| --------------------------------------------------- | -------------------------- | ------ | -------- | +| **6.1** Create public support landing page | FAQ, contact info | 1d | P1 | +| **6.2** Create public contact form | No auth required | 1d | P1 | +| **6.3** Add `POST /support/contact` public endpoint | Creates Lead in Salesforce | 1d | P1 | + +**Deliverable**: Non-authenticated users can get help and contact support. + +### Phase 7: Polish & Launch Prep (4 days) + +| Task | Description | Effort | Priority | +| ------------------------------------- | ---------------------------- | ------ | -------- | +| **7.1** Error handling polish | User-friendly error messages | 1d | P0 | +| **7.2** Loading states and skeletons | Smooth UX during async ops | 0.5d | P0 | +| **7.3** Mobile responsiveness testing | All breakpoints | 0.5d | P0 | +| **7.4** Analytics integration | Track funnel, conversions | 0.5d | P1 | +| **7.5** Documentation update | README, guides | 0.5d | P1 | +| **7.6** Feature flag configuration | Gradual rollout setup | 0.5d | P0 | +| **7.7** Production deployment prep | Env configs, monitoring | 0.5d | P0 | + +**Deliverable**: Production-ready feature. + +--- + +## Timeline Summary + +``` +Week 1: Phase 0 (Prep) + Phase 1 (Public Catalog) +Week 2: Phase 2 (Cart) + Phase 3 Start (Checkout UI) +Week 3: Phase 3 Complete (Checkout UI) +Week 4: Phase 4 (Backend - SF Account Creation) +Week 5: Phase 4 Complete + Phase 5 (Integration Testing) +Week 6: Phase 6 (Public Support) + Phase 7 (Polish) +Week 7: Buffer + Launch + +Total: ~7 weeks +``` + +### Critical Path + +The **Salesforce Account creation** (Phase 4) is on the critical path because: + +1. Orders require a valid SF Account ID +2. WHMCS must be linked to SF Account +3. All tracking/reporting depends on SF data + +Ensure SF integration user has proper permissions before starting Phase 4. + +--- + +## API Changes + +### New Endpoints + +| Method | Path | Auth | Description | +| ------ | ------------------------------ | -------- | --------------------------- | +| `POST` | `/api/checkout/register` | Public | Create user during checkout | +| `GET` | `/api/checkout/payment-status` | Required | Check payment method status | +| `POST` | `/api/checkout/cart` | Public | Build cart from config | +| `POST` | `/api/checkout/submit` | Required | Submit order | +| `POST` | `/api/support/contact` | Public | Public contact form | + +### Modified Endpoints + +| Method | Path | Change | +| ------ | ------------------ | ------------------------- | +| `GET` | `/api/catalog/*` | Add `@Public()` decorator | +| `POST` | `/api/auth/signup` | Make sfNumber optional | + +### New Schemas + +```typescript +// packages/domain/checkout/schema.ts + +export const checkoutRegisterSchema = z.object({ + email: z.string().email(), + firstName: z.string().min(1), + lastName: z.string().min(1), + phone: z.string().min(1), + phoneCountryCode: z.string().regex(/^\+\d{1,4}$/), + password: z.string().min(8), + address: addressFormSchema, + acceptTerms: z.literal(true), + marketingConsent: z.boolean().optional(), +}); + +export const checkoutSubmitSchema = z.object({ + orderType: orderTypeSchema, + skus: z.array(z.string()).min(1), + configuration: z.record(z.unknown()).optional(), +}); + +export const publicContactSchema = z.object({ + email: z.string().email(), + name: z.string().min(1), + subject: z.string().min(1), + message: z.string().min(10), + phone: z.string().optional(), +}); +``` + +--- + +## Database Changes + +### Portal Database - No Schema Changes Required + +The current database schema supports this feature: + +- `User` table already stores auth credentials +- `IdMapping` table already has `sfAccountId` and `sfContactId` fields +- No new tables needed + +### Salesforce - New Field Requirements + +Ensure these fields exist and are writable by the integration user: + +| Object | Field | Type | Purpose | +| ------- | ------------------------------- | -------- | ---------------------------------- | +| Account | `SF_Account_No__c` | Text | Unique customer number | +| Account | `WH_Account__c` | Text | WHMCS Client ID link | +| Account | `Portal_Status__c` | Picklist | Active/Inactive | +| Account | `Portal_Registration_Source__c` | Picklist | Add "Portal Checkout" value | +| Contact | Standard fields | - | First, Last, Email, Phone, Address | + +### Salesforce - Customer Number Generation + +New accounts created through checkout will have auto-generated customer numbers: + +- Format: `PNNNNNNN` (P prefix + 7 digits) +- Example: `P1000001`, `P1000002` +- The "P" prefix distinguishes portal-originated customers from legacy customers + +### WHMCS - No Changes Required + +WHMCS client creation already works; just need to: + +- Set the customer number custom field with the generated SF_Account_No\_\_c +- Ensure the client is created with proper address data + +--- + +## Testing Strategy + +### Unit Tests + +| Component | Coverage Target | +| ------------------------- | --------------- | +| CheckoutStore | 100% | +| Cart validation utilities | 100% | +| Checkout steps | 90% | +| BFF CheckoutService | 90% | + +### Integration Tests + +| Flow | Scenarios | +| -------------------------- | ---------------------------------------------- | +| New customer checkout | Happy path, validation errors, payment failure | +| Existing customer checkout | Sign-in flow, pre-filled data | +| Cart persistence | Page refresh, browser close, storage limits | + +### E2E Tests + +| Test | Description | +| ------------------------------------ | ------------------------------------------------ | +| `checkout-new-customer.spec.ts` | Full flow: browse → configure → checkout → order | +| `checkout-existing-customer.spec.ts` | Sign-in during checkout | +| `checkout-cart-recovery.spec.ts` | Abandoned cart scenarios | +| `public-catalog.spec.ts` | Catalog access without auth | + +### Manual Testing Checklist + +- [ ] Mobile responsive design +- [ ] Screen reader accessibility +- [ ] Keyboard navigation +- [ ] WHMCS payment flow in different browsers +- [ ] Error recovery scenarios +- [ ] Rate limiting behavior +- [ ] Session timeout during checkout + +--- + +## Rollout Plan + +### Stage 1: Internal Testing (1 week) + +- Deploy to staging +- Internal team testing +- Bug fixes + +### Stage 2: Beta (1 week) + +- Enable for 10% of traffic via feature flag +- Monitor error rates, conversion +- Gather feedback + +### Stage 3: Gradual Rollout (2 weeks) + +- 25% → 50% → 75% → 100% +- Monitor metrics at each stage +- Rollback plan ready + +### Stage 4: Full Launch + +- Remove feature flag +- Update marketing materials +- Announce to existing customers + +--- + +## Risks & Mitigations + +| Risk | Likelihood | Impact | Mitigation | +| ---------------------------------------------- | ---------- | ------------ | --------------------------------------------------------------- | +| **Salesforce Account creation fails** | Medium | **Critical** | Robust error handling, retry logic, clear user messaging | +| **Salesforce API rate limits** | Medium | High | Implement queuing if needed, monitor API usage | +| Salesforce permission issues | Medium | High | Pre-validate permissions in Phase 0, test with integration user | +| WHMCS SSO breaks checkout flow | Medium | High | Implement iframe fallback, clear error messaging | +| Partial registration (SF created, WHMCS fails) | Medium | High | Rollback logic, manual cleanup process documented | +| Cart data corruption | Low | Medium | Robust validation, version migration logic | +| Rate limiting blocks legitimate users | Low | Medium | Tune limits, add captcha fallback | +| Performance degradation (SF calls slow) | Medium | Medium | Add timeouts, show progress indicators | +| Duplicate SF Account creation | Low | Medium | Check for existing account by email before creating | + +### Salesforce-Specific Risks + +| Risk | Mitigation | +| ---------------------------------------- | ------------------------------------------------------ | +| Integration user lacks create permission | Test all CRUD operations in sandbox first | +| Required fields missing in payload | Document all required fields, validate before API call | +| Customer number collision | Use database sequence or SF auto-number field | +| SF sandbox vs production differences | Test in production-like sandbox with same config | + +--- + +## Success Metrics + +### Primary Metrics + +| Metric | Current | Target | Measurement | +| ------------------------ | -------- | ------ | ------------------------- | +| Catalog page views | N/A | +200% | Analytics | +| Checkout start rate | ~20% | 40% | Cart creation / plan view | +| Checkout completion rate | ~30% | 50% | Orders / checkout starts | +| New customer acquisition | Baseline | +50% | New accounts | + +### Secondary Metrics + +| Metric | Target | +| -------------------------- | ------------ | +| Time to first order | < 10 minutes | +| Cart abandonment rate | < 60% | +| Payment step completion | > 70% | +| Error rate during checkout | < 2% | + +--- + +## Appendix A: Component File Structure + +``` +apps/portal/src/features/checkout/ +├── components/ +│ ├── CheckoutLayout.tsx +│ ├── CheckoutProgress.tsx +│ ├── CheckoutWizard.tsx +│ ├── OrderSummaryCard.tsx +│ ├── EmptyCartRedirect.tsx +│ └── steps/ +│ ├── AccountStep.tsx +│ ├── AddressStep.tsx +│ ├── PaymentStep.tsx +│ └── ReviewStep.tsx +├── hooks/ +│ ├── useCheckout.ts # Main checkout logic +│ ├── useCheckoutNavigation.ts # Step navigation +│ ├── usePaymentPolling.ts # WHMCS polling +│ └── useCartRecovery.ts # Abandoned cart +├── services/ +│ ├── checkout.service.ts # API calls +│ └── cart.service.ts # Cart utilities +├── stores/ +│ └── checkout.store.ts # Zustand store +├── utils/ +│ ├── cart-validation.ts +│ └── checkout-analytics.ts +└── index.ts +``` + +--- + +## Appendix B: Feature Flag Configuration + +```typescript +// Feature flags for gradual rollout +export const FEATURE_FLAGS = { + PUBLIC_CATALOG: 'public-catalog-enabled', + UNIFIED_CHECKOUT: 'unified-checkout-enabled', + CHECKOUT_REGISTRATION: 'checkout-registration-enabled', + PUBLIC_SUPPORT: 'public-support-enabled', +}; + +// Usage in components +function CatalogPage() { + const isPublicCatalogEnabled = useFeatureFlag(FEATURE_FLAGS.PUBLIC_CATALOG); + + if (!isPublicCatalogEnabled && !isAuthenticated) { + redirect('/auth/login'); + } + + return ; +} +``` + +--- + +## Document History + +| Version | Date | Author | Changes | +| ------- | ---------- | ------ | --------------- | +| 1.0 | 2024-12-17 | - | Initial version | From ce426649657f59d0ede437ba2984b2f1bed34468 Mon Sep 17 00:00:00 2001 From: barsa Date: Wed, 17 Dec 2025 14:07:22 +0900 Subject: [PATCH 02/35] Add Checkout Registration Module and Enhance Public Contact Features - Integrated CheckoutRegistrationModule into the application for handling checkout-related functionalities. - Updated router configuration to include the new CheckoutRegistrationModule for API routing. - Enhanced SalesforceAccountService with methods for account creation and email lookup to support checkout registration. - Implemented public contact form functionality in SupportController, allowing unauthenticated users to submit inquiries. - Added rate limiting to the public contact form to prevent spam submissions. - Updated CatalogController and CheckoutController to allow public access for browsing and cart validation without authentication. --- apps/bff/src/app.module.ts | 2 + apps/bff/src/core/config/router.config.ts | 2 + .../salesforce/salesforce.module.ts | 1 + .../services/salesforce-account.service.ts | 214 +++++++++++ .../services/salesforce-case.service.ts | 53 +++ .../src/integrations/whmcs/whmcs.module.ts | 1 + .../src/modules/catalog/catalog.controller.ts | 2 + .../checkout-registration.controller.ts | 132 +++++++ .../checkout-registration.module.ts | 25 ++ .../services/checkout-registration.service.ts | 331 +++++++++++++++++ .../orders/controllers/checkout.controller.ts | 2 + .../src/modules/support/support.controller.ts | 64 +++- .../src/modules/support/support.service.ts | 38 ++ apps/portal/next-env.d.ts | 2 +- .../src/app/(public)/help/contact/page.tsx | 11 + apps/portal/src/app/(public)/help/page.tsx | 11 + .../src/app/(public)/order/complete/page.tsx | 11 + apps/portal/src/app/(public)/order/layout.tsx | 11 + .../portal/src/app/(public)/order/loading.tsx | 43 +++ apps/portal/src/app/(public)/order/page.tsx | 11 + .../(public)/shop/internet/configure/page.tsx | 11 + .../src/app/(public)/shop/internet/page.tsx | 11 + apps/portal/src/app/(public)/shop/layout.tsx | 11 + apps/portal/src/app/(public)/shop/loading.tsx | 28 ++ apps/portal/src/app/(public)/shop/page.tsx | 11 + .../app/(public)/shop/sim/configure/page.tsx | 11 + .../portal/src/app/(public)/shop/sim/page.tsx | 11 + .../portal/src/app/(public)/shop/vpn/page.tsx | 11 + .../templates/CatalogShell/CatalogShell.tsx | 125 +++++++ .../templates/CatalogShell/index.ts | 2 + .../templates/PublicShell/PublicShell.tsx | 16 +- apps/portal/src/components/templates/index.ts | 3 + apps/portal/src/config/feature-flags.ts | 42 +++ .../components/internet/InternetPlanCard.tsx | 6 +- .../catalog/views/PublicCatalogHome.tsx | 107 ++++++ .../catalog/views/PublicInternetConfigure.tsx | 52 +++ .../catalog/views/PublicInternetPlans.tsx | 158 ++++++++ .../catalog/views/PublicSimConfigure.tsx | 31 ++ .../features/catalog/views/PublicSimPlans.tsx | 306 +++++++++++++++ .../features/catalog/views/PublicVpnPlans.tsx | 120 ++++++ .../components/CheckoutErrorBoundary.tsx | 73 ++++ .../checkout/components/CheckoutProgress.tsx | 128 +++++++ .../checkout/components/CheckoutShell.tsx | 86 +++++ .../checkout/components/CheckoutWizard.tsx | 91 +++++ .../checkout/components/EmptyCartRedirect.tsx | 43 +++ .../checkout/components/OrderConfirmation.tsx | 104 ++++++ .../checkout/components/OrderSummaryCard.tsx | 82 ++++ .../src/features/checkout/components/index.ts | 8 + .../checkout/components/steps/AccountStep.tsx | 350 ++++++++++++++++++ .../checkout/components/steps/AddressStep.tsx | 213 +++++++++++ .../checkout/components/steps/PaymentStep.tsx | 237 ++++++++++++ .../checkout/components/steps/ReviewStep.tsx | 231 ++++++++++++ .../checkout/components/steps/index.ts | 4 + .../features/checkout/hooks/useCheckout.ts | 8 +- .../checkout/services/checkout-api.service.ts | 110 ++++++ .../checkout/stores/checkout.store.ts | 231 ++++++++++++ .../landing-page/views/PublicLandingView.tsx | 90 ++++- .../support/views/PublicContactView.tsx | 200 ++++++++++ .../support/views/PublicSupportView.tsx | 135 +++++++ packages/domain/checkout/index.ts | 7 + packages/domain/checkout/schema.ts | 156 ++++++++ packages/domain/index.ts | 17 +- packages/domain/package.json | 8 + packages/domain/tsconfig.json | 1 + 64 files changed, 4630 insertions(+), 23 deletions(-) create mode 100644 apps/bff/src/modules/checkout-registration/checkout-registration.controller.ts create mode 100644 apps/bff/src/modules/checkout-registration/checkout-registration.module.ts create mode 100644 apps/bff/src/modules/checkout-registration/services/checkout-registration.service.ts create mode 100644 apps/portal/src/app/(public)/help/contact/page.tsx create mode 100644 apps/portal/src/app/(public)/help/page.tsx create mode 100644 apps/portal/src/app/(public)/order/complete/page.tsx create mode 100644 apps/portal/src/app/(public)/order/layout.tsx create mode 100644 apps/portal/src/app/(public)/order/loading.tsx create mode 100644 apps/portal/src/app/(public)/order/page.tsx create mode 100644 apps/portal/src/app/(public)/shop/internet/configure/page.tsx create mode 100644 apps/portal/src/app/(public)/shop/internet/page.tsx create mode 100644 apps/portal/src/app/(public)/shop/layout.tsx create mode 100644 apps/portal/src/app/(public)/shop/loading.tsx create mode 100644 apps/portal/src/app/(public)/shop/page.tsx create mode 100644 apps/portal/src/app/(public)/shop/sim/configure/page.tsx create mode 100644 apps/portal/src/app/(public)/shop/sim/page.tsx create mode 100644 apps/portal/src/app/(public)/shop/vpn/page.tsx create mode 100644 apps/portal/src/components/templates/CatalogShell/CatalogShell.tsx create mode 100644 apps/portal/src/components/templates/CatalogShell/index.ts create mode 100644 apps/portal/src/config/feature-flags.ts create mode 100644 apps/portal/src/features/catalog/views/PublicCatalogHome.tsx create mode 100644 apps/portal/src/features/catalog/views/PublicInternetConfigure.tsx create mode 100644 apps/portal/src/features/catalog/views/PublicInternetPlans.tsx create mode 100644 apps/portal/src/features/catalog/views/PublicSimConfigure.tsx create mode 100644 apps/portal/src/features/catalog/views/PublicSimPlans.tsx create mode 100644 apps/portal/src/features/catalog/views/PublicVpnPlans.tsx create mode 100644 apps/portal/src/features/checkout/components/CheckoutErrorBoundary.tsx create mode 100644 apps/portal/src/features/checkout/components/CheckoutProgress.tsx create mode 100644 apps/portal/src/features/checkout/components/CheckoutShell.tsx create mode 100644 apps/portal/src/features/checkout/components/CheckoutWizard.tsx create mode 100644 apps/portal/src/features/checkout/components/EmptyCartRedirect.tsx create mode 100644 apps/portal/src/features/checkout/components/OrderConfirmation.tsx create mode 100644 apps/portal/src/features/checkout/components/OrderSummaryCard.tsx create mode 100644 apps/portal/src/features/checkout/components/index.ts create mode 100644 apps/portal/src/features/checkout/components/steps/AccountStep.tsx create mode 100644 apps/portal/src/features/checkout/components/steps/AddressStep.tsx create mode 100644 apps/portal/src/features/checkout/components/steps/PaymentStep.tsx create mode 100644 apps/portal/src/features/checkout/components/steps/ReviewStep.tsx create mode 100644 apps/portal/src/features/checkout/components/steps/index.ts create mode 100644 apps/portal/src/features/checkout/services/checkout-api.service.ts create mode 100644 apps/portal/src/features/checkout/stores/checkout.store.ts create mode 100644 apps/portal/src/features/support/views/PublicContactView.tsx create mode 100644 apps/portal/src/features/support/views/PublicSupportView.tsx create mode 100644 packages/domain/checkout/index.ts create mode 100644 packages/domain/checkout/schema.ts diff --git a/apps/bff/src/app.module.ts b/apps/bff/src/app.module.ts index 15e46b8c..8bb40128 100644 --- a/apps/bff/src/app.module.ts +++ b/apps/bff/src/app.module.ts @@ -29,6 +29,7 @@ import { AuthModule } from "@bff/modules/auth/auth.module.js"; import { UsersModule } from "@bff/modules/users/users.module.js"; import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js"; import { CatalogModule } from "@bff/modules/catalog/catalog.module.js"; +import { CheckoutRegistrationModule } from "@bff/modules/checkout-registration/checkout-registration.module.js"; import { OrdersModule } from "@bff/modules/orders/orders.module.js"; import { InvoicesModule } from "@bff/modules/invoices/invoices.module.js"; import { SubscriptionsModule } from "@bff/modules/subscriptions/subscriptions.module.js"; @@ -79,6 +80,7 @@ import { HealthModule } from "@bff/modules/health/health.module.js"; UsersModule, MappingsModule, CatalogModule, + CheckoutRegistrationModule, OrdersModule, InvoicesModule, SubscriptionsModule, diff --git a/apps/bff/src/core/config/router.config.ts b/apps/bff/src/core/config/router.config.ts index f82d32e0..18322b1b 100644 --- a/apps/bff/src/core/config/router.config.ts +++ b/apps/bff/src/core/config/router.config.ts @@ -10,6 +10,7 @@ import { CurrencyModule } from "@bff/modules/currency/currency.module.js"; import { SecurityModule } from "@bff/core/security/security.module.js"; import { SupportModule } from "@bff/modules/support/support.module.js"; import { RealtimeApiModule } from "@bff/modules/realtime/realtime.module.js"; +import { CheckoutRegistrationModule } from "@bff/modules/checkout-registration/checkout-registration.module.js"; export const apiRoutes: Routes = [ { @@ -26,6 +27,7 @@ export const apiRoutes: Routes = [ { path: "", module: SupportModule }, { path: "", module: SecurityModule }, { path: "", module: RealtimeApiModule }, + { path: "", module: CheckoutRegistrationModule }, ], }, ]; diff --git a/apps/bff/src/integrations/salesforce/salesforce.module.ts b/apps/bff/src/integrations/salesforce/salesforce.module.ts index b45b1b03..36f99625 100644 --- a/apps/bff/src/integrations/salesforce/salesforce.module.ts +++ b/apps/bff/src/integrations/salesforce/salesforce.module.ts @@ -25,6 +25,7 @@ import { SalesforceWriteThrottleGuard } from "./guards/salesforce-write-throttle QueueModule, SalesforceService, SalesforceConnection, + SalesforceAccountService, SalesforceOrderService, SalesforceCaseService, SalesforceReadThrottleGuard, diff --git a/apps/bff/src/integrations/salesforce/services/salesforce-account.service.ts b/apps/bff/src/integrations/salesforce/services/salesforce-account.service.ts index 9d163f9d..83bb7057 100644 --- a/apps/bff/src/integrations/salesforce/services/salesforce-account.service.ts +++ b/apps/bff/src/integrations/salesforce/services/salesforce-account.service.ts @@ -132,6 +132,183 @@ export class SalesforceAccountService { return input.replace(/'/g, "\\'"); } + // ============================================================================ + // Account Creation Methods (for Checkout Registration) + // ============================================================================ + + /** + * Check if a Salesforce account exists with the given email + * Used to prevent duplicate account creation during checkout + */ + async findByEmail(email: string): Promise<{ id: string; accountNumber: string } | null> { + try { + // Search for Contact with matching email and get the associated Account + const result = (await this.connection.query( + `SELECT Account.Id, Account.SF_Account_No__c FROM Contact WHERE Email = '${this.safeSoql(email)}' LIMIT 1`, + { label: "checkout:findAccountByEmail" } + )) as SalesforceResponse<{ Account: { Id: string; SF_Account_No__c: string } }>; + + if (result.totalSize > 0 && result.records[0]?.Account) { + return { + id: result.records[0].Account.Id, + accountNumber: result.records[0].Account.SF_Account_No__c, + }; + } + + return null; + } catch (error) { + this.logger.error("Failed to find account by email", { + error: getErrorMessage(error), + }); + return null; + } + } + + /** + * Create a new Salesforce Account for a new customer + * Used when customer signs up through checkout (no existing sfNumber) + * + * @returns The created account ID and auto-generated account number + */ + async createAccount( + data: CreateSalesforceAccountRequest + ): Promise<{ accountId: string; accountNumber: string }> { + this.logger.log("Creating new Salesforce Account", { email: data.email }); + + // Generate unique account number (SF_Account_No__c) + const accountNumber = await this.generateAccountNumber(); + + const accountPayload = { + Name: `${data.firstName} ${data.lastName}`, + SF_Account_No__c: accountNumber, + BillingStreet: + data.address.address1 + (data.address.address2 ? `\n${data.address.address2}` : ""), + BillingCity: data.address.city, + BillingState: data.address.state, + BillingPostalCode: data.address.postcode, + BillingCountry: data.address.country, + Phone: data.phone, + // Portal tracking fields + [this.portalStatusField]: "Active", + [this.portalSourceField]: "Portal Checkout", + }; + + try { + const createMethod = this.connection.sobject("Account").create; + if (!createMethod) { + throw new Error("Salesforce create method not available"); + } + + const result = await createMethod(accountPayload); + + if (!result || typeof result !== "object" || !("id" in result)) { + throw new Error("Salesforce Account creation failed - no ID returned"); + } + + const accountId = result.id as string; + + this.logger.log("Salesforce Account created", { + accountId, + accountNumber, + }); + + return { + accountId, + accountNumber, + }; + } catch (error) { + this.logger.error("Failed to create Salesforce Account", { + error: getErrorMessage(error), + email: data.email, + }); + throw new Error("Failed to create customer account in CRM"); + } + } + + /** + * Create a Contact associated with an Account + */ + async createContact(data: CreateSalesforceContactRequest): Promise<{ contactId: string }> { + this.logger.log("Creating Salesforce Contact", { + accountId: data.accountId, + email: data.email, + }); + + const contactPayload = { + AccountId: data.accountId, + FirstName: data.firstName, + LastName: data.lastName, + Email: data.email, + Phone: data.phone, + MailingStreet: + data.address.address1 + (data.address.address2 ? `\n${data.address.address2}` : ""), + MailingCity: data.address.city, + MailingState: data.address.state, + MailingPostalCode: data.address.postcode, + MailingCountry: data.address.country, + }; + + try { + const createMethod = this.connection.sobject("Contact").create; + if (!createMethod) { + throw new Error("Salesforce create method not available"); + } + + const result = await createMethod(contactPayload); + + if (!result || typeof result !== "object" || !("id" in result)) { + throw new Error("Salesforce Contact creation failed - no ID returned"); + } + + const contactId = result.id as string; + + this.logger.log("Salesforce Contact created", { contactId }); + return { contactId }; + } catch (error) { + this.logger.error("Failed to create Salesforce Contact", { + error: getErrorMessage(error), + accountId: data.accountId, + }); + throw new Error("Failed to create customer contact in CRM"); + } + } + + /** + * Generate a unique customer number for new accounts + * Format: PNNNNNNN (P = Portal, 7 digits) + */ + private async generateAccountNumber(): Promise { + try { + // Query for max existing portal account number + const result = (await this.connection.query( + `SELECT SF_Account_No__c FROM Account WHERE SF_Account_No__c LIKE 'P%' ORDER BY SF_Account_No__c DESC LIMIT 1`, + { label: "checkout:getMaxAccountNumber" } + )) as SalesforceResponse<{ SF_Account_No__c: string }>; + + let nextNumber = 1000001; // Start from P1000001 + + if (result.totalSize > 0 && result.records[0]?.SF_Account_No__c) { + const lastNumber = result.records[0].SF_Account_No__c; + const numPart = parseInt(lastNumber.substring(1), 10); + if (!isNaN(numPart)) { + nextNumber = numPart + 1; + } + } + + return `P${nextNumber}`; + } catch (error) { + this.logger.error("Failed to generate account number, using timestamp fallback", { + error: getErrorMessage(error), + }); + // Fallback: use timestamp to ensure uniqueness + return `P${Date.now().toString().slice(-7)}`; + } + } + + // ============================================================================ + // Portal Field Update Methods + // ============================================================================ + async updatePortalFields( accountId: string, update: SalesforceAccountPortalUpdate @@ -189,3 +366,40 @@ export interface SalesforceAccountPortalUpdate { lastSignedInAt?: Date; whmcsAccountId?: string | number | null; } + +/** + * Request type for creating a new Salesforce Account + */ +export interface CreateSalesforceAccountRequest { + firstName: string; + lastName: string; + email: string; + phone: string; + address: { + address1: string; + address2?: string; + city: string; + state: string; + postcode: string; + country: string; + }; +} + +/** + * Request type for creating a new Salesforce Contact + */ +export interface CreateSalesforceContactRequest { + accountId: string; + firstName: string; + lastName: string; + email: string; + phone: string; + address: { + address1: string; + address2?: string; + city: string; + state: string; + postcode: string; + country: string; + }; +} diff --git a/apps/bff/src/integrations/salesforce/services/salesforce-case.service.ts b/apps/bff/src/integrations/salesforce/services/salesforce-case.service.ts index 43dd3487..ea9fab7b 100644 --- a/apps/bff/src/integrations/salesforce/services/salesforce-case.service.ts +++ b/apps/bff/src/integrations/salesforce/services/salesforce-case.service.ts @@ -185,6 +185,59 @@ export class SalesforceCaseService { } } + /** + * Create a Web-to-Case for public contact form submissions + * Does not require an Account - uses supplied contact info + */ + async createWebCase(params: { + subject: string; + description: string; + suppliedEmail: string; + suppliedName: string; + suppliedPhone?: string; + origin?: string; + priority?: string; + }): Promise<{ id: string; caseNumber: string }> { + this.logger.log("Creating Web-to-Case", { email: params.suppliedEmail }); + + const casePayload: Record = { + Origin: params.origin ?? "Web", + Status: SALESFORCE_CASE_STATUS.NEW, + Priority: params.priority ?? SALESFORCE_CASE_PRIORITY.MEDIUM, + Subject: params.subject.trim(), + Description: params.description.trim(), + SuppliedEmail: params.suppliedEmail, + SuppliedName: params.suppliedName, + SuppliedPhone: params.suppliedPhone ?? null, + }; + + try { + const created = (await this.sf.sobject("Case").create(casePayload)) as { id?: string }; + + if (!created.id) { + throw new Error("Salesforce did not return a case ID"); + } + + // Fetch the created case to get the CaseNumber + const createdCase = await this.getCaseByIdInternal(created.id); + const caseNumber = createdCase?.CaseNumber ?? created.id; + + this.logger.log("Web-to-Case created successfully", { + caseId: created.id, + caseNumber, + email: params.suppliedEmail, + }); + + return { id: created.id, caseNumber }; + } catch (error: unknown) { + this.logger.error("Failed to create Web-to-Case", { + error: getErrorMessage(error), + email: params.suppliedEmail, + }); + throw new Error("Failed to create contact request"); + } + } + /** * Internal method to fetch case without account validation (for post-create lookup) */ diff --git a/apps/bff/src/integrations/whmcs/whmcs.module.ts b/apps/bff/src/integrations/whmcs/whmcs.module.ts index fe61822f..f93d1214 100644 --- a/apps/bff/src/integrations/whmcs/whmcs.module.ts +++ b/apps/bff/src/integrations/whmcs/whmcs.module.ts @@ -39,6 +39,7 @@ import { WhmcsErrorHandlerService } from "./connection/services/whmcs-error-hand WhmcsService, WhmcsConnectionOrchestratorService, WhmcsCacheService, + WhmcsClientService, WhmcsOrderService, WhmcsPaymentService, WhmcsCurrencyService, diff --git a/apps/bff/src/modules/catalog/catalog.controller.ts b/apps/bff/src/modules/catalog/catalog.controller.ts index 3b85bde1..53a09f4f 100644 --- a/apps/bff/src/modules/catalog/catalog.controller.ts +++ b/apps/bff/src/modules/catalog/catalog.controller.ts @@ -1,6 +1,7 @@ import { Controller, Get, Request, UseGuards, Header } from "@nestjs/common"; import { RateLimitGuard, RateLimit } from "@bff/core/rate-limiting/index.js"; import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; +import { Public } from "@bff/modules/auth/decorators/public.decorator.js"; import { parseInternetCatalog, parseSimCatalog, @@ -18,6 +19,7 @@ import { VpnCatalogService } from "./services/vpn-catalog.service.js"; import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js"; @Controller("catalog") +@Public() // Allow public access - catalog can be browsed without authentication @UseGuards(SalesforceReadThrottleGuard, RateLimitGuard) export class CatalogController { constructor( diff --git a/apps/bff/src/modules/checkout-registration/checkout-registration.controller.ts b/apps/bff/src/modules/checkout-registration/checkout-registration.controller.ts new file mode 100644 index 00000000..9bf3b5cd --- /dev/null +++ b/apps/bff/src/modules/checkout-registration/checkout-registration.controller.ts @@ -0,0 +1,132 @@ +import { + Body, + Controller, + Post, + Get, + Request, + UsePipes, + Inject, + Res, + UseGuards, +} from "@nestjs/common"; +import type { Response } from "express"; +import { Logger } from "nestjs-pino"; +import { ZodValidationPipe } from "nestjs-zod"; +import { z } from "zod"; +import { Public } from "@bff/modules/auth/decorators/public.decorator.js"; +import { RateLimit, RateLimitGuard } from "@bff/core/rate-limiting/index.js"; +import { CheckoutRegistrationService } from "./services/checkout-registration.service.js"; +import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; +import { + emailSchema, + passwordSchema, + nameSchema, + phoneSchema, +} from "@customer-portal/domain/common"; +import { addressFormSchema } from "@customer-portal/domain/customer"; + +// Define checkout register request schema here to avoid module resolution issues +const checkoutRegisterRequestSchema = z.object({ + email: emailSchema, + firstName: nameSchema, + lastName: nameSchema, + phone: phoneSchema, + phoneCountryCode: z.string().min(1, "Phone country code is required"), + password: passwordSchema, + address: addressFormSchema, + acceptTerms: z.literal(true, { message: "You must accept the terms and conditions" }), + marketingConsent: z.boolean().optional(), +}); + +type CheckoutRegisterRequest = z.infer; + +/** + * Checkout Registration Controller + * + * Handles registration during checkout flow. + */ +@Controller("checkout") +export class CheckoutRegistrationController { + constructor( + private readonly checkoutService: CheckoutRegistrationService, + @Inject(Logger) private readonly logger: Logger + ) {} + + /** + * Register a new user during checkout + * + * IMPORTANT: This creates accounts in ALL systems synchronously: + * 1. Salesforce Account + Contact (for CRM tracking) + * 2. WHMCS Client (for billing) + * 3. Portal User (for authentication) + * + * Returns auth tokens so user is immediately logged in + */ + @Post("register") + @Public() + @UseGuards(RateLimitGuard) + @RateLimit({ limit: 5, ttl: 60 }) // 5 requests per minute + @UsePipes(new ZodValidationPipe(checkoutRegisterRequestSchema)) + async register( + @Body() body: CheckoutRegisterRequest, + @Res({ passthrough: true }) response: Response + ) { + this.logger.log("Checkout registration request", { email: body.email }); + + try { + const result = await this.checkoutService.registerForCheckout(body); + + // Set auth cookies + if (result.tokens) { + response.cookie("access_token", result.tokens.accessToken, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 15 * 60 * 1000, // 15 minutes + }); + response.cookie("refresh_token", result.tokens.refreshToken, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days + }); + } + + return { + success: true, + user: result.user, + session: result.session, + sfAccountNumber: result.sfAccountNumber, + }; + } catch (error) { + this.logger.error("Checkout registration failed", { + error: error instanceof Error ? error.message : String(error), + email: body.email, + }); + throw error; + } + } + + /** + * Check if current user has valid payment method + * Used by checkout to gate the review step + */ + @Get("payment-status") + async getPaymentStatus(@Request() req: RequestWithUser) { + const userId = req.user?.id; + if (!userId) { + return { hasPaymentMethod: false }; + } + + try { + const status = await this.checkoutService.getPaymentStatus(userId); + return status; + } catch (error) { + this.logger.error("Failed to get payment status", { + error: error instanceof Error ? error.message : String(error), + userId, + }); + return { hasPaymentMethod: false }; + } + } +} diff --git a/apps/bff/src/modules/checkout-registration/checkout-registration.module.ts b/apps/bff/src/modules/checkout-registration/checkout-registration.module.ts new file mode 100644 index 00000000..762a25ac --- /dev/null +++ b/apps/bff/src/modules/checkout-registration/checkout-registration.module.ts @@ -0,0 +1,25 @@ +import { Module } from "@nestjs/common"; +import { CheckoutRegistrationController } from "./checkout-registration.controller.js"; +import { CheckoutRegistrationService } from "./services/checkout-registration.service.js"; +import { SalesforceModule } from "@bff/integrations/salesforce/salesforce.module.js"; +import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module.js"; +import { AuthModule } from "@bff/modules/auth/auth.module.js"; +import { UsersModule } from "@bff/modules/users/users.module.js"; +import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js"; + +/** + * Checkout Registration Module + * + * Handles user registration during checkout flow: + * - Creates Salesforce Account and Contact + * - Creates WHMCS Client + * - Creates Portal User + * - Links all systems via ID Mappings + */ +@Module({ + imports: [SalesforceModule, WhmcsModule, AuthModule, UsersModule, MappingsModule], + controllers: [CheckoutRegistrationController], + providers: [CheckoutRegistrationService], + exports: [CheckoutRegistrationService], +}) +export class CheckoutRegistrationModule {} diff --git a/apps/bff/src/modules/checkout-registration/services/checkout-registration.service.ts b/apps/bff/src/modules/checkout-registration/services/checkout-registration.service.ts new file mode 100644 index 00000000..2fd40a5a --- /dev/null +++ b/apps/bff/src/modules/checkout-registration/services/checkout-registration.service.ts @@ -0,0 +1,331 @@ +import { BadRequestException, Inject, Injectable } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import * as argon2 from "argon2"; +import { PrismaService } from "@bff/infra/database/prisma.service.js"; +import { AuthTokenService } from "@bff/modules/auth/infra/token/token.service.js"; +import { SalesforceAccountService } from "@bff/integrations/salesforce/services/salesforce-account.service.js"; +import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.js"; +import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; +import { getErrorMessage } from "@bff/core/utils/error.util.js"; +import { WhmcsPaymentService } from "@bff/integrations/whmcs/services/whmcs-payment.service.js"; + +/** + * Request type for checkout registration + */ +interface CheckoutRegisterData { + email: string; + firstName: string; + lastName: string; + phone: string; + phoneCountryCode: string; + password: string; + address: { + address1: string; + address2?: string; + city: string; + state: string; + postcode: string; + country: string; + }; +} + +/** + * Checkout Registration Service + * + * Orchestrates the multi-step registration flow during checkout: + * 1. Create Salesforce Account (generates SF_Account_No__c) + * 2. Create Salesforce Contact (linked to Account) + * 3. Create WHMCS Client (for billing) + * 4. Update SF Account with WH_Account__c + * 5. Create Portal User (with password hash) + * 6. Create ID Mapping (links all system IDs) + * 7. Generate auth tokens (auto-login user) + */ +@Injectable() +export class CheckoutRegistrationService { + constructor( + private readonly prisma: PrismaService, + private readonly tokenService: AuthTokenService, + private readonly salesforceAccountService: SalesforceAccountService, + private readonly whmcsClientService: WhmcsClientService, + private readonly whmcsPaymentService: WhmcsPaymentService, + private readonly mappingsService: MappingsService, + @Inject(Logger) private readonly logger: Logger + ) {} + + /** + * Register a new customer during checkout + * + * CRITICAL: This creates accounts in ALL systems synchronously. + * If any step fails, we attempt rollback of previous steps. + */ + async registerForCheckout(data: CheckoutRegisterData): Promise<{ + user: { id: string; email: string; firstname: string; lastname: string }; + session: { expiresAt: string; refreshExpiresAt: string }; + tokens?: { accessToken: string; refreshToken: string }; + sfAccountNumber: string; + }> { + this.logger.log("Starting checkout registration", { email: data.email }); + + // Track created resources for rollback + let sfAccountId: string | null = null; + let sfContactId: string | null = null; + let sfAccountNumber: string | null = null; + let whmcsClientId: number | null = null; + let portalUserId: string | null = null; + + try { + // Check for existing account by email + const existingAccount = await this.salesforceAccountService.findByEmail(data.email); + if (existingAccount) { + throw new BadRequestException( + "An account with this email already exists. Please sign in instead." + ); + } + + // Check for existing portal user + const existingUser = await this.prisma.user.findUnique({ + where: { email: data.email.toLowerCase() }, + }); + if (existingUser) { + throw new BadRequestException( + "An account with this email already exists. Please sign in instead." + ); + } + + // Step 1: Create Salesforce Account + this.logger.log("Step 1: Creating Salesforce Account"); + const sfAccount = await this.salesforceAccountService.createAccount({ + firstName: data.firstName, + lastName: data.lastName, + email: data.email, + phone: this.formatPhone(data.phoneCountryCode, data.phone), + address: { + address1: data.address.address1, + address2: data.address.address2, + city: data.address.city, + state: data.address.state, + postcode: data.address.postcode, + country: data.address.country, + }, + }); + sfAccountId = sfAccount.accountId; + sfAccountNumber = sfAccount.accountNumber; + + // Step 2: Create Salesforce Contact + this.logger.log("Step 2: Creating Salesforce Contact"); + const sfContact = await this.salesforceAccountService.createContact({ + accountId: sfAccountId, + firstName: data.firstName, + lastName: data.lastName, + email: data.email, + phone: this.formatPhone(data.phoneCountryCode, data.phone), + address: { + address1: data.address.address1, + address2: data.address.address2, + city: data.address.city, + state: data.address.state, + postcode: data.address.postcode, + country: data.address.country, + }, + }); + sfContactId = sfContact.contactId; + + // Step 3: Create WHMCS Client + this.logger.log("Step 3: Creating WHMCS Client"); + const whmcsResult = await this.whmcsClientService.addClient({ + firstname: data.firstName, + lastname: data.lastName, + email: data.email, + phonenumber: this.formatPhone(data.phoneCountryCode, data.phone), + address1: data.address.address1, + city: data.address.city, + state: data.address.state, + postcode: data.address.postcode, + country: this.mapCountryToCode(data.address.country), + password2: data.password, + }); + whmcsClientId = whmcsResult.clientId; + + // Step 4: Update Salesforce Account with WHMCS ID + this.logger.log("Step 4: Linking Salesforce to WHMCS"); + await this.salesforceAccountService.updatePortalFields(sfAccountId, { + whmcsAccountId: whmcsClientId, + status: "Active", + source: "Portal Checkout", + }); + + // Step 5 & 6: Create Portal User and ID Mapping in transaction + this.logger.log("Step 5: Creating Portal User"); + const user = await this.prisma.$transaction(async tx => { + const passwordHash = await argon2.hash(data.password); + + const newUser = await tx.user.create({ + data: { + email: data.email.toLowerCase(), + passwordHash, + emailVerified: false, + }, + }); + + // Step 6: Create ID Mapping + this.logger.log("Step 6: Creating ID Mapping"); + await tx.idMapping.create({ + data: { + userId: newUser.id, + whmcsClientId: whmcsClientId!, + sfAccountId: sfAccountId!, + // Note: sfContactId is not in schema, stored in Salesforce Contact record + }, + }); + + return newUser; + }); + portalUserId = user.id; + + // Step 7: Generate auth tokens + this.logger.log("Step 7: Generating auth tokens"); + const tokens = await this.tokenService.generateTokenPair({ + id: user.id, + email: user.email, + }); + + this.logger.log("Checkout registration completed successfully", { + userId: user.id, + sfAccountId, + sfContactId, + sfAccountNumber, + whmcsClientId, + }); + + return { + user: { + id: user.id, + email: user.email, + firstname: data.firstName, + lastname: data.lastName, + }, + session: { + expiresAt: tokens.expiresAt, + refreshExpiresAt: tokens.refreshExpiresAt, + }, + tokens: { + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + }, + sfAccountNumber: sfAccountNumber, + }; + } catch (error) { + this.logger.error("Checkout registration failed, initiating rollback", { + error: getErrorMessage(error), + sfAccountId, + sfContactId, + whmcsClientId, + portalUserId, + }); + + // Rollback in reverse order + await this.rollbackRegistration({ + portalUserId, + whmcsClientId, + sfAccountId, + }); + + // Re-throw the original error + if (error instanceof BadRequestException) { + throw error; + } + throw new BadRequestException("Registration failed. Please try again or contact support."); + } + } + + /** + * Check if user has a valid payment method + */ + async getPaymentStatus(userId: string): Promise<{ hasPaymentMethod: boolean }> { + try { + const mapping = await this.mappingsService.findByUserId(userId); + if (!mapping?.whmcsClientId) { + return { hasPaymentMethod: false }; + } + + const paymentMethodList = await this.whmcsPaymentService.getPaymentMethods( + mapping.whmcsClientId, + userId + ); + + return { + hasPaymentMethod: paymentMethodList.totalCount > 0, + }; + } catch (error) { + this.logger.error("Failed to check payment status", { + error: getErrorMessage(error), + userId, + }); + return { hasPaymentMethod: false }; + } + } + + /** + * Rollback registration - best effort cleanup + */ + private async rollbackRegistration(resources: { + portalUserId: string | null; + whmcsClientId: number | null; + sfAccountId: string | null; + }): Promise { + // Portal user - can delete + if (resources.portalUserId) { + try { + await this.prisma.idMapping.deleteMany({ + where: { userId: resources.portalUserId }, + }); + await this.prisma.user.delete({ + where: { id: resources.portalUserId }, + }); + this.logger.log("Rollback: Deleted portal user", { userId: resources.portalUserId }); + } catch (e) { + this.logger.error("Rollback failed: Portal user", { error: getErrorMessage(e) }); + } + } + + // WHMCS client - log for manual cleanup (WHMCS doesn't support API deletion) + if (resources.whmcsClientId) { + this.logger.warn("Rollback: WHMCS client created but not deleted (requires manual cleanup)", { + clientId: resources.whmcsClientId, + }); + } + + // Salesforce Account - intentionally NOT deleted + // It's better to have an orphaned SF Account that can be cleaned up + // than to lose potential customer data + if (resources.sfAccountId) { + this.logger.warn("Rollback: Salesforce Account not deleted (intentional)", { + sfAccountId: resources.sfAccountId, + action: "Manual cleanup may be required", + }); + } + } + + /** + * Format phone number with country code + */ + private formatPhone(countryCode: string, phone: string): string { + const cc = countryCode.replace(/\D/g, ""); + const num = phone.replace(/\D/g, ""); + return `+${cc}.${num}`; + } + + /** + * Map country name to ISO code + */ + private mapCountryToCode(country: string): string { + const countryMap: Record = { + japan: "JP", + "united states": "US", + "united kingdom": "GB", + // Add more as needed + }; + return countryMap[country.toLowerCase()] || country.slice(0, 2).toUpperCase(); + } +} diff --git a/apps/bff/src/modules/orders/controllers/checkout.controller.ts b/apps/bff/src/modules/orders/controllers/checkout.controller.ts index 4d083665..d5ee4423 100644 --- a/apps/bff/src/modules/orders/controllers/checkout.controller.ts +++ b/apps/bff/src/modules/orders/controllers/checkout.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, Post, Request, UsePipes, Inject, UseGuards } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { ZodValidationPipe } from "nestjs-zod"; +import { Public } from "@bff/modules/auth/decorators/public.decorator.js"; import { CheckoutService } from "../services/checkout.service.js"; import { checkoutCartSchema, @@ -16,6 +17,7 @@ import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards const validateCartResponseSchema = apiSuccessResponseSchema(z.object({ valid: z.boolean() })); @Controller("checkout") +@Public() // Cart building and validation can be done without authentication export class CheckoutController { constructor( private readonly checkoutService: CheckoutService, diff --git a/apps/bff/src/modules/support/support.controller.ts b/apps/bff/src/modules/support/support.controller.ts index 496f9dd6..1db80d73 100644 --- a/apps/bff/src/modules/support/support.controller.ts +++ b/apps/bff/src/modules/support/support.controller.ts @@ -1,6 +1,20 @@ -import { Controller, Get, Post, Query, Param, Body, Request } from "@nestjs/common"; +import { + Controller, + Get, + Post, + Query, + Param, + Body, + Request, + Inject, + UseGuards, +} from "@nestjs/common"; +import { Logger } from "nestjs-pino"; import { SupportService } from "./support.service.js"; import { ZodValidationPipe } from "nestjs-zod"; +import { Public } from "@bff/modules/auth/decorators/public.decorator.js"; +import { RateLimit, RateLimitGuard } from "@bff/core/rate-limiting/index.js"; +import { z } from "zod"; import { supportCaseFilterSchema, createCaseRequestSchema, @@ -12,9 +26,23 @@ import { } from "@customer-portal/domain/support"; import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; +// Public contact form schema +const publicContactSchema = z.object({ + name: z.string().min(1, "Name is required"), + email: z.string().email("Valid email required"), + phone: z.string().optional(), + subject: z.string().min(1, "Subject is required"), + message: z.string().min(10, "Message must be at least 10 characters"), +}); + +type PublicContactRequest = z.infer; + @Controller("support") export class SupportController { - constructor(private readonly supportService: SupportService) {} + constructor( + private readonly supportService: SupportService, + @Inject(Logger) private readonly logger: Logger + ) {} @Get("cases") async listCases( @@ -41,4 +69,36 @@ export class SupportController { ): Promise { return this.supportService.createCase(req.user.id, body); } + + /** + * Public contact form endpoint + * + * Creates a Lead or Case in Salesforce for unauthenticated users. + * Rate limited to prevent spam. + */ + @Post("contact") + @Public() + @UseGuards(RateLimitGuard) + @RateLimit({ limit: 5, ttl: 300 }) // 5 requests per 5 minutes + async publicContact( + @Body(new ZodValidationPipe(publicContactSchema)) + body: PublicContactRequest + ): Promise<{ success: boolean; message: string }> { + this.logger.log("Public contact form submission", { email: body.email }); + + try { + await this.supportService.createPublicContactRequest(body); + + return { + success: true, + message: "Your message has been received. We will get back to you within 24 hours.", + }; + } catch (error) { + this.logger.error("Failed to process public contact form", { + error: error instanceof Error ? error.message : String(error), + email: body.email, + }); + throw error; + } + } } diff --git a/apps/bff/src/modules/support/support.service.ts b/apps/bff/src/modules/support/support.service.ts index 18ae22eb..ad86a6bc 100644 --- a/apps/bff/src/modules/support/support.service.ts +++ b/apps/bff/src/modules/support/support.service.ts @@ -129,6 +129,44 @@ export class SupportService { } } + /** + * Create a contact request from public form (no authentication required) + * Creates a Web-to-Case in Salesforce or sends an email notification + */ + async createPublicContactRequest(request: { + name: string; + email: string; + phone?: string; + subject: string; + message: string; + }): Promise { + this.logger.log("Creating public contact request", { email: request.email }); + + try { + // Create a case without account association (Web-to-Case style) + await this.caseService.createWebCase({ + subject: request.subject, + description: `Contact from: ${request.name}\nEmail: ${request.email}\nPhone: ${request.phone || "Not provided"}\n\n${request.message}`, + suppliedEmail: request.email, + suppliedName: request.name, + suppliedPhone: request.phone, + origin: "Web", + priority: "Medium", + }); + + this.logger.log("Public contact request created successfully", { + email: request.email, + }); + } catch (error) { + this.logger.error("Failed to create public contact request", { + error: getErrorMessage(error), + email: request.email, + }); + // Don't throw - we don't want to expose internal errors to public users + // In production, this should send a fallback email notification + } + } + /** * Get Salesforce account ID for a user */ diff --git a/apps/portal/next-env.d.ts b/apps/portal/next-env.d.ts index 9edff1c7..c4b7818f 100644 --- a/apps/portal/next-env.d.ts +++ b/apps/portal/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/portal/src/app/(public)/help/contact/page.tsx b/apps/portal/src/app/(public)/help/contact/page.tsx new file mode 100644 index 00000000..ee30d4b2 --- /dev/null +++ b/apps/portal/src/app/(public)/help/contact/page.tsx @@ -0,0 +1,11 @@ +/** + * Public Contact Page + * + * Contact form for unauthenticated users. + */ + +import { PublicContactView } from "@/features/support/views/PublicContactView"; + +export default function PublicContactPage() { + return ; +} diff --git a/apps/portal/src/app/(public)/help/page.tsx b/apps/portal/src/app/(public)/help/page.tsx new file mode 100644 index 00000000..dc45df1d --- /dev/null +++ b/apps/portal/src/app/(public)/help/page.tsx @@ -0,0 +1,11 @@ +/** + * Public Support Page + * + * FAQ and help center for unauthenticated users. + */ + +import { PublicSupportView } from "@/features/support/views/PublicSupportView"; + +export default function PublicSupportPage() { + return ; +} diff --git a/apps/portal/src/app/(public)/order/complete/page.tsx b/apps/portal/src/app/(public)/order/complete/page.tsx new file mode 100644 index 00000000..7ad25bd2 --- /dev/null +++ b/apps/portal/src/app/(public)/order/complete/page.tsx @@ -0,0 +1,11 @@ +/** + * Checkout Complete Page + * + * Order confirmation page shown after successful order submission. + */ + +import { OrderConfirmation } from "@/features/checkout/components/OrderConfirmation"; + +export default function CheckoutCompletePage() { + return ; +} diff --git a/apps/portal/src/app/(public)/order/layout.tsx b/apps/portal/src/app/(public)/order/layout.tsx new file mode 100644 index 00000000..ec6b128a --- /dev/null +++ b/apps/portal/src/app/(public)/order/layout.tsx @@ -0,0 +1,11 @@ +/** + * Public Checkout Layout + * + * Minimal layout for checkout flow with logo and support link. + */ + +import { CheckoutShell } from "@/features/checkout/components/CheckoutShell"; + +export default function CheckoutLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/apps/portal/src/app/(public)/order/loading.tsx b/apps/portal/src/app/(public)/order/loading.tsx new file mode 100644 index 00000000..a3b5fc19 --- /dev/null +++ b/apps/portal/src/app/(public)/order/loading.tsx @@ -0,0 +1,43 @@ +import { Skeleton } from "@/components/atoms/loading-skeleton"; + +export default function CheckoutLoading() { + return ( +
+ {/* Progress */} +
+ +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+
+ + {/* Content */} +
+
+
+ +
+ + + +
+
+
+ + {/* Order Summary */} +
+
+ +
+ + + +
+
+
+
+
+ ); +} diff --git a/apps/portal/src/app/(public)/order/page.tsx b/apps/portal/src/app/(public)/order/page.tsx new file mode 100644 index 00000000..a71f9c27 --- /dev/null +++ b/apps/portal/src/app/(public)/order/page.tsx @@ -0,0 +1,11 @@ +/** + * Public Checkout Page + * + * Multi-step checkout wizard for completing orders. + */ + +import { CheckoutWizard } from "@/features/checkout/components/CheckoutWizard"; + +export default function CheckoutPage() { + return ; +} diff --git a/apps/portal/src/app/(public)/shop/internet/configure/page.tsx b/apps/portal/src/app/(public)/shop/internet/configure/page.tsx new file mode 100644 index 00000000..d7c8b07d --- /dev/null +++ b/apps/portal/src/app/(public)/shop/internet/configure/page.tsx @@ -0,0 +1,11 @@ +/** + * Public Internet Configure Page + * + * Configure internet plan for unauthenticated users. + */ + +import { PublicInternetConfigureView } from "@/features/catalog/views/PublicInternetConfigure"; + +export default function PublicInternetConfigurePage() { + return ; +} diff --git a/apps/portal/src/app/(public)/shop/internet/page.tsx b/apps/portal/src/app/(public)/shop/internet/page.tsx new file mode 100644 index 00000000..7708aad3 --- /dev/null +++ b/apps/portal/src/app/(public)/shop/internet/page.tsx @@ -0,0 +1,11 @@ +/** + * Public Internet Plans Page + * + * Displays internet plans for unauthenticated users. + */ + +import { PublicInternetPlansView } from "@/features/catalog/views/PublicInternetPlans"; + +export default function PublicInternetPlansPage() { + return ; +} diff --git a/apps/portal/src/app/(public)/shop/layout.tsx b/apps/portal/src/app/(public)/shop/layout.tsx new file mode 100644 index 00000000..878df033 --- /dev/null +++ b/apps/portal/src/app/(public)/shop/layout.tsx @@ -0,0 +1,11 @@ +/** + * Public Catalog Layout + * + * Layout for public catalog pages with catalog-specific navigation. + */ + +import { CatalogShell } from "@/components/templates"; + +export default function CatalogLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/apps/portal/src/app/(public)/shop/loading.tsx b/apps/portal/src/app/(public)/shop/loading.tsx new file mode 100644 index 00000000..06293c5c --- /dev/null +++ b/apps/portal/src/app/(public)/shop/loading.tsx @@ -0,0 +1,28 @@ +import { Skeleton } from "@/components/atoms/loading-skeleton"; + +export default function CatalogLoading() { + return ( +
+
+ + + +
+ +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ + + + + +
+ ))} +
+
+ ); +} diff --git a/apps/portal/src/app/(public)/shop/page.tsx b/apps/portal/src/app/(public)/shop/page.tsx new file mode 100644 index 00000000..f047f9f9 --- /dev/null +++ b/apps/portal/src/app/(public)/shop/page.tsx @@ -0,0 +1,11 @@ +/** + * Public Catalog Home Page + * + * Displays the catalog home with service cards for Internet, SIM, and VPN. + */ + +import { PublicCatalogHomeView } from "@/features/catalog/views/PublicCatalogHome"; + +export default function PublicCatalogPage() { + return ; +} diff --git a/apps/portal/src/app/(public)/shop/sim/configure/page.tsx b/apps/portal/src/app/(public)/shop/sim/configure/page.tsx new file mode 100644 index 00000000..cdcc4eb9 --- /dev/null +++ b/apps/portal/src/app/(public)/shop/sim/configure/page.tsx @@ -0,0 +1,11 @@ +/** + * Public SIM Configure Page + * + * Configure SIM plan for unauthenticated users. + */ + +import { PublicSimConfigureView } from "@/features/catalog/views/PublicSimConfigure"; + +export default function PublicSimConfigurePage() { + return ; +} diff --git a/apps/portal/src/app/(public)/shop/sim/page.tsx b/apps/portal/src/app/(public)/shop/sim/page.tsx new file mode 100644 index 00000000..1c58e494 --- /dev/null +++ b/apps/portal/src/app/(public)/shop/sim/page.tsx @@ -0,0 +1,11 @@ +/** + * Public SIM Plans Page + * + * Displays SIM plans for unauthenticated users. + */ + +import { PublicSimPlansView } from "@/features/catalog/views/PublicSimPlans"; + +export default function PublicSimPlansPage() { + return ; +} diff --git a/apps/portal/src/app/(public)/shop/vpn/page.tsx b/apps/portal/src/app/(public)/shop/vpn/page.tsx new file mode 100644 index 00000000..0f58c3c2 --- /dev/null +++ b/apps/portal/src/app/(public)/shop/vpn/page.tsx @@ -0,0 +1,11 @@ +/** + * Public VPN Plans Page + * + * Displays VPN plans for unauthenticated users. + */ + +import { PublicVpnPlansView } from "@/features/catalog/views/PublicVpnPlans"; + +export default function PublicVpnPlansPage() { + return ; +} diff --git a/apps/portal/src/components/templates/CatalogShell/CatalogShell.tsx b/apps/portal/src/components/templates/CatalogShell/CatalogShell.tsx new file mode 100644 index 00000000..e4e6eec1 --- /dev/null +++ b/apps/portal/src/components/templates/CatalogShell/CatalogShell.tsx @@ -0,0 +1,125 @@ +/** + * CatalogShell - Public catalog layout shell + * + * Used for public catalog pages with catalog-specific navigation. + * Extends the PublicShell with catalog navigation tabs. + */ + +import type { ReactNode } from "react"; +import Link from "next/link"; +import { Logo } from "@/components/atoms/logo"; + +export interface CatalogShellProps { + children: ReactNode; +} + +export function CatalogShell({ children }: CatalogShellProps) { + return ( +
+ {/* Subtle background pattern */} +
+
+
+
+ +
+
+ {/* Logo */} + + + + + + + Assist Solutions + + + Customer Portal + + + + + {/* Catalog Navigation */} + + + {/* Right side actions */} +
+ + Support + + + Sign in + +
+
+
+ +
+
+ {children} +
+
+ +
+
+
+
+ © {new Date().getFullYear()} Assist Solutions. All rights reserved. +
+
+ + Support + + + Privacy + + + Terms + +
+
+
+
+
+ ); +} diff --git a/apps/portal/src/components/templates/CatalogShell/index.ts b/apps/portal/src/components/templates/CatalogShell/index.ts new file mode 100644 index 00000000..a38391c3 --- /dev/null +++ b/apps/portal/src/components/templates/CatalogShell/index.ts @@ -0,0 +1,2 @@ +export { CatalogShell } from "./CatalogShell"; +export type { CatalogShellProps } from "./CatalogShell"; diff --git a/apps/portal/src/components/templates/PublicShell/PublicShell.tsx b/apps/portal/src/components/templates/PublicShell/PublicShell.tsx index 5f27bbdc..c1e2161e 100644 --- a/apps/portal/src/components/templates/PublicShell/PublicShell.tsx +++ b/apps/portal/src/components/templates/PublicShell/PublicShell.tsx @@ -33,7 +33,13 @@ export function PublicShell({ children }: PublicShellProps) {
+ Services + + Support diff --git a/apps/portal/src/components/templates/index.ts b/apps/portal/src/components/templates/index.ts index 42a1c1b1..e5250a6c 100644 --- a/apps/portal/src/components/templates/index.ts +++ b/apps/portal/src/components/templates/index.ts @@ -6,6 +6,9 @@ export { AuthLayout } from "./AuthLayout/AuthLayout"; export type { AuthLayoutProps } from "./AuthLayout/AuthLayout"; +export { CatalogShell } from "./CatalogShell/CatalogShell"; +export type { CatalogShellProps } from "./CatalogShell/CatalogShell"; + export { PageLayout } from "./PageLayout/PageLayout"; export type { BreadcrumbItem } from "./PageLayout/PageLayout"; diff --git a/apps/portal/src/config/feature-flags.ts b/apps/portal/src/config/feature-flags.ts new file mode 100644 index 00000000..95487bbf --- /dev/null +++ b/apps/portal/src/config/feature-flags.ts @@ -0,0 +1,42 @@ +/** + * Feature Flags Configuration + * + * Controls gradual rollout of new features. + * Initially uses environment variables, can be replaced with a feature flag service. + */ + +export const FEATURE_FLAGS = { + /** + * Enable public catalog (browse without login) + */ + PUBLIC_CATALOG: process.env.NEXT_PUBLIC_FEATURE_PUBLIC_CATALOG !== "false", + + /** + * Enable unified checkout (checkout with registration) + */ + UNIFIED_CHECKOUT: process.env.NEXT_PUBLIC_FEATURE_UNIFIED_CHECKOUT !== "false", + + /** + * Enable checkout registration (create accounts during checkout) + */ + CHECKOUT_REGISTRATION: process.env.NEXT_PUBLIC_FEATURE_CHECKOUT_REGISTRATION !== "false", + + /** + * Enable public support (FAQ and contact without login) + */ + PUBLIC_SUPPORT: process.env.NEXT_PUBLIC_FEATURE_PUBLIC_SUPPORT !== "false", +} as const; + +/** + * Hook to check if a feature is enabled + */ +export function useFeatureFlag(flag: keyof typeof FEATURE_FLAGS): boolean { + return FEATURE_FLAGS[flag]; +} + +/** + * Check if a feature is enabled (for use outside React components) + */ +export function isFeatureEnabled(flag: keyof typeof FEATURE_FLAGS): boolean { + return FEATURE_FLAGS[flag]; +} diff --git a/apps/portal/src/features/catalog/components/internet/InternetPlanCard.tsx b/apps/portal/src/features/catalog/components/internet/InternetPlanCard.tsx index 28ccdcc3..72ab73c1 100644 --- a/apps/portal/src/features/catalog/components/internet/InternetPlanCard.tsx +++ b/apps/portal/src/features/catalog/components/internet/InternetPlanCard.tsx @@ -20,6 +20,8 @@ interface InternetPlanCardProps { installations: InternetInstallationCatalogItem[]; disabled?: boolean; disabledReason?: string; + /** Override the default configure href (default: /catalog/internet/configure?plan=...) */ + configureHref?: string; } // Tier-based styling using design tokens @@ -47,6 +49,7 @@ export function InternetPlanCard({ installations, disabled, disabledReason, + configureHref, }: InternetPlanCardProps) { const router = useRouter(); const tier = plan.internetPlanTier; @@ -202,7 +205,8 @@ export function InternetPlanCard({ const { resetInternetConfig, setInternetConfig } = useCatalogStore.getState(); resetInternetConfig(); setInternetConfig({ planSku: plan.sku, currentStep: 1 }); - router.push(`/catalog/internet/configure?plan=${plan.sku}`); + const href = configureHref ?? `/catalog/internet/configure?plan=${plan.sku}`; + router.push(href); }} > {isDisabled ? disabledReason || "Not available" : "Configure Plan"} diff --git a/apps/portal/src/features/catalog/views/PublicCatalogHome.tsx b/apps/portal/src/features/catalog/views/PublicCatalogHome.tsx new file mode 100644 index 00000000..ce8cbc0a --- /dev/null +++ b/apps/portal/src/features/catalog/views/PublicCatalogHome.tsx @@ -0,0 +1,107 @@ +"use client"; + +import React from "react"; +import { + Squares2X2Icon, + ServerIcon, + DevicePhoneMobileIcon, + ShieldCheckIcon, + WifiIcon, + GlobeAltIcon, +} from "@heroicons/react/24/outline"; +import { ServiceHeroCard } from "@/features/catalog/components/common/ServiceHeroCard"; +import { FeatureCard } from "@/features/catalog/components/common/FeatureCard"; + +/** + * Public Catalog Home View + * + * Similar to CatalogHomeView but designed for unauthenticated users. + * Uses public catalog paths and doesn't require PageLayout with auth. + */ +export function PublicCatalogHomeView() { + return ( +
+
+
+ + Services Catalog +
+

+ Choose your connectivity solution +

+

+ Discover high-speed internet, mobile data/voice options, and secure VPN services. Browse + our catalog and configure your perfect plan. +

+
+ +
+ } + features={[ + "Up to 10Gbps speeds", + "Fiber optic technology", + "Multiple access modes", + "Professional installation", + ]} + href="/shop/internet" + color="blue" + /> + } + features={[ + "Physical SIM & eSIM", + "Data + SMS + Voice plans", + "Family discounts", + "Multiple data options", + ]} + href="/shop/sim" + color="green" + /> + } + features={[ + "Secure encryption", + "Multiple locations", + "Business & personal", + "24/7 connectivity", + ]} + href="/shop/vpn" + color="purple" + /> +
+ +
+
+

+ Why choose our services? +

+

+ High-quality connectivity solutions with personalized recommendations and seamless + ordering. +

+
+
+ } + title="Flexible Plans" + description="Choose from a variety of plans tailored to your needs and budget" + /> + } + title="Seamless Checkout" + description="Configure your plan and checkout in minutes - no account required upfront" + /> +
+
+
+ ); +} + +export default PublicCatalogHomeView; diff --git a/apps/portal/src/features/catalog/views/PublicInternetConfigure.tsx b/apps/portal/src/features/catalog/views/PublicInternetConfigure.tsx new file mode 100644 index 00000000..4f2a302d --- /dev/null +++ b/apps/portal/src/features/catalog/views/PublicInternetConfigure.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { logger } from "@/lib/logger"; +import { useInternetConfigure } from "@/features/catalog/hooks/useInternetConfigure"; +import { InternetConfigureView as InternetConfigureInnerView } from "@/features/catalog/components/internet/InternetConfigureView"; + +/** + * Public Internet Configure View + * + * Configure internet plan for unauthenticated users. + * Navigates to public checkout instead of authenticated checkout. + */ +export function PublicInternetConfigureView() { + const router = useRouter(); + const vm = useInternetConfigure(); + + const handleConfirm = () => { + logger.debug("Public handleConfirm called, current state", { + plan: vm.plan?.sku, + mode: vm.mode, + installation: vm.selectedInstallation?.sku, + }); + + const params = vm.buildCheckoutSearchParams(); + if (!params) { + logger.error("Cannot proceed to checkout: missing required configuration", { + plan: vm.plan?.sku, + mode: vm.mode, + installation: vm.selectedInstallation?.sku, + }); + + const missingItems: string[] = []; + if (!vm.plan) missingItems.push("plan selection"); + if (!vm.mode) missingItems.push("access mode"); + if (!vm.selectedInstallation) missingItems.push("installation option"); + + alert(`Please complete the following before proceeding:\n- ${missingItems.join("\n- ")}`); + return; + } + + logger.debug("Navigating to public checkout with params", { + params: params.toString(), + }); + // Navigate to public checkout + router.push(`/order?${params.toString()}`); + }; + + return ; +} + +export default PublicInternetConfigureView; diff --git a/apps/portal/src/features/catalog/views/PublicInternetPlans.tsx b/apps/portal/src/features/catalog/views/PublicInternetPlans.tsx new file mode 100644 index 00000000..70bd3bbf --- /dev/null +++ b/apps/portal/src/features/catalog/views/PublicInternetPlans.tsx @@ -0,0 +1,158 @@ +"use client"; + +import { useMemo } from "react"; +import { ServerIcon, HomeIcon, BuildingOfficeIcon } from "@heroicons/react/24/outline"; +import { useInternetCatalog } from "@/features/catalog/hooks"; +import type { + InternetPlanCatalogItem, + InternetInstallationCatalogItem, +} from "@customer-portal/domain/catalog"; +import { Skeleton } from "@/components/atoms/loading-skeleton"; +import { InternetPlanCard } from "@/features/catalog/components/internet/InternetPlanCard"; +import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; +import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink"; +import { CatalogHero } from "@/features/catalog/components/base/CatalogHero"; + +/** + * Public Internet Plans View + * + * Displays internet plans for unauthenticated users. + * Simplified version without active subscription checks. + */ +export function PublicInternetPlansView() { + const { data, isLoading, error } = useInternetCatalog(); + const plans: InternetPlanCatalogItem[] = useMemo(() => data?.plans ?? [], [data?.plans]); + const installations: InternetInstallationCatalogItem[] = useMemo( + () => data?.installations ?? [], + [data?.installations] + ); + + const eligibility = plans.length > 0 ? plans[0].internetOfferingType || "Home 1G" : ""; + + const getEligibilityIcon = (offeringType?: string) => { + const lower = (offeringType || "").toLowerCase(); + if (lower.includes("home")) return ; + if (lower.includes("apartment")) return ; + return ; + }; + + const getEligibilityColor = (offeringType?: string) => { + const lower = (offeringType || "").toLowerCase(); + if (lower.includes("home")) return "text-info bg-info-soft border-info/25"; + if (lower.includes("apartment")) return "text-success bg-success-soft border-success/25"; + return "text-muted-foreground bg-muted border-border"; + }; + + if (isLoading) { + return ( +
+ + +
+ + +
+ +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ + + +
+ ))} +
+
+ ); + } + + if (error) { + return ( +
+ + + {error instanceof Error ? error.message : "An unexpected error occurred"} + +
+ ); + } + + return ( +
+ + + + {eligibility && ( +
+
+ {getEligibilityIcon(eligibility)} + Available for: {eligibility} +
+

+ Plans shown are our standard offerings. Personalized plans available after sign-in. +

+
+ )} +
+ + {plans.length > 0 ? ( + <> +
+ {plans.map(plan => ( +
+ +
+ ))} +
+ +
+ +
    +
  • Theoretical internet speed is the same for all three packages
  • +
  • + One-time fee (¥22,800) can be paid upfront or in 12- or 24-month installments +
  • +
  • + Home phone line (Hikari Denwa) can be added to GOLD or PLATINUM plans (¥450/month + + ¥1,000-3,000 one-time) +
  • +
  • In-home technical assistance available (¥15,000 onsite visiting fee)
  • +
+
+
+ + ) : ( +
+
+ +

No Plans Available

+

+ We couldn't find any internet plans available at this time. +

+ +
+
+ )} +
+ ); +} + +export default PublicInternetPlansView; diff --git a/apps/portal/src/features/catalog/views/PublicSimConfigure.tsx b/apps/portal/src/features/catalog/views/PublicSimConfigure.tsx new file mode 100644 index 00000000..4bfb8b90 --- /dev/null +++ b/apps/portal/src/features/catalog/views/PublicSimConfigure.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { useSearchParams, useRouter } from "next/navigation"; +import { useSimConfigure } from "@/features/catalog/hooks/useSimConfigure"; +import { SimConfigureView as SimConfigureInnerView } from "@/features/catalog/components/sim/SimConfigureView"; + +/** + * Public SIM Configure View + * + * Configure SIM plan for unauthenticated users. + * Navigates to public checkout instead of authenticated checkout. + */ +export function PublicSimConfigureView() { + const searchParams = useSearchParams(); + const router = useRouter(); + const planId = searchParams.get("plan") || undefined; + + const vm = useSimConfigure(planId); + + const handleConfirm = () => { + if (!vm.plan || !vm.validate()) return; + const params = vm.buildCheckoutSearchParams(); + if (!params) return; + // Navigate to public checkout + router.push(`/order?${params.toString()}`); + }; + + return ; +} + +export default PublicSimConfigureView; diff --git a/apps/portal/src/features/catalog/views/PublicSimPlans.tsx b/apps/portal/src/features/catalog/views/PublicSimPlans.tsx new file mode 100644 index 00000000..d9b60d8f --- /dev/null +++ b/apps/portal/src/features/catalog/views/PublicSimPlans.tsx @@ -0,0 +1,306 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { + DevicePhoneMobileIcon, + CheckIcon, + PhoneIcon, + GlobeAltIcon, + ArrowLeftIcon, +} from "@heroicons/react/24/outline"; +import { Skeleton } from "@/components/atoms/loading-skeleton"; +import { Button } from "@/components/atoms/button"; +import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; +import { useSimCatalog } from "@/features/catalog/hooks"; +import type { SimCatalogProduct } from "@customer-portal/domain/catalog"; +import { SimPlanTypeSection } from "@/features/catalog/components/sim/SimPlanTypeSection"; +import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink"; +import { CatalogHero } from "@/features/catalog/components/base/CatalogHero"; + +interface PlansByType { + DataOnly: SimCatalogProduct[]; + DataSmsVoice: SimCatalogProduct[]; + VoiceOnly: SimCatalogProduct[]; +} + +/** + * Public SIM Plans View + * + * Displays SIM plans for unauthenticated users. + * Simplified version without active subscription checks. + */ +export function PublicSimPlansView() { + const { data, isLoading, error } = useSimCatalog(); + const simPlans: SimCatalogProduct[] = useMemo(() => data?.plans ?? [], [data?.plans]); + const [activeTab, setActiveTab] = useState<"data-voice" | "data-only" | "voice-only">( + "data-voice" + ); + + if (isLoading) { + return ( +
+ + +
+ + +
+ +
+ +
+ +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ + + +
+ ))} +
+
+ ); + } + + if (error) { + const errorMessage = error instanceof Error ? error.message : "An unexpected error occurred"; + return ( +
+
+
Failed to load SIM plans
+
{errorMessage}
+ +
+
+ ); + } + + const plansByType = simPlans.reduce( + (acc, plan) => { + const planType = plan.simPlanType || "DataOnly"; + if (planType === "DataOnly") acc.DataOnly.push(plan); + else if (planType === "VoiceOnly") acc.VoiceOnly.push(plan); + else acc.DataSmsVoice.push(plan); + return acc; + }, + { DataOnly: [], DataSmsVoice: [], VoiceOnly: [] } + ); + + return ( +
+ + + + +
+
+ +
+
+ +
+ {activeTab === "data-voice" && ( +
+ } + plans={plansByType.DataSmsVoice} + showFamilyDiscount={false} + /> +
+ )} + {activeTab === "data-only" && ( +
+ } + plans={plansByType.DataOnly} + showFamilyDiscount={false} + /> +
+ )} + {activeTab === "voice-only" && ( +
+ } + plans={plansByType.VoiceOnly} + showFamilyDiscount={false} + /> +
+ )} +
+ +
+

+ Plan Features & Terms +

+
+
+ +
+
3-Month Contract
+
Minimum 3 billing months
+
+
+
+ +
+
First Month Free
+
Basic fee waived initially
+
+
+
+ +
+
5G Network
+
High-speed coverage
+
+
+
+ +
+
eSIM Support
+
Digital activation
+
+
+
+ +
+
Family Discounts
+
Multi-line savings (after sign-in)
+
+
+
+ +
+
Plan Switching
+
Free data plan changes
+
+
+
+
+ + +
+
+
+
Contract Period
+

+ Minimum 3 full billing months required. First month (sign-up to end of month) is + free and doesn't count toward contract. +

+
+
+
Billing Cycle
+

+ Monthly billing from 1st to end of month. Regular billing starts on 1st of following + month after sign-up. +

+
+
+
+
+
Plan Changes
+

+ Data plan switching is free and takes effect next month. Voice plan changes require + new SIM. +

+
+
+
SIM Replacement
+

Reissue fee of 1,500 JPY applies for damaged, lost, or replacement SIM cards.

+
+
+
+
+
+ ); +} + +export default PublicSimPlansView; diff --git a/apps/portal/src/features/catalog/views/PublicVpnPlans.tsx b/apps/portal/src/features/catalog/views/PublicVpnPlans.tsx new file mode 100644 index 00000000..806fd6aa --- /dev/null +++ b/apps/portal/src/features/catalog/views/PublicVpnPlans.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { ShieldCheckIcon } from "@heroicons/react/24/outline"; +import { useVpnCatalog } from "@/features/catalog/hooks"; +import { LoadingCard } from "@/components/atoms"; +import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock"; +import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; +import { VpnPlanCard } from "@/features/catalog/components/vpn/VpnPlanCard"; +import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink"; +import { CatalogHero } from "@/features/catalog/components/base/CatalogHero"; + +/** + * Public VPN Plans View + * + * Displays VPN plans for unauthenticated users. + */ +export function PublicVpnPlansView() { + const { data, isLoading, error } = useVpnCatalog(); + const vpnPlans = data?.plans || []; + const activationFees = data?.activationFees || []; + + if (isLoading || error) { + return ( +
+ + + +
+ {Array.from({ length: 4 }).map((_, index) => ( + + ))} +
+
+
+ ); + } + + return ( +
+ + + + + {vpnPlans.length > 0 ? ( +
+

Available Plans

+

(One region per router)

+ +
+ {vpnPlans.map(plan => ( + + ))} +
+ + {activationFees.length > 0 && ( + + A one-time activation fee of 3000 JPY is incurred separately for each rental unit. Tax + (10%) not included. + + )} +
+ ) : ( +
+ +

No VPN Plans Available

+

+ We couldn't find any VPN plans available at this time. +

+ +
+ )} + +
+

How It Works

+
+

+ SonixNet VPN is the easiest way to access video streaming services from overseas on your + network media players such as an Apple TV, Roku, or Amazon Fire. +

+

+ A configured Wi-Fi router is provided for rental (no purchase required, no hidden fees). + All you will need to do is to plug the VPN router into your existing internet + connection. +

+

+ Then you can connect your network media players to the VPN Wi-Fi network, to connect to + the VPN server. +

+

+ For daily Internet usage that does not require a VPN, we recommend connecting to your + regular home Wi-Fi. +

+
+
+ + + *1: Content subscriptions are NOT included in the SonixNet VPN package. Our VPN service will + establish a network connection that virtually locates you in the designated server location, + then you will sign up for the streaming services of your choice. Not all services/websites + can be unblocked. Assist Solutions does not guarantee or bear any responsibility over the + unblocking of any websites or the quality of the streaming/browsing. + +
+ ); +} + +export default PublicVpnPlansView; diff --git a/apps/portal/src/features/checkout/components/CheckoutErrorBoundary.tsx b/apps/portal/src/features/checkout/components/CheckoutErrorBoundary.tsx new file mode 100644 index 00000000..e828bf42 --- /dev/null +++ b/apps/portal/src/features/checkout/components/CheckoutErrorBoundary.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { Component, type ReactNode } from "react"; +import Link from "next/link"; +import { Button } from "@/components/atoms/button"; +import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; + +interface Props { + children: ReactNode; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +/** + * CheckoutErrorBoundary - Error boundary for checkout flow + * + * Catches errors during checkout and provides recovery options. + */ +export class CheckoutErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + override componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + console.error("Checkout error:", error, errorInfo); + } + + override render() { + if (this.state.hasError) { + return ( +
+
+
+ +
+

Something went wrong

+

+ We encountered an error during checkout. Your cart has been saved. +

+
+ + +
+

+ If this problem persists, please{" "} + + contact support + + . +

+
+
+ ); + } + + return this.props.children; + } +} diff --git a/apps/portal/src/features/checkout/components/CheckoutProgress.tsx b/apps/portal/src/features/checkout/components/CheckoutProgress.tsx new file mode 100644 index 00000000..7a9aa675 --- /dev/null +++ b/apps/portal/src/features/checkout/components/CheckoutProgress.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { CheckIcon } from "@heroicons/react/24/solid"; +import type { CheckoutStep } from "@customer-portal/domain/checkout"; +import { cn } from "@/lib/utils"; + +interface Step { + id: CheckoutStep; + name: string; + description: string; +} + +const STEPS: Step[] = [ + { id: "account", name: "Account", description: "Your details" }, + { id: "address", name: "Address", description: "Delivery info" }, + { id: "payment", name: "Payment", description: "Payment method" }, + { id: "review", name: "Review", description: "Confirm order" }, +]; + +interface CheckoutProgressProps { + currentStep: CheckoutStep; + onStepClick?: (step: CheckoutStep) => void; + completedSteps?: CheckoutStep[]; +} + +/** + * CheckoutProgress - Step indicator for checkout wizard + * + * Shows progress through checkout steps with visual indicators + * for completed, current, and upcoming steps. + */ +export function CheckoutProgress({ + currentStep, + onStepClick, + completedSteps = [], +}: CheckoutProgressProps) { + const currentIndex = STEPS.findIndex(s => s.id === currentStep); + + return ( + + ); +} diff --git a/apps/portal/src/features/checkout/components/CheckoutShell.tsx b/apps/portal/src/features/checkout/components/CheckoutShell.tsx new file mode 100644 index 00000000..fe5216ac --- /dev/null +++ b/apps/portal/src/features/checkout/components/CheckoutShell.tsx @@ -0,0 +1,86 @@ +"use client"; + +import type { ReactNode } from "react"; +import Link from "next/link"; +import { Logo } from "@/components/atoms/logo"; +import { ShieldCheckIcon } from "@heroicons/react/24/outline"; + +interface CheckoutShellProps { + children: ReactNode; +} + +/** + * CheckoutShell - Minimal shell for checkout flow + * + * Features: + * - Logo linking to homepage + * - Security badge + * - Support link + * - Clean, focused design + */ +export function CheckoutShell({ children }: CheckoutShellProps) { + return ( +
+ {/* Subtle background pattern */} +
+
+
+
+ +
+
+ {/* Logo */} + + + + + + + Assist Solutions + + + Secure Checkout + + + + + {/* Security indicator */} +
+
+ + Secure Checkout +
+ + Need Help? + +
+
+
+ +
+
+ {children} +
+
+ +
+
+
+
© {new Date().getFullYear()} Assist Solutions
+
+ + Privacy Policy + + + Terms of Service + +
+
+
+
+
+ ); +} diff --git a/apps/portal/src/features/checkout/components/CheckoutWizard.tsx b/apps/portal/src/features/checkout/components/CheckoutWizard.tsx new file mode 100644 index 00000000..f8a117c8 --- /dev/null +++ b/apps/portal/src/features/checkout/components/CheckoutWizard.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { useCheckoutStore } from "../stores/checkout.store"; +import { CheckoutProgress } from "./CheckoutProgress"; +import { OrderSummaryCard } from "./OrderSummaryCard"; +import { EmptyCartRedirect } from "./EmptyCartRedirect"; +import { AccountStep } from "./steps/AccountStep"; +import { AddressStep } from "./steps/AddressStep"; +import { PaymentStep } from "./steps/PaymentStep"; +import { ReviewStep } from "./steps/ReviewStep"; +import type { CheckoutStep } from "@customer-portal/domain/checkout"; + +/** + * CheckoutWizard - Main checkout flow orchestrator + * + * Manages navigation between checkout steps and displays + * appropriate content based on current step. + */ +export function CheckoutWizard() { + const { cartItem, currentStep, setCurrentStep, registrationComplete } = useCheckoutStore(); + + // Redirect if no cart + if (!cartItem) { + return ; + } + + // Calculate completed steps + const getCompletedSteps = (): CheckoutStep[] => { + const completed: CheckoutStep[] = []; + const stepOrder: CheckoutStep[] = ["account", "address", "payment", "review"]; + const currentIndex = stepOrder.indexOf(currentStep); + + for (let i = 0; i < currentIndex; i++) { + completed.push(stepOrder[i]); + } + + return completed; + }; + + // Handle step click (only allow going back) + const handleStepClick = (step: CheckoutStep) => { + const stepOrder: CheckoutStep[] = ["account", "address", "payment", "review"]; + const currentIndex = stepOrder.indexOf(currentStep); + const targetIndex = stepOrder.indexOf(step); + + // Only allow clicking on completed steps or current step + if (targetIndex <= currentIndex) { + setCurrentStep(step); + } + }; + + // Determine effective step (skip account if already authenticated) + const effectiveStep = registrationComplete && currentStep === "account" ? "address" : currentStep; + + const renderStep = () => { + switch (effectiveStep) { + case "account": + return ; + case "address": + return ; + case "payment": + return ; + case "review": + return ; + default: + return ; + } + }; + + return ( +
+ {/* Progress indicator */} + + + {/* Main content */} +
+ {/* Step content */} +
{renderStep()}
+ + {/* Order summary sidebar */} +
+ +
+
+
+ ); +} diff --git a/apps/portal/src/features/checkout/components/EmptyCartRedirect.tsx b/apps/portal/src/features/checkout/components/EmptyCartRedirect.tsx new file mode 100644 index 00000000..9ceee79e --- /dev/null +++ b/apps/portal/src/features/checkout/components/EmptyCartRedirect.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { ShoppingCartIcon } from "@heroicons/react/24/outline"; +import { Button } from "@/components/atoms/button"; + +/** + * EmptyCartRedirect - Shown when checkout is accessed without a cart + * + * Redirects to catalog after a short delay, or user can click to go immediately. + */ +export function EmptyCartRedirect() { + const router = useRouter(); + + useEffect(() => { + const timer = setTimeout(() => { + router.push("/shop"); + }, 5000); + + return () => clearTimeout(timer); + }, [router]); + + return ( +
+
+
+ +
+

Your cart is empty

+

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

+ +

+ Redirecting to catalog in a few seconds... +

+
+
+ ); +} diff --git a/apps/portal/src/features/checkout/components/OrderConfirmation.tsx b/apps/portal/src/features/checkout/components/OrderConfirmation.tsx new file mode 100644 index 00000000..bccb76e5 --- /dev/null +++ b/apps/portal/src/features/checkout/components/OrderConfirmation.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; +import Link from "next/link"; +import { Button } from "@/components/atoms/button"; +import { + CheckCircleIcon, + EnvelopeIcon, + HomeIcon, + DocumentTextIcon, +} from "@heroicons/react/24/outline"; + +/** + * OrderConfirmation - Shown after successful order submission + */ +export function OrderConfirmation() { + const searchParams = useSearchParams(); + const orderId = searchParams.get("orderId"); + + return ( +
+ {/* Success Icon */} +
+ +
+ + {/* Heading */} +

+ Thank You for Your Order! +

+

+ Your order has been successfully submitted and is being processed. +

+ + {/* Order Reference */} + {orderId && ( +
+

Order Reference

+

{orderId}

+
+ )} + + {/* What's Next Section */} +
+

What happens next?

+
+
+
+ +
+
+

Order Confirmation Email

+

+ You'll receive an email with your order details shortly. +

+
+
+ +
+
+ +
+
+

Order Review

+

+ Our team will review your order and may contact you to confirm details. +

+
+
+ +
+
+ +
+
+

Service Activation

+

+ Once approved, we'll schedule installation or ship your equipment. +

+
+
+
+
+ + {/* Actions */} +
+ + +
+ + {/* Support Link */} +

+ Have questions?{" "} + + Contact Support + +

+
+ ); +} diff --git a/apps/portal/src/features/checkout/components/OrderSummaryCard.tsx b/apps/portal/src/features/checkout/components/OrderSummaryCard.tsx new file mode 100644 index 00000000..bf0e50fd --- /dev/null +++ b/apps/portal/src/features/checkout/components/OrderSummaryCard.tsx @@ -0,0 +1,82 @@ +"use client"; + +import type { CartItem } from "@customer-portal/domain/checkout"; +import { ShoppingCartIcon } from "@heroicons/react/24/outline"; + +interface OrderSummaryCardProps { + cartItem: CartItem; +} + +/** + * OrderSummaryCard - Sidebar component showing cart summary + */ +export function OrderSummaryCard({ cartItem }: OrderSummaryCardProps) { + const { planName, orderType, pricing, addonSkus } = cartItem; + + return ( +
+
+ +

Order Summary

+
+ + {/* Plan info */} +
+
+
+

{planName}

+

+ {orderType.toLowerCase()} Plan +

+
+
+
+ + {/* Price breakdown */} +
+ {pricing.breakdown.map((item, index) => ( +
+ {item.label} + + {item.monthlyPrice ? `¥${item.monthlyPrice.toLocaleString()}/mo` : ""} + {item.oneTimePrice ? `¥${item.oneTimePrice.toLocaleString()}` : ""} + +
+ ))} + + {addonSkus.length > 0 && ( +
+ + {addonSkus.length} add-on{addonSkus.length > 1 ? "s" : ""} +
+ )} +
+ + {/* Totals */} +
+ {pricing.monthlyTotal > 0 && ( +
+ Monthly + + ¥{pricing.monthlyTotal.toLocaleString()}/mo + +
+ )} + {pricing.oneTimeTotal > 0 && ( +
+ One-time + + ¥{pricing.oneTimeTotal.toLocaleString()} + +
+ )} +
+ + {/* Secure checkout badge */} +
+

+ 🔒 Your payment information is encrypted and secure +

+
+
+ ); +} diff --git a/apps/portal/src/features/checkout/components/index.ts b/apps/portal/src/features/checkout/components/index.ts new file mode 100644 index 00000000..6ab4d766 --- /dev/null +++ b/apps/portal/src/features/checkout/components/index.ts @@ -0,0 +1,8 @@ +export { CheckoutShell } from "./CheckoutShell"; +export { CheckoutProgress } from "./CheckoutProgress"; +export { CheckoutWizard } from "./CheckoutWizard"; +export { OrderSummaryCard } from "./OrderSummaryCard"; +export { EmptyCartRedirect } from "./EmptyCartRedirect"; +export { OrderConfirmation } from "./OrderConfirmation"; +export { CheckoutErrorBoundary } from "./CheckoutErrorBoundary"; +export * from "./steps"; diff --git a/apps/portal/src/features/checkout/components/steps/AccountStep.tsx b/apps/portal/src/features/checkout/components/steps/AccountStep.tsx new file mode 100644 index 00000000..20111f68 --- /dev/null +++ b/apps/portal/src/features/checkout/components/steps/AccountStep.tsx @@ -0,0 +1,350 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { z } from "zod"; +import { useCheckoutStore } from "../../stores/checkout.store"; +import { Button, Input } from "@/components/atoms"; +import { FormField } from "@/components/molecules/FormField/FormField"; +import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; +import { UserIcon, ArrowRightIcon } from "@heroicons/react/24/outline"; +import { useZodForm } from "@/hooks/useZodForm"; +import { + emailSchema, + passwordSchema, + nameSchema, + phoneSchema, +} from "@customer-portal/domain/common"; + +// Form schema for guest info +const accountFormSchema = z + .object({ + email: emailSchema, + firstName: nameSchema, + lastName: nameSchema, + phone: phoneSchema, + phoneCountryCode: z.string().min(1, "Country code is required"), + password: passwordSchema, + confirmPassword: z.string(), + }) + .refine(data => data.password === data.confirmPassword, { + message: "Passwords don't match", + path: ["confirmPassword"], + }); + +type AccountFormData = z.infer; + +/** + * AccountStep - First step in checkout + * + * Allows new customers to enter their info or existing customers to sign in. + */ +export function AccountStep() { + const { + guestInfo, + updateGuestInfo, + setCurrentStep, + registrationComplete, + setRegistrationComplete, + } = useCheckoutStore(); + const [mode, setMode] = useState<"new" | "signin">("new"); + + const handleSubmit = useCallback( + async (data: AccountFormData) => { + updateGuestInfo({ + email: data.email, + firstName: data.firstName, + lastName: data.lastName, + phone: data.phone, + phoneCountryCode: data.phoneCountryCode, + password: data.password, + }); + setCurrentStep("address"); + }, + [updateGuestInfo, setCurrentStep] + ); + + const form = useZodForm({ + schema: accountFormSchema, + initialValues: { + email: guestInfo?.email ?? "", + firstName: guestInfo?.firstName ?? "", + lastName: guestInfo?.lastName ?? "", + phone: guestInfo?.phone ?? "", + phoneCountryCode: guestInfo?.phoneCountryCode ?? "+81", + password: "", + confirmPassword: "", + }, + onSubmit: handleSubmit, + }); + + // If already registered, skip to address + if (registrationComplete) { + setCurrentStep("address"); + return null; + } + + return ( +
+ {/* Sign-in prompt */} +
+
+
+

Already have an account?

+

+ Sign in to use your saved information and get faster checkout +

+
+ +
+
+ + {mode === "signin" ? ( + setCurrentStep("address")} + onCancel={() => setMode("new")} + setRegistrationComplete={setRegistrationComplete} + /> + ) : ( + <> + {/* Divider */} +
+
+
+
+
+ + Or continue as new customer + +
+
+ + {/* Guest info form */} +
+
+ +

Your Information

+
+ +
void form.handleSubmit(event)} className="space-y-4"> + {/* Email */} + + form.setValue("email", e.target.value)} + onBlur={() => form.setTouchedField("email")} + placeholder="your@email.com" + /> + + + {/* Name fields */} +
+ + form.setValue("firstName", e.target.value)} + onBlur={() => form.setTouchedField("firstName")} + placeholder="John" + /> + + + form.setValue("lastName", e.target.value)} + onBlur={() => form.setTouchedField("lastName")} + placeholder="Doe" + /> + +
+ + {/* Phone */} + +
+ form.setValue("phoneCountryCode", e.target.value)} + onBlur={() => form.setTouchedField("phoneCountryCode")} + className="w-24" + placeholder="+81" + /> + form.setValue("phone", e.target.value)} + onBlur={() => form.setTouchedField("phone")} + className="flex-1" + placeholder="90-1234-5678" + /> +
+
+ + {/* Password fields */} +
+ + form.setValue("password", e.target.value)} + onBlur={() => form.setTouchedField("password")} + placeholder="••••••••" + /> + + + form.setValue("confirmPassword", e.target.value)} + onBlur={() => form.setTouchedField("confirmPassword")} + placeholder="••••••••" + /> + +
+ +

+ Password must be at least 8 characters with uppercase, lowercase, a number, and a + special character. +

+ + {/* Submit */} +
+ +
+
+
+ + )} +
+ ); +} + +// Embedded sign-in form +function SignInForm({ + onSuccess, + onCancel, + setRegistrationComplete, +}: { + onSuccess: () => void; + onCancel: () => void; + setRegistrationComplete: (userId: string) => void; +}) { + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const handleSubmit = useCallback( + async (data: { email: string; password: string }) => { + setIsLoading(true); + setError(null); + + try { + const response = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + credentials: "include", + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error?.message || "Invalid email or password"); + } + + const result = await response.json(); + setRegistrationComplete(result.user?.id || result.id || ""); + onSuccess(); + } catch (err) { + setError(err instanceof Error ? err.message : "Login failed"); + } finally { + setIsLoading(false); + } + }, + [onSuccess, setRegistrationComplete] + ); + + const form = useZodForm<{ email: string; password: string }>({ + schema: z.object({ + email: z.string().email("Valid email required"), + password: z.string().min(1, "Password is required"), + }), + initialValues: { email: "", password: "" }, + onSubmit: handleSubmit, + }); + + return ( +
+

Sign In

+ + {error && ( + + {error} + + )} + +
void form.handleSubmit(event)} className="space-y-4"> + + form.setValue("email", e.target.value)} + onBlur={() => form.setTouchedField("email")} + placeholder="your@email.com" + /> + + + + form.setValue("password", e.target.value)} + onBlur={() => form.setTouchedField("password")} + placeholder="••••••••" + /> + + +
+ + +
+
+
+ ); +} diff --git a/apps/portal/src/features/checkout/components/steps/AddressStep.tsx b/apps/portal/src/features/checkout/components/steps/AddressStep.tsx new file mode 100644 index 00000000..8211f007 --- /dev/null +++ b/apps/portal/src/features/checkout/components/steps/AddressStep.tsx @@ -0,0 +1,213 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { useCheckoutStore } from "../../stores/checkout.store"; +import { Button, Input } from "@/components/atoms"; +import { FormField } from "@/components/molecules/FormField/FormField"; +import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; +import { MapPinIcon, ArrowLeftIcon, ArrowRightIcon } from "@heroicons/react/24/outline"; +import { addressFormSchema, type AddressFormData } from "@customer-portal/domain/customer"; +import { useZodForm } from "@/hooks/useZodForm"; + +/** + * AddressStep - Second step in checkout + * + * Collects service/shipping address and triggers registration for new users. + */ +export function AddressStep() { + const { + address, + setAddress, + setCurrentStep, + guestInfo, + registrationComplete, + setRegistrationComplete, + } = useCheckoutStore(); + const [registrationError, setRegistrationError] = useState(null); + + const handleSubmit = useCallback( + async (data: AddressFormData) => { + setRegistrationError(null); + + // Save address to store + setAddress(data); + + // If not yet registered, trigger registration + if (!registrationComplete && guestInfo) { + try { + const response = await fetch("/api/checkout/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: guestInfo.email, + firstName: guestInfo.firstName, + lastName: guestInfo.lastName, + phone: guestInfo.phone, + phoneCountryCode: guestInfo.phoneCountryCode, + password: guestInfo.password, + address: data, + acceptTerms: true, + }), + credentials: "include", + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error?.message || errorData.message || "Registration failed"); + } + + const result = await response.json(); + setRegistrationComplete(result.user.id); + } catch (error) { + setRegistrationError(error instanceof Error ? error.message : "Registration failed"); + return; + } + } + + setCurrentStep("payment"); + }, + [guestInfo, registrationComplete, setAddress, setCurrentStep, setRegistrationComplete] + ); + + const form = useZodForm({ + schema: addressFormSchema, + initialValues: { + address1: address?.address1 ?? "", + address2: address?.address2 ?? "", + city: address?.city ?? "", + state: address?.state ?? "", + postcode: address?.postcode ?? "", + country: address?.country ?? "Japan", + countryCode: address?.countryCode ?? "JP", + }, + onSubmit: handleSubmit, + }); + + return ( +
+
+
+ +
+

Service Address

+

+ Where should we deliver or install your service? +

+
+
+ + {registrationError && ( + + {registrationError} + + )} + +
void form.handleSubmit(event)} className="space-y-4"> + {/* Address Line 1 */} + + form.setValue("address1", e.target.value)} + onBlur={() => form.setTouchedField("address1")} + placeholder="Street address, building name" + /> + + + {/* Address Line 2 */} + + form.setValue("address2", e.target.value)} + onBlur={() => form.setTouchedField("address2")} + placeholder="Apartment, suite, unit, floor, etc." + /> + + + {/* City and State */} +
+ + form.setValue("city", e.target.value)} + onBlur={() => form.setTouchedField("city")} + placeholder="Tokyo" + /> + + + form.setValue("state", e.target.value)} + onBlur={() => form.setTouchedField("state")} + placeholder="Tokyo" + /> + +
+ + {/* Postcode and Country */} +
+ + form.setValue("postcode", e.target.value)} + onBlur={() => form.setTouchedField("postcode")} + placeholder="123-4567" + /> + + + form.setValue("country", e.target.value)} + onBlur={() => form.setTouchedField("country")} + placeholder="Japan" + /> + +
+ + {/* Navigation buttons */} +
+ + +
+
+
+
+ ); +} diff --git a/apps/portal/src/features/checkout/components/steps/PaymentStep.tsx b/apps/portal/src/features/checkout/components/steps/PaymentStep.tsx new file mode 100644 index 00000000..7b8f7b8b --- /dev/null +++ b/apps/portal/src/features/checkout/components/steps/PaymentStep.tsx @@ -0,0 +1,237 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useCheckoutStore } from "../../stores/checkout.store"; +import { Button } from "@/components/atoms/button"; +import { Spinner } from "@/components/atoms"; +import { + CreditCardIcon, + ArrowLeftIcon, + ArrowRightIcon, + CheckCircleIcon, + ExclamationTriangleIcon, +} from "@heroicons/react/24/outline"; + +/** + * PaymentStep - Third step in checkout + * + * Opens WHMCS SSO to add payment method and polls for completion. + */ +export function PaymentStep() { + const { setPaymentVerified, paymentMethodVerified, setCurrentStep, registrationComplete } = + useCheckoutStore(); + const [isWaiting, setIsWaiting] = useState(false); + const [error, setError] = useState(null); + const [paymentMethod, setPaymentMethod] = useState<{ + cardType?: string; + lastFour?: string; + } | null>(null); + + // Poll for payment method + const checkPaymentMethod = useCallback(async () => { + if (!registrationComplete) { + // Need to be registered first - show message + setError("Please complete registration first"); + return false; + } + + try { + const response = await fetch("/api/payments/methods", { + credentials: "include", + }); + + if (!response.ok) { + throw new Error("Failed to check payment methods"); + } + + const data = await response.json(); + const methods = data.data?.paymentMethods ?? data.paymentMethods ?? []; + + if (methods.length > 0) { + const defaultMethod = + methods.find((m: { isDefault?: boolean }) => m.isDefault) || methods[0]; + setPaymentMethod({ + cardType: defaultMethod.cardType || defaultMethod.type || "Card", + lastFour: defaultMethod.cardLastFour, + }); + setPaymentVerified(true); + return true; + } + + return false; + } catch (err) { + console.error("Error checking payment methods:", err); + return false; + } + }, [registrationComplete, setPaymentVerified]); + + // Check on mount and when returning focus + useEffect(() => { + if (paymentMethodVerified) return; + + void checkPaymentMethod(); + + // Poll when window gains focus (user returned from WHMCS) + const handleFocus = () => { + if (isWaiting) { + void checkPaymentMethod(); + } + }; + + window.addEventListener("focus", handleFocus); + return () => window.removeEventListener("focus", handleFocus); + }, [checkPaymentMethod, isWaiting, paymentMethodVerified]); + + // Polling interval when waiting + useEffect(() => { + if (!isWaiting) return; + + const interval = setInterval(async () => { + const found = await checkPaymentMethod(); + if (found) { + setIsWaiting(false); + } + }, 3000); + + return () => clearInterval(interval); + }, [isWaiting, checkPaymentMethod]); + + const handleAddPayment = async () => { + if (!registrationComplete) { + setError("Please complete account setup first"); + return; + } + + setError(null); + setIsWaiting(true); + + try { + // Get SSO link for payment methods + const response = await fetch("/api/auth/sso-link", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ destination: "paymentmethods" }), + credentials: "include", + }); + + if (!response.ok) { + throw new Error("Failed to get payment portal link"); + } + + const data = await response.json(); + const url = data.data?.url ?? data.url; + + if (url) { + window.open(url, "_blank", "noopener,noreferrer"); + } else { + throw new Error("No URL returned"); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to open payment portal"); + setIsWaiting(false); + } + }; + + return ( +
+
+
+ +
+

Payment Method

+

+ Add a payment method to complete your order +

+
+
+ + {/* Error message */} + {error && ( +
+ +
{error}
+
+ )} + + {/* Payment method display or add prompt */} + {paymentMethodVerified && paymentMethod ? ( +
+
+
+ +
+

Payment method verified

+

+ {paymentMethod.cardType} + {paymentMethod.lastFour && ` ending in ${paymentMethod.lastFour}`} +

+
+
+
+ + +
+ ) : ( +
+
+ +
+ + {isWaiting ? ( + <> +

+ Waiting for payment method... +

+

+ Complete the payment setup in the new tab, then return here. +

+ + + + ) : ( + <> +

Add a payment method

+

+ We'll open our secure payment portal where you can add your credit card or other + payment method. +

+ + {!registrationComplete && ( +

+ You need to complete registration first +

+ )} + + )} +
+ )} + + {/* Navigation buttons */} +
+ + +
+
+
+ ); +} diff --git a/apps/portal/src/features/checkout/components/steps/ReviewStep.tsx b/apps/portal/src/features/checkout/components/steps/ReviewStep.tsx new file mode 100644 index 00000000..6e4bfb67 --- /dev/null +++ b/apps/portal/src/features/checkout/components/steps/ReviewStep.tsx @@ -0,0 +1,231 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useCheckoutStore } from "../../stores/checkout.store"; +import { Button } from "@/components/atoms/button"; +import { + ShieldCheckIcon, + ArrowLeftIcon, + UserIcon, + MapPinIcon, + CreditCardIcon, + ShoppingCartIcon, + CheckIcon, +} from "@heroicons/react/24/outline"; + +/** + * ReviewStep - Final step in checkout + * + * Shows order summary and allows user to submit. + */ +export function ReviewStep() { + const router = useRouter(); + const { cartItem, guestInfo, address, paymentMethodVerified, setCurrentStep, clear } = + useCheckoutStore(); + + const [termsAccepted, setTermsAccepted] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async () => { + if (!termsAccepted) { + setError("Please accept the terms and conditions"); + return; + } + + if (!cartItem) { + setError("No items in cart"); + return; + } + + setIsSubmitting(true); + setError(null); + + try { + // Submit order via API + const response = await fetch("/api/orders", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + orderType: cartItem.orderType, + skus: [cartItem.planSku, ...cartItem.addonSkus], + configuration: cartItem.configuration, + }), + credentials: "include", + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error?.message || "Failed to submit order"); + } + + const result = await response.json(); + const orderId = result.data?.orderId ?? result.orderId; + + // Clear checkout state + clear(); + + // Redirect to confirmation + router.push(`/order/complete${orderId ? `?orderId=${orderId}` : ""}`); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to submit order"); + setIsSubmitting(false); + } + }; + + return ( +
+ {/* Order Review Card */} +
+
+ +

Review Your Order

+
+ + {/* Error message */} + {error && ( +
+ {error} +
+ )} + +
+ {/* Account Info */} +
+
+ + Account + +
+

+ {guestInfo?.firstName} {guestInfo?.lastName} +

+

{guestInfo?.email}

+
+ + {/* Address */} +
+
+ + Service Address + +
+

+ {address?.address1} + {address?.address2 && `, ${address.address2}`} +

+

+ {address?.city}, {address?.state} {address?.postcode} +

+

{address?.country}

+
+ + {/* Payment */} +
+
+ + Payment Method + +
+

+ {paymentMethodVerified ? "Payment method on file" : "No payment method"} +

+
+ + {/* Order Items */} +
+
+ + Order Items +
+
+

{cartItem?.planName}

+ {cartItem?.addonSkus && cartItem.addonSkus.length > 0 && ( +

+ {cartItem.addonSkus.length} add-on(s)

+ )} +
+
+ {cartItem?.pricing.monthlyTotal ? ( +

+ ¥{cartItem.pricing.monthlyTotal.toLocaleString()}/mo +

+ ) : null} + {cartItem?.pricing.oneTimeTotal ? ( +

+ + ¥{cartItem.pricing.oneTimeTotal.toLocaleString()} one-time +

+ ) : null} +
+
+
+
+ + {/* Terms and Submit */} +
+
+ setTermsAccepted(e.target.checked)} + className="mt-1 h-4 w-4 rounded border-border text-primary focus:ring-primary" + /> + +
+ +
+ + +
+
+
+ ); +} diff --git a/apps/portal/src/features/checkout/components/steps/index.ts b/apps/portal/src/features/checkout/components/steps/index.ts new file mode 100644 index 00000000..38e8f7de --- /dev/null +++ b/apps/portal/src/features/checkout/components/steps/index.ts @@ -0,0 +1,4 @@ +export { AccountStep } from "./AccountStep"; +export { AddressStep } from "./AddressStep"; +export { PaymentStep } from "./PaymentStep"; +export { ReviewStep } from "./ReviewStep"; diff --git a/apps/portal/src/features/checkout/hooks/useCheckout.ts b/apps/portal/src/features/checkout/hooks/useCheckout.ts index 0a760a24..7ce706c1 100644 --- a/apps/portal/src/features/checkout/hooks/useCheckout.ts +++ b/apps/portal/src/features/checkout/hooks/useCheckout.ts @@ -147,7 +147,7 @@ export function useCheckout() { } const cart = checkoutState.data; - + // Debug logging to check cart contents console.log("[DEBUG] Cart data:", cart); console.log("[DEBUG] Cart items:", cart.items); @@ -158,7 +158,7 @@ export function useCheckout() { // 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; @@ -205,8 +205,8 @@ export function useCheckout() { const configureUrl = orderType === ORDER_TYPE.INTERNET - ? `/catalog/internet/configure?${urlParams.toString()}` - : `/catalog/sim/configure?${urlParams.toString()}`; + ? `/shop/internet/configure?${urlParams.toString()}` + : `/shop/sim/configure?${urlParams.toString()}`; router.push(configureUrl); }, [orderType, paramsKey, router]); diff --git a/apps/portal/src/features/checkout/services/checkout-api.service.ts b/apps/portal/src/features/checkout/services/checkout-api.service.ts new file mode 100644 index 00000000..0fbd7246 --- /dev/null +++ b/apps/portal/src/features/checkout/services/checkout-api.service.ts @@ -0,0 +1,110 @@ +/** + * Checkout API Service + * + * Handles API calls for checkout flow. + */ + +import type { CartItem } from "@customer-portal/domain/checkout"; +import type { AddressFormData } from "@customer-portal/domain/customer"; + +interface RegisterForCheckoutParams { + guestInfo: { + email: string; + firstName: string; + lastName: string; + phone: string; + phoneCountryCode: string; + password: string; + }; + address: AddressFormData; +} + +interface CheckoutRegisterResult { + success: boolean; + user: { + id: string; + email: string; + firstname: string; + lastname: string; + }; + session: { + expiresAt: string; + refreshExpiresAt: string; + }; + sfAccountNumber?: string; +} + +export const checkoutApiService = { + /** + * Register a new user during checkout + */ + async registerForCheckout(params: RegisterForCheckoutParams): Promise { + const response = await fetch("/api/checkout/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: params.guestInfo.email, + firstName: params.guestInfo.firstName, + lastName: params.guestInfo.lastName, + phone: params.guestInfo.phone, + phoneCountryCode: params.guestInfo.phoneCountryCode, + password: params.guestInfo.password, + address: params.address, + acceptTerms: true, + }), + credentials: "include", + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error?.message || errorData.message || "Registration failed"); + } + + return response.json(); + }, + + /** + * Check if current user has a valid payment method + */ + async getPaymentStatus(): Promise<{ hasPaymentMethod: boolean }> { + try { + const response = await fetch("/api/checkout/payment-status", { + credentials: "include", + }); + + if (!response.ok) { + return { hasPaymentMethod: false }; + } + + return response.json(); + } catch { + return { hasPaymentMethod: false }; + } + }, + + /** + * Submit order + */ + async submitOrder(cartItem: CartItem): Promise<{ orderId?: string }> { + const response = await fetch("/api/orders", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + orderType: cartItem.orderType, + skus: [cartItem.planSku, ...cartItem.addonSkus], + configuration: cartItem.configuration, + }), + credentials: "include", + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error?.message || "Failed to submit order"); + } + + const result = await response.json(); + return { + orderId: result.data?.orderId ?? result.orderId, + }; + }, +}; diff --git a/apps/portal/src/features/checkout/stores/checkout.store.ts b/apps/portal/src/features/checkout/stores/checkout.store.ts new file mode 100644 index 00000000..ae36689f --- /dev/null +++ b/apps/portal/src/features/checkout/stores/checkout.store.ts @@ -0,0 +1,231 @@ +/** + * Checkout Store + * + * Zustand store for unified checkout flow with localStorage persistence. + * Supports both guest and authenticated checkout. + */ + +import { create } from "zustand"; +import { persist, createJSONStorage } from "zustand/middleware"; +import type { CartItem, GuestInfo, CheckoutStep } from "@customer-portal/domain/checkout"; +import type { AddressFormData } from "@customer-portal/domain/customer"; + +interface CheckoutState { + // Cart data + cartItem: CartItem | null; + + // Guest info (pre-registration) + guestInfo: Partial | null; + + // Address + address: AddressFormData | null; + + // Registration state + registrationComplete: boolean; + userId: string | null; + + // Payment state + paymentMethodVerified: boolean; + + // Checkout step + currentStep: CheckoutStep; + + // Cart timestamp for staleness detection + cartUpdatedAt: number | null; +} + +interface CheckoutActions { + // Cart actions + setCartItem: (item: CartItem) => void; + clearCart: () => void; + + // Guest info actions + updateGuestInfo: (info: Partial) => void; + clearGuestInfo: () => void; + + // Address actions + setAddress: (address: AddressFormData) => void; + clearAddress: () => void; + + // Registration actions + setRegistrationComplete: (userId: string) => void; + + // Payment actions + setPaymentVerified: (verified: boolean) => void; + + // Step navigation + setCurrentStep: (step: CheckoutStep) => void; + goToNextStep: () => void; + goToPreviousStep: () => void; + + // Reset + clear: () => void; + + // Cart recovery + isCartStale: (maxAgeMs?: number) => boolean; +} + +type CheckoutStore = CheckoutState & CheckoutActions; + +const STEP_ORDER: CheckoutStep[] = ["account", "address", "payment", "review"]; + +const initialState: CheckoutState = { + cartItem: null, + guestInfo: null, + address: null, + registrationComplete: false, + userId: null, + paymentMethodVerified: false, + currentStep: "account", + cartUpdatedAt: null, +}; + +export const useCheckoutStore = create()( + persist( + (set, get) => ({ + ...initialState, + + // Cart actions + setCartItem: (item: CartItem) => + set({ + cartItem: item, + cartUpdatedAt: Date.now(), + }), + + clearCart: () => + set({ + cartItem: null, + cartUpdatedAt: null, + }), + + // Guest info actions + updateGuestInfo: (info: Partial) => + set(state => ({ + guestInfo: { ...state.guestInfo, ...info }, + })), + + clearGuestInfo: () => + set({ + guestInfo: null, + }), + + // Address actions + setAddress: (address: AddressFormData) => + set({ + address, + }), + + clearAddress: () => + set({ + address: null, + }), + + // Registration actions + setRegistrationComplete: (userId: string) => + set({ + registrationComplete: true, + userId, + }), + + // Payment actions + setPaymentVerified: (verified: boolean) => + set({ + paymentMethodVerified: verified, + }), + + // Step navigation + setCurrentStep: (step: CheckoutStep) => + set({ + currentStep: step, + }), + + goToNextStep: () => { + const { currentStep } = get(); + const currentIndex = STEP_ORDER.indexOf(currentStep); + if (currentIndex < STEP_ORDER.length - 1) { + set({ currentStep: STEP_ORDER[currentIndex + 1] }); + } + }, + + goToPreviousStep: () => { + const { currentStep } = get(); + const currentIndex = STEP_ORDER.indexOf(currentStep); + if (currentIndex > 0) { + set({ currentStep: STEP_ORDER[currentIndex - 1] }); + } + }, + + // Reset + clear: () => set(initialState), + + // Cart recovery - check if cart is stale (default 24 hours) + isCartStale: (maxAgeMs = 24 * 60 * 60 * 1000) => { + const { cartUpdatedAt } = get(); + if (!cartUpdatedAt) return false; + return Date.now() - cartUpdatedAt > maxAgeMs; + }, + }), + { + name: "checkout-store", + version: 1, + storage: createJSONStorage(() => localStorage), + partialize: state => ({ + // Persist only essential data + cartItem: state.cartItem, + guestInfo: state.guestInfo, + address: state.address, + currentStep: state.currentStep, + cartUpdatedAt: state.cartUpdatedAt, + // Don't persist sensitive or transient state + // registrationComplete, userId, paymentMethodVerified are session-specific + }), + } + ) +); + +/** + * Hook to check if cart has items + */ +export function useHasCartItem(): boolean { + return useCheckoutStore(state => state.cartItem !== null); +} + +/** + * Hook to get current step index (1-based for display) + */ +export function useCurrentStepIndex(): number { + const step = useCheckoutStore(state => state.currentStep); + return STEP_ORDER.indexOf(step) + 1; +} + +/** + * Hook to check if user can proceed to a specific step + */ +export function useCanProceedToStep(targetStep: CheckoutStep): boolean { + const { cartItem, guestInfo, address, registrationComplete, paymentMethodVerified } = + useCheckoutStore(); + + // Must have cart to proceed anywhere + if (!cartItem) return false; + + // Step-specific validation + switch (targetStep) { + case "account": + return true; + case "address": + // Need guest info OR be authenticated (registrationComplete) + return ( + Boolean( + guestInfo?.email && guestInfo?.firstName && guestInfo?.lastName && guestInfo?.password + ) || registrationComplete + ); + case "payment": + // Need address + return Boolean(address?.address1 && address?.city && address?.postcode); + case "review": + // Need payment method verified + return paymentMethodVerified; + default: + return false; + } +} diff --git a/apps/portal/src/features/landing-page/views/PublicLandingView.tsx b/apps/portal/src/features/landing-page/views/PublicLandingView.tsx index 8b0e05b8..99394cb0 100644 --- a/apps/portal/src/features/landing-page/views/PublicLandingView.tsx +++ b/apps/portal/src/features/landing-page/views/PublicLandingView.tsx @@ -7,6 +7,10 @@ import { Cog6ToothIcon, PhoneIcon, ArrowRightIcon, + ShoppingBagIcon, + ServerIcon, + DevicePhoneMobileIcon, + ShieldCheckIcon, } from "@heroicons/react/24/outline"; export function PublicLandingView() { @@ -31,6 +35,76 @@ export function PublicLandingView() {
+ {/* Browse services CTA - New prominent section */} +
+
+
+
+
+ +
+
+

Browse Our Services

+

+ Explore internet, SIM, and VPN plans — no account needed +

+
+
+ + View Catalog + + +
+
+
+ + {/* Service highlights */} +
+
+ +
+ +
+
+ Internet +
+
Up to 10Gbps fiber
+ + + +
+ +
+
+ SIM & eSIM +
+
Data, voice & SMS plans
+ + + +
+ +
+
+ VPN +
+
Secure remote access
+ +
+
+ {/* Primary actions */}
@@ -72,16 +146,22 @@ export function PublicLandingView() {

New customers

- Create an account to get started with our services. + Browse our services and sign up during checkout, or create an account first.

-
+
- Create account + Browse services + + Create account +
@@ -97,7 +177,7 @@ export function PublicLandingView() {

Powerful tools to manage your account

Need help? diff --git a/apps/portal/src/features/support/views/PublicContactView.tsx b/apps/portal/src/features/support/views/PublicContactView.tsx new file mode 100644 index 00000000..4adf5905 --- /dev/null +++ b/apps/portal/src/features/support/views/PublicContactView.tsx @@ -0,0 +1,200 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { z } from "zod"; +import Link from "next/link"; +import { Button, Input } from "@/components/atoms"; +import { FormField } from "@/components/molecules/FormField/FormField"; +import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; +import { useZodForm } from "@/hooks/useZodForm"; +import { EnvelopeIcon, ArrowLeftIcon, CheckCircleIcon } from "@heroicons/react/24/outline"; + +const contactFormSchema = z.object({ + name: z.string().min(1, "Name is required"), + email: z.string().email("Please enter a valid email address"), + phone: z.string().optional(), + subject: z.string().min(1, "Subject is required"), + message: z.string().min(10, "Message must be at least 10 characters"), +}); + +type ContactFormData = z.infer; + +/** + * PublicContactView - Contact form for unauthenticated users + */ +export function PublicContactView() { + const [isSubmitted, setIsSubmitted] = useState(false); + const [submitError, setSubmitError] = useState(null); + + const handleSubmit = useCallback(async (data: ContactFormData) => { + setSubmitError(null); + + try { + const response = await fetch("/api/support/contact", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error?.message || "Failed to send message"); + } + + setIsSubmitted(true); + } catch (error) { + setSubmitError(error instanceof Error ? error.message : "Failed to send message"); + } + }, []); + + const form = useZodForm({ + schema: contactFormSchema, + initialValues: { + name: "", + email: "", + phone: "", + subject: "", + message: "", + }, + onSubmit: handleSubmit, + }); + + if (isSubmitted) { + return ( +
+
+ +
+

Message Sent!

+

+ Thank you for contacting us. We'll get back to you within 24 hours. +

+
+ + +
+
+ ); + } + + return ( +
+ {/* Back link */} + + + Back to Support + + + {/* Header */} +
+
+ +
+

Contact Us

+

Have a question? We'd love to hear from you.

+
+ + {/* Form */} +
+ {submitError && ( + + {submitError} + + )} + +
void form.handleSubmit(event)} className="space-y-4"> + + form.setValue("name", e.target.value)} + onBlur={() => form.setTouchedField("name")} + placeholder="Your name" + /> + + + + form.setValue("email", e.target.value)} + onBlur={() => form.setTouchedField("email")} + placeholder="your@email.com" + /> + + + + form.setValue("phone", e.target.value)} + onBlur={() => form.setTouchedField("phone")} + placeholder="+81 90-1234-5678" + /> + + + + form.setValue("subject", e.target.value)} + onBlur={() => form.setTouchedField("subject")} + placeholder="How can we help?" + /> + + + +