diff --git a/.cursor/plans/restructure_to_account_portal_efdb4b10.plan.md b/.cursor/plans/restructure_to_account_portal_efdb4b10.plan.md new file mode 100644 index 00000000..9fea44ba --- /dev/null +++ b/.cursor/plans/restructure_to_account_portal_efdb4b10.plan.md @@ -0,0 +1,390 @@ +--- +name: Restructure to Account Portal +overview: Restructure the app to have public pages under (public)/ and all authenticated portal pages under /account/*, with auth-aware headers in public shells. +todos: + - id: auth-aware-public-shell + content: "Make PublicShell auth-aware: show 'My Account' for logged-in users, 'Sign in' for guests" + status: pending + - id: auth-aware-catalog-shell + content: Make CatalogShell auth-aware with same pattern + status: pending + - id: create-account-layout + content: Create account/layout.tsx with AppShell and auth guard redirect + status: pending + - id: move-dashboard-to-account + content: Move dashboard page to account/page.tsx + status: pending + - id: move-billing-to-account + content: Move billing pages to account/billing/* + status: pending + - id: move-subscriptions-to-services + content: Move subscriptions to account/services/* + status: pending + - id: move-orders-to-account + content: Move orders to account/orders/* + status: pending + - id: move-support-to-account + content: Move support cases to account/support/* + status: pending + - id: move-profile-to-settings + content: Move account/profile to account/settings/* + status: pending + - id: fix-shop-double-header + content: Fix shop layout to not create double header - add CatalogNav only + status: pending + - id: create-contact-route + content: Create (public)/contact/page.tsx for contact form + status: pending + - id: update-navigation + content: Update AppShell navigation.ts with /account/* paths + status: pending + - id: update-catalog-links + content: Replace all /catalog links with /shop + status: pending + - id: update-portal-links + content: Replace all old portal links with /account/* paths + status: pending + - id: remove-sfnumber + content: Remove sfNumber from domain schema and signup components + status: pending + - id: delete-old-authenticated + content: Delete (authenticated)/ directory after migration + status: pending + - id: rebuild-test + content: Rebuild domain package and test all routes + status: pending +--- + +# Restructure Portal to /account/\* Architecture + +## Target Architecture + +```mermaid +flowchart TB + subgraph public ["(public)/ - Public Pages"] + P1["/"] --> Home["Homepage"] + P2["/auth/*"] --> Auth["Login, Signup, etc"] + P3["/shop/*"] --> Shop["Product Catalog"] + P4["/help"] --> Help["FAQ & Knowledge Base"] + P5["/contact"] --> Contact["Contact Form"] + P6["/order/*"] --> Order["Checkout Flow"] + end + + subgraph account ["/account/* - My Portal"] + A1["/account"] --> Dashboard["Dashboard"] + A2["/account/billing"] --> Billing["Invoices & Payments"] + A3["/account/services"] --> Services["My Subscriptions"] + A4["/account/orders"] --> Orders["Order History"] + A5["/account/support"] --> Support["My Tickets"] + A6["/account/settings"] --> Settings["Profile Settings"] + end + + public -.->|"Auth-aware header"| account +``` + +--- + +## Phase 1: Make Shells Auth-Aware + +### 1.1 Update PublicShell + +**File:** `apps/portal/src/components/templates/PublicShell/PublicShell.tsx` + +Add auth detection to header navigation: + +```tsx +"use client"; +import { useAuthStore } from "@/features/auth/services/auth.store"; + +export function PublicShell({ children }: PublicShellProps) { + const { isAuthenticated } = useAuthStore(); + + return ( +
+
+ +
+ ... +
+ ); +} +``` + +### 1.2 Update CatalogShell + +**File:** `apps/portal/src/components/templates/CatalogShell/CatalogShell.tsx` + +Same auth-aware pattern - show "My Account" or "Sign in" based on auth state. + +--- + +## Phase 2: Create /account Route Structure + +### 2.1 Create Account Layout with Auth Guard + +**File:** `apps/portal/src/app/account/layout.tsx` (NEW) + +```tsx +import { redirect } from "next/navigation"; +import { cookies } from "next/headers"; +import { AppShell } from "@/components/organisms/AppShell"; + +export default async function AccountLayout({ children }: { children: React.ReactNode }) { + const cookieStore = await cookies(); + const hasAuthToken = cookieStore.has("access_token"); + + if (!hasAuthToken) { + redirect("/auth/login?redirect=/account"); + } + + return {children}; +} +``` + +### 2.2 Create Account Pages + +Move and rename pages: + +| Current Path | New Path | New File | + +|--------------|----------|----------| + +| `(authenticated)/dashboard/page.tsx` | `/account` | `account/page.tsx` | + +| `(authenticated)/billing/*` | `/account/billing/*` | `account/billing/*` | + +| `(authenticated)/subscriptions/*` | `/account/services/*` | `account/services/*` | + +| `(authenticated)/orders/*` | `/account/orders/*` | `account/orders/*` | + +| `(authenticated)/support/*` | `/account/support/*` | `account/support/*` | + +| `(authenticated)/account/*` | `/account/settings/*` | `account/settings/*` | + +--- + +## Phase 3: Update Navigation + +### 3.1 Update AppShell Navigation + +**File:** `apps/portal/src/components/organisms/AppShell/navigation.ts` + +Update all paths to use `/account/*`: + +```typescript +export const baseNavigation: NavigationItem[] = [ + { name: "Dashboard", href: "/account", icon: HomeIcon }, + { name: "Orders", href: "/account/orders", icon: ClipboardDocumentListIcon }, + { + name: "Billing", + icon: CreditCardIcon, + children: [ + { name: "Invoices", href: "/account/billing/invoices" }, + { name: "Payment Methods", href: "/account/billing/payments" }, + ], + }, + { + name: "My Services", + icon: ServerIcon, + children: [{ name: "All Services", href: "/account/services" }], + }, + { name: "Shop", href: "/shop", icon: Squares2X2Icon }, // Links to public shop + { + name: "Support", + icon: ChatBubbleLeftRightIcon, + children: [ + { name: "My Tickets", href: "/account/support" }, + { name: "New Ticket", href: "/account/support/new" }, + ], + }, + { name: "Settings", href: "/account/settings", icon: UserIcon }, + { name: "Log out", href: "#", icon: ArrowRightStartOnRectangleIcon, isLogout: true }, +]; +``` + +--- + +## Phase 4: Fix Public Routes + +### 4.1 Fix Double Header in Shop + +Remove the nested shell issue by having CatalogShell NOT render a full page wrapper, or by not nesting it under PublicShell. + +**Option A:** Move shop out of (public) to its own route with CatalogShell only + +**Option B:** Have (public)/shop/layout.tsx return just children with catalog nav (no shell) + +Recommended: **Option B** - Keep shop under (public) but have shop layout add only catalog navigation, not a full shell. + +**File:** `apps/portal/src/app/(public)/shop/layout.tsx` + +```tsx +import { CatalogNav } from "@/components/templates/CatalogShell"; + +export default function ShopLayout({ children }: { children: React.ReactNode }) { + // Don't wrap with another shell - parent (public) layout already has PublicShell + return ( + <> + + {children} + + ); +} +``` + +**File:** `apps/portal/src/components/templates/CatalogShell/CatalogShell.tsx` + +Split into two exports: + +- `CatalogShell` - full shell (if ever needed standalone) +- `CatalogNav` - just the navigation bar + +### 4.2 Create /contact Route + +**File:** `apps/portal/src/app/(public)/contact/page.tsx` (NEW) + +Move content from `(public)/help/contact/` to `(public)/contact/`. + +--- + +## Phase 5: Delete Old Routes + +### 5.1 Delete (authenticated) Directory + +After moving all content to /account/: + +- Delete entire `apps/portal/src/app/(authenticated)/` directory + +### 5.2 Clean Up Unused Files + +- Delete `(public)/help/contact/` (moved to /contact) +- Keep `(public)/help/page.tsx` for FAQ + +--- + +## Phase 6: Update All Internal Links + +### 6.1 Update /catalog to /shop Links + +Replace in feature components (11 files, 27 occurrences): + +``` +/catalog → /shop +/catalog/internet → /shop/internet +/catalog/sim → /shop/sim +/catalog/vpn → /shop/vpn +``` + +### 6.2 Update Dashboard/Portal Links + +Replace throughout codebase: + +``` +/dashboard → /account +/billing → /account/billing +/subscriptions → /account/services +/orders → /account/orders +/support/cases → /account/support +``` + +--- + +## Phase 7: Remove sfNumber from Signup + +### 7.1 Update Domain Schema + +**File:** `packages/domain/auth/schema.ts` + +```typescript +// Line 44: Remove required sfNumber +// Before: +sfNumber: z.string().min(6, "Customer number must be at least 6 characters"), + +// After: +sfNumber: z.string().optional(), +``` + +Also update `validateSignupRequestSchema` to not require sfNumber. + +### 7.2 Update SignupForm Components + +- `SignupForm.tsx` - Remove sfNumber from initialValues and validation +- `AccountStep.tsx` - Remove Customer Number form field +- `ReviewStep.tsx` - Remove Customer Number display + +--- + +## Phase 8: Rebuild and Test + +### 8.1 Rebuild Domain Package + +```bash +pnpm --filter @customer-portal/domain build +``` + +### 8.2 Test Matrix + +| Scenario | URL | Expected | + +|----------|-----|----------| + +| Public homepage | `/` | PublicShell, homepage content | + +| Public shop | `/shop` | CatalogShell (auth-aware), products | + +| Auth user in shop | `/shop` | "My Account" button, personalized pricing | + +| Public help | `/help` | FAQ content | + +| Public contact | `/contact` | Contact form, prefills if logged in | + +| Login | `/auth/login` | Login form | + +| Signup | `/auth/signup` | No sfNumber field | + +| Account dashboard | `/account` | AppShell, dashboard (redirect if not auth) | + +| My services | `/account/services` | Subscriptions list | + +| My tickets | `/account/support` | Support cases | + +| Checkout | `/order` | CheckoutShell, wizard | + +--- + +## Files Summary + +| Category | Action | Count | + +|----------|--------|-------| + +| New account/ routes | Create | ~15 files | + +| Shell components | Modify | 2 (PublicShell, CatalogShell) | + +| Shop layout | Modify | 1 | + +| Navigation | Modify | 1 | + +| Link updates | Modify | ~20 files | + +| Domain schema | Modify | 1 | + +| Signup components | Modify | 3 | + +| Delete old routes | Delete | ~20 files | + +**Total: ~60+ file operations** diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 535e0be9..c3941a56 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -23,8 +23,6 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4 - with: - version: 10.25.0 - name: Setup Node.js uses: actions/setup-node@v4 diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 01286ba2..f83adcc9 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -22,8 +22,6 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4 - with: - version: "10.25.0" - name: Get pnpm store directory id: pnpm-cache diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 3559a8ce..eaadb1bc 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -30,8 +30,6 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4 - with: - version: "10.25.0" - name: Get pnpm store directory id: pnpm-cache @@ -139,8 +137,6 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4 - with: - version: "10.25.0" - name: Check for outdated dependencies run: | diff --git a/.husky/pre-commit b/.husky/pre-commit index 087f7e62..a069af19 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,10 @@ +# Stage all modified tracked files (includes manual fixes to already-staged files) +git add -u + +# Run lint-staged on staged files (checks linting, formats with prettier, and re-stages) pnpm lint-staged + +# Run type check pnpm type-check # Security audit is enforced in CI (`.github/workflows/security.yml`). diff --git a/.lintstagedrc.json b/.lintstagedrc.json index 49351bb5..23248968 100644 --- a/.lintstagedrc.json +++ b/.lintstagedrc.json @@ -1,4 +1,4 @@ { - "*.{ts,tsx,js,jsx}": ["eslint --fix", "prettier -w"], + "*.{ts,tsx,js,jsx}": ["eslint --fix --no-warn-ignored", "prettier -w"], "*.{json,md,yml,yaml,css,scss}": ["prettier -w"] } diff --git a/README.md b/README.md index 6a06d0bc..1f399ae0 100644 --- a/README.md +++ b/README.md @@ -280,7 +280,7 @@ When running `pnpm dev:tools`, you get access to: - `POST /api/auth/signup` - Create portal user → WHMCS AddClient → SF upsert - `POST /api/auth/login` - Portal authentication -- `POST /api/auth/link-whmcs` - OIDC callback or ValidateLogin +- `POST /api/auth/migrate` - Account migration from legacy portal - `POST /api/auth/set-password` - Required after WHMCS link ### User Management @@ -292,7 +292,7 @@ When running `pnpm dev:tools`, you get access to: ### Catalog & Orders -- `GET /api/catalog` - WHMCS GetProducts (cached 5-15m) +- `GET /api/services/*` - Services catalog endpoints (internet/sim/vpn) - `POST /api/orders` - WHMCS AddOrder with idempotency ### Invoices @@ -481,7 +481,7 @@ rm -rf node_modules && pnpm install - **[Deployment Guide](docs/DEPLOY.md)** - Production deployment instructions - **[Architecture](docs/STRUCTURE.md)** - Code organization and conventions - **[Logging](docs/LOGGING.md)** - Logging configuration and best practices -- **Portal Guides** - High-level flow, data ownership, and error handling (`docs/portal-guides/README.md`) +- **Portal Guides** - High-level flow, data ownership, and error handling (`docs/how-it-works/README.md`) ## Contributing diff --git a/SECURITY.md b/SECURITY.md index 17ee66e5..68f89263 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -119,8 +119,8 @@ Security audits are automatically run on: ### Internal Documentation -- [Environment Configuration](./docs/portal-guides/COMPLETE-GUIDE.md) -- [Deployment Guide](./docs/portal-guides/) +- [Environment Configuration](./docs/how-it-works/COMPLETE-GUIDE.md) +- [Deployment Guide](./docs/getting-started/) ### External Resources diff --git a/apps/bff/package.json b/apps/bff/package.json index 7a41c31b..cb2b7a39 100644 --- a/apps/bff/package.json +++ b/apps/bff/package.json @@ -38,6 +38,7 @@ "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.1.9", "@nestjs/platform-express": "^11.1.9", + "@nestjs/schedule": "^6.1.0", "@prisma/adapter-pg": "^7.1.0", "@prisma/client": "^7.1.0", "@sendgrid/mail": "^8.1.6", diff --git a/apps/bff/prisma/migrations/20251218060000_add_residence_card_submissions/migration.sql b/apps/bff/prisma/migrations/20251218060000_add_residence_card_submissions/migration.sql new file mode 100644 index 00000000..3651f8a8 --- /dev/null +++ b/apps/bff/prisma/migrations/20251218060000_add_residence_card_submissions/migration.sql @@ -0,0 +1,27 @@ +-- Add residence card verification storage + +CREATE TYPE "ResidenceCardStatus" AS ENUM ('PENDING', 'VERIFIED', 'REJECTED'); + +CREATE TABLE "residence_card_submissions" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "status" "ResidenceCardStatus" NOT NULL DEFAULT 'PENDING', + "filename" TEXT NOT NULL, + "mime_type" TEXT NOT NULL, + "size_bytes" INTEGER NOT NULL, + "content" BYTEA NOT NULL, + "submitted_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "reviewed_at" TIMESTAMP(3), + "reviewer_notes" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "residence_card_submissions_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "residence_card_submissions_user_id_key" ON "residence_card_submissions"("user_id"); + +ALTER TABLE "residence_card_submissions" +ADD CONSTRAINT "residence_card_submissions_user_id_fkey" +FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/apps/bff/prisma/migrations/20251223023617_add_notifications/migration.sql b/apps/bff/prisma/migrations/20251223023617_add_notifications/migration.sql new file mode 100644 index 00000000..b6ac7e78 --- /dev/null +++ b/apps/bff/prisma/migrations/20251223023617_add_notifications/migration.sql @@ -0,0 +1,40 @@ +-- CreateEnum +CREATE TYPE "NotificationType" AS ENUM ('ELIGIBILITY_ELIGIBLE', 'ELIGIBILITY_INELIGIBLE', 'VERIFICATION_VERIFIED', 'VERIFICATION_REJECTED', 'ORDER_APPROVED', 'ORDER_ACTIVATED', 'ORDER_FAILED', 'CANCELLATION_SCHEDULED', 'CANCELLATION_COMPLETE', 'PAYMENT_METHOD_EXPIRING', 'INVOICE_DUE', 'SYSTEM_ANNOUNCEMENT'); + +-- CreateEnum +CREATE TYPE "NotificationSource" AS ENUM ('SALESFORCE', 'WHMCS', 'PORTAL', 'SYSTEM'); + +-- AlterTable +ALTER TABLE "residence_card_submissions" ALTER COLUMN "updated_at" DROP DEFAULT; + +-- CreateTable +CREATE TABLE "notifications" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "type" "NotificationType" NOT NULL, + "title" TEXT NOT NULL, + "message" TEXT, + "action_url" TEXT, + "action_label" TEXT, + "source" "NotificationSource" NOT NULL DEFAULT 'SALESFORCE', + "source_id" TEXT, + "read" BOOLEAN NOT NULL DEFAULT false, + "read_at" TIMESTAMP(3), + "dismissed" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "notifications_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "notifications_user_id_read_dismissed_idx" ON "notifications"("user_id", "read", "dismissed"); + +-- CreateIndex +CREATE INDEX "notifications_user_id_created_at_idx" ON "notifications"("user_id", "created_at"); + +-- CreateIndex +CREATE INDEX "notifications_expires_at_idx" ON "notifications"("expires_at"); + +-- AddForeignKey +ALTER TABLE "notifications" ADD CONSTRAINT "notifications_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/bff/prisma/schema.prisma b/apps/bff/prisma/schema.prisma index 9c127bbf..76a869c7 100644 --- a/apps/bff/prisma/schema.prisma +++ b/apps/bff/prisma/schema.prisma @@ -36,6 +36,8 @@ model User { updatedAt DateTime @updatedAt @map("updated_at") auditLogs AuditLog[] idMapping IdMapping? + residenceCardSubmission ResidenceCardSubmission? + notifications Notification[] @@map("users") } @@ -91,6 +93,30 @@ enum AuditAction { SYSTEM_MAINTENANCE } +enum ResidenceCardStatus { + PENDING + VERIFIED + REJECTED +} + +model ResidenceCardSubmission { + id String @id @default(uuid()) + userId String @unique @map("user_id") + status ResidenceCardStatus @default(PENDING) + filename String + mimeType String @map("mime_type") + sizeBytes Int @map("size_bytes") + content Bytes @db.ByteA + submittedAt DateTime @default(now()) @map("submitted_at") + reviewedAt DateTime? @map("reviewed_at") + reviewerNotes String? @map("reviewer_notes") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("residence_card_submissions") +} + // Per-SIM daily usage snapshot used to build full-month charts model SimUsageDaily { id Int @id @default(autoincrement()) @@ -191,3 +217,63 @@ model SimHistoryImport { @@map("sim_history_imports") } + +// ============================================================================= +// Notifications - In-app notifications synced with Salesforce email triggers +// ============================================================================= + +model Notification { + id String @id @default(uuid()) + userId String @map("user_id") + + // Notification content + type NotificationType + title String + message String? + + // Action (optional CTA button) + actionUrl String? @map("action_url") + actionLabel String? @map("action_label") + + // Source tracking for deduplication + source NotificationSource @default(SALESFORCE) + sourceId String? @map("source_id") // SF Account ID, Order ID, etc. + + // Status + read Boolean @default(false) + readAt DateTime? @map("read_at") + dismissed Boolean @default(false) + + // Timestamps + createdAt DateTime @default(now()) @map("created_at") + expiresAt DateTime @map("expires_at") // 30 days from creation + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, read, dismissed]) + @@index([userId, createdAt]) + @@index([expiresAt]) + @@map("notifications") +} + +enum NotificationType { + ELIGIBILITY_ELIGIBLE + ELIGIBILITY_INELIGIBLE + VERIFICATION_VERIFIED + VERIFICATION_REJECTED + ORDER_APPROVED + ORDER_ACTIVATED + ORDER_FAILED + CANCELLATION_SCHEDULED + CANCELLATION_COMPLETE + PAYMENT_METHOD_EXPIRING + INVOICE_DUE + SYSTEM_ANNOUNCEMENT +} + +enum NotificationSource { + SALESFORCE + WHMCS + PORTAL + SYSTEM +} diff --git a/apps/bff/src/app.module.ts b/apps/bff/src/app.module.ts index 15e46b8c..b8724f28 100644 --- a/apps/bff/src/app.module.ts +++ b/apps/bff/src/app.module.ts @@ -2,6 +2,7 @@ import { Module } from "@nestjs/common"; import { APP_PIPE } from "@nestjs/core"; import { RouterModule } from "@nestjs/core"; import { ConfigModule } from "@nestjs/config"; +import { ScheduleModule } from "@nestjs/schedule"; import { ZodValidationPipe } from "nestjs-zod"; // Configuration @@ -27,14 +28,17 @@ import { SalesforceEventsModule } from "@bff/integrations/salesforce/events/even // Feature Modules import { AuthModule } from "@bff/modules/auth/auth.module.js"; import { UsersModule } from "@bff/modules/users/users.module.js"; +import { MeStatusModule } from "@bff/modules/me-status/me-status.module.js"; import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js"; -import { CatalogModule } from "@bff/modules/catalog/catalog.module.js"; +import { ServicesModule } from "@bff/modules/services/services.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"; import { CurrencyModule } from "@bff/modules/currency/currency.module.js"; import { SupportModule } from "@bff/modules/support/support.module.js"; import { RealtimeApiModule } from "@bff/modules/realtime/realtime.module.js"; +import { VerificationModule } from "@bff/modules/verification/verification.module.js"; +import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js"; // System Modules import { HealthModule } from "@bff/modules/health/health.module.js"; @@ -55,6 +59,7 @@ import { HealthModule } from "@bff/modules/health/health.module.js"; imports: [ // === CONFIGURATION === ConfigModule.forRoot(appConfig), + ScheduleModule.forRoot(), // === INFRASTRUCTURE === LoggingModule, @@ -77,14 +82,17 @@ import { HealthModule } from "@bff/modules/health/health.module.js"; // === FEATURE MODULES === AuthModule, UsersModule, + MeStatusModule, MappingsModule, - CatalogModule, + ServicesModule, OrdersModule, InvoicesModule, SubscriptionsModule, CurrencyModule, SupportModule, RealtimeApiModule, + VerificationModule, + NotificationsModule, // === SYSTEM MODULES === HealthModule, diff --git a/apps/bff/src/core/config/env.validation.ts b/apps/bff/src/core/config/env.validation.ts index abdb2964..f2f6bc68 100644 --- a/apps/bff/src/core/config/env.validation.ts +++ b/apps/bff/src/core/config/env.validation.ts @@ -54,6 +54,20 @@ export const envSchema = z.object({ "Authentication service is temporarily unavailable for maintenance. Please try again later." ), + /** + * Services catalog/eligibility cache safety TTL. + * + * Primary invalidation is event-driven (Salesforce CDC / Platform Events). + * This TTL is a safety net to self-heal if events are missed. + * + * Set to 0 to disable safety TTL (pure event-driven). + */ + SERVICES_CACHE_SAFETY_TTL_SECONDS: z.coerce + .number() + .int() + .min(0) + .default(60 * 60 * 12), + DATABASE_URL: z.string().url(), WHMCS_BASE_URL: z.string().url().optional(), @@ -132,6 +146,25 @@ export const envSchema = z.object({ // Salesforce Field Mappings - Account ACCOUNT_INTERNET_ELIGIBILITY_FIELD: z.string().default("Internet_Eligibility__c"), + ACCOUNT_INTERNET_ELIGIBILITY_STATUS_FIELD: z.string().default("Internet_Eligibility_Status__c"), + ACCOUNT_INTERNET_ELIGIBILITY_REQUESTED_AT_FIELD: z + .string() + .default("Internet_Eligibility_Request_Date_Time__c"), + ACCOUNT_INTERNET_ELIGIBILITY_CHECKED_AT_FIELD: z + .string() + .default("Internet_Eligibility_Checked_Date_Time__c"), + + ACCOUNT_ID_VERIFICATION_STATUS_FIELD: z.string().default("Id_Verification_Status__c"), + ACCOUNT_ID_VERIFICATION_SUBMITTED_AT_FIELD: z + .string() + .default("Id_Verification_Submitted_Date_Time__c"), + ACCOUNT_ID_VERIFICATION_VERIFIED_AT_FIELD: z + .string() + .default("Id_Verification_Verified_Date_Time__c"), + ACCOUNT_ID_VERIFICATION_NOTE_FIELD: z.string().default("Id_Verification_Note__c"), + ACCOUNT_ID_VERIFICATION_REJECTION_MESSAGE_FIELD: z + .string() + .default("Id_Verification_Rejection_Message__c"), ACCOUNT_CUSTOMER_NUMBER_FIELD: z.string().default("SF_Account_No__c"), // Salesforce Field Mappings - Product diff --git a/apps/bff/src/core/config/router.config.ts b/apps/bff/src/core/config/router.config.ts index f82d32e0..08b80968 100644 --- a/apps/bff/src/core/config/router.config.ts +++ b/apps/bff/src/core/config/router.config.ts @@ -1,8 +1,9 @@ import type { Routes } from "@nestjs/core"; import { AuthModule } from "@bff/modules/auth/auth.module.js"; import { UsersModule } from "@bff/modules/users/users.module.js"; +import { MeStatusModule } from "@bff/modules/me-status/me-status.module.js"; import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js"; -import { CatalogModule } from "@bff/modules/catalog/catalog.module.js"; +import { ServicesModule } from "@bff/modules/services/services.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"; @@ -10,6 +11,8 @@ 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 { VerificationModule } from "@bff/modules/verification/verification.module.js"; +import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js"; export const apiRoutes: Routes = [ { @@ -17,8 +20,9 @@ export const apiRoutes: Routes = [ children: [ { path: "", module: AuthModule }, { path: "", module: UsersModule }, + { path: "", module: MeStatusModule }, { path: "", module: MappingsModule }, - { path: "", module: CatalogModule }, + { path: "", module: ServicesModule }, { path: "", module: OrdersModule }, { path: "", module: InvoicesModule }, { path: "", module: SubscriptionsModule }, @@ -26,6 +30,8 @@ export const apiRoutes: Routes = [ { path: "", module: SupportModule }, { path: "", module: SecurityModule }, { path: "", module: RealtimeApiModule }, + { path: "", module: VerificationModule }, + { path: "", module: NotificationsModule }, ], }, ]; diff --git a/apps/bff/src/core/security/middleware/csrf.middleware.ts b/apps/bff/src/core/security/middleware/csrf.middleware.ts index 55689ef4..18cbfecd 100644 --- a/apps/bff/src/core/security/middleware/csrf.middleware.ts +++ b/apps/bff/src/core/security/middleware/csrf.middleware.ts @@ -51,7 +51,7 @@ export class CsrfMiddleware implements NestMiddleware { "/api/auth/request-password-reset", "/api/auth/reset-password", // Public auth endpoint for password reset "/api/auth/set-password", // Public auth endpoint for setting password after WHMCS link - "/api/auth/link-whmcs", // Public auth endpoint for WHMCS account linking + "/api/auth/migrate", // Public auth endpoint for account migration "/api/health", "/docs", "/api/webhooks", // Webhooks typically don't use CSRF diff --git a/apps/bff/src/infra/cache/README.md b/apps/bff/src/infra/cache/README.md index f469e043..8936038e 100644 --- a/apps/bff/src/infra/cache/README.md +++ b/apps/bff/src/infra/cache/README.md @@ -14,8 +14,8 @@ Redis-backed caching system with CDC (Change Data Capture) event-driven invalida │ ┌───────────────────────────▼─────────────────────────────────┐ │ Domain-Specific Cache Services │ -│ - OrdersCacheService (CDC-driven, no TTL) │ -│ - CatalogCacheService (CDC-driven, no TTL) │ +│ - OrdersCacheService (CDC-driven) │ +│ - ServicesCacheService (CDC-driven + safety TTL) │ │ - WhmcsCacheService (TTL-based) │ │ │ │ Features: │ @@ -61,18 +61,26 @@ Redis-backed caching system with CDC (Change Data Capture) event-driven invalida ### 1. CDC-Driven (Orders, Catalog) -**No TTL** - Cache persists indefinitely until CDC event triggers invalidation. +**Event-driven invalidation + safety TTL** - Cache is invalidated on CDC events, and also expires after a long TTL as a safety net. + +Why: CDC is the primary freshness mechanism, but a safety TTL helps self-heal if events are missed (deploy downtime, subscriber issues, replay gaps). + +Config: + +- `SERVICES_CACHE_SAFETY_TTL_SECONDS` (default: 12 hours, set to `0` to disable) **Pros:** + - Real-time invalidation when data changes - Zero stale data for customer-visible fields - Optimal for frequently read, infrequently changed data **Example:** + ```typescript @Injectable() export class OrdersCacheService { - // No TTL = CDC-only invalidation + // CDC invalidation + safety TTL (service-specific) async getOrderSummaries( sfAccountId: string, fetcher: () => Promise @@ -88,11 +96,13 @@ export class OrdersCacheService { **Fixed TTL** - Cache expires after a set duration. **Pros:** + - Simple, predictable behavior - Good for external systems without CDC - Automatic cleanup of stale data **Example:** + ```typescript @Injectable() export class WhmcsCacheService { @@ -152,7 +162,7 @@ All cache services track performance metrics: ```typescript { - catalog: { hits: 1250, misses: 48 }, + services: { hits: 1250, misses: 48 }, static: { hits: 890, misses: 12 }, volatile: { hits: 450, misses: 120 }, invalidations: 15 @@ -160,7 +170,8 @@ All cache services track performance metrics: ``` Access via health endpoints: -- `GET /health/catalog/cache` + +- `GET /api/health/services/cache` - `GET /health` ## Creating a New Cache Service @@ -194,7 +205,7 @@ export class MyDomainCacheService { ```typescript async getMyData(id: string, fetcher: () => Promise): Promise { const key = `mydomain:${id}`; - + // Check cache const cached = await this.cache.get(key); if (cached) { @@ -211,7 +222,7 @@ async getMyData(id: string, fetcher: () => Promise): Promise { const fetchPromise = (async () => { try { const fresh = await fetcher(); - await this.cache.set(key, fresh); // No TTL = CDC-driven + await this.cache.set(key, fresh); // CDC-driven (TTL varies by domain) return fresh; } finally { this.inflightRequests.delete(key); @@ -255,10 +266,11 @@ domain:type:identifier[:subkey] ``` Examples: + - `orders:account:001xx000003EgI1AAK` - `orders:detail:80122000000D4UGAA0` -- `catalog:internet:acc_001:jp` -- `catalog:deps:product:01t22000003xABCAA2` +- `services:internet:acc_001:jp` +- `services:deps:product:01t22000003xABCAA2` - `mapping:userId:user_12345` ## Configuration @@ -287,8 +299,8 @@ Provides global `REDIS_CLIENT` using ioredis. # Overall system health (includes Redis check) GET /health -# Catalog cache metrics -GET /health/catalog/cache +# Services cache metrics +GET /api/health/services/cache ``` ### Response Format @@ -302,7 +314,7 @@ GET /health/catalog/cache "invalidations": 15 }, "ttl": { - "catalogSeconds": null, + "servicesSeconds": null, "staticSeconds": null, "volatileSeconds": 60 } @@ -357,4 +369,3 @@ console.log(`${count} keys using ${usage} bytes`); - [Salesforce CDC Events](../../integrations/salesforce/events/README.md) - [Order Fulfillment Flow](../../modules/orders/docs/FULFILLMENT.md) - [Redis Configuration](../redis/README.md) - diff --git a/apps/bff/src/infra/cache/cache.module.ts b/apps/bff/src/infra/cache/cache.module.ts index f90d90f0..dcca5c70 100644 --- a/apps/bff/src/infra/cache/cache.module.ts +++ b/apps/bff/src/infra/cache/cache.module.ts @@ -1,16 +1,17 @@ import { Global, Module } from "@nestjs/common"; import { CacheService } from "./cache.service.js"; +import { DistributedLockService } from "./distributed-lock.service.js"; /** * Global cache module * * Provides Redis-backed caching infrastructure for the entire application. - * Exports CacheService for use in domain-specific cache services. + * Exports CacheService and DistributedLockService for use in domain services. */ @Global() @Module({ - providers: [CacheService], - exports: [CacheService], + providers: [CacheService, DistributedLockService], + exports: [CacheService, DistributedLockService], }) export class CacheModule {} diff --git a/apps/bff/src/infra/cache/cache.service.ts b/apps/bff/src/infra/cache/cache.service.ts index b1db02f0..ddc68fcc 100644 --- a/apps/bff/src/infra/cache/cache.service.ts +++ b/apps/bff/src/infra/cache/cache.service.ts @@ -74,7 +74,7 @@ export class CacheService { /** * Delete all keys matching a pattern * Uses SCAN for safe operation on large datasets - * @param pattern Redis pattern (e.g., "orders:*", "catalog:product:*") + * @param pattern Redis pattern (e.g., "orders:*", "services:product:*") */ async delPattern(pattern: string): Promise { const pipeline = this.redis.pipeline(); diff --git a/apps/bff/src/infra/cache/distributed-lock.service.ts b/apps/bff/src/infra/cache/distributed-lock.service.ts new file mode 100644 index 00000000..1b5a44b8 --- /dev/null +++ b/apps/bff/src/infra/cache/distributed-lock.service.ts @@ -0,0 +1,188 @@ +/** + * Distributed Lock Service + * + * Redis-based distributed locking for preventing race conditions + * in operations that span multiple systems (e.g., Salesforce + Portal). + * + * Uses Redis SET NX PX pattern for atomic lock acquisition with TTL. + */ + +import { Injectable, Inject } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import type { Redis } from "ioredis"; + +const LOCK_PREFIX = "lock:"; +const DEFAULT_TTL_MS = 30_000; // 30 seconds +const DEFAULT_RETRY_DELAY_MS = 100; +const DEFAULT_MAX_RETRIES = 50; // 5 seconds total with 100ms delay + +export interface LockOptions { + /** Lock TTL in milliseconds (default: 30000) */ + ttlMs?: number; + /** Delay between retry attempts in milliseconds (default: 100) */ + retryDelayMs?: number; + /** Maximum number of retry attempts (default: 50) */ + maxRetries?: number; +} + +export interface Lock { + /** The lock key */ + key: string; + /** Unique token for this lock instance */ + token: string; + /** Release the lock */ + release: () => Promise; +} + +@Injectable() +export class DistributedLockService { + constructor( + @Inject("REDIS_CLIENT") private readonly redis: Redis, + @Inject(Logger) private readonly logger: Logger + ) {} + + /** + * Acquire a distributed lock + * + * @param key - Unique key identifying the resource to lock + * @param options - Lock options + * @returns Lock object if acquired, null if unable to acquire + */ + async acquire(key: string, options?: LockOptions): Promise { + const lockKey = LOCK_PREFIX + key; + const token = this.generateToken(); + const ttlMs = options?.ttlMs ?? DEFAULT_TTL_MS; + const retryDelayMs = options?.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS; + const maxRetries = options?.maxRetries ?? DEFAULT_MAX_RETRIES; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + // SET key token NX PX ttl - atomic set if not exists with TTL + const result = await this.redis.set(lockKey, token, "PX", ttlMs, "NX"); + + if (result === "OK") { + this.logger.debug("Lock acquired", { key: lockKey, attempt }); + return { + key: lockKey, + token, + release: () => this.release(lockKey, token), + }; + } + + // Lock is held by someone else, wait and retry + if (attempt < maxRetries) { + await this.delay(retryDelayMs); + } + } + + this.logger.warn("Failed to acquire lock after max retries", { + key: lockKey, + maxRetries, + }); + return null; + } + + /** + * Execute a function with a lock + * + * Automatically acquires lock before execution and releases after. + * If lock cannot be acquired, throws an error. + * + * @param key - Unique key identifying the resource to lock + * @param fn - Function to execute while holding the lock + * @param options - Lock options + * @returns Result of the function + */ + async withLock(key: string, fn: () => Promise, options?: LockOptions): Promise { + const lock = await this.acquire(key, options); + + if (!lock) { + throw new Error(`Unable to acquire lock for key: ${key}`); + } + + try { + return await fn(); + } finally { + await lock.release(); + } + } + + /** + * Try to execute a function with a lock + * + * Unlike withLock, this returns null if lock cannot be acquired + * instead of throwing an error. + * + * @param key - Unique key identifying the resource to lock + * @param fn - Function to execute while holding the lock + * @param options - Lock options + * @returns Result of the function, or null if lock not acquired + */ + async tryWithLock( + key: string, + fn: () => Promise, + options?: LockOptions + ): Promise<{ success: true; result: T } | { success: false; result: null }> { + const lock = await this.acquire(key, { + ...options, + maxRetries: 0, // Don't retry for try semantics + }); + + if (!lock) { + return { success: false, result: null }; + } + + try { + const result = await fn(); + return { success: true, result }; + } finally { + await lock.release(); + } + } + + /** + * Release a lock + * + * Uses a Lua script to ensure we only release our own lock. + */ + private async release(lockKey: string, token: string): Promise { + // Lua script: only delete if the token matches + const script = ` + if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("del", KEYS[1]) + else + return 0 + end + `; + + try { + const result = await this.redis.eval(script, 1, lockKey, token); + + if (result === 1) { + this.logger.debug("Lock released", { key: lockKey }); + } else { + this.logger.warn("Lock release failed - token mismatch or expired", { + key: lockKey, + }); + } + } catch (error) { + this.logger.error("Error releasing lock", { + key: lockKey, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + /** + * Generate a unique token for lock ownership + */ + private generateToken(): string { + return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`; + } + + /** + * Delay helper + */ + private delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } +} diff --git a/apps/bff/src/infra/realtime/realtime.types.ts b/apps/bff/src/infra/realtime/realtime.types.ts index 9a548fd8..d92190f4 100644 --- a/apps/bff/src/infra/realtime/realtime.types.ts +++ b/apps/bff/src/infra/realtime/realtime.types.ts @@ -8,7 +8,7 @@ export interface RealtimePubSubMessage IntegrationsModule), forwardRef(() => OrdersModule), - forwardRef(() => CatalogModule), + forwardRef(() => ServicesModule), + forwardRef(() => NotificationsModule), ], providers: [ - CatalogCdcSubscriber, // CDC for catalog cache invalidation + ServicesCdcSubscriber, // CDC for services cache invalidation + notifications OrderCdcSubscriber, // CDC for order cache invalidation ], }) diff --git a/apps/bff/src/integrations/salesforce/events/catalog-cdc.subscriber.ts b/apps/bff/src/integrations/salesforce/events/services-cdc.subscriber.ts similarity index 75% rename from apps/bff/src/integrations/salesforce/events/catalog-cdc.subscriber.ts rename to apps/bff/src/integrations/salesforce/events/services-cdc.subscriber.ts index 8ba18a2b..aa81728a 100644 --- a/apps/bff/src/integrations/salesforce/events/catalog-cdc.subscriber.ts +++ b/apps/bff/src/integrations/salesforce/events/services-cdc.subscriber.ts @@ -1,11 +1,12 @@ -import { Injectable, Inject } from "@nestjs/common"; +import { Injectable, Inject, Optional } from "@nestjs/common"; import type { OnModuleInit, OnModuleDestroy } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { Logger } from "nestjs-pino"; import PubSubApiClientPkg from "salesforce-pubsub-api-client"; import { SalesforceConnection } from "../services/salesforce-connection.service.js"; -import { CatalogCacheService } from "@bff/modules/catalog/services/catalog-cache.service.js"; +import { ServicesCacheService } from "@bff/modules/services/services/services-cache.service.js"; import { RealtimeService } from "@bff/infra/realtime/realtime.service.js"; +import { AccountNotificationHandler } from "@bff/modules/notifications/account-cdc-listener.service.js"; type PubSubCallback = ( subscription: { topicName?: string }, @@ -27,7 +28,7 @@ type PubSubCtor = new (opts: { }) => PubSubClient; @Injectable() -export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy { +export class ServicesCdcSubscriber implements OnModuleInit, OnModuleDestroy { private client: PubSubClient | null = null; private pubSubCtor: PubSubCtor | null = null; private productChannel: string | null = null; @@ -38,9 +39,10 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy { constructor( private readonly config: ConfigService, private readonly sfConnection: SalesforceConnection, - private readonly catalogCache: CatalogCacheService, + private readonly catalogCache: ServicesCacheService, private readonly realtime: RealtimeService, - @Inject(Logger) private readonly logger: Logger + @Inject(Logger) private readonly logger: Logger, + @Optional() private readonly accountNotificationHandler?: AccountNotificationHandler ) { this.numRequested = this.resolveNumRequested(); } @@ -192,9 +194,9 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy { productIds, } ); - await this.invalidateAllCatalogs(); - // Full invalidation already implies all clients should refetch catalog - this.realtime.publish("global:catalog", "catalog.changed", { + await this.invalidateAllServices(); + // Full invalidation already implies all clients should refetch services + this.realtime.publish("global:services", "services.changed", { reason: "product.cdc.fallback_full_invalidation", timestamp: new Date().toISOString(), }); @@ -202,7 +204,7 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy { } // Product changes can affect catalog results for all users - this.realtime.publish("global:catalog", "catalog.changed", { + this.realtime.publish("global:services", "services.changed", { reason: "product.cdc", timestamp: new Date().toISOString(), }); @@ -246,15 +248,15 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy { productId, } ); - await this.invalidateAllCatalogs(); - this.realtime.publish("global:catalog", "catalog.changed", { + await this.invalidateAllServices(); + this.realtime.publish("global:services", "services.changed", { reason: "pricebook.cdc.fallback_full_invalidation", timestamp: new Date().toISOString(), }); return; } - this.realtime.publish("global:catalog", "catalog.changed", { + this.realtime.publish("global:services", "services.changed", { reason: "pricebook.cdc", timestamp: new Date().toISOString(), }); @@ -269,9 +271,22 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy { if (!this.isDataCallback(callbackType)) return; const payload = this.extractPayload(data); const accountId = this.extractStringField(payload, ["AccountId__c", "AccountId", "Id"]); - const eligibility = this.extractStringField(payload, [ - "Internet_Eligibility__c", - "InternetEligibility__c", + const eligibility = this.extractStringField(payload, ["Internet_Eligibility__c"]); + const status = this.extractStringField(payload, ["Internet_Eligibility_Status__c"]); + const requestedAt = this.extractStringField(payload, [ + "Internet_Eligibility_Request_Date_Time__c", + ]); + const checkedAt = this.extractStringField(payload, [ + "Internet_Eligibility_Checked_Date_Time__c", + ]); + + // Note: Request ID field is not used in this environment + const requestId = undefined; + + // Also extract ID verification fields for notifications + const verificationStatus = this.extractStringField(payload, ["Id_Verification_Status__c"]); + const verificationRejection = this.extractStringField(payload, [ + "Id_Verification_Rejection_Message__c", ]); if (!accountId) { @@ -288,19 +303,55 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy { }); await this.catalogCache.invalidateEligibility(accountId); - await this.catalogCache.setEligibilityValue(accountId, eligibility ?? null); + const hasDetails = Boolean(status || eligibility || requestedAt || checkedAt || requestId); + if (hasDetails) { + await this.catalogCache.setEligibilityDetails(accountId, { + status: this.mapEligibilityStatus(status, eligibility), + eligibility: eligibility ?? null, + requestId: requestId ?? null, + requestedAt: requestedAt ?? null, + checkedAt: checkedAt ?? null, + notes: null, // Field not used + }); + } // Notify connected portals immediately (multi-instance safe via Redis pub/sub) - this.realtime.publish(`account:sf:${accountId}`, "catalog.eligibility.changed", { + this.realtime.publish(`account:sf:${accountId}`, "services.eligibility.changed", { timestamp: new Date().toISOString(), }); + + // Create in-app notifications for eligibility/verification status changes + if (this.accountNotificationHandler && (status || verificationStatus)) { + void this.accountNotificationHandler.processAccountEvent({ + accountId, + eligibilityStatus: status, + eligibilityValue: eligibility, + verificationStatus, + verificationRejectionMessage: verificationRejection, + }); + } } - private async invalidateAllCatalogs(): Promise { + private mapEligibilityStatus( + statusRaw: string | undefined, + eligibilityRaw: string | undefined + ): "not_requested" | "pending" | "eligible" | "ineligible" { + const normalizedStatus = typeof statusRaw === "string" ? statusRaw.trim().toLowerCase() : ""; + const eligibility = typeof eligibilityRaw === "string" ? eligibilityRaw.trim() : ""; + + if (normalizedStatus === "pending" || normalizedStatus === "checking") return "pending"; + if (normalizedStatus === "eligible") return "eligible"; + if (normalizedStatus === "ineligible" || normalizedStatus === "not available") + return "ineligible"; + if (eligibility.length > 0) return "eligible"; + return "not_requested"; + } + + private async invalidateAllServices(): Promise { try { - await this.catalogCache.invalidateAllCatalogs(); + await this.catalogCache.invalidateAllServices(); } catch (error) { - this.logger.warn("Failed to invalidate catalog caches", { + this.logger.warn("Failed to invalidate services caches", { error: error instanceof Error ? error.message : String(error), }); } diff --git a/apps/bff/src/integrations/salesforce/salesforce.module.ts b/apps/bff/src/integrations/salesforce/salesforce.module.ts index b45b1b03..5398b5c7 100644 --- a/apps/bff/src/integrations/salesforce/salesforce.module.ts +++ b/apps/bff/src/integrations/salesforce/salesforce.module.ts @@ -6,6 +6,8 @@ import { SalesforceConnection } from "./services/salesforce-connection.service.j import { SalesforceAccountService } from "./services/salesforce-account.service.js"; import { SalesforceOrderService } from "./services/salesforce-order.service.js"; import { SalesforceCaseService } from "./services/salesforce-case.service.js"; +import { SalesforceOpportunityService } from "./services/salesforce-opportunity.service.js"; +import { OpportunityResolutionService } from "./services/opportunity-resolution.service.js"; import { OrderFieldConfigModule } from "@bff/modules/orders/config/order-field-config.module.js"; import { SalesforceReadThrottleGuard } from "./guards/salesforce-read-throttle.guard.js"; import { SalesforceWriteThrottleGuard } from "./guards/salesforce-write-throttle.guard.js"; @@ -17,6 +19,8 @@ import { SalesforceWriteThrottleGuard } from "./guards/salesforce-write-throttle SalesforceAccountService, SalesforceOrderService, SalesforceCaseService, + SalesforceOpportunityService, + OpportunityResolutionService, SalesforceService, SalesforceReadThrottleGuard, SalesforceWriteThrottleGuard, @@ -25,8 +29,11 @@ import { SalesforceWriteThrottleGuard } from "./guards/salesforce-write-throttle QueueModule, SalesforceService, SalesforceConnection, + SalesforceAccountService, SalesforceOrderService, SalesforceCaseService, + SalesforceOpportunityService, + OpportunityResolutionService, SalesforceReadThrottleGuard, SalesforceWriteThrottleGuard, ], diff --git a/apps/bff/src/integrations/salesforce/services/opportunity-resolution.service.ts b/apps/bff/src/integrations/salesforce/services/opportunity-resolution.service.ts new file mode 100644 index 00000000..efcad846 --- /dev/null +++ b/apps/bff/src/integrations/salesforce/services/opportunity-resolution.service.ts @@ -0,0 +1,144 @@ +import { Injectable, Inject } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { DistributedLockService } from "@bff/infra/cache/distributed-lock.service.js"; +import { SalesforceOpportunityService } from "./salesforce-opportunity.service.js"; +import { assertSalesforceId } from "../utils/soql.util.js"; +import type { OrderTypeValue } from "@customer-portal/domain/orders"; +import { + APPLICATION_STAGE, + OPPORTUNITY_PRODUCT_TYPE, + OPPORTUNITY_SOURCE, + OPPORTUNITY_STAGE, + OPPORTUNITY_MATCH_STAGES_INTERNET_ELIGIBILITY, + OPPORTUNITY_MATCH_STAGES_ORDER_PLACEMENT, + type OpportunityProductTypeValue, +} from "@customer-portal/domain/opportunity"; + +/** + * Opportunity Resolution Service + * + * Centralizes the "find or create" rules for Opportunities so eligibility, checkout, + * and other flows cannot drift over time. + * + * Key principle: + * - Eligibility can only match the initial Introduction opportunity. + * - Order placement can match Introduction/Ready. It must never match Active. + */ +@Injectable() +export class OpportunityResolutionService { + constructor( + private readonly opportunities: SalesforceOpportunityService, + private readonly lockService: DistributedLockService, + @Inject(Logger) private readonly logger: Logger + ) {} + + /** + * Resolve (find or create) an Internet Opportunity for eligibility request. + * + * NOTE: The eligibility flow itself should ensure idempotency for Case creation. + * This method only resolves the Opportunity link. + */ + async findOrCreateForInternetEligibility(accountId: string): Promise<{ + opportunityId: string; + wasCreated: boolean; + }> { + const safeAccountId = assertSalesforceId(accountId, "accountId"); + const lockKey = `opportunity:eligibility:${safeAccountId}:Internet`; + + return this.lockService.withLock( + lockKey, + async () => { + const existing = await this.opportunities.findOpenOpportunityForAccount( + safeAccountId, + OPPORTUNITY_PRODUCT_TYPE.INTERNET, + { stages: OPPORTUNITY_MATCH_STAGES_INTERNET_ELIGIBILITY } + ); + + if (existing) { + return { opportunityId: existing, wasCreated: false }; + } + + const created = await this.opportunities.createOpportunity({ + accountId: safeAccountId, + productType: OPPORTUNITY_PRODUCT_TYPE.INTERNET, + stage: OPPORTUNITY_STAGE.INTRODUCTION, + source: OPPORTUNITY_SOURCE.INTERNET_ELIGIBILITY, + applicationStage: APPLICATION_STAGE.INTRO_1, + }); + + return { opportunityId: created, wasCreated: true }; + }, + { ttlMs: 10_000 } + ); + } + + /** + * Resolve (find or create) an Opportunity for order placement. + * + * - If an OpportunityId is already provided, use it as-is. + * - Otherwise, match only Introduction/Ready to avoid corrupting lifecycle tracking. + * - If none found, create a new Opportunity in Post Processing stage. + */ + async resolveForOrderPlacement(params: { + accountId: string | null; + orderType: OrderTypeValue; + existingOpportunityId?: string; + }): Promise { + if (!params.accountId) return null; + + const safeAccountId = assertSalesforceId(params.accountId, "accountId"); + + if (params.existingOpportunityId) { + return assertSalesforceId(params.existingOpportunityId, "existingOpportunityId"); + } + + const productType = this.mapOrderTypeToProductType(params.orderType); + const lockKey = `opportunity:order:${safeAccountId}:${productType}`; + + return this.lockService.withLock( + lockKey, + async () => { + const existing = await this.opportunities.findOpenOpportunityForAccount( + safeAccountId, + productType, + { stages: OPPORTUNITY_MATCH_STAGES_ORDER_PLACEMENT } + ); + + if (existing) { + return existing; + } + + const created = await this.opportunities.createOpportunity({ + accountId: safeAccountId, + productType, + stage: OPPORTUNITY_STAGE.POST_PROCESSING, + source: OPPORTUNITY_SOURCE.ORDER_PLACEMENT, + applicationStage: APPLICATION_STAGE.INTRO_1, + }); + + this.logger.log("Created new Opportunity for order placement", { + accountIdTail: safeAccountId.slice(-4), + opportunityIdTail: created.slice(-4), + productType, + orderType: params.orderType, + }); + + return created; + }, + { ttlMs: 10_000 } + ); + } + + private mapOrderTypeToProductType(orderType: OrderTypeValue): OpportunityProductTypeValue { + switch (orderType) { + case "Internet": + return OPPORTUNITY_PRODUCT_TYPE.INTERNET; + case "SIM": + return OPPORTUNITY_PRODUCT_TYPE.SIM; + case "VPN": + return OPPORTUNITY_PRODUCT_TYPE.VPN; + default: + return OPPORTUNITY_PRODUCT_TYPE.SIM; + } + } +} 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..1ef17bfe 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,159 @@ 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 }); + + const accountPayload = { + Name: `${data.firstName} ${data.lastName}`, + 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; + + // Query back the auto-generated account number + const accountRecord = (await this.connection.query( + `SELECT SF_Account_No__c FROM Account WHERE Id = '${this.safeSoql(accountId)}'`, + { label: "checkout:getCreatedAccountNumber" } + )) as SalesforceResponse<{ SF_Account_No__c: string }>; + + const accountNumber = accountRecord.records[0]?.SF_Account_No__c || ""; + + if (!accountNumber) { + this.logger.warn("Account number not found for newly created account", { accountId }); + } + + 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"); + } + } + + // ============================================================================ + // Portal Field Update Methods + // ============================================================================ + async updatePortalFields( accountId: string, update: SalesforceAccountPortalUpdate @@ -189,3 +342,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..a4d01721 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) */ @@ -205,4 +258,148 @@ export class SalesforceCaseService { return result.records?.[0] ?? null; } + + // ========================================================================== + // Opportunity-Linked Cases + // ========================================================================== + + /** + * Create an eligibility check case linked to an Opportunity + * + * @param params - Case parameters including Opportunity link + * @returns Created case ID + */ + async createEligibilityCase(params: { + accountId: string; + opportunityId: string; + subject: string; + description: string; + }): Promise { + const safeAccountId = assertSalesforceId(params.accountId, "accountId"); + const safeOpportunityId = assertSalesforceId(params.opportunityId, "opportunityId"); + + this.logger.log("Creating eligibility check case linked to Opportunity", { + accountIdTail: safeAccountId.slice(-4), + opportunityIdTail: safeOpportunityId.slice(-4), + }); + + const casePayload: Record = { + Origin: SALESFORCE_CASE_ORIGIN.PORTAL_WEBSITE, + Status: SALESFORCE_CASE_STATUS.NEW, + Priority: SALESFORCE_CASE_PRIORITY.MEDIUM, + Subject: params.subject, + Description: params.description, + AccountId: safeAccountId, + // Link Case to Opportunity - this is a standard lookup field + OpportunityId: safeOpportunityId, + }; + + 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"); + } + + this.logger.log("Eligibility case created and linked to Opportunity", { + caseId: created.id, + opportunityIdTail: safeOpportunityId.slice(-4), + }); + + return created.id; + } catch (error: unknown) { + this.logger.error("Failed to create eligibility case", { + error: getErrorMessage(error), + accountIdTail: safeAccountId.slice(-4), + }); + throw new Error("Failed to create eligibility check case"); + } + } + + /** + * Create a cancellation request case linked to an Opportunity + * + * All customer-provided details (comments, alternative email) go here. + * The Opportunity only gets the core lifecycle fields (dates, status). + * + * @param params - Cancellation case parameters + * @returns Created case ID + */ + async createCancellationCase(params: { + accountId: string; + opportunityId?: string; + whmcsServiceId: number; + productType: string; + cancellationMonth: string; + cancellationDate: string; + alternativeEmail?: string; + comments?: string; + }): Promise { + const safeAccountId = assertSalesforceId(params.accountId, "accountId"); + const safeOpportunityId = params.opportunityId + ? assertSalesforceId(params.opportunityId, "opportunityId") + : null; + + this.logger.log("Creating cancellation request case", { + accountIdTail: safeAccountId.slice(-4), + opportunityId: safeOpportunityId ? safeOpportunityId.slice(-4) : "none", + whmcsServiceId: params.whmcsServiceId, + }); + + // Build description with all form data + const descriptionLines = [ + `Cancellation Request from Portal`, + ``, + `Product Type: ${params.productType}`, + `WHMCS Service ID: ${params.whmcsServiceId}`, + `Cancellation Month: ${params.cancellationMonth}`, + `Service End Date: ${params.cancellationDate}`, + ``, + ]; + + if (params.alternativeEmail) { + descriptionLines.push(`Alternative Contact Email: ${params.alternativeEmail}`); + } + + if (params.comments) { + descriptionLines.push(``, `Customer Comments:`, params.comments); + } + + descriptionLines.push(``, `Submitted: ${new Date().toISOString()}`); + + const casePayload: Record = { + Origin: "Portal", + Status: SALESFORCE_CASE_STATUS.NEW, + Priority: SALESFORCE_CASE_PRIORITY.HIGH, + Subject: `Cancellation Request - ${params.productType} (${params.cancellationMonth})`, + Description: descriptionLines.join("\n"), + AccountId: safeAccountId, + }; + + // Link to Opportunity if we have one + if (safeOpportunityId) { + casePayload.OpportunityId = safeOpportunityId; + } + + 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"); + } + + this.logger.log("Cancellation case created", { + caseId: created.id, + hasOpportunityLink: !!safeOpportunityId, + }); + + return created.id; + } catch (error: unknown) { + this.logger.error("Failed to create cancellation case", { + error: getErrorMessage(error), + accountIdTail: safeAccountId.slice(-4), + }); + throw new Error("Failed to create cancellation request case"); + } + } } diff --git a/apps/bff/src/integrations/salesforce/services/salesforce-opportunity.service.ts b/apps/bff/src/integrations/salesforce/services/salesforce-opportunity.service.ts new file mode 100644 index 00000000..20255bd0 --- /dev/null +++ b/apps/bff/src/integrations/salesforce/services/salesforce-opportunity.service.ts @@ -0,0 +1,741 @@ +/** + * Salesforce Opportunity Integration Service + * + * Manages Opportunity records for service lifecycle tracking. + * Opportunities track customer journeys from interest through cancellation. + * + * Key responsibilities: + * - Create Opportunities at interest triggers (eligibility request, registration) + * - Update Opportunity stages as orders progress + * - Link WHMCS services to Opportunities for cancellation workflows + * - Store cancellation form data on Opportunities + * + * Uses existing Salesforce stage values: + * - Introduction → Ready → Post Processing → Active → △Cancelling → 〇Cancelled + * + * @see docs/salesforce/OPPORTUNITY-LIFECYCLE-GUIDE.md for complete documentation + */ + +import { Injectable, Inject } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { SalesforceConnection } from "./salesforce-connection.service.js"; +import { assertSalesforceId } from "../utils/soql.util.js"; +import { getErrorMessage } from "@bff/core/utils/error.util.js"; +import type { SalesforceResponse } from "@customer-portal/domain/common"; +import { + type OpportunityStageValue, + type OpportunityProductTypeValue, + type OpportunitySourceValue, + type ApplicationStageValue, + type CancellationNoticeValue, + type LineReturnStatusValue, + type CommodityTypeValue, + type CancellationOpportunityData, + type CreateOpportunityRequest, + type OpportunityRecord, + OPPORTUNITY_STAGE, + APPLICATION_STAGE, + OPEN_OPPORTUNITY_STAGES, + COMMODITY_TYPE, + OPPORTUNITY_PRODUCT_TYPE, + getDefaultCommodityType, + getCommodityTypeProductType, +} from "@customer-portal/domain/opportunity"; +import { + OPPORTUNITY_FIELD_MAP, + OPPORTUNITY_MATCH_QUERY_FIELDS, + OPPORTUNITY_DETAIL_QUERY_FIELDS, + OPPORTUNITY_CANCELLATION_QUERY_FIELDS, +} from "../config/opportunity-field-map.js"; + +// ============================================================================ +// Types +// ============================================================================ + +/** + * Raw Opportunity record from Salesforce query + */ +interface SalesforceOpportunityRecord { + Id: string; + Name: string; + AccountId: string; + StageName: string; + CloseDate: string; + IsClosed: boolean; + IsWon?: boolean; + CreatedDate: string; + LastModifiedDate: string; + // Existing custom fields + Application_Stage__c?: string; + CommodityType?: string; // Existing product type field + ScheduledCancellationDateAndTime__c?: string; + CancellationNotice__c?: string; + LineReturn__c?: string; + // New custom fields (to be created) + Portal_Source__c?: string; + WHMCS_Service_ID__c?: number; + // Note: Cases and Orders link TO Opportunity via their OpportunityId field + // Cancellation comments and alternative email are on the Cancellation Case + // Relationship fields + Account?: { Name?: string }; +} + +// ============================================================================ +// Service +// ============================================================================ + +@Injectable() +export class SalesforceOpportunityService { + constructor( + private readonly sf: SalesforceConnection, + @Inject(Logger) private readonly logger: Logger + ) {} + + // ========================================================================== + // Core CRUD Operations + // ========================================================================== + + /** + * Create a new Opportunity in Salesforce + * + * @param request - Opportunity creation parameters + * @returns The created Opportunity ID + * + * @example + * // Create for Internet eligibility request + * const oppId = await service.createOpportunity({ + * accountId: 'SF_ACCOUNT_ID', + * productType: 'Internet', + * stage: 'Introduction', + * source: 'Portal - Internet Eligibility Request', + * }); + * + * // Then create a Case linked to this Opportunity: + * await caseService.createCase({ + * type: 'Eligibility Check', + * opportunityId: oppId, // Case links TO Opportunity + * ... + * }); + */ + async createOpportunity(request: CreateOpportunityRequest): Promise { + const safeAccountId = assertSalesforceId(request.accountId, "accountId"); + + this.logger.log("Creating Opportunity for service lifecycle tracking", { + accountId: safeAccountId, + productType: request.productType, + stage: request.stage, + source: request.source, + }); + + // Opportunity Name - Salesforce workflow will auto-generate the real name + // We provide a placeholder that includes product type for debugging + const opportunityName = `Portal - ${request.productType}`; + + // Calculate close date (default: 30 days from now) + const closeDate = + request.closeDate ?? this.calculateCloseDate(request.productType, request.stage); + + // Application stage defaults to INTRO-1 for portal + const applicationStage = request.applicationStage ?? APPLICATION_STAGE.INTRO_1; + + // Get the CommodityType from the simplified product type + const commodityType = getDefaultCommodityType(request.productType); + + const payload: Record = { + [OPPORTUNITY_FIELD_MAP.name]: opportunityName, + [OPPORTUNITY_FIELD_MAP.accountId]: safeAccountId, + [OPPORTUNITY_FIELD_MAP.stage]: request.stage, + [OPPORTUNITY_FIELD_MAP.closeDate]: closeDate, + [OPPORTUNITY_FIELD_MAP.applicationStage]: applicationStage, + [OPPORTUNITY_FIELD_MAP.commodityType]: commodityType, + }; + + // Add optional custom fields (only if they exist in Salesforce) + if (request.source) { + payload[OPPORTUNITY_FIELD_MAP.source] = request.source; + } + // Note: Cases (eligibility, ID verification) link TO Opportunity via Case.OpportunityId + // Orders link TO Opportunity via Order.OpportunityId + + try { + const createMethod = this.sf.sobject("Opportunity").create; + if (!createMethod) { + throw new Error("Salesforce Opportunity create method not available"); + } + + const result = (await createMethod(payload)) as { id?: string; success?: boolean }; + + if (!result?.id) { + throw new Error("Salesforce did not return Opportunity ID"); + } + + this.logger.log("Opportunity created successfully", { + opportunityId: result.id, + productType: request.productType, + stage: request.stage, + }); + + return result.id; + } catch (error) { + this.logger.error("Failed to create Opportunity", { + error: getErrorMessage(error), + accountId: safeAccountId, + productType: request.productType, + }); + throw new Error("Failed to create service lifecycle record"); + } + } + + /** + * Update Opportunity stage + * + * @param opportunityId - Salesforce Opportunity ID + * @param stage - New stage value (must be valid Salesforce picklist value) + * @param reason - Optional reason for stage change (for audit) + */ + async updateStage( + opportunityId: string, + stage: OpportunityStageValue, + reason?: string + ): Promise { + const safeOppId = assertSalesforceId(opportunityId, "opportunityId"); + + this.logger.log("Updating Opportunity stage", { + opportunityId: safeOppId, + newStage: stage, + reason, + }); + + const payload: Record = { + Id: safeOppId, + [OPPORTUNITY_FIELD_MAP.stage]: stage, + }; + + try { + const updateMethod = this.sf.sobject("Opportunity").update; + if (!updateMethod) { + throw new Error("Salesforce Opportunity update method not available"); + } + + await updateMethod(payload as Record & { Id: string }); + + this.logger.log("Opportunity stage updated successfully", { + opportunityId: safeOppId, + stage, + }); + } catch (error) { + this.logger.error("Failed to update Opportunity stage", { + error: getErrorMessage(error), + opportunityId: safeOppId, + stage, + }); + throw new Error("Failed to update service lifecycle stage"); + } + } + + /** + * Update Opportunity with cancellation data from form submission + * + * Sets: + * - Stage to △Cancelling + * - ScheduledCancellationDateAndTime__c + * - CancellationNotice__c to 有 (received) + * - LineReturn__c to NotYet + * + * NOTE: Comments and alternative email go on the Cancellation Case, not Opportunity. + * The Case is created separately and linked to this Opportunity via Case.OpportunityId. + * + * @param opportunityId - Salesforce Opportunity ID + * @param data - Cancellation data (dates and status flags) + */ + async updateCancellationData( + opportunityId: string, + data: CancellationOpportunityData + ): Promise { + const safeOppId = assertSalesforceId(opportunityId, "opportunityId"); + + this.logger.log("Updating Opportunity with cancellation data", { + opportunityId: safeOppId, + scheduledDate: data.scheduledCancellationDate, + cancellationNotice: data.cancellationNotice, + }); + + const payload: Record = { + Id: safeOppId, + [OPPORTUNITY_FIELD_MAP.stage]: OPPORTUNITY_STAGE.CANCELLING, + [OPPORTUNITY_FIELD_MAP.scheduledCancellationDate]: data.scheduledCancellationDate, + [OPPORTUNITY_FIELD_MAP.cancellationNotice]: data.cancellationNotice, + [OPPORTUNITY_FIELD_MAP.lineReturnStatus]: data.lineReturnStatus, + }; + + try { + const updateMethod = this.sf.sobject("Opportunity").update; + if (!updateMethod) { + throw new Error("Salesforce Opportunity update method not available"); + } + + await updateMethod(payload as Record & { Id: string }); + + this.logger.log("Opportunity cancellation data updated successfully", { + opportunityId: safeOppId, + scheduledDate: data.scheduledCancellationDate, + }); + } catch (error) { + this.logger.error("Failed to update Opportunity cancellation data", { + error: getErrorMessage(error), + opportunityId: safeOppId, + }); + throw new Error("Failed to update cancellation information"); + } + } + + // ========================================================================== + // Lookup Operations + // ========================================================================== + + /** + * Find an open Opportunity for an account by product type + * + * Used for matching orders to existing Opportunities + * + * @param accountId - Salesforce Account ID + * @param productType - Product type to match + * @returns Opportunity ID if found, null otherwise + */ + async findOpenOpportunityForAccount( + accountId: string, + productType: OpportunityProductTypeValue, + options?: { stages?: OpportunityStageValue[] } + ): Promise { + const safeAccountId = assertSalesforceId(accountId, "accountId"); + + // Get the CommodityType value(s) that match this product type + const commodityTypeValues = this.getCommodityTypesForProductType(productType); + + const stages = + Array.isArray(options?.stages) && options?.stages.length > 0 + ? options.stages + : OPEN_OPPORTUNITY_STAGES; + + this.logger.debug("Looking for open Opportunity", { + accountId: safeAccountId, + productType, + commodityTypes: commodityTypeValues, + stages, + }); + + // Build stage filter for open stages + const stageList = stages.map((s: OpportunityStageValue) => `'${s}'`).join(", "); + const commodityTypeList = commodityTypeValues.map(ct => `'${ct}'`).join(", "); + + const soql = ` + SELECT ${OPPORTUNITY_MATCH_QUERY_FIELDS.join(", ")} + FROM Opportunity + WHERE ${OPPORTUNITY_FIELD_MAP.accountId} = '${safeAccountId}' + AND ${OPPORTUNITY_FIELD_MAP.commodityType} IN (${commodityTypeList}) + AND ${OPPORTUNITY_FIELD_MAP.stage} IN (${stageList}) + AND ${OPPORTUNITY_FIELD_MAP.isClosed} = false + ORDER BY CreatedDate DESC + LIMIT 1 + `; + + try { + const result = (await this.sf.query(soql, { + label: "opportunity:findOpenForAccount", + })) as SalesforceResponse; + + const record = result.records?.[0]; + + if (record) { + this.logger.debug("Found open Opportunity", { + opportunityId: record.Id, + stage: record.StageName, + productType, + }); + return record.Id; + } + + this.logger.debug("No open Opportunity found", { + accountId: safeAccountId, + productType, + }); + return null; + } catch (error) { + this.logger.error("Failed to find open Opportunity", { + error: getErrorMessage(error), + accountId: safeAccountId, + productType, + }); + // Don't throw - return null to allow fallback to creation + return null; + } + } + + /** + * Find Opportunity linked to an Order + * + * @param orderId - Salesforce Order ID + * @returns Opportunity ID if found, null otherwise + */ + async findOpportunityByOrderId(orderId: string): Promise { + const safeOrderId = assertSalesforceId(orderId, "orderId"); + + this.logger.debug("Looking for Opportunity by Order ID", { + orderId: safeOrderId, + }); + + const soql = ` + SELECT OpportunityId + FROM Order + WHERE Id = '${safeOrderId}' + LIMIT 1 + `; + + try { + const result = (await this.sf.query(soql, { + label: "opportunity:findByOrderId", + })) as SalesforceResponse<{ OpportunityId?: string }>; + + const record = result.records?.[0]; + const opportunityId = record?.OpportunityId; + + if (opportunityId) { + this.logger.debug("Found Opportunity for Order", { + orderId: safeOrderId, + opportunityId, + }); + return opportunityId; + } + + return null; + } catch (error) { + this.logger.error("Failed to find Opportunity by Order ID", { + error: getErrorMessage(error), + orderId: safeOrderId, + }); + return null; + } + } + + /** + * Find Opportunity by WHMCS Service ID + * + * Used for cancellation workflows to find the Opportunity to update + * + * @param whmcsServiceId - WHMCS Service/Hosting ID + * @returns Opportunity ID if found, null otherwise + */ + async findOpportunityByWhmcsServiceId(whmcsServiceId: number): Promise { + this.logger.debug("Looking for Opportunity by WHMCS Service ID", { + whmcsServiceId, + }); + + const soql = ` + SELECT Id, ${OPPORTUNITY_FIELD_MAP.stage} + FROM Opportunity + WHERE ${OPPORTUNITY_FIELD_MAP.whmcsServiceId} = ${whmcsServiceId} + ORDER BY CreatedDate DESC + LIMIT 1 + `; + + try { + const result = (await this.sf.query(soql, { + label: "opportunity:findByWhmcsServiceId", + })) as SalesforceResponse; + + const record = result.records?.[0]; + + if (record) { + this.logger.debug("Found Opportunity for WHMCS Service", { + opportunityId: record.Id, + whmcsServiceId, + }); + return record.Id; + } + + return null; + } catch (error) { + this.logger.error("Failed to find Opportunity by WHMCS Service ID", { + error: getErrorMessage(error), + whmcsServiceId, + }); + return null; + } + } + + /** + * Get full Opportunity details by ID + * + * @param opportunityId - Salesforce Opportunity ID + * @returns Opportunity record or null if not found + */ + async getOpportunityById(opportunityId: string): Promise { + const safeOppId = assertSalesforceId(opportunityId, "opportunityId"); + + const soql = ` + SELECT ${OPPORTUNITY_DETAIL_QUERY_FIELDS.join(", ")} + FROM Opportunity + WHERE Id = '${safeOppId}' + LIMIT 1 + `; + + try { + const result = (await this.sf.query(soql, { + label: "opportunity:getById", + })) as SalesforceResponse; + + const record = result.records?.[0]; + + if (!record) { + return null; + } + + return this.transformToOpportunityRecord(record); + } catch (error) { + this.logger.error("Failed to get Opportunity by ID", { + error: getErrorMessage(error), + opportunityId: safeOppId, + }); + return null; + } + } + + /** + * Get cancellation status for display in portal + * + * @param whmcsServiceId - WHMCS Service ID + * @returns Cancellation status details or null + */ + async getCancellationStatus(whmcsServiceId: number): Promise<{ + stage: OpportunityStageValue; + isPending: boolean; + isComplete: boolean; + scheduledEndDate?: string; + rentalReturnStatus?: LineReturnStatusValue; + } | null> { + const soql = ` + SELECT ${OPPORTUNITY_CANCELLATION_QUERY_FIELDS.join(", ")} + FROM Opportunity + WHERE ${OPPORTUNITY_FIELD_MAP.whmcsServiceId} = ${whmcsServiceId} + ORDER BY CreatedDate DESC + LIMIT 1 + `; + + try { + const result = (await this.sf.query(soql, { + label: "opportunity:getCancellationStatus", + })) as SalesforceResponse; + + const record = result.records?.[0]; + if (!record) return null; + + const stage = record.StageName as OpportunityStageValue; + const isPending = stage === OPPORTUNITY_STAGE.CANCELLING; + const isComplete = stage === OPPORTUNITY_STAGE.CANCELLED; + + return { + stage, + isPending, + isComplete, + scheduledEndDate: record.ScheduledCancellationDateAndTime__c, + rentalReturnStatus: record.LineReturn__c as LineReturnStatusValue | undefined, + }; + } catch (error) { + this.logger.error("Failed to get cancellation status", { + error: getErrorMessage(error), + whmcsServiceId, + }); + return null; + } + } + + // ========================================================================== + // Lifecycle Helpers + // ========================================================================== + + /** + * Link a WHMCS Service ID to an Opportunity + * + * Called after provisioning to enable cancellation workflows + * + * @param opportunityId - Salesforce Opportunity ID + * @param whmcsServiceId - WHMCS Service/Hosting ID + */ + async linkWhmcsServiceToOpportunity( + opportunityId: string, + whmcsServiceId: number + ): Promise { + const safeOppId = assertSalesforceId(opportunityId, "opportunityId"); + + this.logger.log("Linking WHMCS Service to Opportunity", { + opportunityId: safeOppId, + whmcsServiceId, + }); + + const payload: Record = { + Id: safeOppId, + [OPPORTUNITY_FIELD_MAP.whmcsServiceId]: whmcsServiceId, + }; + + try { + const updateMethod = this.sf.sobject("Opportunity").update; + if (!updateMethod) { + throw new Error("Salesforce Opportunity update method not available"); + } + + await updateMethod(payload as Record & { Id: string }); + + this.logger.log("WHMCS Service linked to Opportunity", { + opportunityId: safeOppId, + whmcsServiceId, + }); + } catch (error) { + this.logger.error("Failed to link WHMCS Service to Opportunity", { + error: getErrorMessage(error), + opportunityId: safeOppId, + whmcsServiceId, + }); + // Don't throw - this is a non-critical update + } + } + + /** + * Link an Order to an Opportunity (update Order.OpportunityId) + * + * Note: This updates the Order record, not the Opportunity + * + * @param orderId - Salesforce Order ID + * @param opportunityId - Salesforce Opportunity ID + */ + async linkOrderToOpportunity(orderId: string, opportunityId: string): Promise { + const safeOrderId = assertSalesforceId(orderId, "orderId"); + const safeOppId = assertSalesforceId(opportunityId, "opportunityId"); + + this.logger.log("Linking Order to Opportunity", { + orderId: safeOrderId, + opportunityId: safeOppId, + }); + + try { + const updateMethod = this.sf.sobject("Order").update; + if (!updateMethod) { + throw new Error("Salesforce Order update method not available"); + } + + await updateMethod({ + Id: safeOrderId, + OpportunityId: safeOppId, + }); + + this.logger.log("Order linked to Opportunity", { + orderId: safeOrderId, + opportunityId: safeOppId, + }); + } catch (error) { + this.logger.error("Failed to link Order to Opportunity", { + error: getErrorMessage(error), + orderId: safeOrderId, + opportunityId: safeOppId, + }); + // Don't throw - this is a non-critical update + } + } + + /** + * Mark cancellation as complete + * + * @param opportunityId - Opportunity ID + */ + async markCancellationComplete(opportunityId: string): Promise { + await this.updateStage(opportunityId, OPPORTUNITY_STAGE.CANCELLED, "Cancellation completed"); + } + + // ========================================================================== + // Private Helpers + // ========================================================================== + + /** + * Calculate close date based on product type and stage + */ + private calculateCloseDate( + productType: OpportunityProductTypeValue, + stage: OpportunityStageValue + ): string { + const today = new Date(); + let daysToAdd: number; + + // Different close date expectations based on stage/product + switch (stage) { + case OPPORTUNITY_STAGE.INTRODUCTION: + // Internet eligibility - may take 30 days + daysToAdd = 30; + break; + case OPPORTUNITY_STAGE.READY: + // Ready to order - expected soon + daysToAdd = 14; + break; + case OPPORTUNITY_STAGE.POST_PROCESSING: + // Order placed - expected within 7 days + daysToAdd = 7; + break; + default: + // Default: 30 days + daysToAdd = 30; + } + + const closeDate = new Date(today); + closeDate.setDate(closeDate.getDate() + daysToAdd); + + return closeDate.toISOString().slice(0, 10); + } + + /** + * Get CommodityType values that match a simplified product type + * Used for querying opportunities by product category + */ + private getCommodityTypesForProductType( + productType: OpportunityProductTypeValue + ): CommodityTypeValue[] { + switch (productType) { + case OPPORTUNITY_PRODUCT_TYPE.INTERNET: + return [COMMODITY_TYPE.PERSONAL_HOME_INTERNET, COMMODITY_TYPE.CORPORATE_HOME_INTERNET]; + case OPPORTUNITY_PRODUCT_TYPE.SIM: + return [COMMODITY_TYPE.SIM]; + case OPPORTUNITY_PRODUCT_TYPE.VPN: + return [COMMODITY_TYPE.VPN]; + default: + return []; + } + } + + /** + * Transform Salesforce record to domain OpportunityRecord + */ + private transformToOpportunityRecord(record: SalesforceOpportunityRecord): OpportunityRecord { + // Derive productType from CommodityType (existing Salesforce field) + const commodityType = record.CommodityType as CommodityTypeValue | undefined; + const productType = commodityType ? getCommodityTypeProductType(commodityType) : undefined; + + return { + id: record.Id, + name: record.Name, + accountId: record.AccountId, + stage: record.StageName as OpportunityStageValue, + closeDate: record.CloseDate, + commodityType, + productType: productType ?? undefined, + source: record.Portal_Source__c as OpportunitySourceValue | undefined, + applicationStage: record.Application_Stage__c as ApplicationStageValue | undefined, + isClosed: record.IsClosed, + // Note: Related Cases and Orders are queried separately via their OpportunityId field + whmcsServiceId: record.WHMCS_Service_ID__c, + // Cancellation fields (updated by CS when processing cancellation Case) + scheduledCancellationDate: record.ScheduledCancellationDateAndTime__c, + cancellationNotice: record.CancellationNotice__c as CancellationNoticeValue | undefined, + lineReturnStatus: record.LineReturn__c as LineReturnStatusValue | undefined, + // NOTE: alternativeContactEmail and cancellationComments are on Cancellation Case + createdDate: record.CreatedDate, + lastModifiedDate: record.LastModifiedDate, + }; + } +} diff --git a/apps/bff/src/integrations/salesforce/utils/catalog-query-builder.ts b/apps/bff/src/integrations/salesforce/utils/services-query-builder.ts similarity index 87% rename from apps/bff/src/integrations/salesforce/utils/catalog-query-builder.ts rename to apps/bff/src/integrations/salesforce/utils/services-query-builder.ts index d45016a5..b9c2a1f3 100644 --- a/apps/bff/src/integrations/salesforce/utils/catalog-query-builder.ts +++ b/apps/bff/src/integrations/salesforce/utils/services-query-builder.ts @@ -1,8 +1,8 @@ /** - * Salesforce Catalog Query Builders + * Salesforce Services Query Builders * - * SOQL query builders for Product2 catalog queries. - * Extracted from BaseCatalogService for consistency with order query builders. + * SOQL query builders for Product2 services queries. + * Extracted from BaseServicesService for consistency with order query builders. */ import { sanitizeSoqlLiteral, assertSoqlFieldName } from "./soql.util.js"; @@ -41,9 +41,9 @@ export function buildProductQuery( } /** - * Build catalog service query (Service items only) + * Build services query (Service items only) */ -export function buildCatalogServiceQuery( +export function buildServicesQuery( portalPricebookId: string, portalCategoryField: string, category: string, diff --git a/apps/bff/src/integrations/whmcs/cache/whmcs-cache.service.ts b/apps/bff/src/integrations/whmcs/cache/whmcs-cache.service.ts index 2f3362bc..a4d8da7d 100644 --- a/apps/bff/src/integrations/whmcs/cache/whmcs-cache.service.ts +++ b/apps/bff/src/integrations/whmcs/cache/whmcs-cache.service.ts @@ -42,11 +42,21 @@ export class WhmcsCacheService { ttl: 600, // 10 minutes - individual subscriptions rarely change tags: ["subscription", "services"], }, + subscriptionInvoices: { + prefix: "whmcs:subscription:invoices", + ttl: 300, // 5 minutes + tags: ["subscription", "invoices"], + }, client: { prefix: "whmcs:client", ttl: 1800, // 30 minutes - client data rarely changes tags: ["client", "user"], }, + clientEmail: { + prefix: "whmcs:client:email", + ttl: 1800, // 30 minutes + tags: ["client", "email"], + }, sso: { prefix: "whmcs:sso", ttl: 3600, // 1 hour - SSO tokens have their own expiry @@ -144,6 +154,36 @@ export class WhmcsCacheService { await this.set(key, data, "subscription", [`user:${userId}`, `subscription:${subscriptionId}`]); } + /** + * Get cached subscription invoices + */ + async getSubscriptionInvoices( + userId: string, + subscriptionId: number, + page: number, + limit: number + ): Promise { + const key = this.buildSubscriptionInvoicesKey(userId, subscriptionId, page, limit); + return this.get(key, "subscriptionInvoices"); + } + + /** + * Cache subscription invoices + */ + async setSubscriptionInvoices( + userId: string, + subscriptionId: number, + page: number, + limit: number, + data: InvoiceList + ): Promise { + const key = this.buildSubscriptionInvoicesKey(userId, subscriptionId, page, limit); + await this.set(key, data, "subscriptionInvoices", [ + `user:${userId}`, + `subscription:${subscriptionId}`, + ]); + } + /** * Get cached client data * Returns WhmcsClient (type inferred from domain) @@ -161,6 +201,22 @@ export class WhmcsCacheService { await this.set(key, data, "client", [`client:${clientId}`]); } + /** + * Get cached client ID by email + */ + async getClientIdByEmail(email: string): Promise { + const key = this.buildClientEmailKey(email); + return this.get(key, "clientEmail"); + } + + /** + * Cache client ID for email + */ + async setClientIdByEmail(email: string, clientId: number): Promise { + const key = this.buildClientEmailKey(email); + await this.set(key, clientId, "clientEmail"); + } + /** * Invalidate all cache for a specific user */ @@ -383,6 +439,18 @@ export class WhmcsCacheService { return `${this.cacheConfigs.subscription.prefix}:${userId}:${subscriptionId}`; } + /** + * Build cache key for subscription invoices + */ + private buildSubscriptionInvoicesKey( + userId: string, + subscriptionId: number, + page: number, + limit: number + ): string { + return `${this.cacheConfigs.subscriptionInvoices.prefix}:${userId}:${subscriptionId}:${page}:${limit}`; + } + /** * Build cache key for client data */ @@ -390,6 +458,13 @@ export class WhmcsCacheService { return `${this.cacheConfigs.client.prefix}:${clientId}`; } + /** + * Build cache key for client email mapping + */ + private buildClientEmailKey(email: string): string { + return `${this.cacheConfigs.clientEmail.prefix}:${email.toLowerCase()}`; + } + /** * Build cache key for payment methods */ diff --git a/apps/bff/src/integrations/whmcs/connection/services/whmcs-connection-orchestrator.service.ts b/apps/bff/src/integrations/whmcs/connection/services/whmcs-connection-orchestrator.service.ts index da0c9916..c662ecad 100644 --- a/apps/bff/src/integrations/whmcs/connection/services/whmcs-connection-orchestrator.service.ts +++ b/apps/bff/src/integrations/whmcs/connection/services/whmcs-connection-orchestrator.service.ts @@ -33,7 +33,7 @@ import type { } from "@customer-portal/domain/payments"; import type { WhmcsGetClientsProductsParams } from "@customer-portal/domain/subscriptions"; import type { WhmcsProductListResponse } from "@customer-portal/domain/subscriptions"; -import type { WhmcsCatalogProductListResponse } from "@customer-portal/domain/catalog"; +import type { WhmcsCatalogProductListResponse } from "@customer-portal/domain/services"; import type { WhmcsErrorResponse } from "@customer-portal/domain/common"; import type { WhmcsRequestOptions, WhmcsConnectionStats } from "../types/connection.types.js"; diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-account-discovery.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-account-discovery.service.ts new file mode 100644 index 00000000..94355b62 --- /dev/null +++ b/apps/bff/src/integrations/whmcs/services/whmcs-account-discovery.service.ts @@ -0,0 +1,93 @@ +import { Injectable, NotFoundException, Inject } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { getErrorMessage } from "@bff/core/utils/error.util.js"; +import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service.js"; +import { WhmcsCacheService } from "../cache/whmcs-cache.service.js"; +import { Providers as CustomerProviders } from "@customer-portal/domain/customer"; +import type { WhmcsClient } from "@customer-portal/domain/customer"; + +/** + * Service for discovering and verifying WHMCS accounts by email. + * Separated from WhmcsClientService to isolate "discovery" logic from "authenticated" logic. + */ +@Injectable() +export class WhmcsAccountDiscoveryService { + constructor( + private readonly connectionService: WhmcsConnectionOrchestratorService, + private readonly cacheService: WhmcsCacheService, + @Inject(Logger) private readonly logger: Logger + ) {} + + /** + * Find a client by email address. + * This is a "discovery" operation used during signup/login flows. + * It uses a specialized cache to map Email -> Client ID. + */ + async findClientByEmail(email: string): Promise { + try { + // 1. Try to find client ID by email from cache + const cachedClientId = await this.cacheService.getClientIdByEmail(email); + if (cachedClientId) { + this.logger.debug(`Cache hit for email-to-id: ${email} -> ${cachedClientId}`); + // If we have ID, fetch the full client data (which has its own cache) + return this.getClientDetailsById(cachedClientId); + } + + // 2. If no mapping, fetch from API + // We use a try-catch here because the connection service might throw if not found + // or if the API returns a specific error for "no results" + const response = await this.connectionService.getClientDetailsByEmail(email); + + if (!response || !response.client) { + // Not found is a valid state for discovery (return null) + return null; + } + + const client = CustomerProviders.Whmcs.transformWhmcsClientResponse(response); + + // 3. Cache both the data and the mapping + await Promise.all([ + this.cacheService.setClientData(client.id, client), + this.cacheService.setClientIdByEmail(email, client.id), + ]); + + this.logger.log(`Discovered client by email: ${email}`); + return client; + } catch (error) { + // Handle "Not Found" specifically + if ( + error instanceof NotFoundException || + (error instanceof Error && error.message.includes("not found")) + ) { + return null; + } + + // Log other errors but don't crash - return null to indicate lookup failed safely + this.logger.warn(`Failed to discover client by email: ${email}`, { + error: getErrorMessage(error), + }); + return null; + } + } + + /** + * Helper to get details by ID, reusing the cache logic from ClientService logic + * We duplicate this small fetch to avoid circular dependency or tight coupling with WhmcsClientService + */ + private async getClientDetailsById(clientId: number): Promise { + // Try cache first + const cached = await this.cacheService.getClientData(clientId); + if (cached) { + return cached; + } + + const response = await this.connectionService.getClientDetails(clientId); + if (!response || !response.client) { + throw new NotFoundException(`Client ${clientId} not found`); + } + + const client = CustomerProviders.Whmcs.transformWhmcsClientResponse(response); + await this.cacheService.setClientData(client.id, client); + return client; + } +} diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-client.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-client.service.ts index 21a718af..a36d7c7f 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-client.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-client.service.ts @@ -86,35 +86,6 @@ export class WhmcsClientService { } } - /** - * Get client details by email - * Returns WhmcsClient (type inferred from domain mapper) - */ - async getClientDetailsByEmail(email: string): Promise { - try { - const response = await this.connectionService.getClientDetailsByEmail(email); - - if (!response || !response.client) { - this.logger.error(`WHMCS API did not return client data for email: ${email}`, { - hasResponse: !!response, - responseKeys: response ? Object.keys(response) : [], - }); - throw new NotFoundException(`Client with email ${email} not found`); - } - - const client = CustomerProviders.Whmcs.transformWhmcsClientResponse(response); - await this.cacheService.setClientData(client.id, client); - - this.logger.log(`Fetched client details by email: ${email}`); - return client; - } catch (error) { - this.logger.error(`Failed to fetch client details by email: ${email}`, { - error: getErrorMessage(error), - }); - throw error; - } - } - /** * Update client details */ diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-payment.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-payment.service.ts index 56104db4..13409a87 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-payment.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-payment.service.ts @@ -11,7 +11,7 @@ import type { import { Providers as CatalogProviders, type WhmcsCatalogProductNormalized, -} from "@customer-portal/domain/catalog"; +} from "@customer-portal/domain/services"; import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service.js"; import { WhmcsCacheService } from "../cache/whmcs-cache.service.js"; import type { WhmcsCreateSsoTokenParams } from "@customer-portal/domain/customer"; diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-subscription.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-subscription.service.ts index f0fbf54d..7ad3e55f 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-subscription.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-subscription.service.ts @@ -123,11 +123,40 @@ export class WhmcsSubscriptionService { return cached; } - // Get all subscriptions and find the specific one - const subscriptionList = await this.getSubscriptions(clientId, userId); - const subscription = subscriptionList.subscriptions.find( - (s: Subscription) => s.id === subscriptionId - ); + // 2. Check if we have the FULL list cached. + // If we do, searching memory is faster than an API call. + const cachedList = await this.cacheService.getSubscriptionsList(userId); + if (cachedList) { + const found = cachedList.subscriptions.find((s: Subscription) => s.id === subscriptionId); + if (found) { + this.logger.debug( + `Cache hit (via list) for subscription: user ${userId}, subscription ${subscriptionId}` + ); + // Cache this individual item for faster direct access next time + await this.cacheService.setSubscription(userId, subscriptionId, found); + return found; + } + // If list is cached but item not found, it might be new or not in that list? + // Proceed to fetch single item. + } + + // 3. Fetch ONLY this subscription from WHMCS (Optimized) + // Instead of fetching all products, use serviceid filter + const params: WhmcsGetClientsProductsParams = { + clientid: clientId, + serviceid: subscriptionId, + }; + + const rawResponse = await this.connectionService.getClientsProducts(params); + + // Transform response + const defaultCurrency = this.currencyService.getDefaultCurrency(); + const resultList = Providers.Whmcs.transformWhmcsSubscriptionListResponse(rawResponse, { + defaultCurrencyCode: defaultCurrency.code, + defaultCurrencySymbol: defaultCurrency.prefix || defaultCurrency.suffix, + }); + + const subscription = resultList.subscriptions.find(s => s.id === subscriptionId); if (!subscription) { throw new NotFoundException(`Subscription ${subscriptionId} not found`); diff --git a/apps/bff/src/integrations/whmcs/whmcs.module.ts b/apps/bff/src/integrations/whmcs/whmcs.module.ts index fe61822f..da982bd6 100644 --- a/apps/bff/src/integrations/whmcs/whmcs.module.ts +++ b/apps/bff/src/integrations/whmcs/whmcs.module.ts @@ -10,6 +10,7 @@ import { WhmcsPaymentService } from "./services/whmcs-payment.service.js"; import { WhmcsSsoService } from "./services/whmcs-sso.service.js"; import { WhmcsOrderService } from "./services/whmcs-order.service.js"; import { WhmcsCurrencyService } from "./services/whmcs-currency.service.js"; +import { WhmcsAccountDiscoveryService } from "./services/whmcs-account-discovery.service.js"; // Connection services import { WhmcsConnectionOrchestratorService } from "./connection/services/whmcs-connection-orchestrator.service.js"; import { WhmcsConfigService } from "./connection/config/whmcs-config.service.js"; @@ -33,15 +34,18 @@ import { WhmcsErrorHandlerService } from "./connection/services/whmcs-error-hand WhmcsSsoService, WhmcsOrderService, WhmcsCurrencyService, + WhmcsAccountDiscoveryService, WhmcsService, ], exports: [ WhmcsService, WhmcsConnectionOrchestratorService, WhmcsCacheService, + WhmcsClientService, WhmcsOrderService, WhmcsPaymentService, WhmcsCurrencyService, + WhmcsAccountDiscoveryService, ], }) export class WhmcsModule {} diff --git a/apps/bff/src/integrations/whmcs/whmcs.service.ts b/apps/bff/src/integrations/whmcs/whmcs.service.ts index 408a7412..4f239f93 100644 --- a/apps/bff/src/integrations/whmcs/whmcs.service.ts +++ b/apps/bff/src/integrations/whmcs/whmcs.service.ts @@ -20,7 +20,7 @@ import { WhmcsOrderService } from "./services/whmcs-order.service.js"; import type { WhmcsAddClientParams, WhmcsClientResponse } from "@customer-portal/domain/customer"; import type { WhmcsGetClientsProductsParams } from "@customer-portal/domain/subscriptions"; import type { WhmcsProductListResponse } from "@customer-portal/domain/subscriptions"; -import type { WhmcsCatalogProductNormalized } from "@customer-portal/domain/catalog"; +import type { WhmcsCatalogProductNormalized } from "@customer-portal/domain/services"; import { Logger } from "nestjs-pino"; @Injectable() @@ -131,14 +131,6 @@ export class WhmcsService { return this.clientService.getClientDetails(clientId); } - /** - * Get client details by email - * Returns internal WhmcsClient (type inferred) - */ - async getClientDetailsByEmail(email: string): Promise { - return this.clientService.getClientDetailsByEmail(email); - } - /** * Update client details in WHMCS */ diff --git a/apps/bff/src/modules/auth/application/auth.facade.ts b/apps/bff/src/modules/auth/application/auth.facade.ts index 504e69a0..f0d62cd1 100644 --- a/apps/bff/src/modules/auth/application/auth.facade.ts +++ b/apps/bff/src/modules/auth/application/auth.facade.ts @@ -4,6 +4,7 @@ import * as argon2 from "argon2"; import { UsersFacade } from "@bff/modules/users/application/users.facade.js"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js"; +import { WhmcsAccountDiscoveryService } from "@bff/integrations/whmcs/services/whmcs-account-discovery.service.js"; import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service.js"; import { AuditService, AuditAction } from "@bff/infra/audit/audit.service.js"; import { getErrorMessage } from "@bff/core/utils/error.util.js"; @@ -39,6 +40,7 @@ export class AuthFacade { private readonly mappingsService: MappingsService, private readonly configService: ConfigService, private readonly whmcsService: WhmcsService, + private readonly discoveryService: WhmcsAccountDiscoveryService, private readonly salesforceService: SalesforceService, private readonly auditService: AuditService, private readonly tokenBlacklistService: TokenBlacklistService, @@ -418,14 +420,9 @@ export class AuthFacade { if (mapped) { whmcsExists = true; } else { - // Try a direct WHMCS lookup by email (best-effort) - try { - const client = await this.whmcsService.getClientDetailsByEmail(normalized); - whmcsExists = !!client; - } catch (e) { - // Treat not found as no; other errors as unknown (leave whmcsExists false) - this.logger.debug("Account status: WHMCS lookup", { error: getErrorMessage(e) }); - } + // Try a direct WHMCS lookup by email using discovery service (returns null if not found) + const client = await this.discoveryService.findClientByEmail(normalized); + whmcsExists = !!client; } let state: "none" | "portal_only" | "whmcs_only" | "both_mapped" = "none"; diff --git a/apps/bff/src/modules/auth/decorators/public.decorator.ts b/apps/bff/src/modules/auth/decorators/public.decorator.ts index 7beff6f4..834188ee 100644 --- a/apps/bff/src/modules/auth/decorators/public.decorator.ts +++ b/apps/bff/src/modules/auth/decorators/public.decorator.ts @@ -2,3 +2,12 @@ import { SetMetadata } from "@nestjs/common"; export const IS_PUBLIC_KEY = "isPublic"; export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); + +/** + * Marks a route/controller as public *and* disables optional session attachment. + * + * Why: some endpoints must be strictly non-personalized for caching/security correctness + * (e.g. public service catalogs). These endpoints should ignore cookies/tokens entirely. + */ +export const IS_PUBLIC_NO_SESSION_KEY = "isPublicNoSession"; +export const PublicNoSession = () => SetMetadata(IS_PUBLIC_NO_SESSION_KEY, true); diff --git a/apps/bff/src/modules/auth/infra/workflows/workflows/signup-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/workflows/signup-workflow.service.ts index bce6f5f2..e15240fc 100644 --- a/apps/bff/src/modules/auth/infra/workflows/workflows/signup-workflow.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/workflows/signup-workflow.service.ts @@ -1,9 +1,9 @@ import { BadRequestException, ConflictException, + HttpStatus, Inject, Injectable, - NotFoundException, } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { Logger } from "nestjs-pino"; @@ -13,16 +13,20 @@ import { AuditService, AuditAction } from "@bff/infra/audit/audit.service.js"; import { UsersFacade } from "@bff/modules/users/application/users.facade.js"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js"; +import { WhmcsAccountDiscoveryService } from "@bff/integrations/whmcs/services/whmcs-account-discovery.service.js"; import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service.js"; +import { SalesforceAccountService } from "@bff/integrations/salesforce/services/salesforce-account.service.js"; import { PrismaService } from "@bff/infra/database/prisma.service.js"; import { AuthTokenService } from "../../token/token.service.js"; import { AuthRateLimitService } from "../../rate-limiting/auth-rate-limit.service.js"; import { getErrorMessage } from "@bff/core/utils/error.util.js"; +import { DomainHttpException } from "@bff/core/http/domain-http.exception.js"; import { signupRequestSchema, type SignupRequest, type ValidateSignupRequest, } from "@customer-portal/domain/auth"; +import { ErrorCode } from "@customer-portal/domain/common"; import { Providers as CustomerProviders } from "@customer-portal/domain/customer"; import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js"; import type { User as PrismaUser } from "@prisma/client"; @@ -54,7 +58,9 @@ export class SignupWorkflowService { private readonly usersFacade: UsersFacade, private readonly mappingsService: MappingsService, private readonly whmcsService: WhmcsService, + private readonly discoveryService: WhmcsAccountDiscoveryService, private readonly salesforceService: SalesforceService, + private readonly salesforceAccountService: SalesforceAccountService, private readonly configService: ConfigService, private readonly prisma: PrismaService, private readonly auditService: AuditService, @@ -66,14 +72,30 @@ export class SignupWorkflowService { async validateSignup(validateData: ValidateSignupRequest, request?: Request) { const { sfNumber } = validateData; + const normalizedCustomerNumber = this.normalizeCustomerNumber(sfNumber); + + if (!normalizedCustomerNumber) { + await this.auditService.logAuthEvent( + AuditAction.SIGNUP, + undefined, + { sfNumber: sfNumber ?? null, reason: "no_customer_number_provided" }, + request, + true + ); + + return { + valid: true, + message: "Customer number is not required for signup", + }; + } try { - const accountSnapshot = await this.getAccountSnapshot(sfNumber); + const accountSnapshot = await this.getAccountSnapshot(normalizedCustomerNumber); if (!accountSnapshot) { await this.auditService.logAuthEvent( AuditAction.SIGNUP, undefined, - { sfNumber, reason: "SF number not found" }, + { sfNumber: normalizedCustomerNumber, reason: "SF number not found" }, request, false, "Customer number not found in Salesforce" @@ -118,7 +140,7 @@ export class SignupWorkflowService { await this.auditService.logAuthEvent( AuditAction.SIGNUP, undefined, - { sfNumber, sfAccountId: accountSnapshot.id, step: "validation" }, + { sfNumber: normalizedCustomerNumber, sfAccountId: accountSnapshot.id, step: "validation" }, request, true ); @@ -136,7 +158,7 @@ export class SignupWorkflowService { await this.auditService.logAuthEvent( AuditAction.SIGNUP, undefined, - { sfNumber, error: getErrorMessage(error) }, + { sfNumber: normalizedCustomerNumber, error: getErrorMessage(error) }, request, false, getErrorMessage(error) @@ -189,39 +211,100 @@ export class SignupWorkflowService { const passwordHash = await argon2.hash(password); try { - const accountSnapshot = await this.getAccountSnapshot(sfNumber); - if (!accountSnapshot) { - throw new BadRequestException( - `Salesforce account not found for Customer Number: ${sfNumber}` - ); - } + const normalizedCustomerNumber = this.normalizeCustomerNumber(sfNumber); + let accountSnapshot: SignupAccountSnapshot; + let customerNumberForWhmcs: string | null = normalizedCustomerNumber; - if (accountSnapshot.WH_Account__c && accountSnapshot.WH_Account__c.trim() !== "") { - throw new ConflictException( - "You already have an account. Please use the login page to access your existing account." - ); + if (normalizedCustomerNumber) { + const resolved = await this.getAccountSnapshot(normalizedCustomerNumber); + if (!resolved) { + throw new BadRequestException( + `Salesforce account not found for Customer Number: ${normalizedCustomerNumber}` + ); + } + + if (resolved.WH_Account__c && resolved.WH_Account__c.trim() !== "") { + throw new ConflictException( + "You already have an account. Please use the login page to access your existing account." + ); + } + + accountSnapshot = resolved; + } else { + const normalizedEmail = email.toLowerCase().trim(); + const existingAccount = await this.salesforceAccountService.findByEmail(normalizedEmail); + if (existingAccount) { + throw new ConflictException( + "An account already exists for this email. Please sign in or transfer your account." + ); + } + + if ( + !address?.address1 || + !address?.city || + !address?.state || + !address?.postcode || + !address?.country + ) { + throw new BadRequestException( + "Complete address information is required for account creation" + ); + } + + if (!phone) { + throw new BadRequestException("Phone number is required for account creation"); + } + + const created = await this.salesforceAccountService.createAccount({ + firstName, + lastName, + email: normalizedEmail, + phone, + address: { + address1: address.address1, + address2: address.address2 || undefined, + city: address.city, + state: address.state, + postcode: address.postcode, + country: address.country, + }, + }); + + await this.salesforceAccountService.createContact({ + accountId: created.accountId, + firstName, + lastName, + email: normalizedEmail, + phone, + address: { + address1: address.address1, + address2: address.address2 || undefined, + city: address.city, + state: address.state, + postcode: address.postcode, + country: address.country, + }, + }); + + accountSnapshot = { + id: created.accountId, + Name: `${firstName} ${lastName}`, + WH_Account__c: null, + }; + customerNumberForWhmcs = created.accountNumber; } let whmcsClient: { clientId: number }; try { - try { - const existingWhmcs = await this.whmcsService.getClientDetailsByEmail(email); - if (existingWhmcs) { - const existingMapping = await this.mappingsService.findByWhmcsClientId( - existingWhmcs.id - ); - if (existingMapping) { - throw new ConflictException("You already have an account. Please sign in."); - } + // Check if a WHMCS client already exists for this email using discovery service + const existingWhmcs = await this.discoveryService.findClientByEmail(email); + if (existingWhmcs) { + const existingMapping = await this.mappingsService.findByWhmcsClientId(existingWhmcs.id); + if (existingMapping) { + throw new ConflictException("You already have an account. Please sign in."); + } - throw new ConflictException( - "We found an existing billing account for this email. Please link your account instead." - ); - } - } catch (pre) { - if (!(pre instanceof NotFoundException)) { - throw pre; - } + throw new DomainHttpException(ErrorCode.LEGACY_ACCOUNT_EXISTS, HttpStatus.CONFLICT); } const customerNumberFieldId = this.configService.get( @@ -232,7 +315,9 @@ export class SignupWorkflowService { const nationalityFieldId = this.configService.get("WHMCS_NATIONALITY_FIELD_ID"); const customfieldsMap: Record = {}; - if (customerNumberFieldId) customfieldsMap[customerNumberFieldId] = sfNumber; + if (customerNumberFieldId && customerNumberForWhmcs) { + customfieldsMap[customerNumberFieldId] = customerNumberForWhmcs; + } if (dobFieldId && dateOfBirth) customfieldsMap[dobFieldId] = dateOfBirth; if (genderFieldId && gender) customfieldsMap[genderFieldId] = gender; if (nationalityFieldId && nationality) customfieldsMap[nationalityFieldId] = nationality; @@ -253,7 +338,12 @@ export class SignupWorkflowService { throw new BadRequestException("Phone number is required for billing account creation"); } - this.logger.log("Creating WHMCS client", { email, firstName, lastName, sfNumber }); + this.logger.log("Creating WHMCS client", { + email, + firstName, + lastName, + sfNumber: customerNumberForWhmcs, + }); whmcsClient = await this.whmcsService.addClient({ firstname: firstName, @@ -399,6 +489,7 @@ export class SignupWorkflowService { async signupPreflight(signupData: SignupRequest) { const { email, sfNumber } = signupData; const normalizedEmail = email.toLowerCase().trim(); + const normalizedCustomerNumber = this.normalizeCustomerNumber(sfNumber); const result: { ok: boolean; @@ -440,24 +531,9 @@ export class SignupWorkflowService { return result; } - const accountSnapshot = await this.getAccountSnapshot(sfNumber); - if (!accountSnapshot) { - result.nextAction = "fix_input"; - result.messages.push("Customer number not found in Salesforce"); - return result; - } - result.salesforce.accountId = accountSnapshot.id; - - const existingMapping = await this.mappingsService.findBySfAccountId(accountSnapshot.id); - if (existingMapping) { - result.salesforce.alreadyMapped = true; - result.nextAction = "login"; - result.messages.push("This customer number is already registered. Please sign in."); - return result; - } - - try { - const client = await this.whmcsService.getClientDetailsByEmail(normalizedEmail); + if (!normalizedCustomerNumber) { + // Check for existing WHMCS client using discovery service (returns null if not found) + const client = await this.discoveryService.findClientByEmail(normalizedEmail); if (client) { result.whmcs.clientExists = true; result.whmcs.clientId = client.id; @@ -475,17 +551,68 @@ export class SignupWorkflowService { result.nextAction = "link_whmcs"; result.messages.push( - "We found an existing billing account for this email. Please link your account." + "We found an existing billing account for this email. Please transfer your account to continue." ); return result; } - } catch (err) { - if (!(err instanceof NotFoundException)) { - this.logger.warn("WHMCS preflight check failed", { error: getErrorMessage(err) }); - result.messages.push("Unable to verify billing system. Please try again later."); - result.nextAction = "blocked"; - return result; + + try { + const existingSf = await this.salesforceAccountService.findByEmail(normalizedEmail); + if (existingSf) { + result.nextAction = "blocked"; + result.messages.push( + "We found an existing customer record for this email. Please transfer your account or contact support." + ); + return result; + } + } catch (sfErr) { + this.logger.warn("Salesforce preflight check failed", { error: getErrorMessage(sfErr) }); } + + result.canProceed = true; + result.nextAction = "proceed_signup"; + result.messages.push("All checks passed. Ready to create your account."); + return result; + } + + const accountSnapshot = await this.getAccountSnapshot(normalizedCustomerNumber); + if (!accountSnapshot) { + result.nextAction = "fix_input"; + result.messages.push("Customer number not found in Salesforce"); + return result; + } + result.salesforce.accountId = accountSnapshot.id; + + const existingMapping = await this.mappingsService.findBySfAccountId(accountSnapshot.id); + if (existingMapping) { + result.salesforce.alreadyMapped = true; + result.nextAction = "login"; + result.messages.push("This customer number is already registered. Please sign in."); + return result; + } + + // Check for existing WHMCS client using discovery service (returns null if not found) + const client = await this.discoveryService.findClientByEmail(normalizedEmail); + if (client) { + result.whmcs.clientExists = true; + result.whmcs.clientId = client.id; + + try { + const mapped = await this.mappingsService.findByWhmcsClientId(client.id); + if (mapped) { + result.nextAction = "login"; + result.messages.push("This billing account is already linked. Please sign in."); + return result; + } + } catch { + // ignore; treat as unmapped + } + + result.nextAction = "link_whmcs"; + result.messages.push( + "We found an existing billing account for this email. Please transfer your account to continue." + ); + return result; } result.canProceed = true; @@ -494,7 +621,9 @@ export class SignupWorkflowService { return result; } - private async getAccountSnapshot(sfNumber: string): Promise { + private async getAccountSnapshot( + sfNumber?: string | null + ): Promise { const normalized = this.normalizeCustomerNumber(sfNumber); if (!normalized) { return null; @@ -519,7 +648,7 @@ export class SignupWorkflowService { return resolved; } - private normalizeCustomerNumber(sfNumber: string): string | null { + private normalizeCustomerNumber(sfNumber?: string | null): string | null { if (typeof sfNumber !== "string") { return null; } diff --git a/apps/bff/src/modules/auth/infra/workflows/workflows/whmcs-link-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/workflows/whmcs-link-workflow.service.ts index 11244409..024f1ef6 100644 --- a/apps/bff/src/modules/auth/infra/workflows/workflows/whmcs-link-workflow.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/workflows/whmcs-link-workflow.service.ts @@ -10,6 +10,7 @@ import { Logger } from "nestjs-pino"; import { UsersFacade } from "@bff/modules/users/application/users.facade.js"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js"; +import { WhmcsAccountDiscoveryService } from "@bff/integrations/whmcs/services/whmcs-account-discovery.service.js"; import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service.js"; import { getErrorMessage } from "@bff/core/utils/error.util.js"; import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js"; @@ -26,6 +27,7 @@ export class WhmcsLinkWorkflowService { private readonly usersFacade: UsersFacade, private readonly mappingsService: MappingsService, private readonly whmcsService: WhmcsService, + private readonly discoveryService: WhmcsAccountDiscoveryService, private readonly salesforceService: SalesforceService, @Inject(Logger) private readonly logger: Logger ) {} @@ -51,21 +53,19 @@ export class WhmcsLinkWorkflowService { try { let clientDetails; // Type inferred from WHMCS service try { - clientDetails = await this.whmcsService.getClientDetailsByEmail(email); - } catch (error) { - this.logger.error("WHMCS client lookup failed", { - error: getErrorMessage(error), - email, // Safe to log email for debugging since it's not sensitive - }); - - // Provide more specific error messages based on the error type - // Use BadRequestException (400) instead of UnauthorizedException (401) - // to avoid triggering "session expired" logic in the frontend - if (error instanceof Error && error.message.includes("not found")) { + clientDetails = await this.discoveryService.findClientByEmail(email); + if (!clientDetails) { throw new BadRequestException( "No billing account found with this email address. Please check your email or contact support." ); } + } catch (error) { + if (error instanceof BadRequestException) throw error; + + this.logger.error("WHMCS client lookup failed", { + error: getErrorMessage(error), + email, // Safe to log email for debugging since it's not sensitive + }); throw new BadRequestException("Unable to verify account. Please try again later."); } diff --git a/apps/bff/src/modules/auth/presentation/http/auth.controller.ts b/apps/bff/src/modules/auth/presentation/http/auth.controller.ts index 6e3805e8..c765a78e 100644 --- a/apps/bff/src/modules/auth/presentation/http/auth.controller.ts +++ b/apps/bff/src/modules/auth/presentation/http/auth.controller.ts @@ -230,11 +230,11 @@ export class AuthController { } @Public() - @Post("link-whmcs") + @Post("migrate") @UseGuards(RateLimitGuard, SalesforceWriteThrottleGuard) @RateLimit({ limit: 5, ttl: 600 }) // 5 attempts per 10 minutes per IP (industry standard) @UsePipes(new ZodValidationPipe(linkWhmcsRequestSchema)) - async linkWhmcs(@Body() linkData: LinkWhmcsRequest, @Req() _req: Request) { + async migrateAccount(@Body() linkData: LinkWhmcsRequest, @Req() _req: Request) { const result = await this.authFacade.linkWhmcsUser(linkData); return linkWhmcsResponseSchema.parse(result); } diff --git a/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts b/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts index fb7368a0..15737857 100644 --- a/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts +++ b/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts @@ -5,7 +5,7 @@ import { Reflector } from "@nestjs/core"; import type { Request } from "express"; import { TokenBlacklistService } from "../../../infra/token/token-blacklist.service.js"; -import { IS_PUBLIC_KEY } from "../../../decorators/public.decorator.js"; +import { IS_PUBLIC_KEY, IS_PUBLIC_NO_SESSION_KEY } from "../../../decorators/public.decorator.js"; import { getErrorMessage } from "@bff/core/utils/error.util.js"; import { JoseJwtService } from "../../../infra/token/jose-jwt.service.js"; import { UsersFacade } from "@bff/modules/users/application/users.facade.js"; @@ -45,8 +45,27 @@ export class GlobalAuthGuard implements CanActivate { context.getHandler(), context.getClass(), ]); + const isPublicNoSession = this.reflector.getAllAndOverride(IS_PUBLIC_NO_SESSION_KEY, [ + context.getHandler(), + context.getClass(), + ]); if (isPublic) { + if (isPublicNoSession) { + this.logger.debug(`Strict public route accessed (no session attach): ${route}`); + return true; + } + + const token = extractAccessTokenFromRequest(request); + if (token) { + try { + await this.attachUserFromToken(request, token); + this.logger.debug(`Authenticated session detected on public route: ${route}`); + } catch { + // Public endpoints should remain accessible even if the session is missing/expired/invalid. + this.logger.debug(`Ignoring invalid session on public route: ${route}`); + } + } this.logger.debug(`Public route accessed: ${route}`); return true; } @@ -61,45 +80,7 @@ export class GlobalAuthGuard implements CanActivate { throw new UnauthorizedException("Missing token"); } - const payload = await this.jwtService.verify<{ sub?: string; email?: string; exp?: number }>( - token - ); - - const tokenType = (payload as { type?: unknown }).type; - if (typeof tokenType === "string" && tokenType !== "access") { - throw new UnauthorizedException("Invalid access token"); - } - - if (!payload.sub || !payload.email) { - throw new UnauthorizedException("Invalid token payload"); - } - - // Explicit expiry buffer check to avoid tokens expiring mid-request - if (typeof payload.exp !== "number") { - throw new UnauthorizedException("Token missing expiration claim"); - } - const nowSeconds = Math.floor(Date.now() / 1000); - if (payload.exp < nowSeconds + 60) { - throw new UnauthorizedException("Token expired or expiring soon"); - } - - // Then check token blacklist - const isBlacklisted = await this.tokenBlacklistService.isTokenBlacklisted(token); - if (isBlacklisted) { - this.logger.warn(`Blacklisted token attempted access to: ${route}`); - throw new UnauthorizedException("Token has been revoked"); - } - - const prismaUser = await this.usersFacade.findByIdInternal(payload.sub); - if (!prismaUser) { - throw new UnauthorizedException("User not found"); - } - if (prismaUser.email !== payload.email) { - throw new UnauthorizedException("Token subject does not match user record"); - } - - const profile: UserAuth = mapPrismaUserToDomain(prismaUser); - (request as RequestWithRoute & { user?: UserAuth }).user = profile; + await this.attachUserFromToken(request, token, route); this.logger.debug(`Authenticated access to: ${route}`); return true; @@ -168,4 +149,52 @@ export class GlobalAuthGuard implements CanActivate { const normalized = path.endsWith("/") ? path.slice(0, -1) : path; return normalized === "/auth/logout" || normalized === "/api/auth/logout"; } + + private async attachUserFromToken( + request: RequestWithRoute, + token: string, + route?: string + ): Promise { + const payload = await this.jwtService.verify<{ sub?: string; email?: string; exp?: number }>( + token + ); + + const tokenType = (payload as { type?: unknown }).type; + if (typeof tokenType === "string" && tokenType !== "access") { + throw new UnauthorizedException("Invalid access token"); + } + + if (!payload.sub || !payload.email) { + throw new UnauthorizedException("Invalid token payload"); + } + + // Explicit expiry buffer check to avoid tokens expiring mid-request + if (typeof payload.exp !== "number") { + throw new UnauthorizedException("Token missing expiration claim"); + } + const nowSeconds = Math.floor(Date.now() / 1000); + if (payload.exp < nowSeconds + 60) { + throw new UnauthorizedException("Token expired or expiring soon"); + } + + // Then check token blacklist + const isBlacklisted = await this.tokenBlacklistService.isTokenBlacklisted(token); + if (isBlacklisted) { + if (route) { + this.logger.warn(`Blacklisted token attempted access to: ${route}`); + } + throw new UnauthorizedException("Token has been revoked"); + } + + const prismaUser = await this.usersFacade.findByIdInternal(payload.sub); + if (!prismaUser) { + throw new UnauthorizedException("User not found"); + } + if (prismaUser.email !== payload.email) { + throw new UnauthorizedException("Token subject does not match user record"); + } + + const profile: UserAuth = mapPrismaUserToDomain(prismaUser); + (request as RequestWithRoute & { user?: UserAuth }).user = profile; + } } diff --git a/apps/bff/src/modules/catalog/catalog.module.ts b/apps/bff/src/modules/catalog/catalog.module.ts deleted file mode 100644 index a648b03a..00000000 --- a/apps/bff/src/modules/catalog/catalog.module.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Module, forwardRef } from "@nestjs/common"; -import { CatalogController } from "./catalog.controller.js"; -import { CatalogHealthController } from "./catalog-health.controller.js"; -import { IntegrationsModule } from "@bff/integrations/integrations.module.js"; -import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js"; -import { CoreConfigModule } from "@bff/core/config/config.module.js"; -import { CacheModule } from "@bff/infra/cache/cache.module.js"; -import { QueueModule } from "@bff/core/queue/queue.module.js"; - -import { BaseCatalogService } from "./services/base-catalog.service.js"; -import { InternetCatalogService } from "./services/internet-catalog.service.js"; -import { SimCatalogService } from "./services/sim-catalog.service.js"; -import { VpnCatalogService } from "./services/vpn-catalog.service.js"; -import { CatalogCacheService } from "./services/catalog-cache.service.js"; - -@Module({ - imports: [ - forwardRef(() => IntegrationsModule), - MappingsModule, - CoreConfigModule, - CacheModule, - QueueModule, - ], - controllers: [CatalogController, CatalogHealthController], - providers: [ - BaseCatalogService, - InternetCatalogService, - SimCatalogService, - VpnCatalogService, - CatalogCacheService, - ], - exports: [InternetCatalogService, SimCatalogService, VpnCatalogService, CatalogCacheService], -}) -export class CatalogModule {} diff --git a/apps/bff/src/modules/catalog/services/internet-catalog.service.ts b/apps/bff/src/modules/catalog/services/internet-catalog.service.ts deleted file mode 100644 index 8acf5640..00000000 --- a/apps/bff/src/modules/catalog/services/internet-catalog.service.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { Injectable, Inject } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { BaseCatalogService } from "./base-catalog.service.js"; -import { CatalogCacheService } from "./catalog-cache.service.js"; -import type { - SalesforceProduct2WithPricebookEntries, - InternetPlanCatalogItem, - InternetInstallationCatalogItem, - InternetAddonCatalogItem, -} from "@customer-portal/domain/catalog"; -import { - Providers as CatalogProviders, - enrichInternetPlanMetadata, - inferAddonTypeFromSku, - inferInstallationTermFromSku, -} from "@customer-portal/domain/catalog"; -import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; -import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js"; -import { Logger } from "nestjs-pino"; -import { getErrorMessage } from "@bff/core/utils/error.util.js"; -import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util.js"; -import { buildAccountEligibilityQuery } from "@bff/integrations/salesforce/utils/catalog-query-builder.js"; - -interface SalesforceAccount { - Id: string; - Internet_Eligibility__c?: string; -} - -@Injectable() -export class InternetCatalogService extends BaseCatalogService { - constructor( - sf: SalesforceConnection, - configService: ConfigService, - @Inject(Logger) logger: Logger, - private mappingsService: MappingsService, - private catalogCache: CatalogCacheService - ) { - super(sf, configService, logger); - } - - async getPlans(): Promise { - const cacheKey = this.catalogCache.buildCatalogKey("internet", "plans"); - - return this.catalogCache.getCachedCatalog( - cacheKey, - async () => { - const soql = this.buildCatalogServiceQuery("Internet", [ - "Internet_Plan_Tier__c", - "Internet_Offering_Type__c", - "Catalog_Order__c", - ]); - const records = await this.executeQuery( - soql, - "Internet Plans" - ); - - return records.map(record => { - const entry = this.extractPricebookEntry(record); - const plan = CatalogProviders.Salesforce.mapInternetPlan(record, entry); - return enrichInternetPlanMetadata(plan); - }); - }, - { - resolveDependencies: plans => ({ - productIds: plans.map(plan => plan.id).filter((id): id is string => Boolean(id)), - }), - } - ); - } - - async getInstallations(): Promise { - const cacheKey = this.catalogCache.buildCatalogKey("internet", "installations"); - - return this.catalogCache.getCachedCatalog( - cacheKey, - async () => { - const soql = this.buildProductQuery("Internet", "Installation", [ - "Billing_Cycle__c", - "Catalog_Order__c", - ]); - const records = await this.executeQuery( - soql, - "Internet Installations" - ); - - this.logger.log(`Found ${records.length} installation records`); - - return records - .map(record => { - const entry = this.extractPricebookEntry(record); - const installation = CatalogProviders.Salesforce.mapInternetInstallation(record, entry); - return { - ...installation, - catalogMetadata: { - ...installation.catalogMetadata, - installationTerm: inferInstallationTermFromSku(installation.sku ?? ""), - }, - }; - }) - .sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0)); - }, - { - resolveDependencies: installations => ({ - productIds: installations.map(item => item.id).filter((id): id is string => Boolean(id)), - }), - } - ); - } - - async getAddons(): Promise { - const cacheKey = this.catalogCache.buildCatalogKey("internet", "addons"); - - return this.catalogCache.getCachedCatalog( - cacheKey, - async () => { - const soql = this.buildProductQuery("Internet", "Add-on", [ - "Billing_Cycle__c", - "Catalog_Order__c", - "Bundled_Addon__c", - "Is_Bundled_Addon__c", - ]); - const records = await this.executeQuery( - soql, - "Internet Add-ons" - ); - - this.logger.log(`Found ${records.length} addon records`); - - return records - .map(record => { - const entry = this.extractPricebookEntry(record); - const addon = CatalogProviders.Salesforce.mapInternetAddon(record, entry); - return { - ...addon, - catalogMetadata: { - ...addon.catalogMetadata, - addonType: inferAddonTypeFromSku(addon.sku ?? ""), - }, - }; - }) - .sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0)); - }, - { - resolveDependencies: addons => ({ - productIds: addons.map(addon => addon.id).filter((id): id is string => Boolean(id)), - }), - } - ); - } - - async getCatalogData() { - const [plans, installations, addons] = await Promise.all([ - this.getPlans(), - this.getInstallations(), - this.getAddons(), - ]); - return { plans, installations, addons }; - } - - async getPlansForUser(userId: string): Promise { - try { - // Get all plans first - const allPlans = await this.getPlans(); - - // Get user's Salesforce account mapping - const mapping = await this.mappingsService.findByUserId(userId); - if (!mapping?.sfAccountId) { - this.logger.warn(`No Salesforce mapping found for user ${userId}, returning all plans`); - return allPlans; - } - - // Get customer's eligibility from Salesforce - const sfAccountId = assertSalesforceId(mapping.sfAccountId, "sfAccountId"); - const eligibilityKey = this.catalogCache.buildEligibilityKey("internet", sfAccountId); - const account = await this.catalogCache.getCachedEligibility( - eligibilityKey, - async () => { - const soql = buildAccountEligibilityQuery(sfAccountId); - const accounts = await this.executeQuery(soql, "Customer Eligibility"); - return accounts.length > 0 ? (accounts[0] as unknown as SalesforceAccount) : null; - } - ); - - if (!account) { - this.logger.warn(`No Salesforce account found for user ${userId}, returning all plans`); - return allPlans; - } - - const eligibility = account.Internet_Eligibility__c; - - if (!eligibility) { - this.logger.log(`No eligibility field for user ${userId}, filtering to Home 1G plans only`); - const homeGPlans = allPlans.filter(plan => plan.internetOfferingType === "Home 1G"); - return homeGPlans; - } - - // Filter plans based on eligibility - const eligiblePlans = allPlans.filter(plan => { - const isEligible = this.checkPlanEligibility(plan, eligibility); - if (!isEligible) { - this.logger.debug( - `Plan ${plan.name} (${plan.internetPlanTier ?? "Unknown"}) not eligible for user ${userId} with eligibility: ${eligibility}` - ); - } - return isEligible; - }); - - this.logger.log( - `Filtered ${allPlans.length} plans to ${eligiblePlans.length} eligible plans for user ${userId} with eligibility: ${eligibility}` - ); - return eligiblePlans; - } catch (error) { - this.logger.error(`Failed to get eligible plans for user ${userId}`, { - error: getErrorMessage(error), - }); - // Fallback to all plans if there's an error - return this.getPlans(); - } - } - - private checkPlanEligibility(plan: InternetPlanCatalogItem, eligibility: string): boolean { - // Simple match: user's eligibility field must equal plan's Salesforce offering type - // e.g., eligibility "Home 1G" matches plan.internetOfferingType "Home 1G" - return plan.internetOfferingType === eligibility; - } -} diff --git a/apps/bff/src/modules/catalog/services/vpn-catalog.service.ts b/apps/bff/src/modules/catalog/services/vpn-catalog.service.ts deleted file mode 100644 index 478d2038..00000000 --- a/apps/bff/src/modules/catalog/services/vpn-catalog.service.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { Injectable, Inject } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { Logger } from "nestjs-pino"; -import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js"; -import { BaseCatalogService } from "./base-catalog.service.js"; -import type { - SalesforceProduct2WithPricebookEntries, - VpnCatalogProduct, -} from "@customer-portal/domain/catalog"; -import { Providers as CatalogProviders } from "@customer-portal/domain/catalog"; - -@Injectable() -export class VpnCatalogService extends BaseCatalogService { - constructor( - sf: SalesforceConnection, - configService: ConfigService, - @Inject(Logger) logger: Logger - ) { - super(sf, configService, logger); - } - async getPlans(): Promise { - const soql = this.buildCatalogServiceQuery("VPN", ["VPN_Region__c", "Catalog_Order__c"]); - const records = await this.executeQuery( - soql, - "VPN Plans" - ); - - return records.map(record => { - const entry = this.extractPricebookEntry(record); - const product = CatalogProviders.Salesforce.mapVpnProduct(record, entry); - return { - ...product, - description: product.description || product.name, - } satisfies VpnCatalogProduct; - }); - } - - async getActivationFees(): Promise { - const soql = this.buildProductQuery("VPN", "Activation", ["VPN_Region__c"]); - const records = await this.executeQuery( - soql, - "VPN Activation Fees" - ); - - return records.map(record => { - const pricebookEntry = this.extractPricebookEntry(record); - const product = CatalogProviders.Salesforce.mapVpnProduct(record, pricebookEntry); - - return { - ...product, - description: product.description ?? product.name, - } satisfies VpnCatalogProduct; - }); - } - - async getCatalogData() { - const [plans, activationFees] = await Promise.all([this.getPlans(), this.getActivationFees()]); - return { plans, activationFees }; - } -} diff --git a/apps/bff/src/modules/me-status/me-status.controller.ts b/apps/bff/src/modules/me-status/me-status.controller.ts new file mode 100644 index 00000000..99413bb2 --- /dev/null +++ b/apps/bff/src/modules/me-status/me-status.controller.ts @@ -0,0 +1,16 @@ +import { Controller, Get, Req, UseGuards } from "@nestjs/common"; +import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; +import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js"; +import { MeStatusService } from "./me-status.service.js"; +import type { MeStatus } from "@customer-portal/domain/dashboard"; + +@Controller("me") +export class MeStatusController { + constructor(private readonly meStatus: MeStatusService) {} + + @UseGuards(SalesforceReadThrottleGuard) + @Get("status") + async getStatus(@Req() req: RequestWithUser): Promise { + return this.meStatus.getStatusForUser(req.user.id); + } +} diff --git a/apps/bff/src/modules/me-status/me-status.module.ts b/apps/bff/src/modules/me-status/me-status.module.ts new file mode 100644 index 00000000..e06f3711 --- /dev/null +++ b/apps/bff/src/modules/me-status/me-status.module.ts @@ -0,0 +1,27 @@ +import { Module } from "@nestjs/common"; +import { MeStatusController } from "./me-status.controller.js"; +import { MeStatusService } from "./me-status.service.js"; +import { UsersModule } from "@bff/modules/users/users.module.js"; +import { OrdersModule } from "@bff/modules/orders/orders.module.js"; +import { ServicesModule } from "@bff/modules/services/services.module.js"; +import { VerificationModule } from "@bff/modules/verification/verification.module.js"; +import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module.js"; +import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js"; +import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js"; +import { SalesforceModule } from "@bff/integrations/salesforce/salesforce.module.js"; + +@Module({ + imports: [ + UsersModule, + OrdersModule, + ServicesModule, + VerificationModule, + WhmcsModule, + MappingsModule, + NotificationsModule, + SalesforceModule, + ], + controllers: [MeStatusController], + providers: [MeStatusService], +}) +export class MeStatusModule {} diff --git a/apps/bff/src/modules/me-status/me-status.service.ts b/apps/bff/src/modules/me-status/me-status.service.ts new file mode 100644 index 00000000..876ce0d3 --- /dev/null +++ b/apps/bff/src/modules/me-status/me-status.service.ts @@ -0,0 +1,269 @@ +import { Injectable, Inject } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { UsersFacade } from "@bff/modules/users/application/users.facade.js"; +import { OrderOrchestrator } from "@bff/modules/orders/services/order-orchestrator.service.js"; +import { InternetServicesService } from "@bff/modules/services/services/internet-services.service.js"; +import { ResidenceCardService } from "@bff/modules/verification/residence-card.service.js"; +import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; +import { WhmcsPaymentService } from "@bff/integrations/whmcs/services/whmcs-payment.service.js"; +import { NotificationService } from "@bff/modules/notifications/notifications.service.js"; +import { + meStatusSchema, + type DashboardSummary, + type DashboardTask, + type MeStatus, + type PaymentMethodsStatus, +} from "@customer-portal/domain/dashboard"; +import { NOTIFICATION_SOURCE, NOTIFICATION_TYPE } from "@customer-portal/domain/notifications"; +import type { InternetEligibilityDetails } from "@customer-portal/domain/services"; +import type { ResidenceCardVerification } from "@customer-portal/domain/customer"; +import type { OrderSummary } from "@customer-portal/domain/orders"; + +@Injectable() +export class MeStatusService { + constructor( + private readonly users: UsersFacade, + private readonly orders: OrderOrchestrator, + private readonly internetCatalog: InternetServicesService, + private readonly residenceCards: ResidenceCardService, + private readonly mappings: MappingsService, + private readonly whmcsPayments: WhmcsPaymentService, + private readonly notifications: NotificationService, + @Inject(Logger) private readonly logger: Logger + ) {} + + async getStatusForUser(userId: string): Promise { + try { + const [summary, internetEligibility, residenceCardVerification, orders] = await Promise.all([ + this.users.getUserSummary(userId), + this.internetCatalog.getEligibilityDetailsForUser(userId), + this.residenceCards.getStatusForUser(userId), + this.safeGetOrders(userId), + ]); + + const paymentMethods = await this.safeGetPaymentMethodsStatus(userId); + + const tasks = this.computeTasks({ + summary, + paymentMethods, + internetEligibility, + residenceCardVerification, + orders, + }); + + await this.maybeCreateInvoiceDueNotification(userId, summary); + + return meStatusSchema.parse({ + summary, + paymentMethods, + internetEligibility, + residenceCardVerification, + tasks, + }); + } catch (error) { + this.logger.error({ userId, err: error }, "Failed to get status for user"); + throw error; + } + } + + private async safeGetOrders(userId: string): Promise { + try { + const result = await this.orders.getOrdersForUser(userId); + return Array.isArray(result) ? result : []; + } catch (error) { + this.logger.warn( + { userId, err: error instanceof Error ? error.message : String(error) }, + "Failed to load orders for status payload" + ); + return null; + } + } + + private async safeGetPaymentMethodsStatus(userId: string): Promise { + try { + const mapping = await this.mappings.findByUserId(userId); + if (!mapping?.whmcsClientId) { + return { totalCount: null }; + } + + const list = await this.whmcsPayments.getPaymentMethods(mapping.whmcsClientId, userId); + return { totalCount: typeof list?.totalCount === "number" ? list.totalCount : 0 }; + } catch (error) { + this.logger.warn( + { userId, err: error instanceof Error ? error.message : String(error) }, + "Failed to load payment methods for status payload" + ); + return { totalCount: null }; + } + } + + private computeTasks(params: { + summary: DashboardSummary; + paymentMethods: PaymentMethodsStatus; + internetEligibility: InternetEligibilityDetails; + residenceCardVerification: ResidenceCardVerification; + orders: OrderSummary[] | null; + }): DashboardTask[] { + const tasks: DashboardTask[] = []; + + const { summary, paymentMethods, internetEligibility, residenceCardVerification, orders } = + params; + + // Priority 1: next unpaid invoice + if (summary.nextInvoice) { + const dueDate = new Date(summary.nextInvoice.dueDate); + const isValid = !Number.isNaN(dueDate.getTime()); + const isOverdue = isValid ? dueDate.getTime() < Date.now() : false; + + const formattedAmount = new Intl.NumberFormat("ja-JP", { + style: "currency", + currency: summary.nextInvoice.currency, + maximumFractionDigits: 0, + }).format(summary.nextInvoice.amount); + + const dueText = isValid + ? dueDate.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) + : "soon"; + + tasks.push({ + id: `invoice-${summary.nextInvoice.id}`, + priority: 1, + type: "invoice", + title: isOverdue ? "Pay overdue invoice" : "Pay upcoming invoice", + description: `Invoice #${summary.nextInvoice.id} · ${formattedAmount} · Due ${dueText}`, + actionLabel: "Pay now", + detailHref: `/account/billing/invoices/${summary.nextInvoice.id}`, + requiresSsoAction: true, + tone: "critical", + metadata: { + invoiceId: summary.nextInvoice.id, + amount: summary.nextInvoice.amount, + currency: summary.nextInvoice.currency, + ...(isValid ? { dueDate: dueDate.toISOString() } : {}), + }, + }); + } + + // Priority 2: no payment method (only when we could verify) + if (paymentMethods.totalCount === 0) { + tasks.push({ + id: "add-payment-method", + priority: 2, + type: "payment_method", + title: "Add a payment method", + description: "Required to place orders and process invoices", + actionLabel: "Add method", + detailHref: "/account/billing/payments", + requiresSsoAction: true, + tone: "warning", + }); + } + + // Priority 3: pending orders + if (orders && orders.length > 0) { + const pendingOrders = orders.filter( + o => + o.status === "Draft" || + o.status === "Pending" || + (o.status === "Activated" && o.activationStatus !== "Completed") + ); + + if (pendingOrders.length > 0) { + const order = pendingOrders[0]; + const statusText = + order.status === "Pending" + ? "awaiting review" + : order.status === "Draft" + ? "in draft" + : "being activated"; + + tasks.push({ + id: `order-${order.id}`, + priority: 3, + type: "order", + title: "Order in progress", + description: `${order.orderType || "Your"} order is ${statusText}`, + actionLabel: "View details", + detailHref: `/account/orders/${order.id}`, + tone: "info", + metadata: { orderId: order.id }, + }); + } + } + + // Priority 4: Internet eligibility review (only when explicitly pending) + if (internetEligibility.status === "pending") { + tasks.push({ + id: "internet-eligibility-review", + priority: 4, + type: "internet_eligibility", + title: "Internet availability review", + description: + "We’re verifying if our service is available at your residence. We’ll notify you when review is complete.", + actionLabel: "View status", + detailHref: "/account/services/internet", + tone: "info", + }); + } + + // Priority 4: ID verification rejected + if (residenceCardVerification.status === "rejected") { + tasks.push({ + id: "id-verification-rejected", + priority: 4, + type: "id_verification", + title: "ID verification requires attention", + description: "We couldn’t verify your ID. Please review the feedback and resubmit.", + actionLabel: "Resubmit", + detailHref: "/account/settings/verification", + tone: "warning", + }); + } + + // Priority 4: onboarding (only when no other tasks) + if (summary.stats.activeSubscriptions === 0 && tasks.length === 0) { + tasks.push({ + id: "start-subscription", + priority: 4, + type: "onboarding", + title: "Start your first service", + description: "Browse our catalog and subscribe to internet, SIM, or VPN", + actionLabel: "Browse services", + detailHref: "/services", + tone: "neutral", + }); + } + + return tasks.sort((a, b) => a.priority - b.priority); + } + + private async maybeCreateInvoiceDueNotification( + userId: string, + summary: DashboardSummary + ): Promise { + const invoice = summary.nextInvoice; + if (!invoice) return; + + try { + const dueDate = new Date(invoice.dueDate); + if (Number.isNaN(dueDate.getTime())) return; + + const daysUntilDue = (dueDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24); + // Notify when due within a week (or overdue). + if (daysUntilDue > 7) return; + + await this.notifications.createNotification({ + userId, + type: NOTIFICATION_TYPE.INVOICE_DUE, + source: NOTIFICATION_SOURCE.SYSTEM, + sourceId: `invoice:${invoice.id}`, + actionUrl: `/account/billing/invoices/${invoice.id}`, + }); + } catch (error) { + this.logger.warn( + { userId, err: error instanceof Error ? error.message : String(error) }, + "Failed to create invoice due notification" + ); + } + } +} diff --git a/apps/bff/src/modules/notifications/account-cdc-listener.service.ts b/apps/bff/src/modules/notifications/account-cdc-listener.service.ts new file mode 100644 index 00000000..2c256534 --- /dev/null +++ b/apps/bff/src/modules/notifications/account-cdc-listener.service.ts @@ -0,0 +1,167 @@ +/** + * Account Notification Handler + * + * Processes Salesforce Account events and creates in-app notifications + * when eligibility or verification status changes. + * + * This is called by the ServicesCdcSubscriber when account + * events are received. Works alongside Salesforce's email notifications, + * providing both push (email) and pull (in-app) notification channels. + */ + +import { Injectable, Inject } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; +import { NotificationService } from "./notifications.service.js"; +import { getErrorMessage } from "@bff/core/utils/error.util.js"; +import { NOTIFICATION_TYPE, NOTIFICATION_SOURCE } from "@customer-portal/domain/notifications"; + +export interface AccountEventPayload { + accountId: string; + eligibilityStatus?: string | null; + eligibilityValue?: string | null; + verificationStatus?: string | null; + verificationRejectionMessage?: string | null; +} + +@Injectable() +export class AccountNotificationHandler { + constructor( + private readonly mappingsService: MappingsService, + private readonly notificationService: NotificationService, + @Inject(Logger) private readonly logger: Logger + ) {} + + /** + * Process an account event and create notifications if needed + */ + async processAccountEvent(payload: AccountEventPayload): Promise { + try { + const { + accountId, + eligibilityStatus, + eligibilityValue, + verificationStatus, + verificationRejectionMessage, + } = payload; + + // Find the portal user for this account + const mapping = await this.mappingsService.findBySfAccountId(accountId); + if (!mapping?.userId) { + this.logger.debug("No portal user for account, skipping notification", { + accountIdTail: accountId.slice(-4), + }); + return; + } + + // Process eligibility status change + if (eligibilityStatus) { + await this.processEligibilityChange( + mapping.userId, + accountId, + eligibilityStatus, + eligibilityValue ?? undefined + ); + } + + // Process verification status change + if (verificationStatus) { + await this.processVerificationChange( + mapping.userId, + accountId, + verificationStatus, + verificationRejectionMessage ?? undefined + ); + } + } catch (error) { + this.logger.error("Error processing account event for notifications", { + error: getErrorMessage(error), + accountIdTail: payload.accountId.slice(-4), + }); + } + } + + /** + * Process eligibility status change + */ + private async processEligibilityChange( + userId: string, + accountId: string, + status: string, + eligibilityValue?: string + ): Promise { + const normalizedStatus = status.trim().toLowerCase(); + + // Only notify on final states, not "pending" + if (normalizedStatus === "pending" || normalizedStatus === "checking") { + return; + } + + const isEligible = normalizedStatus === "eligible" || Boolean(eligibilityValue); + const notificationType = isEligible + ? NOTIFICATION_TYPE.ELIGIBILITY_ELIGIBLE + : NOTIFICATION_TYPE.ELIGIBILITY_INELIGIBLE; + + // Create customized message if we have the eligibility value + let message: string | undefined; + if (isEligible && eligibilityValue) { + message = `We've confirmed ${eligibilityValue} service is available at your address. You can now select a plan and complete your order.`; + } + + await this.notificationService.createNotification({ + userId, + type: notificationType, + message, + source: NOTIFICATION_SOURCE.SALESFORCE, + sourceId: accountId, + }); + + this.logger.log("Eligibility notification created", { + userId, + type: notificationType, + accountIdTail: accountId.slice(-4), + }); + } + + /** + * Process ID verification status change + */ + private async processVerificationChange( + userId: string, + accountId: string, + status: string, + rejectionMessage?: string + ): Promise { + const normalizedStatus = status.trim().toLowerCase(); + + // Only notify on final states + if (normalizedStatus !== "verified" && normalizedStatus !== "rejected") { + return; + } + + const isVerified = normalizedStatus === "verified"; + const notificationType = isVerified + ? NOTIFICATION_TYPE.VERIFICATION_VERIFIED + : NOTIFICATION_TYPE.VERIFICATION_REJECTED; + + // Include rejection reason in message + let message: string | undefined; + if (!isVerified && rejectionMessage) { + message = `We couldn't verify your ID: ${rejectionMessage}. Please resubmit a clearer image.`; + } + + await this.notificationService.createNotification({ + userId, + type: notificationType, + message, + source: NOTIFICATION_SOURCE.SALESFORCE, + sourceId: accountId, + }); + + this.logger.log("Verification notification created", { + userId, + type: notificationType, + accountIdTail: accountId.slice(-4), + }); + } +} diff --git a/apps/bff/src/modules/notifications/notification-cleanup.service.ts b/apps/bff/src/modules/notifications/notification-cleanup.service.ts new file mode 100644 index 00000000..378813cb --- /dev/null +++ b/apps/bff/src/modules/notifications/notification-cleanup.service.ts @@ -0,0 +1,39 @@ +/** + * Notification Cleanup Service + * + * Scheduled job to remove expired notifications from the database. + * Runs daily to clean up notifications older than 30 days. + */ + +import { Injectable, Inject } from "@nestjs/common"; +import { Cron, CronExpression } from "@nestjs/schedule"; +import { Logger } from "nestjs-pino"; +import { NotificationService } from "./notifications.service.js"; + +@Injectable() +export class NotificationCleanupService { + constructor( + private readonly notificationService: NotificationService, + @Inject(Logger) private readonly logger: Logger + ) {} + + /** + * Clean up expired notifications daily at 3 AM + */ + @Cron(CronExpression.EVERY_DAY_AT_3AM) + async handleCleanup(): Promise { + this.logger.debug("Starting notification cleanup job"); + + try { + const count = await this.notificationService.cleanupExpired(); + + if (count > 0) { + this.logger.log("Notification cleanup completed", { deletedCount: count }); + } + } catch (error) { + this.logger.error("Notification cleanup job failed", { + error: error instanceof Error ? error.message : String(error), + }); + } + } +} diff --git a/apps/bff/src/modules/notifications/notifications.controller.ts b/apps/bff/src/modules/notifications/notifications.controller.ts new file mode 100644 index 00000000..247f04ff --- /dev/null +++ b/apps/bff/src/modules/notifications/notifications.controller.ts @@ -0,0 +1,93 @@ +/** + * Notifications Controller + * + * API endpoints for managing in-app notifications. + */ + +import { + Controller, + Get, + Post, + Param, + Query, + Req, + UseGuards, + ParseIntPipe, + DefaultValuePipe, + ParseBoolPipe, +} from "@nestjs/common"; +import { RateLimit, RateLimitGuard } from "@bff/core/rate-limiting/index.js"; +import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; +import { NotificationService } from "./notifications.service.js"; +import type { NotificationListResponse } from "@customer-portal/domain/notifications"; + +@Controller("notifications") +@UseGuards(RateLimitGuard) +export class NotificationsController { + constructor(private readonly notificationService: NotificationService) {} + + /** + * Get notifications for the current user + */ + @Get() + @RateLimit({ limit: 60, ttl: 60 }) + async getNotifications( + @Req() req: RequestWithUser, + @Query("limit", new DefaultValuePipe(20), ParseIntPipe) limit: number, + @Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number, + @Query("includeRead", new DefaultValuePipe(true), ParseBoolPipe) + includeRead: boolean + ): Promise { + return this.notificationService.getNotifications(req.user.id, { + limit: Math.min(limit, 50), // Cap at 50 + offset, + includeRead, + }); + } + + /** + * Get unread notification count for the current user + */ + @Get("unread-count") + @RateLimit({ limit: 120, ttl: 60 }) + async getUnreadCount(@Req() req: RequestWithUser): Promise<{ count: number }> { + const count = await this.notificationService.getUnreadCount(req.user.id); + return { count }; + } + + /** + * Mark a specific notification as read + */ + @Post(":id/read") + @RateLimit({ limit: 60, ttl: 60 }) + async markAsRead( + @Req() req: RequestWithUser, + @Param("id") notificationId: string + ): Promise<{ success: boolean }> { + await this.notificationService.markAsRead(notificationId, req.user.id); + return { success: true }; + } + + /** + * Mark all notifications as read + */ + @Post("read-all") + @RateLimit({ limit: 10, ttl: 60 }) + async markAllAsRead(@Req() req: RequestWithUser): Promise<{ success: boolean }> { + await this.notificationService.markAllAsRead(req.user.id); + return { success: true }; + } + + /** + * Dismiss a notification (hide from UI) + */ + @Post(":id/dismiss") + @RateLimit({ limit: 60, ttl: 60 }) + async dismiss( + @Req() req: RequestWithUser, + @Param("id") notificationId: string + ): Promise<{ success: boolean }> { + await this.notificationService.dismiss(notificationId, req.user.id); + return { success: true }; + } +} diff --git a/apps/bff/src/modules/notifications/notifications.module.ts b/apps/bff/src/modules/notifications/notifications.module.ts new file mode 100644 index 00000000..649ee7e0 --- /dev/null +++ b/apps/bff/src/modules/notifications/notifications.module.ts @@ -0,0 +1,24 @@ +/** + * Notifications Module + * + * Provides in-app notification functionality: + * - NotificationService: CRUD operations for notifications + * - NotificationsController: API endpoints + * - AccountNotificationHandler: Creates notifications from SF events + * - NotificationCleanupService: Removes expired notifications + */ + +import { Module, forwardRef } from "@nestjs/common"; +import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js"; +import { NotificationService } from "./notifications.service.js"; +import { NotificationsController } from "./notifications.controller.js"; +import { AccountNotificationHandler } from "./account-cdc-listener.service.js"; +import { NotificationCleanupService } from "./notification-cleanup.service.js"; + +@Module({ + imports: [forwardRef(() => MappingsModule)], + controllers: [NotificationsController], + providers: [NotificationService, AccountNotificationHandler, NotificationCleanupService], + exports: [NotificationService, AccountNotificationHandler], +}) +export class NotificationsModule {} diff --git a/apps/bff/src/modules/notifications/notifications.service.ts b/apps/bff/src/modules/notifications/notifications.service.ts new file mode 100644 index 00000000..0b08a0d2 --- /dev/null +++ b/apps/bff/src/modules/notifications/notifications.service.ts @@ -0,0 +1,336 @@ +/** + * Notification Service + * + * Manages in-app notifications stored in the portal database. + * Notifications are created in response to Salesforce CDC events + * and displayed alongside email notifications. + */ + +import { Injectable, Inject } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { PrismaService } from "@bff/infra/database/prisma.service.js"; +import { getErrorMessage } from "@bff/core/utils/error.util.js"; +import { + NOTIFICATION_SOURCE, + NOTIFICATION_TEMPLATES, + type NotificationTypeValue, + type NotificationSourceValue, + type Notification, + type NotificationListResponse, +} from "@customer-portal/domain/notifications"; + +// Notification expiry in days +const NOTIFICATION_EXPIRY_DAYS = 30; + +// Dedupe window (in hours) per notification type. +// Defaults to 1 hour when not specified. +const NOTIFICATION_DEDUPE_WINDOW_HOURS: Partial> = { + // These are often evaluated opportunistically (e.g., on dashboard load), + // so keep the dedupe window larger to avoid spam. + INVOICE_DUE: 24, + PAYMENT_METHOD_EXPIRING: 24, + SYSTEM_ANNOUNCEMENT: 24, +}; + +export interface CreateNotificationParams { + userId: string; + type: NotificationTypeValue; + title?: string; + message?: string; + actionUrl?: string; + actionLabel?: string; + source?: NotificationSourceValue; + sourceId?: string; +} + +@Injectable() +export class NotificationService { + constructor( + private readonly prisma: PrismaService, + @Inject(Logger) private readonly logger: Logger + ) {} + + /** + * Create a notification for a user + */ + async createNotification(params: CreateNotificationParams): Promise { + const template = NOTIFICATION_TEMPLATES[params.type]; + if (!template) { + throw new Error(`Unknown notification type: ${params.type}`); + } + + // Calculate expiry date (30 days from now) + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + NOTIFICATION_EXPIRY_DAYS); + + try { + // Check for duplicate notification (same type + sourceId within a short window) + if (params.sourceId) { + const dedupeHours = NOTIFICATION_DEDUPE_WINDOW_HOURS[params.type] ?? 1; + const since = new Date(Date.now() - dedupeHours * 60 * 60 * 1000); + + const existingNotification = await this.prisma.notification.findFirst({ + where: { + userId: params.userId, + type: params.type, + sourceId: params.sourceId, + createdAt: { gte: since }, + }, + }); + + if (existingNotification) { + this.logger.debug("Duplicate notification detected, skipping", { + userId: params.userId, + type: params.type, + sourceId: params.sourceId, + }); + return this.mapToNotification(existingNotification); + } + } + + const notification = await this.prisma.notification.create({ + data: { + userId: params.userId, + type: params.type, + title: params.title ?? template.title, + message: params.message ?? template.message, + actionUrl: params.actionUrl ?? template.actionUrl ?? null, + actionLabel: params.actionLabel ?? template.actionLabel ?? null, + source: params.source ?? NOTIFICATION_SOURCE.SALESFORCE, + sourceId: params.sourceId ?? null, + expiresAt, + }, + }); + + this.logger.log("Notification created", { + notificationId: notification.id, + userId: params.userId, + type: params.type, + }); + + return this.mapToNotification(notification); + } catch (error) { + this.logger.error("Failed to create notification", { + error: getErrorMessage(error), + userId: params.userId, + type: params.type, + }); + throw new Error("Failed to create notification"); + } + } + + /** + * Get notifications for a user + */ + async getNotifications( + userId: string, + options?: { + limit?: number; + offset?: number; + includeRead?: boolean; + includeDismissed?: boolean; + } + ): Promise { + const limit = options?.limit ?? 20; + const offset = options?.offset ?? 0; + const now = new Date(); + + const where = { + userId, + expiresAt: { gt: now }, + ...(options?.includeDismissed ? {} : { dismissed: false }), + // By default we include read notifications. If includeRead=false, filter them out. + ...(options?.includeRead === false ? { read: false } : {}), + }; + + try { + const [notifications, total, unreadCount] = await Promise.all([ + this.prisma.notification.findMany({ + where, + orderBy: { createdAt: "desc" }, + take: limit, + skip: offset, + }), + this.prisma.notification.count({ where }), + this.prisma.notification.count({ + where: { + userId, + read: false, + dismissed: false, + expiresAt: { gt: now }, + }, + }), + ]); + + return { + notifications: notifications.map(n => this.mapToNotification(n)), + unreadCount, + total, + }; + } catch (error) { + this.logger.error("Failed to get notifications", { + error: getErrorMessage(error), + userId, + }); + throw new Error("Failed to get notifications"); + } + } + + /** + * Get unread notification count for a user + */ + async getUnreadCount(userId: string): Promise { + const now = new Date(); + + try { + return await this.prisma.notification.count({ + where: { + userId, + read: false, + dismissed: false, + expiresAt: { gt: now }, + }, + }); + } catch (error) { + this.logger.error("Failed to get unread count", { + error: getErrorMessage(error), + userId, + }); + return 0; + } + } + + /** + * Mark a notification as read + */ + async markAsRead(notificationId: string, userId: string): Promise { + try { + await this.prisma.notification.updateMany({ + where: { id: notificationId, userId }, + data: { read: true, readAt: new Date() }, + }); + + this.logger.debug("Notification marked as read", { + notificationId, + userId, + }); + } catch (error) { + this.logger.error("Failed to mark notification as read", { + error: getErrorMessage(error), + notificationId, + userId, + }); + throw new Error("Failed to update notification"); + } + } + + /** + * Mark all notifications as read for a user + */ + async markAllAsRead(userId: string): Promise { + try { + const result = await this.prisma.notification.updateMany({ + where: { userId, read: false }, + data: { read: true, readAt: new Date() }, + }); + + this.logger.debug("All notifications marked as read", { + userId, + count: result.count, + }); + } catch (error) { + this.logger.error("Failed to mark all notifications as read", { + error: getErrorMessage(error), + userId, + }); + throw new Error("Failed to update notifications"); + } + } + + /** + * Dismiss a notification (hide from UI) + */ + async dismiss(notificationId: string, userId: string): Promise { + try { + await this.prisma.notification.updateMany({ + where: { id: notificationId, userId }, + data: { dismissed: true, read: true, readAt: new Date() }, + }); + + this.logger.debug("Notification dismissed", { + notificationId, + userId, + }); + } catch (error) { + this.logger.error("Failed to dismiss notification", { + error: getErrorMessage(error), + notificationId, + userId, + }); + throw new Error("Failed to dismiss notification"); + } + } + + /** + * Clean up expired notifications (called by scheduled job) + */ + async cleanupExpired(): Promise { + try { + const result = await this.prisma.notification.deleteMany({ + where: { + expiresAt: { lt: new Date() }, + }, + }); + + if (result.count > 0) { + this.logger.log("Cleaned up expired notifications", { + count: result.count, + }); + } + + return result.count; + } catch (error) { + this.logger.error("Failed to cleanup expired notifications", { + error: getErrorMessage(error), + }); + return 0; + } + } + + /** + * Map Prisma model to domain type + */ + private mapToNotification(record: { + id: string; + userId: string; + type: string; + title: string; + message: string | null; + actionUrl: string | null; + actionLabel: string | null; + source: string; + sourceId: string | null; + read: boolean; + readAt: Date | null; + dismissed: boolean; + createdAt: Date; + expiresAt: Date; + }): Notification { + return { + id: record.id, + userId: record.userId, + type: record.type as NotificationTypeValue, + title: record.title, + message: record.message, + actionUrl: record.actionUrl, + actionLabel: record.actionLabel, + source: record.source as NotificationSourceValue, + sourceId: record.sourceId, + read: record.read, + readAt: record.readAt?.toISOString() ?? null, + dismissed: record.dismissed, + createdAt: record.createdAt.toISOString(), + expiresAt: record.expiresAt.toISOString(), + }; + } +} diff --git a/apps/bff/src/modules/orders/config/order-field-map.service.ts b/apps/bff/src/modules/orders/config/order-field-map.service.ts index c2ab098f..53d9bf29 100644 --- a/apps/bff/src/modules/orders/config/order-field-map.service.ts +++ b/apps/bff/src/modules/orders/config/order-field-map.service.ts @@ -42,6 +42,7 @@ export class OrderFieldMapService { "CreatedDate", "LastModifiedDate", "Pricebook2Id", + "OpportunityId", // Linked Opportunity for lifecycle tracking order.activationType, order.activationStatus, order.activationScheduledAt, diff --git a/apps/bff/src/modules/orders/controllers/checkout.controller.ts b/apps/bff/src/modules/orders/controllers/checkout.controller.ts index 4d083665..773a8761 100644 --- a/apps/bff/src/modules/orders/controllers/checkout.controller.ts +++ b/apps/bff/src/modules/orders/controllers/checkout.controller.ts @@ -1,11 +1,25 @@ -import { Body, Controller, Post, Request, UsePipes, Inject, UseGuards } from "@nestjs/common"; +import { + Body, + Controller, + Get, + Param, + Post, + Request, + UseGuards, + UsePipes, + Inject, +} from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { ZodValidationPipe } from "nestjs-zod"; +import { Public } from "@bff/modules/auth/decorators/public.decorator.js"; import { CheckoutService } from "../services/checkout.service.js"; +import { CheckoutSessionService } from "../services/checkout-session.service.js"; import { + checkoutItemSchema, checkoutCartSchema, checkoutBuildCartRequestSchema, checkoutBuildCartResponseSchema, + checkoutTotalsSchema, } from "@customer-portal/domain/orders"; import type { CheckoutCart, CheckoutBuildCartRequest } from "@customer-portal/domain/orders"; import { apiSuccessResponseSchema } from "@customer-portal/domain/common"; @@ -14,11 +28,28 @@ import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js"; const validateCartResponseSchema = apiSuccessResponseSchema(z.object({ valid: z.boolean() })); +const checkoutSessionIdParamSchema = z.object({ sessionId: z.string().uuid() }); + +const checkoutCartSummarySchema = z.object({ + items: z.array(checkoutItemSchema), + totals: checkoutTotalsSchema, +}); + +const checkoutSessionResponseSchema = apiSuccessResponseSchema( + z.object({ + sessionId: z.string().uuid(), + expiresAt: z.string(), + orderType: z.enum(["Internet", "SIM", "VPN", "Other"]), + cart: checkoutCartSummarySchema, + }) +); @Controller("checkout") +@Public() // Cart building and validation can be done without authentication export class CheckoutController { constructor( private readonly checkoutService: CheckoutService, + private readonly checkoutSessions: CheckoutSessionService, @Inject(Logger) private readonly logger: Logger ) {} @@ -53,6 +84,61 @@ export class CheckoutController { } } + /** + * Create a short-lived checkout session to avoid trusting client-side state. + * This returns a cart summary (items + totals) and stores the full request+cart server-side. + */ + @Post("session") + @UseGuards(SalesforceReadThrottleGuard) + @UsePipes(new ZodValidationPipe(checkoutBuildCartRequestSchema)) + async createSession(@Request() req: RequestWithUser, @Body() body: CheckoutBuildCartRequest) { + this.logger.log("Creating checkout session", { + userId: req.user?.id, + orderType: body.orderType, + }); + + const cart = await this.checkoutService.buildCart( + body.orderType, + body.selections, + body.configuration, + req.user?.id + ); + + const session = await this.checkoutSessions.createSession(body, cart); + + return checkoutSessionResponseSchema.parse({ + success: true, + data: { + sessionId: session.sessionId, + expiresAt: session.expiresAt, + orderType: body.orderType, + cart: { + items: cart.items, + totals: cart.totals, + }, + }, + }); + } + + @Get("session/:sessionId") + @UseGuards(SalesforceReadThrottleGuard) + @UsePipes(new ZodValidationPipe(checkoutSessionIdParamSchema)) + async getSession(@Param() params: { sessionId: string }) { + const session = await this.checkoutSessions.getSession(params.sessionId); + return checkoutSessionResponseSchema.parse({ + success: true, + data: { + sessionId: params.sessionId, + expiresAt: session.expiresAt, + orderType: session.request.orderType, + cart: { + items: session.cart.items, + totals: session.cart.totals, + }, + }, + }); + } + @Post("validate") @UsePipes(new ZodValidationPipe(checkoutCartSchema)) validateCart(@Body() cart: CheckoutCart) { diff --git a/apps/bff/src/modules/orders/orders.controller.ts b/apps/bff/src/modules/orders/orders.controller.ts index 8350e3de..54883b84 100644 --- a/apps/bff/src/modules/orders/orders.controller.ts +++ b/apps/bff/src/modules/orders/orders.controller.ts @@ -29,12 +29,21 @@ import { Observable } from "rxjs"; import { OrderEventsService } from "./services/order-events.service.js"; import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js"; import { SalesforceWriteThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-write-throttle.guard.js"; +import { CheckoutService } from "./services/checkout.service.js"; +import { CheckoutSessionService } from "./services/checkout-session.service.js"; +import { z } from "zod"; + +const checkoutSessionCreateOrderSchema = z.object({ + checkoutSessionId: z.string().uuid(), +}); @Controller("orders") @UseGuards(RateLimitGuard) export class OrdersController { constructor( private orderOrchestrator: OrderOrchestrator, + private readonly checkoutService: CheckoutService, + private readonly checkoutSessions: CheckoutSessionService, private readonly orderEvents: OrderEventsService, private readonly logger: Logger ) {} @@ -71,6 +80,58 @@ export class OrdersController { } } + @Post("from-checkout-session") + @UseGuards(SalesforceWriteThrottleGuard) + @RateLimit({ limit: 5, ttl: 60 }) // 5 order creations per minute + @UsePipes(new ZodValidationPipe(checkoutSessionCreateOrderSchema)) + async createFromCheckoutSession( + @Request() req: RequestWithUser, + @Body() body: { checkoutSessionId: string } + ) { + this.logger.log( + { + userId: req.user?.id, + checkoutSessionId: body.checkoutSessionId, + }, + "Order creation from checkout session request received" + ); + + const session = await this.checkoutSessions.getSession(body.checkoutSessionId); + + const cart = await this.checkoutService.buildCart( + session.request.orderType, + session.request.selections, + session.request.configuration, + req.user?.id + ); + + const uniqueSkus = Array.from( + new Set( + cart.items + .map(item => item.sku) + .filter((sku): sku is string => typeof sku === "string" && sku.trim().length > 0) + ) + ); + + if (uniqueSkus.length === 0) { + throw new NotFoundException("Checkout session contains no items"); + } + + const orderBody: CreateOrderRequest = { + orderType: session.request.orderType, + skus: uniqueSkus, + ...(Object.keys(cart.configuration ?? {}).length > 0 + ? { configurations: cart.configuration } + : {}), + }; + + const result = await this.orderOrchestrator.createOrder(req.user.id, orderBody); + + await this.checkoutSessions.deleteSession(body.checkoutSessionId); + + return this.createOrderResponseSchema.parse({ success: true, data: result }); + } + @Get("user") @UseGuards(SalesforceReadThrottleGuard) async getUserOrders(@Request() req: RequestWithUser) { diff --git a/apps/bff/src/modules/orders/orders.module.ts b/apps/bff/src/modules/orders/orders.module.ts index e865c8a1..74a4d28a 100644 --- a/apps/bff/src/modules/orders/orders.module.ts +++ b/apps/bff/src/modules/orders/orders.module.ts @@ -6,8 +6,10 @@ import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js"; import { UsersModule } from "@bff/modules/users/users.module.js"; import { CoreConfigModule } from "@bff/core/config/config.module.js"; import { DatabaseModule } from "@bff/core/database/database.module.js"; -import { CatalogModule } from "@bff/modules/catalog/catalog.module.js"; +import { ServicesModule } from "@bff/modules/services/services.module.js"; import { CacheModule } from "@bff/infra/cache/cache.module.js"; +import { VerificationModule } from "@bff/modules/verification/verification.module.js"; +import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js"; // Clean modular order services import { OrderValidator } from "./services/order-validator.service.js"; @@ -17,6 +19,7 @@ import { OrderPricebookService } from "./services/order-pricebook.service.js"; import { OrderOrchestrator } from "./services/order-orchestrator.service.js"; import { PaymentValidatorService } from "./services/payment-validator.service.js"; import { CheckoutService } from "./services/checkout.service.js"; +import { CheckoutSessionService } from "./services/checkout-session.service.js"; import { OrderEventsService } from "./services/order-events.service.js"; import { OrdersCacheService } from "./services/orders-cache.service.js"; @@ -36,8 +39,10 @@ import { OrderFieldConfigModule } from "./config/order-field-config.module.js"; UsersModule, CoreConfigModule, DatabaseModule, - CatalogModule, + ServicesModule, CacheModule, + VerificationModule, + NotificationsModule, OrderFieldConfigModule, ], controllers: [OrdersController, CheckoutController], @@ -54,6 +59,7 @@ import { OrderFieldConfigModule } from "./config/order-field-config.module.js"; OrderOrchestrator, OrdersCacheService, CheckoutService, + CheckoutSessionService, // Order fulfillment services (modular) OrderFulfillmentValidator, diff --git a/apps/bff/src/modules/orders/services/checkout-session.service.ts b/apps/bff/src/modules/orders/services/checkout-session.service.ts new file mode 100644 index 00000000..cbb02c40 --- /dev/null +++ b/apps/bff/src/modules/orders/services/checkout-session.service.ts @@ -0,0 +1,64 @@ +import { Inject, Injectable, NotFoundException } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { randomUUID } from "crypto"; +import { CacheService } from "@bff/infra/cache/cache.service.js"; +import type { CheckoutBuildCartRequest, CheckoutCart } from "@customer-portal/domain/orders"; + +type CheckoutSessionRecord = { + request: CheckoutBuildCartRequest; + cart: CheckoutCart; + createdAt: string; + expiresAt: string; +}; + +@Injectable() +export class CheckoutSessionService { + private readonly ttlSeconds = 2 * 60 * 60; // 2 hours + private readonly keyPrefix = "checkout-session"; + + constructor( + private readonly cache: CacheService, + @Inject(Logger) private readonly logger: Logger + ) {} + + async createSession(request: CheckoutBuildCartRequest, cart: CheckoutCart) { + const sessionId = randomUUID(); + const createdAt = new Date(); + const expiresAt = new Date(createdAt.getTime() + this.ttlSeconds * 1000); + + const record: CheckoutSessionRecord = { + request, + cart, + createdAt: createdAt.toISOString(), + expiresAt: expiresAt.toISOString(), + }; + + const key = this.buildKey(sessionId); + await this.cache.set(key, record, this.ttlSeconds); + + this.logger.debug("Checkout session created", { sessionId, expiresAt: record.expiresAt }); + + return { + sessionId, + expiresAt: record.expiresAt, + }; + } + + async getSession(sessionId: string): Promise { + const key = this.buildKey(sessionId); + const record = await this.cache.get(key); + if (!record) { + throw new NotFoundException("Checkout session not found"); + } + return record; + } + + async deleteSession(sessionId: string): Promise { + const key = this.buildKey(sessionId); + await this.cache.del(key); + } + + private buildKey(sessionId: string): string { + return `${this.keyPrefix}:${sessionId}`; + } +} diff --git a/apps/bff/src/modules/orders/services/checkout.service.ts b/apps/bff/src/modules/orders/services/checkout.service.ts index 407ae94e..62fb89fd 100644 --- a/apps/bff/src/modules/orders/services/checkout.service.ts +++ b/apps/bff/src/modules/orders/services/checkout.service.ts @@ -20,19 +20,19 @@ import type { SimCatalogProduct, SimActivationFeeCatalogItem, VpnCatalogProduct, -} from "@customer-portal/domain/catalog"; -import { InternetCatalogService } from "@bff/modules/catalog/services/internet-catalog.service.js"; -import { SimCatalogService } from "@bff/modules/catalog/services/sim-catalog.service.js"; -import { VpnCatalogService } from "@bff/modules/catalog/services/vpn-catalog.service.js"; +} from "@customer-portal/domain/services"; +import { InternetServicesService } from "@bff/modules/services/services/internet-services.service.js"; +import { SimServicesService } from "@bff/modules/services/services/sim-services.service.js"; +import { VpnServicesService } from "@bff/modules/services/services/vpn-services.service.js"; import { getErrorMessage } from "@bff/core/utils/error.util.js"; @Injectable() export class CheckoutService { constructor( @Inject(Logger) private readonly logger: Logger, - private readonly internetCatalogService: InternetCatalogService, - private readonly simCatalogService: SimCatalogService, - private readonly vpnCatalogService: VpnCatalogService + private readonly internetCatalogService: InternetServicesService, + private readonly simCatalogService: SimServicesService, + private readonly vpnCatalogService: VpnServicesService ) {} /** @@ -155,6 +155,14 @@ export class CheckoutService { userId?: string ): Promise<{ items: CheckoutItem[] }> { const items: CheckoutItem[] = []; + if (userId) { + const eligibility = await this.internetCatalogService.getEligibilityForUser(userId); + if (typeof eligibility !== "string" || eligibility.trim().length === 0) { + throw new BadRequestException( + "Internet availability check required before ordering. Please request an availability check and try again once confirmed." + ); + } + } const plans: InternetPlanCatalogItem[] = userId ? await this.internetCatalogService.getPlansForUser(userId) : await this.internetCatalogService.getPlans(); @@ -229,9 +237,11 @@ export class CheckoutService { userId?: string ): Promise<{ items: CheckoutItem[] }> { const items: CheckoutItem[] = []; - const plans: SimCatalogProduct[] = userId - ? await this.simCatalogService.getPlansForUser(userId) - : await this.simCatalogService.getPlans(); + if (!userId) { + throw new BadRequestException("Please sign in to order SIM service."); + } + + const plans: SimCatalogProduct[] = await this.simCatalogService.getPlansForUser(userId); const rawActivationFees: SimActivationFeeCatalogItem[] = await this.simCatalogService.getActivationFees(); const activationFees = this.filterActivationFeesWithSku(rawActivationFees); diff --git a/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts b/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts index de95bd49..eb6c187f 100644 --- a/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts +++ b/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts @@ -1,6 +1,7 @@ import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service.js"; +import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js"; import { WhmcsOrderService } from "@bff/integrations/whmcs/services/whmcs-order.service.js"; import type { WhmcsOrderResult } from "@bff/integrations/whmcs/services/whmcs-order.service.js"; import { OrderOrchestrator } from "./order-orchestrator.service.js"; @@ -11,11 +12,16 @@ import { DistributedTransactionService } from "@bff/core/database/services/distr import { getErrorMessage } from "@bff/core/utils/error.util.js"; import { OrderEventsService } from "./order-events.service.js"; import { OrdersCacheService } from "./orders-cache.service.js"; +import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; +import { NotificationService } from "@bff/modules/notifications/notifications.service.js"; import { type OrderDetails, type OrderFulfillmentValidationResult, Providers as OrderProviders, } from "@customer-portal/domain/orders"; +import { OPPORTUNITY_STAGE } from "@customer-portal/domain/opportunity"; +import { NOTIFICATION_SOURCE, NOTIFICATION_TYPE } from "@customer-portal/domain/notifications"; +import { salesforceAccountIdSchema } from "@customer-portal/domain/common"; import { OrderValidationException, FulfillmentException, @@ -51,6 +57,7 @@ export class OrderFulfillmentOrchestrator { constructor( @Inject(Logger) private readonly logger: Logger, private readonly salesforceService: SalesforceService, + private readonly opportunityService: SalesforceOpportunityService, private readonly whmcsOrderService: WhmcsOrderService, private readonly orderOrchestrator: OrderOrchestrator, private readonly orderFulfillmentValidator: OrderFulfillmentValidator, @@ -58,7 +65,9 @@ export class OrderFulfillmentOrchestrator { private readonly simFulfillmentService: SimFulfillmentService, private readonly distributedTransactionService: DistributedTransactionService, private readonly orderEvents: OrderEventsService, - private readonly ordersCache: OrdersCacheService + private readonly ordersCache: OrdersCacheService, + private readonly mappingsService: MappingsService, + private readonly notifications: NotificationService ) {} /** @@ -171,6 +180,12 @@ export class OrderFulfillmentOrchestrator { source: "fulfillment", timestamp: new Date().toISOString(), }); + await this.safeNotifyOrder({ + type: NOTIFICATION_TYPE.ORDER_APPROVED, + sfOrderId, + accountId: context.validation?.sfOrder?.AccountId, + actionUrl: `/account/orders/${sfOrderId}`, + }); return result; }), rollback: async () => { @@ -232,12 +247,16 @@ export class OrderFulfillmentOrchestrator { `Provisioned from Salesforce Order ${sfOrderId}` ); + // Get OpportunityId from order details for WHMCS lifecycle linking + const sfOpportunityId = context.orderDetails?.opportunityId; + const result = await this.whmcsOrderService.addOrder({ clientId: context.validation.clientId, items: mappingResult.whmcsItems, paymentMethod: "stripe", promoCode: "1st Month Free (Monthly Plan)", sfOrderId, + sfOpportunityId, // Pass to WHMCS for bidirectional linking notes: orderNotes, noinvoiceemail: true, noemail: true, @@ -336,6 +355,12 @@ export class OrderFulfillmentOrchestrator { whmcsServiceIds: whmcsCreateResult?.serviceIds, }, }); + await this.safeNotifyOrder({ + type: NOTIFICATION_TYPE.ORDER_ACTIVATED, + sfOrderId, + accountId: context.validation?.sfOrder?.AccountId, + actionUrl: "/account/services", + }); return result; }), rollback: async () => { @@ -346,6 +371,54 @@ export class OrderFulfillmentOrchestrator { }, critical: true, }, + { + id: "opportunity_update", + description: "Update Opportunity with WHMCS Service ID and Active stage", + execute: this.createTrackedStep(context, "opportunity_update", async () => { + const opportunityId = context.orderDetails?.opportunityId; + const serviceId = whmcsCreateResult?.serviceIds?.[0]; + + if (!opportunityId) { + this.logger.debug("No Opportunity linked to order, skipping update", { + sfOrderId, + }); + return { skipped: true as const }; + } + + try { + // Update Opportunity stage to Active and set WHMCS Service ID + await this.opportunityService.updateStage( + opportunityId, + OPPORTUNITY_STAGE.ACTIVE, + "Service activated via fulfillment" + ); + + if (serviceId) { + await this.opportunityService.linkWhmcsServiceToOpportunity( + opportunityId, + serviceId + ); + } + + this.logger.log("Opportunity updated with Active stage and WHMCS link", { + opportunityIdTail: opportunityId.slice(-4), + whmcsServiceId: serviceId, + sfOrderId, + }); + + return { opportunityId, whmcsServiceId: serviceId }; + } catch (error) { + // Log but don't fail - Opportunity update is non-critical + this.logger.warn("Failed to update Opportunity after fulfillment", { + error: getErrorMessage(error), + opportunityId, + sfOrderId, + }); + return { failed: true as const, error: getErrorMessage(error) }; + } + }), + critical: false, // Opportunity update failure shouldn't rollback fulfillment + }, ], { description: `Order fulfillment for ${sfOrderId}`, @@ -387,6 +460,12 @@ export class OrderFulfillmentOrchestrator { } catch (error) { await this.invalidateOrderCaches(sfOrderId, context.validation?.sfOrder?.AccountId); await this.handleFulfillmentError(context, error as Error); + await this.safeNotifyOrder({ + type: NOTIFICATION_TYPE.ORDER_FAILED, + sfOrderId, + accountId: context.validation?.sfOrder?.AccountId, + actionUrl: `/account/orders/${sfOrderId}`, + }); this.orderEvents.publish(sfOrderId, { orderId: sfOrderId, status: "Pending Review", @@ -446,6 +525,38 @@ export class OrderFulfillmentOrchestrator { } } + private async safeNotifyOrder(params: { + type: (typeof NOTIFICATION_TYPE)[keyof typeof NOTIFICATION_TYPE]; + sfOrderId: string; + accountId?: unknown; + actionUrl: string; + }): Promise { + try { + const sfAccountId = salesforceAccountIdSchema.safeParse(params.accountId); + if (!sfAccountId.success) return; + + const mapping = await this.mappingsService.findBySfAccountId(sfAccountId.data); + if (!mapping?.userId) return; + + await this.notifications.createNotification({ + userId: mapping.userId, + type: params.type, + source: NOTIFICATION_SOURCE.SYSTEM, + sourceId: params.sfOrderId, + actionUrl: params.actionUrl, + }); + } catch (error) { + this.logger.warn( + { + sfOrderId: params.sfOrderId, + type: params.type, + err: error instanceof Error ? error.message : String(error), + }, + "Failed to create in-app order notification" + ); + } + } + /** * Handle fulfillment errors and update Salesforce */ diff --git a/apps/bff/src/modules/orders/services/order-orchestrator.service.ts b/apps/bff/src/modules/orders/services/order-orchestrator.service.ts index 43f7ff72..477e5007 100644 --- a/apps/bff/src/modules/orders/services/order-orchestrator.service.ts +++ b/apps/bff/src/modules/orders/services/order-orchestrator.service.ts @@ -1,13 +1,16 @@ import { Injectable, Inject, NotFoundException } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { SalesforceOrderService } from "@bff/integrations/salesforce/services/salesforce-order.service.js"; +import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js"; +import { OpportunityResolutionService } from "@bff/integrations/salesforce/services/opportunity-resolution.service.js"; import { OrderValidator } from "./order-validator.service.js"; import { OrderBuilder } from "./order-builder.service.js"; import { OrderItemBuilder } from "./order-item-builder.service.js"; import type { OrderItemCompositePayload } from "./order-item-builder.service.js"; import { OrdersCacheService } from "./orders-cache.service.js"; -import type { OrderDetails, OrderSummary } from "@customer-portal/domain/orders"; +import type { OrderDetails, OrderSummary, OrderTypeValue } from "@customer-portal/domain/orders"; import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util.js"; +import { OPPORTUNITY_STAGE } from "@customer-portal/domain/opportunity"; type OrderDetailsResponse = OrderDetails; type OrderSummaryResponse = OrderSummary; @@ -21,6 +24,8 @@ export class OrderOrchestrator { constructor( @Inject(Logger) private readonly logger: Logger, private readonly salesforceOrderService: SalesforceOrderService, + private readonly opportunityService: SalesforceOpportunityService, + private readonly opportunityResolution: OpportunityResolutionService, private readonly orderValidator: OrderValidator, private readonly orderBuilder: OrderBuilder, private readonly orderItemBuilder: OrderItemBuilder, @@ -46,9 +51,18 @@ export class OrderOrchestrator { "Order validation completed successfully" ); - // 2) Build order fields (includes address snapshot) + // 2) Resolve Opportunity for this order + const opportunityId = await this.resolveOpportunityForOrder( + validatedBody.orderType, + userMapping.sfAccountId ?? null, + validatedBody.opportunityId + ); + + // 3) Build order fields with Opportunity link + const bodyWithOpportunity = opportunityId ? { ...validatedBody, opportunityId } : validatedBody; + const orderFields = await this.orderBuilder.buildOrderFields( - validatedBody, + bodyWithOpportunity, userMapping, pricebookId, validatedBody.userId @@ -63,6 +77,7 @@ export class OrderOrchestrator { orderType: validatedBody.orderType, skuCount: validatedBody.skus.length, orderItemCount: orderItemsPayload.length, + hasOpportunity: !!opportunityId, }, "Order payload prepared" ); @@ -72,6 +87,27 @@ export class OrderOrchestrator { orderItemsPayload ); + // 4) Update Opportunity stage to Post Processing + if (opportunityId) { + try { + await this.opportunityService.updateStage( + opportunityId, + OPPORTUNITY_STAGE.POST_PROCESSING, + "Order placed via Portal" + ); + this.logger.log("Opportunity stage updated to Post Processing", { + opportunityIdTail: opportunityId.slice(-4), + orderId: created.id, + }); + } catch { + // Log but don't fail the order + this.logger.warn("Failed to update Opportunity stage after order", { + opportunityId, + orderId: created.id, + }); + } + } + if (userMapping.sfAccountId) { await this.ordersCache.invalidateAccountOrders(userMapping.sfAccountId); } @@ -82,6 +118,7 @@ export class OrderOrchestrator { orderId: created.id, skuCount: validatedBody.skus.length, orderItemCount: orderItemsPayload.length, + opportunityId, }, "Order creation workflow completed successfully" ); @@ -93,6 +130,40 @@ export class OrderOrchestrator { }; } + /** + * Resolve Opportunity for an order + * + * - If order already has an Opportunity ID, use it + * - Otherwise, find existing open Opportunity for this product type + * - If none found, create a new one with Post Processing stage + */ + private async resolveOpportunityForOrder( + orderType: OrderTypeValue, + sfAccountId: string | null, + existingOpportunityId?: string + ): Promise { + try { + const resolved = await this.opportunityResolution.resolveForOrderPlacement({ + accountId: sfAccountId, + orderType, + existingOpportunityId, + }); + if (resolved) { + this.logger.debug("Resolved Opportunity for order", { + opportunityIdTail: resolved.slice(-4), + orderType, + }); + } + return resolved; + } catch { + const accountIdTail = + typeof sfAccountId === "string" && sfAccountId.length >= 4 ? sfAccountId.slice(-4) : "none"; + this.logger.warn("Failed to resolve Opportunity for order", { orderType, accountIdTail }); + // Don't fail the order if Opportunity resolution fails + return null; + } + } + /** * Get order by ID with order items */ diff --git a/apps/bff/src/modules/orders/services/order-pricebook.service.ts b/apps/bff/src/modules/orders/services/order-pricebook.service.ts index 4b57d278..ec650e3b 100644 --- a/apps/bff/src/modules/orders/services/order-pricebook.service.ts +++ b/apps/bff/src/modules/orders/services/order-pricebook.service.ts @@ -5,7 +5,7 @@ import { SalesforceConnection } from "@bff/integrations/salesforce/services/sale import type { SalesforceProduct2Record, SalesforcePricebookEntryRecord, -} from "@customer-portal/domain/catalog"; +} from "@customer-portal/domain/services"; import type { SalesforceResponse } from "@customer-portal/domain/common"; import { assertSalesforceId, diff --git a/apps/bff/src/modules/orders/services/order-validator.service.ts b/apps/bff/src/modules/orders/services/order-validator.service.ts index 277c40a8..e3414932 100644 --- a/apps/bff/src/modules/orders/services/order-validator.service.ts +++ b/apps/bff/src/modules/orders/services/order-validator.service.ts @@ -13,9 +13,11 @@ import { import type { Providers } from "@customer-portal/domain/subscriptions"; type WhmcsProduct = Providers.WhmcsRaw.WhmcsProductRaw; -import { SimCatalogService } from "@bff/modules/catalog/services/sim-catalog.service.js"; +import { SimServicesService } from "@bff/modules/services/services/sim-services.service.js"; +import { InternetServicesService } from "@bff/modules/services/services/internet-services.service.js"; import { OrderPricebookService, type PricebookProductMeta } from "./order-pricebook.service.js"; import { PaymentValidatorService } from "./payment-validator.service.js"; +import { ResidenceCardService } from "@bff/modules/verification/residence-card.service.js"; /** * Handles all order validation logic - both format and business rules @@ -30,8 +32,10 @@ export class OrderValidator { private readonly mappings: MappingsService, private readonly whmcs: WhmcsConnectionOrchestratorService, private readonly pricebookService: OrderPricebookService, - private readonly simCatalogService: SimCatalogService, - private readonly paymentValidator: PaymentValidatorService + private readonly simCatalogService: SimServicesService, + private readonly internetCatalogService: InternetServicesService, + private readonly paymentValidator: PaymentValidatorService, + private readonly residenceCards: ResidenceCardService ) {} /** @@ -269,6 +273,18 @@ export class OrderValidator { const _productMeta = await this.validateSKUs(businessValidatedBody.skus, pricebookId); if (businessValidatedBody.orderType === "SIM") { + const verification = await this.residenceCards.getStatusForUser(userId); + if (verification.status === "not_submitted") { + throw new BadRequestException( + "Residence card submission required for SIM orders. Please upload your residence card and try again." + ); + } + if (verification.status === "rejected") { + throw new BadRequestException( + "Your residence card submission was rejected. Please resubmit your residence card and try again." + ); + } + const activationFees = await this.simCatalogService.getActivationFees(); const activationSkus = new Set( activationFees @@ -297,6 +313,23 @@ export class OrderValidator { // 4. Order-specific business validation if (businessValidatedBody.orderType === "Internet") { + const eligibility = await this.internetCatalogService.getEligibilityDetailsForUser(userId); + if (eligibility.status === "not_requested") { + throw new BadRequestException( + "Internet eligibility review is required before ordering. Please request an eligibility review from the Internet services page and try again." + ); + } + if (eligibility.status === "pending") { + throw new BadRequestException( + "Internet eligibility review is still in progress. Please wait for review to complete and try again." + ); + } + if (eligibility.status === "ineligible") { + throw new BadRequestException( + "Internet service is not available for your address. Please contact support if you believe this is incorrect." + ); + } + await this.validateInternetDuplication(userId, userMapping.whmcsClientId); } diff --git a/apps/bff/src/modules/realtime/realtime.controller.ts b/apps/bff/src/modules/realtime/realtime.controller.ts index 9c71a826..44e86a5e 100644 --- a/apps/bff/src/modules/realtime/realtime.controller.ts +++ b/apps/bff/src/modules/realtime/realtime.controller.ts @@ -67,14 +67,14 @@ export class RealtimeController { } ); - const globalCatalogStream = this.realtime.subscribe("global:catalog", { + const globalServicesStream = this.realtime.subscribe("global:services", { // Avoid duplicate ready/heartbeat noise on the combined stream. readyEvent: null, heartbeatEvent: null, heartbeatMs: 0, }); - return merge(accountStream, globalCatalogStream).pipe( + return merge(accountStream, globalServicesStream).pipe( finalize(() => { this.limiter.release(req.user.id); this.logger.debug("Account realtime stream disconnected", { diff --git a/apps/bff/src/modules/services/account-services.controller.ts b/apps/bff/src/modules/services/account-services.controller.ts new file mode 100644 index 00000000..5101e8be --- /dev/null +++ b/apps/bff/src/modules/services/account-services.controller.ts @@ -0,0 +1,61 @@ +import { Controller, Get, Header, Request, UseGuards } from "@nestjs/common"; +import { RateLimitGuard, RateLimit } from "@bff/core/rate-limiting/index.js"; +import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; +import { + parseInternetCatalog, + parseSimCatalog, + parseVpnCatalog, + type InternetCatalogCollection, + type SimCatalogCollection, + type VpnCatalogCollection, +} from "@customer-portal/domain/services"; +import { InternetServicesService } from "./services/internet-services.service.js"; +import { SimServicesService } from "./services/sim-services.service.js"; +import { VpnServicesService } from "./services/vpn-services.service.js"; +import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js"; + +@Controller("account/services") +@UseGuards(SalesforceReadThrottleGuard, RateLimitGuard) +export class AccountServicesController { + constructor( + private readonly internetCatalog: InternetServicesService, + private readonly simCatalog: SimServicesService, + private readonly vpnCatalog: VpnServicesService + ) {} + + @Get("internet/plans") + @RateLimit({ limit: 60, ttl: 60 }) // account page refreshes are cheap; still bounded per IP+UA + @Header("Cache-Control", "private, no-store") // personalized + async getInternetCatalogForAccount( + @Request() req: RequestWithUser + ): Promise { + const userId = req.user?.id; + const [plans, installations, addons] = await Promise.all([ + this.internetCatalog.getPlansForUser(userId), + this.internetCatalog.getInstallations(), + this.internetCatalog.getAddons(), + ]); + return parseInternetCatalog({ plans, installations, addons }); + } + + @Get("sim/plans") + @RateLimit({ limit: 60, ttl: 60 }) + @Header("Cache-Control", "private, no-store") // personalized + async getSimCatalogForAccount(@Request() req: RequestWithUser): Promise { + const userId = req.user?.id; + const [plans, activationFees, addons] = await Promise.all([ + this.simCatalog.getPlansForUser(userId), + this.simCatalog.getActivationFees(), + this.simCatalog.getAddons(), + ]); + return parseSimCatalog({ plans, activationFees, addons }); + } + + @Get("vpn/plans") + @RateLimit({ limit: 60, ttl: 60 }) + @Header("Cache-Control", "private, no-store") + async getVpnCatalogForAccount(@Request() _req: RequestWithUser): Promise { + const catalog = await this.vpnCatalog.getCatalogData(); + return parseVpnCatalog(catalog); + } +} diff --git a/apps/bff/src/modules/services/internet-eligibility.controller.ts b/apps/bff/src/modules/services/internet-eligibility.controller.ts new file mode 100644 index 00000000..7b45dfb7 --- /dev/null +++ b/apps/bff/src/modules/services/internet-eligibility.controller.ts @@ -0,0 +1,53 @@ +import { Body, Controller, Get, Header, Post, Req, UseGuards, UsePipes } from "@nestjs/common"; +import { ZodValidationPipe } from "nestjs-zod"; +import { z } from "zod"; +import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; +import { RateLimit, RateLimitGuard } from "@bff/core/rate-limiting/index.js"; +import { InternetServicesService } from "./services/internet-services.service.js"; +import { addressSchema } from "@customer-portal/domain/customer"; +import type { InternetEligibilityDetails } from "@customer-portal/domain/services"; + +const eligibilityRequestSchema = z.object({ + notes: z.string().trim().max(2000).optional(), + address: addressSchema.partial().optional(), +}); + +type EligibilityRequest = z.infer; + +/** + * Internet Eligibility Controller + * + * Authenticated endpoints for: + * - fetching current Salesforce eligibility value + * - requesting a (manual) eligibility/availability check + * + * Note: ServicesController is @Public, so we keep these endpoints in a separate controller + * to ensure GlobalAuthGuard enforces authentication. + */ +@Controller("services/internet") +@UseGuards(RateLimitGuard) +export class InternetEligibilityController { + constructor(private readonly internetCatalog: InternetServicesService) {} + + @Get("eligibility") + @RateLimit({ limit: 60, ttl: 60 }) // 60/min per IP (cheap) + @Header("Cache-Control", "private, no-store") + async getEligibility(@Req() req: RequestWithUser): Promise { + return this.internetCatalog.getEligibilityDetailsForUser(req.user.id); + } + + @Post("eligibility-request") + @RateLimit({ limit: 5, ttl: 300 }) // 5 per 5 minutes per IP + @UsePipes(new ZodValidationPipe(eligibilityRequestSchema)) + @Header("Cache-Control", "private, no-store") + async requestEligibility( + @Req() req: RequestWithUser, + @Body() body: EligibilityRequest + ): Promise<{ requestId: string }> { + const requestId = await this.internetCatalog.requestEligibilityCheckForUser(req.user.id, { + email: req.user.email, + ...body, + }); + return { requestId }; + } +} diff --git a/apps/bff/src/modules/services/public-services.controller.ts b/apps/bff/src/modules/services/public-services.controller.ts new file mode 100644 index 00000000..57397b15 --- /dev/null +++ b/apps/bff/src/modules/services/public-services.controller.ts @@ -0,0 +1,54 @@ +import { Controller, Get, Header, UseGuards } from "@nestjs/common"; +import { RateLimitGuard, RateLimit } from "@bff/core/rate-limiting/index.js"; +import { Public, PublicNoSession } from "@bff/modules/auth/decorators/public.decorator.js"; +import { + parseInternetCatalog, + parseSimCatalog, + parseVpnCatalog, + type InternetCatalogCollection, + type SimCatalogCollection, + type VpnCatalogCollection, +} from "@customer-portal/domain/services"; +import { InternetServicesService } from "./services/internet-services.service.js"; +import { SimServicesService } from "./services/sim-services.service.js"; +import { VpnServicesService } from "./services/vpn-services.service.js"; +import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js"; + +@Controller("public/services") +@Public() +@PublicNoSession() +@UseGuards(SalesforceReadThrottleGuard, RateLimitGuard) +export class PublicServicesController { + constructor( + private readonly internetCatalog: InternetServicesService, + private readonly simCatalog: SimServicesService, + private readonly vpnCatalog: VpnServicesService + ) {} + + @Get("internet/plans") + @RateLimit({ limit: 20, ttl: 60 }) // 20/min per IP+UA + @Header("Cache-Control", "public, max-age=300, s-maxage=300") // safe: strictly non-personalized + async getInternetCatalog(): Promise { + const catalog = await this.internetCatalog.getCatalogData(); + return parseInternetCatalog(catalog); + } + + @Get("sim/plans") + @RateLimit({ limit: 20, ttl: 60 }) // 20/min per IP+UA + @Header("Cache-Control", "public, max-age=300, s-maxage=300") // safe: strictly non-personalized + async getSimCatalog(): Promise { + const catalog = await this.simCatalog.getCatalogData(); + return parseSimCatalog({ + ...catalog, + plans: catalog.plans.filter(plan => !plan.simHasFamilyDiscount), + }); + } + + @Get("vpn/plans") + @RateLimit({ limit: 20, ttl: 60 }) // 20/min per IP+UA + @Header("Cache-Control", "public, max-age=300, s-maxage=300") // safe: strictly non-personalized + async getVpnCatalog(): Promise { + const catalog = await this.vpnCatalog.getCatalogData(); + return parseVpnCatalog(catalog); + } +} diff --git a/apps/bff/src/modules/catalog/catalog-health.controller.ts b/apps/bff/src/modules/services/services-health.controller.ts similarity index 50% rename from apps/bff/src/modules/catalog/catalog-health.controller.ts rename to apps/bff/src/modules/services/services-health.controller.ts index b645a6b1..c722b958 100644 --- a/apps/bff/src/modules/catalog/catalog-health.controller.ts +++ b/apps/bff/src/modules/services/services-health.controller.ts @@ -1,26 +1,26 @@ import { Controller, Get } from "@nestjs/common"; -import { CatalogCacheService } from "./services/catalog-cache.service.js"; -import type { CatalogCacheSnapshot } from "./services/catalog-cache.service.js"; +import { ServicesCacheService } from "./services/services-cache.service.js"; +import type { ServicesCacheSnapshot } from "./services/services-cache.service.js"; import { Public } from "@bff/modules/auth/decorators/public.decorator.js"; -interface CatalogCacheHealthResponse { +interface ServicesCacheHealthResponse { timestamp: string; - metrics: CatalogCacheSnapshot; + metrics: ServicesCacheSnapshot; ttl: { - catalogSeconds: number | null; + servicesSeconds: number | null; eligibilitySeconds: number | null; staticSeconds: number | null; volatileSeconds: number; }; } -@Controller("health/catalog") +@Controller("health/services") @Public() -export class CatalogHealthController { - constructor(private readonly catalogCache: CatalogCacheService) {} +export class ServicesHealthController { + constructor(private readonly catalogCache: ServicesCacheService) {} @Get("cache") - getCacheMetrics(): CatalogCacheHealthResponse { + getCacheMetrics(): ServicesCacheHealthResponse { const ttl = this.catalogCache.getTtlConfiguration(); return { timestamp: new Date().toISOString(), diff --git a/apps/bff/src/modules/catalog/catalog.controller.ts b/apps/bff/src/modules/services/services.controller.ts similarity index 80% rename from apps/bff/src/modules/catalog/catalog.controller.ts rename to apps/bff/src/modules/services/services.controller.ts index 3b85bde1..c49ed4a7 100644 --- a/apps/bff/src/modules/catalog/catalog.controller.ts +++ b/apps/bff/src/modules/services/services.controller.ts @@ -1,9 +1,11 @@ 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, + parseVpnCatalog, type InternetAddonCatalogItem, type InternetInstallationCatalogItem, type InternetPlanCatalogItem, @@ -11,19 +13,21 @@ import { type SimCatalogCollection, type SimCatalogProduct, type VpnCatalogProduct, -} from "@customer-portal/domain/catalog"; -import { InternetCatalogService } from "./services/internet-catalog.service.js"; -import { SimCatalogService } from "./services/sim-catalog.service.js"; -import { VpnCatalogService } from "./services/vpn-catalog.service.js"; + type VpnCatalogCollection, +} from "@customer-portal/domain/services"; +import { InternetServicesService } from "./services/internet-services.service.js"; +import { SimServicesService } from "./services/sim-services.service.js"; +import { VpnServicesService } from "./services/vpn-services.service.js"; import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js"; -@Controller("catalog") +@Controller("services") +@Public() // Allow public access - services can be browsed without authentication @UseGuards(SalesforceReadThrottleGuard, RateLimitGuard) -export class CatalogController { +export class ServicesController { constructor( - private internetCatalog: InternetCatalogService, - private simCatalog: SimCatalogService, - private vpnCatalog: VpnCatalogService + private internetCatalog: InternetServicesService, + private simCatalog: SimServicesService, + private vpnCatalog: VpnServicesService ) {} @Get("internet/plans") @@ -98,8 +102,10 @@ export class CatalogController { @Get("vpn/plans") @RateLimit({ limit: 20, ttl: 60 }) // 20 requests per minute @Header("Cache-Control", "public, max-age=300, s-maxage=300") // 5 minutes - async getVpnPlans(): Promise { - return this.vpnCatalog.getPlans(); + async getVpnPlans(): Promise { + // Backwards-compatible: return the full VPN catalog (plans + activation fees) + const catalog = await this.vpnCatalog.getCatalogData(); + return parseVpnCatalog(catalog); } @Get("vpn/activation-fees") diff --git a/apps/bff/src/modules/services/services.module.ts b/apps/bff/src/modules/services/services.module.ts new file mode 100644 index 00000000..a9db1775 --- /dev/null +++ b/apps/bff/src/modules/services/services.module.ts @@ -0,0 +1,43 @@ +import { Module, forwardRef } from "@nestjs/common"; +import { ServicesController } from "./services.controller.js"; +import { ServicesHealthController } from "./services-health.controller.js"; +import { InternetEligibilityController } from "./internet-eligibility.controller.js"; +import { PublicServicesController } from "./public-services.controller.js"; +import { AccountServicesController } from "./account-services.controller.js"; +import { IntegrationsModule } from "@bff/integrations/integrations.module.js"; +import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js"; +import { CoreConfigModule } from "@bff/core/config/config.module.js"; +import { CacheModule } from "@bff/infra/cache/cache.module.js"; +import { QueueModule } from "@bff/core/queue/queue.module.js"; + +import { BaseServicesService } from "./services/base-services.service.js"; +import { InternetServicesService } from "./services/internet-services.service.js"; +import { SimServicesService } from "./services/sim-services.service.js"; +import { VpnServicesService } from "./services/vpn-services.service.js"; +import { ServicesCacheService } from "./services/services-cache.service.js"; + +@Module({ + imports: [ + forwardRef(() => IntegrationsModule), + MappingsModule, + CoreConfigModule, + CacheModule, + QueueModule, + ], + controllers: [ + ServicesController, + PublicServicesController, + AccountServicesController, + ServicesHealthController, + InternetEligibilityController, + ], + providers: [ + BaseServicesService, + InternetServicesService, + SimServicesService, + VpnServicesService, + ServicesCacheService, + ], + exports: [InternetServicesService, SimServicesService, VpnServicesService, ServicesCacheService], +}) +export class ServicesModule {} diff --git a/apps/bff/src/modules/catalog/services/base-catalog.service.ts b/apps/bff/src/modules/services/services/base-services.service.ts similarity index 88% rename from apps/bff/src/modules/catalog/services/base-catalog.service.ts rename to apps/bff/src/modules/services/services/base-services.service.ts index 694e4d89..259d5ef6 100644 --- a/apps/bff/src/modules/catalog/services/base-catalog.service.ts +++ b/apps/bff/src/modules/services/services/base-services.service.ts @@ -8,18 +8,18 @@ import { } from "@bff/integrations/salesforce/utils/soql.util.js"; import { buildProductQuery, - buildCatalogServiceQuery, -} from "@bff/integrations/salesforce/utils/catalog-query-builder.js"; + buildServicesQuery, +} from "@bff/integrations/salesforce/utils/services-query-builder.js"; import { getErrorMessage } from "@bff/core/utils/error.util.js"; import type { SalesforceProduct2WithPricebookEntries, SalesforcePricebookEntryRecord, -} from "@customer-portal/domain/catalog"; -import { Providers as CatalogProviders } from "@customer-portal/domain/catalog"; +} from "@customer-portal/domain/services"; +import { Providers as CatalogProviders } from "@customer-portal/domain/services"; import type { SalesforceResponse } from "@customer-portal/domain/common"; @Injectable() -export class BaseCatalogService { +export class BaseServicesService { protected readonly portalPriceBookId: string; protected readonly portalCategoryField: string; @@ -41,7 +41,7 @@ export class BaseCatalogService { ): Promise { try { const res = (await this.sf.query(soql, { - label: `catalog:${context.replace(/\s+/g, "_").toLowerCase()}`, + label: `services:${context.replace(/\s+/g, "_").toLowerCase()}`, })) as SalesforceResponse; return res.records ?? []; } catch (error: unknown) { @@ -99,8 +99,8 @@ export class BaseCatalogService { return []; } - protected buildCatalogServiceQuery(category: string, additionalFields: string[] = []): string { - return buildCatalogServiceQuery( + protected buildServicesQuery(category: string, additionalFields: string[] = []): string { + return buildServicesQuery( this.portalPriceBookId, this.portalCategoryField, category, diff --git a/apps/bff/src/modules/services/services/internet-eligibility.types.ts b/apps/bff/src/modules/services/services/internet-eligibility.types.ts new file mode 100644 index 00000000..972deebb --- /dev/null +++ b/apps/bff/src/modules/services/services/internet-eligibility.types.ts @@ -0,0 +1,7 @@ +import type { Address } from "@customer-portal/domain/customer"; + +export type InternetEligibilityCheckRequest = { + email: string; + notes?: string; + address?: Partial
; +}; diff --git a/apps/bff/src/modules/services/services/internet-services.service.ts b/apps/bff/src/modules/services/services/internet-services.service.ts new file mode 100644 index 00000000..d68c7b16 --- /dev/null +++ b/apps/bff/src/modules/services/services/internet-services.service.ts @@ -0,0 +1,541 @@ +import { Injectable, Inject, BadRequestException } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { BaseServicesService } from "./base-services.service.js"; +import { ServicesCacheService } from "./services-cache.service.js"; +import type { + SalesforceProduct2WithPricebookEntries, + InternetPlanCatalogItem, + InternetInstallationCatalogItem, + InternetAddonCatalogItem, + InternetEligibilityDetails, + InternetEligibilityStatus, +} from "@customer-portal/domain/services"; +import { + Providers as CatalogProviders, + enrichInternetPlanMetadata, + inferAddonTypeFromSku, + inferInstallationTermFromSku, + internetEligibilityDetailsSchema, +} from "@customer-portal/domain/services"; +import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; +import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js"; +import { OpportunityResolutionService } from "@bff/integrations/salesforce/services/opportunity-resolution.service.js"; +import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js"; +import { DistributedLockService } from "@bff/infra/cache/distributed-lock.service.js"; +import { Logger } from "nestjs-pino"; +import { getErrorMessage } from "@bff/core/utils/error.util.js"; +import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util.js"; +import { assertSoqlFieldName } from "@bff/integrations/salesforce/utils/soql.util.js"; +import type { InternetEligibilityCheckRequest } from "./internet-eligibility.types.js"; +import type { SalesforceResponse } from "@customer-portal/domain/common"; +// (removed unused opportunity constants import) + +@Injectable() +export class InternetServicesService extends BaseServicesService { + constructor( + sf: SalesforceConnection, + private readonly config: ConfigService, + @Inject(Logger) logger: Logger, + private mappingsService: MappingsService, + private catalogCache: ServicesCacheService, + private lockService: DistributedLockService, + private opportunityResolution: OpportunityResolutionService, + private caseService: SalesforceCaseService + ) { + super(sf, config, logger); + } + + async getPlans(): Promise { + const cacheKey = this.catalogCache.buildServicesKey("internet", "plans"); + + return this.catalogCache.getCachedServices( + cacheKey, + async () => { + const soql = this.buildServicesQuery("Internet", [ + "Internet_Plan_Tier__c", + "Internet_Offering_Type__c", + "Catalog_Order__c", + ]); + const records = await this.executeQuery( + soql, + "Internet Plans" + ); + + const plans = records.map(record => { + const entry = this.extractPricebookEntry(record); + const plan = CatalogProviders.Salesforce.mapInternetPlan(record, entry); + return enrichInternetPlanMetadata(plan); + }); + + // Prefer ordering by offering type (for services UX) over Product2.Name. + // We still respect Catalog_Order__c (mapped to displayOrder) within each offering type. + return plans.sort(compareInternetPlansForServices); + }, + { + resolveDependencies: plans => ({ + productIds: plans.map(plan => plan.id).filter((id): id is string => Boolean(id)), + }), + } + ); + } + + async getInstallations(): Promise { + const cacheKey = this.catalogCache.buildServicesKey("internet", "installations"); + + return this.catalogCache.getCachedServices( + cacheKey, + async () => { + const soql = this.buildProductQuery("Internet", "Installation", [ + "Billing_Cycle__c", + "Catalog_Order__c", + ]); + const records = await this.executeQuery( + soql, + "Internet Installations" + ); + + this.logger.log(`Found ${records.length} installation records`); + + return records + .map(record => { + const entry = this.extractPricebookEntry(record); + const installation = CatalogProviders.Salesforce.mapInternetInstallation(record, entry); + return { + ...installation, + catalogMetadata: { + ...installation.catalogMetadata, + installationTerm: inferInstallationTermFromSku(installation.sku ?? ""), + }, + }; + }) + .sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0)); + }, + { + resolveDependencies: installations => ({ + productIds: installations.map(item => item.id).filter((id): id is string => Boolean(id)), + }), + } + ); + } + + async getAddons(): Promise { + const cacheKey = this.catalogCache.buildServicesKey("internet", "addons"); + + return this.catalogCache.getCachedServices( + cacheKey, + async () => { + const soql = this.buildProductQuery("Internet", "Add-on", [ + "Billing_Cycle__c", + "Catalog_Order__c", + "Bundled_Addon__c", + "Is_Bundled_Addon__c", + ]); + const records = await this.executeQuery( + soql, + "Internet Add-ons" + ); + + this.logger.log(`Found ${records.length} addon records`); + + return records + .map(record => { + const entry = this.extractPricebookEntry(record); + const addon = CatalogProviders.Salesforce.mapInternetAddon(record, entry); + return { + ...addon, + catalogMetadata: { + ...addon.catalogMetadata, + addonType: inferAddonTypeFromSku(addon.sku ?? ""), + }, + }; + }) + .sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0)); + }, + { + resolveDependencies: addons => ({ + productIds: addons.map(addon => addon.id).filter((id): id is string => Boolean(id)), + }), + } + ); + } + + async getCatalogData() { + const [plans, installations, addons] = await Promise.all([ + this.getPlans(), + this.getInstallations(), + this.getAddons(), + ]); + return { plans, installations, addons }; + } + + async getPlansForUser(userId: string): Promise { + try { + // Get all plans first + const allPlans = await this.getPlans(); + + // Get user's Salesforce account mapping + const mapping = await this.mappingsService.findByUserId(userId); + if (!mapping?.sfAccountId) { + this.logger.warn(`No Salesforce mapping found for user ${userId}, returning all plans`); + return allPlans; + } + + // Get customer's eligibility from Salesforce + const sfAccountId = assertSalesforceId(mapping.sfAccountId, "sfAccountId"); + const eligibilityKey = this.catalogCache.buildEligibilityKey("internet", sfAccountId); + const details = await this.catalogCache.getCachedEligibility( + eligibilityKey, + async () => this.queryEligibilityDetails(sfAccountId) + ); + + if (!details) { + this.logger.warn(`No Salesforce account found for user ${userId}, returning all plans`); + return allPlans; + } + + const eligibility = details.eligibility; + + if (!eligibility) { + this.logger.log(`No eligibility field for user ${userId}, filtering to Home 1G plans only`); + const homeGPlans = allPlans.filter(plan => plan.internetOfferingType === "Home 1G"); + return homeGPlans; + } + + // Filter plans based on eligibility + const eligiblePlans = allPlans.filter(plan => { + const isEligible = this.checkPlanEligibility(plan, eligibility); + if (!isEligible) { + this.logger.debug( + `Plan ${plan.name} (${plan.internetPlanTier ?? "Unknown"}) not eligible for user ${userId} with eligibility: ${eligibility}` + ); + } + return isEligible; + }); + + this.logger.log( + `Filtered ${allPlans.length} plans to ${eligiblePlans.length} eligible plans for user ${userId} with eligibility: ${eligibility}` + ); + return eligiblePlans; + } catch (error) { + this.logger.error(`Failed to get eligible plans for user ${userId}`, { + error: getErrorMessage(error), + }); + // Fallback to all plans if there's an error + return this.getPlans(); + } + } + + async getEligibilityForUser(userId: string): Promise { + const details = await this.getEligibilityDetailsForUser(userId); + return details.eligibility; + } + + async getEligibilityDetailsForUser(userId: string): Promise { + const mapping = await this.mappingsService.findByUserId(userId); + if (!mapping?.sfAccountId) { + return internetEligibilityDetailsSchema.parse({ + status: "not_requested", + eligibility: null, + requestId: null, + requestedAt: null, + checkedAt: null, + notes: null, + }); + } + + const sfAccountId = assertSalesforceId(mapping.sfAccountId, "sfAccountId"); + const eligibilityKey = this.catalogCache.buildEligibilityKey("internet", sfAccountId); + + // Explicitly define the validator to handle potential malformed cache data + // If the cache returns undefined or missing fields, we treat it as a cache miss or malformed data + // and force a re-fetch or ensure safe defaults are applied. + return this.catalogCache + .getCachedEligibility(eligibilityKey, async () => + this.queryEligibilityDetails(sfAccountId) + ) + .then(data => { + // Safety check: ensure the data matches the schema before returning. + // This protects against cache corruption (e.g. missing fields treated as undefined). + const result = internetEligibilityDetailsSchema.safeParse(data); + if (!result.success) { + this.logger.warn("Cached eligibility data was malformed, treating as cache miss", { + userId, + sfAccountId, + errors: result.error.format(), + }); + // Invalidate bad cache and re-fetch + this.catalogCache.invalidateEligibility(sfAccountId).catch((error: unknown) => + this.logger.error("Failed to invalidate malformed eligibility cache", { + error: getErrorMessage(error), + }) + ); + return this.queryEligibilityDetails(sfAccountId); + } + return result.data; + }); + } + + async requestEligibilityCheckForUser( + userId: string, + request: InternetEligibilityCheckRequest + ): Promise { + const mapping = await this.mappingsService.findByUserId(userId); + if (!mapping?.sfAccountId) { + throw new Error("No Salesforce mapping found for current user"); + } + + const sfAccountId = assertSalesforceId(mapping.sfAccountId, "sfAccountId"); + + if ( + !request.address || + !request.address.address1 || + !request.address.city || + !request.address.postcode + ) { + throw new BadRequestException("Service address is required to request eligibility review."); + } + + try { + const lockKey = `internet:eligibility:${sfAccountId}`; + + const caseId = await this.lockService.withLock( + lockKey, + async () => { + // Idempotency: if we already have a pending request, do not create a new Case. + // The Case creation is a signal of interest; if status is pending, interest is already signaled/active. + const existing = await this.queryEligibilityDetails(sfAccountId); + + if (existing.status === "pending") { + this.logger.log("Eligibility request already pending; skipping new case creation", { + userId, + sfAccountIdTail: sfAccountId.slice(-4), + }); + + // Try to find the existing open case to return its ID (best effort) + try { + const cases = await this.caseService.getCasesForAccount(sfAccountId); + const openCase = cases.find( + c => c.status !== "Closed" && c.subject.includes("Internet availability check") + ); + if (openCase) { + return openCase.id; + } + } catch (error) { + this.logger.warn("Failed to lookup existing case for pending request", { error }); + } + + // If we can't find the case ID but status is pending, we return a placeholder or empty string. + // The frontend primarily relies on the status change. + return ""; + } + + // 1) Find or create Opportunity for Internet eligibility + const { opportunityId, wasCreated: opportunityCreated } = + await this.opportunityResolution.findOrCreateForInternetEligibility(sfAccountId); + + // 2) Build case description + const subject = "Internet availability check request (Portal)"; + const descriptionLines: string[] = [ + "Portal internet availability check requested.", + "", + `UserId: ${userId}`, + `Email: ${request.email}`, + `SalesforceAccountId: ${sfAccountId}`, + `OpportunityId: ${opportunityId}`, + "", + request.notes ? `Notes: ${request.notes}` : "", + request.address ? `Address: ${formatAddressForLog(request.address)}` : "", + "", + `RequestedAt: ${new Date().toISOString()}`, + ].filter(Boolean); + + // 3) Create Case linked to Opportunity + const createdCaseId = await this.caseService.createEligibilityCase({ + accountId: sfAccountId, + opportunityId, + subject, + description: descriptionLines.join("\n"), + }); + + // 4) Update Account eligibility status + await this.updateAccountEligibilityRequestState(sfAccountId); + await this.catalogCache.invalidateEligibility(sfAccountId); + + this.logger.log("Created eligibility Case linked to Opportunity", { + userId, + sfAccountIdTail: sfAccountId.slice(-4), + caseIdTail: createdCaseId.slice(-4), + opportunityIdTail: opportunityId.slice(-4), + opportunityCreated, + }); + + return createdCaseId; + }, + { ttlMs: 10_000 } + ); + + return caseId; + } catch (error) { + this.logger.error("Failed to create eligibility request", { + userId, + sfAccountId, + error: getErrorMessage(error), + }); + throw new Error("Failed to request availability check. Please try again later."); + } + } + + private checkPlanEligibility(plan: InternetPlanCatalogItem, eligibility: string): boolean { + // Simple match: user's eligibility field must equal plan's Salesforce offering type + // e.g., eligibility "Home 1G" matches plan.internetOfferingType "Home 1G" + return plan.internetOfferingType === eligibility; + } + + private async queryEligibilityDetails(sfAccountId: string): Promise { + const eligibilityField = assertSoqlFieldName( + this.config.get("ACCOUNT_INTERNET_ELIGIBILITY_FIELD") ?? "Internet_Eligibility__c", + "ACCOUNT_INTERNET_ELIGIBILITY_FIELD" + ); + const statusField = assertSoqlFieldName( + this.config.get("ACCOUNT_INTERNET_ELIGIBILITY_STATUS_FIELD") ?? + "Internet_Eligibility_Status__c", + "ACCOUNT_INTERNET_ELIGIBILITY_STATUS_FIELD" + ); + const requestedAtField = assertSoqlFieldName( + this.config.get("ACCOUNT_INTERNET_ELIGIBILITY_REQUESTED_AT_FIELD") ?? + "Internet_Eligibility_Request_Date_Time__c", + "ACCOUNT_INTERNET_ELIGIBILITY_REQUESTED_AT_FIELD" + ); + const checkedAtField = assertSoqlFieldName( + this.config.get("ACCOUNT_INTERNET_ELIGIBILITY_CHECKED_AT_FIELD") ?? + "Internet_Eligibility_Checked_Date_Time__c", + "ACCOUNT_INTERNET_ELIGIBILITY_CHECKED_AT_FIELD" + ); + // Note: Notes and Case ID fields removed as they are not present/needed in the Salesforce schema + + const soql = ` + SELECT Id, ${eligibilityField}, ${statusField}, ${requestedAtField}, ${checkedAtField} + FROM Account + WHERE Id = '${sfAccountId}' + LIMIT 1 + `; + + const res = (await this.sf.query(soql, { + label: "services:internet:eligibility_details", + })) as SalesforceResponse>; + const record = (res.records?.[0] as Record | undefined) ?? undefined; + if (!record) { + return internetEligibilityDetailsSchema.parse({ + status: "not_requested", + eligibility: null, + requestId: null, + requestedAt: null, + checkedAt: null, + notes: null, + }); + } + + const eligibilityRaw = record[eligibilityField]; + const eligibility = + typeof eligibilityRaw === "string" && eligibilityRaw.trim().length > 0 + ? eligibilityRaw.trim() + : null; + + const statusRaw = record[statusField]; + const normalizedStatus = typeof statusRaw === "string" ? statusRaw.trim().toLowerCase() : ""; + + const status: InternetEligibilityStatus = + normalizedStatus === "pending" || normalizedStatus === "checking" + ? "pending" + : normalizedStatus === "eligible" + ? "eligible" + : normalizedStatus === "ineligible" || normalizedStatus === "not available" + ? "ineligible" + : eligibility + ? "eligible" + : "not_requested"; + + const requestedAtRaw = record[requestedAtField]; + const checkedAtRaw = record[checkedAtField]; + + const requestedAt = + typeof requestedAtRaw === "string" + ? requestedAtRaw + : requestedAtRaw instanceof Date + ? requestedAtRaw.toISOString() + : null; + const checkedAt = + typeof checkedAtRaw === "string" + ? checkedAtRaw + : checkedAtRaw instanceof Date + ? checkedAtRaw.toISOString() + : null; + + return internetEligibilityDetailsSchema.parse({ + status, + eligibility, + requestId: null, // Always null as field is not used + requestedAt, + checkedAt, + notes: null, // Always null as field is not used + }); + } + + // Note: createEligibilityCaseOrTask was removed - now using this.caseService.createEligibilityCase() + // which links the Case to the Opportunity + + private async updateAccountEligibilityRequestState(sfAccountId: string): Promise { + const statusField = assertSoqlFieldName( + this.config.get("ACCOUNT_INTERNET_ELIGIBILITY_STATUS_FIELD") ?? + "Internet_Eligibility_Status__c", + "ACCOUNT_INTERNET_ELIGIBILITY_STATUS_FIELD" + ); + const requestedAtField = assertSoqlFieldName( + this.config.get("ACCOUNT_INTERNET_ELIGIBILITY_REQUESTED_AT_FIELD") ?? + "Internet_Eligibility_Request_Date_Time__c", + "ACCOUNT_INTERNET_ELIGIBILITY_REQUESTED_AT_FIELD" + ); + + const update = this.sf.sobject("Account")?.update; + if (!update) { + throw new Error("Salesforce Account update method not available"); + } + + await update({ + Id: sfAccountId, + [statusField]: "Pending", + [requestedAtField]: new Date().toISOString(), + }); + } +} + +function normalizeCatalogString(value: unknown): string { + return typeof value === "string" ? value.trim().toLowerCase() : ""; +} + +function compareInternetPlansForServices( + a: InternetPlanCatalogItem, + b: InternetPlanCatalogItem +): number { + const aOffering = normalizeCatalogString(a.internetOfferingType); + const bOffering = normalizeCatalogString(b.internetOfferingType); + if (aOffering !== bOffering) return aOffering.localeCompare(bOffering); + + const aOrder = typeof a.displayOrder === "number" ? a.displayOrder : Number.MAX_SAFE_INTEGER; + const bOrder = typeof b.displayOrder === "number" ? b.displayOrder : Number.MAX_SAFE_INTEGER; + if (aOrder !== bOrder) return aOrder - bOrder; + + const aName = normalizeCatalogString(a.name); + const bName = normalizeCatalogString(b.name); + return aName.localeCompare(bName); +} + +function formatAddressForLog(address: Record): string { + const address1 = typeof address.address1 === "string" ? address.address1.trim() : ""; + const address2 = typeof address.address2 === "string" ? address.address2.trim() : ""; + const city = typeof address.city === "string" ? address.city.trim() : ""; + const state = typeof address.state === "string" ? address.state.trim() : ""; + const postcode = typeof address.postcode === "string" ? address.postcode.trim() : ""; + const country = typeof address.country === "string" ? address.country.trim() : ""; + return [address1, address2, city, state, postcode, country].filter(Boolean).join(", "); +} diff --git a/apps/bff/src/modules/catalog/services/catalog-cache.service.ts b/apps/bff/src/modules/services/services/services-cache.service.ts similarity index 70% rename from apps/bff/src/modules/catalog/services/catalog-cache.service.ts rename to apps/bff/src/modules/services/services/services-cache.service.ts index 3ed8508c..9102de9a 100644 --- a/apps/bff/src/modules/catalog/services/catalog-cache.service.ts +++ b/apps/bff/src/modules/services/services/services-cache.service.ts @@ -1,16 +1,17 @@ import { Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; import { CacheService } from "@bff/infra/cache/cache.service.js"; import type { CacheBucketMetrics, CacheDependencies } from "@bff/infra/cache/cache.types.js"; -export interface CatalogCacheSnapshot { - catalog: CacheBucketMetrics; +export interface ServicesCacheSnapshot { + services: CacheBucketMetrics; static: CacheBucketMetrics; volatile: CacheBucketMetrics; eligibility: CacheBucketMetrics; invalidations: number; } -export interface CatalogCacheOptions { +export interface ServicesCacheOptions { allowNull?: boolean; resolveDependencies?: ( value: T @@ -24,33 +25,34 @@ interface LegacyCatalogCachePayload { } /** - * Catalog cache service + * Services cache service * * Uses CDC (Change Data Capture) for real-time cache invalidation with * product dependency tracking for granular invalidation. * * Features: - * - CDC-driven invalidation: No TTL, cache persists until CDC event + * - Event-driven invalidation: CDC / Platform Events invalidate caches on change + * - Safety TTL: long TTL to self-heal if events are missed * - Product dependency tracking: Granular invalidation by product IDs * - Request coalescing: Prevents thundering herd on cache miss * - Metrics tracking: Monitors hits, misses, and invalidations * * Cache buckets: - * - catalog: Product catalog data (CDC-driven) - * - static: Static reference data (CDC-driven) - * - eligibility: Account eligibility data (CDC-driven) + * - catalog: Product catalog data (event-driven + safety TTL) + * - static: Static reference data (event-driven + safety TTL) + * - eligibility: Account eligibility data (event-driven + safety TTL) * - volatile: Frequently changing data (60s TTL) */ @Injectable() -export class CatalogCacheService { - // CDC-driven invalidation: null TTL means cache persists until explicit invalidation - private readonly CATALOG_TTL: number | null = null; - private readonly STATIC_TTL: number | null = null; - private readonly ELIGIBILITY_TTL: number | null = null; +export class ServicesCacheService { + // CDC-driven invalidation + safety TTL (self-heal if events are missed) + private readonly SERVICES_TTL: number | null; + private readonly STATIC_TTL: number | null; + private readonly ELIGIBILITY_TTL: number | null; private readonly VOLATILE_TTL = 60; // Volatile data still uses TTL - private readonly metrics: CatalogCacheSnapshot = { - catalog: { hits: 0, misses: 0 }, + private readonly metrics: ServicesCacheSnapshot = { + services: { hits: 0, misses: 0 }, static: { hits: 0, misses: 0 }, volatile: { hits: 0, misses: 0 }, eligibility: { hits: 0, misses: 0 }, @@ -61,21 +63,32 @@ export class CatalogCacheService { // request the same data after CDC invalidation private readonly inflightRequests = new Map>(); - constructor(private readonly cache: CacheService) {} + constructor( + private readonly cache: CacheService, + private readonly config: ConfigService + ) { + const raw = this.config.get("SERVICES_CACHE_SAFETY_TTL_SECONDS", 60 * 60 * 12); + const ttl = typeof raw === "number" && Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : null; - /** - * Get or fetch catalog data (CDC-driven cache, no TTL) - */ - async getCachedCatalog( - key: string, - fetchFn: () => Promise, - options?: CatalogCacheOptions - ): Promise { - return this.getOrSet("catalog", key, this.CATALOG_TTL, fetchFn, options); + // Apply to CDC-driven buckets (catalog + static + eligibility) + this.SERVICES_TTL = ttl; + this.STATIC_TTL = ttl; + this.ELIGIBILITY_TTL = ttl; } /** - * Get or fetch static catalog data (CDC-driven cache, no TTL) + * Get or fetch catalog data (CDC-driven cache with safety TTL) + */ + async getCachedServices( + key: string, + fetchFn: () => Promise, + options?: ServicesCacheOptions + ): Promise { + return this.getOrSet("services", key, this.SERVICES_TTL, fetchFn, options); + } + + /** + * Get or fetch static catalog data (CDC-driven cache with safety TTL) */ async getCachedStatic(key: string, fetchFn: () => Promise): Promise { return this.getOrSet("static", key, this.STATIC_TTL, fetchFn); @@ -89,7 +102,7 @@ export class CatalogCacheService { } /** - * Get or fetch eligibility data (CDC-driven cache, no TTL) + * Get or fetch eligibility data (event-driven cache with safety TTL) */ async getCachedEligibility(key: string, fetchFn: () => Promise): Promise { return this.getOrSet("eligibility", key, this.ELIGIBILITY_TTL, fetchFn, { @@ -100,20 +113,20 @@ export class CatalogCacheService { /** * Build cache key for catalog data */ - buildCatalogKey(catalogType: string, ...parts: string[]): string { - return `catalog:${catalogType}:${parts.join(":")}`; + buildServicesKey(serviceType: string, ...parts: string[]): string { + return `services:${serviceType}:${parts.join(":")}`; } buildEligibilityKey(_catalogType: string, accountId: string): string { - return `catalog:eligibility:${accountId}`; + return `services:eligibility:${accountId}`; } /** * Invalidate catalog cache by pattern */ - async invalidateCatalog(catalogType: string): Promise { + async invalidateServices(serviceType: string): Promise { this.metrics.invalidations++; - await this.cache.delPattern(`catalog:${catalogType}:*`); + await this.cache.delPattern(`services:${serviceType}:*`); await this.flushProductDependencyIndex(); } @@ -129,9 +142,9 @@ export class CatalogCacheService { /** * Invalidate all catalog cache entries */ - async invalidateAllCatalogs(): Promise { + async invalidateAllServices(): Promise { this.metrics.invalidations++; - await this.cache.delPattern("catalog:*"); + await this.cache.delPattern("services:*"); await this.flushProductDependencyIndex(); } @@ -139,13 +152,13 @@ export class CatalogCacheService { * Get TTL configuration for monitoring */ getTtlConfiguration(): { - catalogSeconds: number | null; + servicesSeconds: number | null; eligibilitySeconds: number | null; staticSeconds: number | null; volatileSeconds: number; } { return { - catalogSeconds: this.CATALOG_TTL ?? null, + servicesSeconds: this.SERVICES_TTL ?? null, eligibilitySeconds: this.ELIGIBILITY_TTL ?? null, staticSeconds: this.STATIC_TTL ?? null, volatileSeconds: this.VOLATILE_TTL, @@ -155,9 +168,9 @@ export class CatalogCacheService { /** * Get cache metrics for monitoring */ - getMetrics(): CatalogCacheSnapshot { + getMetrics(): ServicesCacheSnapshot { return { - catalog: { ...this.metrics.catalog }, + services: { ...this.metrics.services }, static: { ...this.metrics.static }, volatile: { ...this.metrics.volatile }, eligibility: { ...this.metrics.eligibility }, @@ -173,10 +186,27 @@ export class CatalogCacheService { eligibility: string | null | undefined ): Promise { const key = this.buildEligibilityKey("", accountId); - const payload = - typeof eligibility === "string" - ? { Id: accountId, Internet_Eligibility__c: eligibility } - : null; + const payload = { + status: eligibility ? "eligible" : "not_requested", + eligibility: typeof eligibility === "string" ? eligibility : null, + requestId: null, + requestedAt: null, + checkedAt: null, + notes: null, + }; + if (this.ELIGIBILITY_TTL === null) { + await this.cache.set(key, payload); + } else { + await this.cache.set(key, payload, this.ELIGIBILITY_TTL); + } + } + + /** + * Set eligibility details payload for an account. + * Used by Salesforce Platform Events to push updates into the cache without re-querying Salesforce. + */ + async setEligibilityDetails(accountId: string, payload: unknown): Promise { + const key = this.buildEligibilityKey("", accountId); if (this.ELIGIBILITY_TTL === null) { await this.cache.set(key, payload); } else { @@ -185,11 +215,11 @@ export class CatalogCacheService { } private async getOrSet( - bucket: "catalog" | "static" | "volatile" | "eligibility", + bucket: "services" | "static" | "volatile" | "eligibility", key: string, ttlSeconds: number | null, fetchFn: () => Promise, - options?: CatalogCacheOptions + options?: ServicesCacheOptions ): Promise { const allowNull = options?.allowNull ?? false; @@ -234,8 +264,8 @@ export class CatalogCacheService { // Store and link dependencies separately if (dependencies) { - await this.storeDependencies(key, dependencies); - await this.linkDependencies(key, dependencies); + await this.storeDependencies(key, dependencies, ttlSeconds); + await this.linkDependencies(key, dependencies, ttlSeconds); } return fresh; @@ -264,8 +294,8 @@ export class CatalogCacheService { } if (cached.dependencies) { - await this.storeDependencies(key, cached.dependencies); - await this.linkDependencies(key, cached.dependencies); + await this.storeDependencies(key, cached.dependencies, ttlSeconds); + await this.linkDependencies(key, cached.dependencies, ttlSeconds); } return normalizedValue; @@ -327,11 +357,19 @@ export class CatalogCacheService { /** * Store dependencies metadata for a cache key */ - private async storeDependencies(key: string, dependencies: CacheDependencies): Promise { + private async storeDependencies( + key: string, + dependencies: CacheDependencies, + ttlSeconds: number | null + ): Promise { const normalized = this.normalizeDependencies(dependencies); if (normalized) { const metaKey = this.buildDependencyMetaKey(key); - await this.cache.set(metaKey, normalized); + if (ttlSeconds === null) { + await this.cache.set(metaKey, normalized); + } else { + await this.cache.set(metaKey, normalized, ttlSeconds); + } } } @@ -358,7 +396,11 @@ export class CatalogCacheService { return { productIds: Array.from(new Set(productIds)) }; } - private async linkDependencies(key: string, dependencies: CacheDependencies): Promise { + private async linkDependencies( + key: string, + dependencies: CacheDependencies, + ttlSeconds: number | null + ): Promise { const normalized = this.normalizeDependencies(dependencies); if (!normalized) { return; @@ -371,7 +413,11 @@ export class CatalogCacheService { if (!existing.includes(key)) { existing.push(key); } - await this.cache.set(indexKey, { keys: existing }); + if (ttlSeconds === null) { + await this.cache.set(indexKey, { keys: existing }); + } else { + await this.cache.set(indexKey, { keys: existing }, ttlSeconds); + } } } } @@ -403,17 +449,12 @@ export class CatalogCacheService { } private buildProductDependencyKey(productId: string): string { - return `catalog:deps:product:${productId}`; + return `services:deps:product:${productId}`; } private async flushProductDependencyIndex(): Promise { - await this.cache.delPattern("catalog:deps:product:*"); + await this.cache.delPattern("services:deps:product:*"); } } -export interface CatalogCacheOptions { - allowNull?: boolean; - resolveDependencies?: ( - value: T - ) => CacheDependencies | Promise | undefined; -} +// (intentionally no duplicate options type; use ServicesCacheOptions above) diff --git a/apps/bff/src/modules/catalog/services/sim-catalog.service.ts b/apps/bff/src/modules/services/services/sim-services.service.ts similarity index 79% rename from apps/bff/src/modules/catalog/services/sim-catalog.service.ts rename to apps/bff/src/modules/services/services/sim-services.service.ts index fccf572c..1d3cd450 100644 --- a/apps/bff/src/modules/catalog/services/sim-catalog.service.ts +++ b/apps/bff/src/modules/services/services/sim-services.service.ts @@ -1,38 +1,38 @@ import { Injectable, Inject } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; -import { BaseCatalogService } from "./base-catalog.service.js"; -import { CatalogCacheService } from "./catalog-cache.service.js"; +import { BaseServicesService } from "./base-services.service.js"; +import { ServicesCacheService } from "./services-cache.service.js"; import type { SalesforceProduct2WithPricebookEntries, SimCatalogProduct, SimActivationFeeCatalogItem, -} from "@customer-portal/domain/catalog"; -import { Providers as CatalogProviders } from "@customer-portal/domain/catalog"; +} from "@customer-portal/domain/services"; +import { Providers as CatalogProviders } from "@customer-portal/domain/services"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js"; import { Logger } from "nestjs-pino"; import { WhmcsConnectionOrchestratorService } from "@bff/integrations/whmcs/connection/services/whmcs-connection-orchestrator.service.js"; @Injectable() -export class SimCatalogService extends BaseCatalogService { +export class SimServicesService extends BaseServicesService { constructor( sf: SalesforceConnection, configService: ConfigService, @Inject(Logger) logger: Logger, private mappingsService: MappingsService, private whmcs: WhmcsConnectionOrchestratorService, - private catalogCache: CatalogCacheService + private catalogCache: ServicesCacheService ) { super(sf, configService, logger); } async getPlans(): Promise { - const cacheKey = this.catalogCache.buildCatalogKey("sim", "plans"); + const cacheKey = this.catalogCache.buildServicesKey("sim", "plans"); - return this.catalogCache.getCachedCatalog( + return this.catalogCache.getCachedServices( cacheKey, async () => { - const soql = this.buildCatalogServiceQuery("SIM", [ + const soql = this.buildServicesQuery("SIM", [ "SIM_Data_Size__c", "SIM_Plan_Type__c", "SIM_Has_Family_Discount__c", @@ -62,9 +62,9 @@ export class SimCatalogService extends BaseCatalogService { } async getActivationFees(): Promise { - const cacheKey = this.catalogCache.buildCatalogKey("sim", "activation-fees"); + const cacheKey = this.catalogCache.buildServicesKey("sim", "activation-fees"); - return this.catalogCache.getCachedCatalog( + return this.catalogCache.getCachedServices( cacheKey, async () => { const soql = this.buildProductQuery("SIM", "Activation", [ @@ -115,9 +115,9 @@ export class SimCatalogService extends BaseCatalogService { } async getAddons(): Promise { - const cacheKey = this.catalogCache.buildCatalogKey("sim", "addons"); + const cacheKey = this.catalogCache.buildServicesKey("sim", "addons"); - return this.catalogCache.getCachedCatalog( + return this.catalogCache.getCachedServices( cacheKey, async () => { const soql = this.buildProductQuery("SIM", "Add-on", [ @@ -184,22 +184,28 @@ export class SimCatalogService extends BaseCatalogService { return false; } - // Check WHMCS for existing SIM services - const products = await this.whmcs.getClientsProducts({ clientid: mapping.whmcsClientId }); - const services = (products?.products?.product || []) as Array<{ - groupname?: string; - status?: string; - }>; - - // Look for active SIM services - const hasActiveSim = services.some( - service => - String(service.groupname || "") - .toLowerCase() - .includes("sim") && String(service.status || "").toLowerCase() === "active" + const cacheKey = this.catalogCache.buildServicesKey( + "sim", + "has-existing-sim", + String(mapping.whmcsClientId) ); - return hasActiveSim; + // This is per-account and can be somewhat expensive (WHMCS call). + // Cache briefly to reduce repeat reads during account page refreshes. + return await this.catalogCache.getCachedVolatile(cacheKey, async () => { + const products = await this.whmcs.getClientsProducts({ clientid: mapping.whmcsClientId }); + const services = (products?.products?.product || []) as Array<{ + groupname?: string; + status?: string; + }>; + + // Look for active SIM services + return services.some(service => { + const group = String(service.groupname || "").toLowerCase(); + const status = String(service.status || "").toLowerCase(); + return group.includes("sim") && status === "active"; + }); + }); } catch (error) { this.logger.warn(`Failed to check existing SIM for user ${userId}`, error); return false; // Default to no existing SIM diff --git a/apps/bff/src/modules/services/services/vpn-services.service.ts b/apps/bff/src/modules/services/services/vpn-services.service.ts new file mode 100644 index 00000000..8b795ff1 --- /dev/null +++ b/apps/bff/src/modules/services/services/vpn-services.service.ts @@ -0,0 +1,86 @@ +import { Injectable, Inject } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Logger } from "nestjs-pino"; +import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js"; +import { BaseServicesService } from "./base-services.service.js"; +import { ServicesCacheService } from "./services-cache.service.js"; +import type { + SalesforceProduct2WithPricebookEntries, + VpnCatalogProduct, +} from "@customer-portal/domain/services"; +import { Providers as CatalogProviders } from "@customer-portal/domain/services"; + +@Injectable() +export class VpnServicesService extends BaseServicesService { + constructor( + sf: SalesforceConnection, + configService: ConfigService, + @Inject(Logger) logger: Logger, + private readonly catalogCache: ServicesCacheService + ) { + super(sf, configService, logger); + } + async getPlans(): Promise { + const cacheKey = this.catalogCache.buildServicesKey("vpn", "plans"); + + return this.catalogCache.getCachedServices( + cacheKey, + async () => { + const soql = this.buildServicesQuery("VPN", ["VPN_Region__c", "Catalog_Order__c"]); + const records = await this.executeQuery( + soql, + "VPN Plans" + ); + + return records.map(record => { + const entry = this.extractPricebookEntry(record); + const product = CatalogProviders.Salesforce.mapVpnProduct(record, entry); + return { + ...product, + description: product.description || product.name, + } satisfies VpnCatalogProduct; + }); + }, + { + resolveDependencies: plans => ({ + productIds: plans.map(plan => plan.id).filter((id): id is string => Boolean(id)), + }), + } + ); + } + + async getActivationFees(): Promise { + const cacheKey = this.catalogCache.buildServicesKey("vpn", "activation-fees"); + + return this.catalogCache.getCachedServices( + cacheKey, + async () => { + const soql = this.buildProductQuery("VPN", "Activation", ["VPN_Region__c"]); + const records = await this.executeQuery( + soql, + "VPN Activation Fees" + ); + + return records.map(record => { + const pricebookEntry = this.extractPricebookEntry(record); + const product = CatalogProviders.Salesforce.mapVpnProduct(record, pricebookEntry); + + return { + ...product, + description: product.description ?? product.name, + } satisfies VpnCatalogProduct; + }); + }, + { + resolveDependencies: fees => ({ + productIds: fees.map(fee => fee.id).filter((id): id is string => Boolean(id)), + }), + } + ); + } + + async getCatalogData() { + const [plans, activationFees] = await Promise.all([this.getPlans(), this.getActivationFees()]); + return { plans, activationFees }; + } +} diff --git a/apps/bff/src/modules/catalog/utils/salesforce-product.pricing.ts b/apps/bff/src/modules/services/utils/salesforce-product.pricing.ts similarity index 100% rename from apps/bff/src/modules/catalog/utils/salesforce-product.pricing.ts rename to apps/bff/src/modules/services/utils/salesforce-product.pricing.ts diff --git a/apps/bff/src/modules/subscriptions/internet-management/internet-management.module.ts b/apps/bff/src/modules/subscriptions/internet-management/internet-management.module.ts new file mode 100644 index 00000000..67b7306e --- /dev/null +++ b/apps/bff/src/modules/subscriptions/internet-management/internet-management.module.ts @@ -0,0 +1,14 @@ +import { Module } from "@nestjs/common"; +import { InternetCancellationService } from "./services/internet-cancellation.service.js"; +import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module.js"; +import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js"; +import { SalesforceModule } from "@bff/integrations/salesforce/salesforce.module.js"; +import { EmailModule } from "@bff/infra/email/email.module.js"; +import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js"; + +@Module({ + imports: [WhmcsModule, MappingsModule, SalesforceModule, EmailModule, NotificationsModule], + providers: [InternetCancellationService], + exports: [InternetCancellationService], +}) +export class InternetManagementModule {} diff --git a/apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts b/apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts new file mode 100644 index 00000000..a1de84dc --- /dev/null +++ b/apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts @@ -0,0 +1,332 @@ +/** + * Internet Cancellation Service + * + * Handles Internet service cancellation flows: + * - Preview available cancellation months + * - Submit cancellation requests (creates SF Case + updates Opportunity) + * + * Internet cancellation differs from SIM in that: + * - No Freebit/MVNO API calls needed + * - Cancellation is processed via Salesforce Case workflow + * - Equipment return may be required (ONU, router) + */ + +import { Injectable, Inject, BadRequestException, NotFoundException } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js"; +import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; +import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js"; +import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js"; +import { EmailService } from "@bff/infra/email/email.service.js"; +import { NotificationService } from "@bff/modules/notifications/notifications.service.js"; +import type { + InternetCancellationPreview, + InternetCancellationMonth, + InternetCancelRequest, +} from "@customer-portal/domain/subscriptions"; +import { + type CancellationOpportunityData, + CANCELLATION_NOTICE, + LINE_RETURN_STATUS, +} from "@customer-portal/domain/opportunity"; +import { NOTIFICATION_SOURCE, NOTIFICATION_TYPE } from "@customer-portal/domain/notifications"; + +@Injectable() +export class InternetCancellationService { + constructor( + private readonly whmcsService: WhmcsService, + private readonly mappingsService: MappingsService, + private readonly caseService: SalesforceCaseService, + private readonly opportunityService: SalesforceOpportunityService, + private readonly emailService: EmailService, + private readonly notifications: NotificationService, + @Inject(Logger) private readonly logger: Logger + ) {} + + /** + * Generate available cancellation months (next 12 months) + * Following the 25th rule: if before 25th, current month is available + */ + private generateCancellationMonths(): InternetCancellationMonth[] { + const months: InternetCancellationMonth[] = []; + const today = new Date(); + const dayOfMonth = today.getDate(); + + // Start from current month if before 25th, otherwise next month + const startOffset = dayOfMonth <= 25 ? 0 : 1; + + for (let i = startOffset; i < startOffset + 12; i++) { + const date = new Date(today.getFullYear(), today.getMonth() + i, 1); + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const monthStr = String(month).padStart(2, "0"); + + months.push({ + value: `${year}-${monthStr}`, + label: date.toLocaleDateString("en-US", { month: "long", year: "numeric" }), + }); + } + + return months; + } + + /** + * Validate that the subscription belongs to the user and is an Internet service + */ + private async validateInternetSubscription( + userId: string, + subscriptionId: number + ): Promise<{ + whmcsClientId: number; + sfAccountId: string; + subscription: { + id: number; + productName: string; + amount: number; + nextDue?: string; + registrationDate?: string; + }; + }> { + const mapping = await this.mappingsService.findByUserId(userId); + if (!mapping?.whmcsClientId || !mapping?.sfAccountId) { + throw new BadRequestException("Account mapping not found"); + } + + // Get subscription from WHMCS + const productsResponse = await this.whmcsService.getClientsProducts({ + clientid: mapping.whmcsClientId, + }); + const productContainer = productsResponse.products?.product; + const products = Array.isArray(productContainer) + ? productContainer + : productContainer + ? [productContainer] + : []; + + const subscription = products.find( + (p: { id?: number | string }) => Number(p.id) === subscriptionId + ); + + if (!subscription) { + throw new NotFoundException("Subscription not found"); + } + + // Verify it's an Internet service + // Match: "Internet", "SonixNet via NTT Optical Fiber", or any NTT-based fiber service + const productName = String(subscription.name || subscription.groupname || ""); + const lowerName = productName.toLowerCase(); + const isInternetService = + lowerName.includes("internet") || + lowerName.includes("sonixnet") || + (lowerName.includes("ntt") && lowerName.includes("fiber")); + + if (!isInternetService) { + throw new BadRequestException("This endpoint is only for Internet subscriptions"); + } + + return { + whmcsClientId: mapping.whmcsClientId, + sfAccountId: mapping.sfAccountId, + subscription: { + id: Number(subscription.id), + productName: productName, + amount: parseFloat(String(subscription.amount || subscription.recurringamount || 0)), + nextDue: String(subscription.nextduedate || ""), + registrationDate: String(subscription.regdate || ""), + }, + }; + } + + /** + * Get cancellation preview with available months and service details + */ + async getCancellationPreview( + userId: string, + subscriptionId: number + ): Promise { + const { whmcsClientId, subscription } = await this.validateInternetSubscription( + userId, + subscriptionId + ); + + // Get customer info from WHMCS + const clientDetails = await this.whmcsService.getClientDetails(whmcsClientId); + const customerName = + `${clientDetails.firstname || ""} ${clientDetails.lastname || ""}`.trim() || "Customer"; + const customerEmail = clientDetails.email || ""; + + return { + productName: subscription.productName, + billingAmount: subscription.amount, + nextDueDate: subscription.nextDue, + registrationDate: subscription.registrationDate, + availableMonths: this.generateCancellationMonths(), + customerEmail, + customerName, + }; + } + + /** + * Submit Internet cancellation request + * + * Creates a Salesforce Case and updates the Opportunity (if found) + */ + async submitCancellation( + userId: string, + subscriptionId: number, + request: InternetCancelRequest + ): Promise { + const { whmcsClientId, sfAccountId, subscription } = await this.validateInternetSubscription( + userId, + subscriptionId + ); + + // Validate confirmations + if (!request.confirmRead || !request.confirmCancel) { + throw new BadRequestException("You must confirm both checkboxes to proceed"); + } + + // Parse cancellation month and calculate end date + const [year, month] = request.cancellationMonth.split("-").map(Number); + if (!year || !month) { + throw new BadRequestException("Invalid cancellation month format"); + } + + // Cancellation date is end of selected month + const lastDayOfMonth = new Date(year, month, 0); + // Use local date components to avoid timezone shifts when converting to string + const cancellationDate = [ + lastDayOfMonth.getFullYear(), + String(lastDayOfMonth.getMonth() + 1).padStart(2, "0"), + String(lastDayOfMonth.getDate()).padStart(2, "0"), + ].join("-"); + + this.logger.log("Processing Internet cancellation request", { + userId, + subscriptionId, + cancellationMonth: request.cancellationMonth, + cancellationDate, + }); + + // Get customer info for notifications + const clientDetails = await this.whmcsService.getClientDetails(whmcsClientId); + const customerName = + `${clientDetails.firstname || ""} ${clientDetails.lastname || ""}`.trim() || "Customer"; + const customerEmail = clientDetails.email || ""; + + // Find existing Opportunity for this subscription (by WHMCS Service ID) + let opportunityId: string | null = null; + try { + opportunityId = await this.opportunityService.findOpportunityByWhmcsServiceId(subscriptionId); + } catch { + // Opportunity lookup failure is not fatal - we'll create Case without link + this.logger.warn("Could not find Opportunity for subscription", { subscriptionId }); + } + + // Create Salesforce Case for cancellation + const caseId = await this.caseService.createCancellationCase({ + accountId: sfAccountId, + opportunityId: opportunityId || undefined, + whmcsServiceId: subscriptionId, + productType: "Internet", + cancellationMonth: request.cancellationMonth, + cancellationDate, + alternativeEmail: request.alternativeEmail || undefined, + comments: request.comments, + }); + + this.logger.log("Cancellation case created", { + caseId, + opportunityId, + }); + + try { + await this.notifications.createNotification({ + userId, + type: NOTIFICATION_TYPE.CANCELLATION_SCHEDULED, + source: NOTIFICATION_SOURCE.SYSTEM, + sourceId: caseId, + actionUrl: `/account/services/${subscriptionId}`, + }); + } catch (error) { + this.logger.warn("Failed to create cancellation notification", { + userId, + subscriptionId, + caseId, + error: error instanceof Error ? error.message : String(error), + }); + } + + // Update Opportunity if found + if (opportunityId) { + try { + const cancellationData: CancellationOpportunityData = { + scheduledCancellationDate: `${cancellationDate}T23:59:59.000Z`, + cancellationNotice: CANCELLATION_NOTICE.RECEIVED, + lineReturnStatus: LINE_RETURN_STATUS.NOT_YET, + }; + + await this.opportunityService.updateCancellationData(opportunityId, cancellationData); + + this.logger.log("Opportunity updated with cancellation data", { + opportunityId, + scheduledDate: cancellationDate, + }); + } catch (error) { + // Log but don't fail - Case was already created + this.logger.error("Failed to update Opportunity cancellation data", { + error: error instanceof Error ? error.message : "Unknown error", + opportunityId, + }); + } + } + + // Send confirmation email to customer + const confirmationSubject = "SonixNet Internet Cancellation Confirmation"; + const confirmationBody = `Dear ${customerName}, + +Your cancellation request for your Internet service has been received. + +Service: ${subscription.productName} +Cancellation effective: End of ${request.cancellationMonth} + +Our team will contact you regarding equipment return (ONU/router) if applicable. + +If you have any questions, please contact us at info@asolutions.co.jp + +With best regards, +Assist Solutions Customer Support +TEL: 0120-660-470 (Mon-Fri / 10AM-6PM) +Email: info@asolutions.co.jp`; + + try { + await this.emailService.sendEmail({ + to: customerEmail, + subject: confirmationSubject, + text: confirmationBody, + }); + + // Send to alternative email if provided + if (request.alternativeEmail && request.alternativeEmail !== customerEmail) { + await this.emailService.sendEmail({ + to: request.alternativeEmail, + subject: confirmationSubject, + text: confirmationBody, + }); + } + } catch (error) { + // Log but don't fail - Case was already created + this.logger.error("Failed to send cancellation confirmation email", { + error: error instanceof Error ? error.message : "Unknown error", + customerEmail, + }); + } + + this.logger.log("Internet cancellation request processed successfully", { + userId, + subscriptionId, + caseId, + cancellationMonth: request.cancellationMonth, + }); + } +} diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts index 175a4a2c..c7caadb9 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts @@ -9,6 +9,8 @@ import type { SimCancelRequest, SimCancelFullRequest } from "@customer-portal/do import { SimScheduleService } from "./sim-schedule.service.js"; import { SimActionRunnerService } from "./sim-action-runner.service.js"; import { SimApiNotificationService } from "./sim-api-notification.service.js"; +import { NotificationService } from "@bff/modules/notifications/notifications.service.js"; +import { NOTIFICATION_SOURCE, NOTIFICATION_TYPE } from "@customer-portal/domain/notifications"; export interface CancellationMonth { value: string; // YYYY-MM format @@ -38,6 +40,7 @@ export class SimCancellationService { private readonly simSchedule: SimScheduleService, private readonly simActionRunner: SimActionRunnerService, private readonly apiNotification: SimApiNotificationService, + private readonly notifications: NotificationService, private readonly configService: ConfigService, @Inject(Logger) private readonly logger: Logger ) {} @@ -254,6 +257,24 @@ export class SimCancellationService { runDate, }); + try { + await this.notifications.createNotification({ + userId, + type: NOTIFICATION_TYPE.CANCELLATION_SCHEDULED, + source: NOTIFICATION_SOURCE.SYSTEM, + sourceId: `sim:${subscriptionId}:${runDate}`, + actionUrl: `/account/services/${subscriptionId}`, + }); + } catch (error) { + this.logger.warn("Failed to create SIM cancellation notification", { + userId, + subscriptionId, + account, + runDate, + error: error instanceof Error ? error.message : String(error), + }); + } + // Send admin notification email const adminEmailBody = this.apiNotification.buildCancellationAdminEmail({ customerName, diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-plan.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-plan.service.ts index 1e1416a1..b2395014 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-plan.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-plan.service.ts @@ -12,8 +12,8 @@ import { SimScheduleService } from "./sim-schedule.service.js"; import { SimActionRunnerService } from "./sim-action-runner.service.js"; import { SimManagementQueueService } from "../queue/sim-management.queue.js"; import { SimApiNotificationService } from "./sim-api-notification.service.js"; -import { SimCatalogService } from "@bff/modules/catalog/services/sim-catalog.service.js"; -import type { SimCatalogProduct } from "@customer-portal/domain/catalog"; +import { SimServicesService } from "@bff/modules/services/services/sim-services.service.js"; +import type { SimCatalogProduct } from "@customer-portal/domain/services"; // Mapping from Salesforce SKU to Freebit plan code const SKU_TO_FREEBIT_PLAN_CODE: Record = { @@ -47,7 +47,7 @@ export class SimPlanService { private readonly simActionRunner: SimActionRunnerService, private readonly simQueue: SimManagementQueueService, private readonly apiNotification: SimApiNotificationService, - private readonly simCatalog: SimCatalogService, + private readonly simCatalog: SimServicesService, private readonly configService: ConfigService, @Inject(Logger) private readonly logger: Logger ) {} diff --git a/apps/bff/src/modules/subscriptions/sim-management/sim-management.module.ts b/apps/bff/src/modules/subscriptions/sim-management/sim-management.module.ts index bf615f04..3eec6618 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/sim-management.module.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/sim-management.module.ts @@ -27,7 +27,8 @@ import { SimManagementQueueService } from "./queue/sim-management.queue.js"; import { SimManagementProcessor } from "./queue/sim-management.processor.js"; import { SimVoiceOptionsService } from "./services/sim-voice-options.service.js"; import { SimCallHistoryService } from "./services/sim-call-history.service.js"; -import { CatalogModule } from "@bff/modules/catalog/catalog.module.js"; +import { ServicesModule } from "@bff/modules/services/services.module.js"; +import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js"; @Module({ imports: [ @@ -36,8 +37,9 @@ import { CatalogModule } from "@bff/modules/catalog/catalog.module.js"; SalesforceModule, MappingsModule, EmailModule, - CatalogModule, + ServicesModule, SftpModule, + NotificationsModule, ], providers: [ // Core services that the SIM services depend on diff --git a/apps/bff/src/modules/subscriptions/subscriptions.controller.ts b/apps/bff/src/modules/subscriptions/subscriptions.controller.ts index f0c6b8df..95558164 100644 --- a/apps/bff/src/modules/subscriptions/subscriptions.controller.ts +++ b/apps/bff/src/modules/subscriptions/subscriptions.controller.ts @@ -51,6 +51,12 @@ import { type ReissueSimRequest, } from "./sim-management/services/esim-management.service.js"; import { SimCallHistoryService } from "./sim-management/services/sim-call-history.service.js"; +import { InternetCancellationService } from "./internet-management/services/internet-cancellation.service.js"; +import { + internetCancelRequestSchema, + type InternetCancelRequest, + type SimActionResponse as SubscriptionActionResponse, +} from "@customer-portal/domain/subscriptions"; const subscriptionInvoiceQuerySchema = createPaginationSchema({ defaultLimit: 10, @@ -68,7 +74,8 @@ export class SubscriptionsController { private readonly simPlanService: SimPlanService, private readonly simCancellationService: SimCancellationService, private readonly esimManagementService: EsimManagementService, - private readonly simCallHistoryService: SimCallHistoryService + private readonly simCallHistoryService: SimCallHistoryService, + private readonly internetCancellationService: InternetCancellationService ) {} @Get() @@ -377,6 +384,41 @@ export class SubscriptionsController { } } + // ==================== Internet Management Endpoints ==================== + + /** + * Get Internet cancellation preview (available months, service details) + */ + @Get(":id/internet/cancellation-preview") + @Header("Cache-Control", "private, max-age=60") + async getInternetCancellationPreview( + @Request() req: RequestWithUser, + @Param("id", ParseIntPipe) subscriptionId: number + ) { + const preview = await this.internetCancellationService.getCancellationPreview( + req.user.id, + subscriptionId + ); + return { success: true, data: preview }; + } + + /** + * Submit Internet cancellation request + */ + @Post(":id/internet/cancel") + @UsePipes(new ZodValidationPipe(internetCancelRequestSchema)) + async cancelInternet( + @Request() req: RequestWithUser, + @Param("id", ParseIntPipe) subscriptionId: number, + @Body() body: InternetCancelRequest + ): Promise { + await this.internetCancellationService.submitCancellation(req.user.id, subscriptionId, body); + return { + success: true, + message: `Internet cancellation scheduled for end of ${body.cancellationMonth}`, + }; + } + // ==================== Call/SMS History Endpoints ==================== /** diff --git a/apps/bff/src/modules/subscriptions/subscriptions.module.ts b/apps/bff/src/modules/subscriptions/subscriptions.module.ts index 13f92bf6..0a0c68ca 100644 --- a/apps/bff/src/modules/subscriptions/subscriptions.module.ts +++ b/apps/bff/src/modules/subscriptions/subscriptions.module.ts @@ -10,9 +10,17 @@ import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js"; import { FreebitModule } from "@bff/integrations/freebit/freebit.module.js"; import { EmailModule } from "@bff/infra/email/email.module.js"; import { SimManagementModule } from "./sim-management/sim-management.module.js"; +import { InternetManagementModule } from "./internet-management/internet-management.module.js"; @Module({ - imports: [WhmcsModule, MappingsModule, FreebitModule, EmailModule, SimManagementModule], + imports: [ + WhmcsModule, + MappingsModule, + FreebitModule, + EmailModule, + SimManagementModule, + InternetManagementModule, + ], controllers: [SubscriptionsController, SimOrdersController], providers: [ SubscriptionsService, diff --git a/apps/bff/src/modules/subscriptions/subscriptions.service.ts b/apps/bff/src/modules/subscriptions/subscriptions.service.ts index 854cdfcf..557a88a8 100644 --- a/apps/bff/src/modules/subscriptions/subscriptions.service.ts +++ b/apps/bff/src/modules/subscriptions/subscriptions.service.ts @@ -12,6 +12,7 @@ import type { } from "@customer-portal/domain/subscriptions"; import type { Invoice, InvoiceItem, InvoiceList } from "@customer-portal/domain/billing"; import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js"; +import { WhmcsCacheService } from "@bff/integrations/whmcs/cache/whmcs-cache.service.js"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; import { Logger } from "nestjs-pino"; import type { Providers } from "@customer-portal/domain/subscriptions"; @@ -26,6 +27,7 @@ export interface GetSubscriptionsOptions { export class SubscriptionsService { constructor( private readonly whmcsService: WhmcsService, + private readonly cacheService: WhmcsCacheService, private readonly mappingsService: MappingsService, @Inject(Logger) private readonly logger: Logger ) {} @@ -316,6 +318,20 @@ export class SubscriptionsService { const batchSize = Math.min(100, Math.max(limit, 25)); try { + // Try cache first + const cached = await this.cacheService.getSubscriptionInvoices( + userId, + subscriptionId, + page, + limit + ); + if (cached) { + this.logger.debug( + `Cache hit for subscription invoices: user ${userId}, subscription ${subscriptionId}` + ); + return cached; + } + // Validate subscription exists and belongs to user await this.getSubscriptionById(userId, subscriptionId); @@ -380,6 +396,9 @@ export class SubscriptionsService { } ); + // Cache the result + await this.cacheService.setSubscriptionInvoices(userId, subscriptionId, page, limit, result); + return result; } catch (error) { this.logger.error(`Failed to get invoices for subscription ${subscriptionId}`, { 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/bff/src/modules/users/infra/user-profile.service.ts b/apps/bff/src/modules/users/infra/user-profile.service.ts index ff8a6f01..da214de0 100644 --- a/apps/bff/src/modules/users/infra/user-profile.service.ts +++ b/apps/bff/src/modules/users/infra/user-profile.service.ts @@ -204,9 +204,13 @@ export class UserProfileService { return summary; } - const [subscriptionsData, invoicesData] = await Promise.allSettled([ + const [subscriptionsData, invoicesData, unpaidInvoicesData] = await Promise.allSettled([ this.whmcsService.getSubscriptions(mapping.whmcsClientId, userId), - this.whmcsService.getInvoices(mapping.whmcsClientId, userId, { limit: 50 }), + this.whmcsService.getInvoices(mapping.whmcsClientId, userId, { limit: 10 }), + this.whmcsService.getInvoices(mapping.whmcsClientId, userId, { + status: "Unpaid", + limit: 1, + }), ]); let activeSubscriptions = 0; @@ -256,12 +260,25 @@ export class UserProfileService { paidDate?: string; currency?: string | null; }> = []; + + // Process unpaid invoices count + if (unpaidInvoicesData.status === "fulfilled") { + unpaidInvoices = unpaidInvoicesData.value.pagination.totalItems; + } else { + this.logger.error(`Failed to fetch unpaid invoices count for user ${userId}`, { + reason: getErrorMessage(unpaidInvoicesData.reason), + }); + } + if (invoicesData.status === "fulfilled") { const invoices: Invoice[] = invoicesData.value.invoices; - unpaidInvoices = invoices.filter( - inv => inv.status === "Unpaid" || inv.status === "Overdue" - ).length; + // Fallback if unpaid invoices call failed, though inaccurate for total count > 10 + if (unpaidInvoicesData.status === "rejected") { + unpaidInvoices = invoices.filter( + inv => inv.status === "Unpaid" || inv.status === "Overdue" + ).length; + } const upcomingInvoices = invoices .filter(inv => (inv.status === "Unpaid" || inv.status === "Overdue") && inv.dueDate) diff --git a/apps/bff/src/modules/verification/residence-card.controller.ts b/apps/bff/src/modules/verification/residence-card.controller.ts new file mode 100644 index 00000000..df0db842 --- /dev/null +++ b/apps/bff/src/modules/verification/residence-card.controller.ts @@ -0,0 +1,71 @@ +import { + BadRequestException, + Controller, + Get, + Post, + Req, + UseGuards, + UseInterceptors, + UploadedFile, +} from "@nestjs/common"; +import { FileInterceptor } from "@nestjs/platform-express"; +import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; +import { RateLimit, RateLimitGuard } from "@bff/core/rate-limiting/index.js"; +import { ResidenceCardService } from "./residence-card.service.js"; +import type { ResidenceCardVerification } from "@customer-portal/domain/customer"; + +const MAX_FILE_BYTES = 5 * 1024 * 1024; // 5MB +const ALLOWED_MIME_TYPES = new Set(["image/jpeg", "image/png", "application/pdf"]); + +type UploadedResidenceCard = { + originalname: string; + mimetype: string; + size: number; + buffer: Buffer; +}; + +@Controller("verification/residence-card") +@UseGuards(RateLimitGuard) +export class ResidenceCardController { + constructor(private readonly residenceCards: ResidenceCardService) {} + + @Get() + @RateLimit({ limit: 60, ttl: 60 }) + async getStatus(@Req() req: RequestWithUser): Promise { + return this.residenceCards.getStatusForUser(req.user.id); + } + + @Post() + @RateLimit({ limit: 3, ttl: 300 }) + @UseInterceptors( + FileInterceptor("file", { + limits: { fileSize: MAX_FILE_BYTES }, + fileFilter: (_req, file, cb) => { + if (!ALLOWED_MIME_TYPES.has(file.mimetype)) { + cb( + new BadRequestException("Unsupported file type. Please upload a JPG, PNG, or PDF."), + false + ); + return; + } + cb(null, true); + }, + }) + ) + async submit( + @Req() req: RequestWithUser, + @UploadedFile() file?: UploadedResidenceCard + ): Promise { + if (!file) { + throw new BadRequestException("Missing file upload."); + } + + return this.residenceCards.submitForUser({ + userId: req.user.id, + filename: file.originalname || "residence-card", + mimeType: file.mimetype, + sizeBytes: file.size, + content: file.buffer as unknown as Uint8Array, + }); + } +} diff --git a/apps/bff/src/modules/verification/residence-card.service.ts b/apps/bff/src/modules/verification/residence-card.service.ts new file mode 100644 index 00000000..11b79001 --- /dev/null +++ b/apps/bff/src/modules/verification/residence-card.service.ts @@ -0,0 +1,281 @@ +import { Injectable, Inject } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Logger } from "nestjs-pino"; +import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js"; +import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; +import { + assertSalesforceId, + assertSoqlFieldName, +} from "@bff/integrations/salesforce/utils/soql.util.js"; +import type { SalesforceResponse } from "@customer-portal/domain/common"; +import { + residenceCardVerificationSchema, + type ResidenceCardVerification, + type ResidenceCardVerificationStatus, +} from "@customer-portal/domain/customer"; +import { getErrorMessage } from "@bff/core/utils/error.util.js"; +import { basename, extname } from "node:path"; + +function mapFileTypeToMime(fileType?: string | null): string | null { + const normalized = String(fileType || "") + .trim() + .toLowerCase(); + if (normalized === "pdf") return "application/pdf"; + if (normalized === "png") return "image/png"; + if (normalized === "jpg" || normalized === "jpeg") return "image/jpeg"; + return null; +} + +@Injectable() +export class ResidenceCardService { + constructor( + private readonly sf: SalesforceConnection, + private readonly mappings: MappingsService, + private readonly config: ConfigService, + @Inject(Logger) private readonly logger: Logger + ) {} + + async getStatusForUser(userId: string): Promise { + const mapping = await this.mappings.findByUserId(userId); + const sfAccountId = mapping?.sfAccountId + ? assertSalesforceId(mapping.sfAccountId, "sfAccountId") + : null; + if (!sfAccountId) { + return residenceCardVerificationSchema.parse({ + status: "not_submitted", + filename: null, + mimeType: null, + sizeBytes: null, + submittedAt: null, + reviewedAt: null, + reviewerNotes: null, + }); + } + + const fields = this.getAccountFieldNames(); + const soql = ` + SELECT Id, ${fields.status}, ${fields.submittedAt}, ${fields.verifiedAt}, ${fields.note}, ${fields.rejectionMessage} + FROM Account + WHERE Id = '${sfAccountId}' + LIMIT 1 + `; + + const accountRes = (await this.sf.query(soql, { + label: "verification:residence_card:account", + })) as SalesforceResponse>; + + const account = (accountRes.records?.[0] as Record | undefined) ?? undefined; + const statusRaw = account ? account[fields.status] : undefined; + const statusText = typeof statusRaw === "string" ? statusRaw.trim().toLowerCase() : ""; + + const status: ResidenceCardVerificationStatus = + statusText === "verified" + ? "verified" + : statusText === "rejected" + ? "rejected" + : statusText === "submitted" + ? "pending" + : statusText === "not submitted" || statusText === "not_submitted" || statusText === "" + ? "not_submitted" + : "pending"; + + const submittedAtRaw = account ? account[fields.submittedAt] : undefined; + const verifiedAtRaw = account ? account[fields.verifiedAt] : undefined; + const noteRaw = account ? account[fields.note] : undefined; + const rejectionRaw = account ? account[fields.rejectionMessage] : undefined; + + const submittedAt = + typeof submittedAtRaw === "string" + ? submittedAtRaw + : submittedAtRaw instanceof Date + ? submittedAtRaw.toISOString() + : null; + const reviewedAt = + typeof verifiedAtRaw === "string" + ? verifiedAtRaw + : verifiedAtRaw instanceof Date + ? verifiedAtRaw.toISOString() + : null; + + const reviewerNotes = + typeof rejectionRaw === "string" && rejectionRaw.trim().length > 0 + ? rejectionRaw.trim() + : typeof noteRaw === "string" && noteRaw.trim().length > 0 + ? noteRaw.trim() + : null; + + const fileMeta = + status === "not_submitted" ? null : await this.getLatestAccountFileMetadata(sfAccountId); + + return residenceCardVerificationSchema.parse({ + status, + filename: fileMeta?.filename ?? null, + mimeType: fileMeta?.mimeType ?? null, + sizeBytes: typeof fileMeta?.sizeBytes === "number" ? fileMeta.sizeBytes : null, + submittedAt: submittedAt ?? fileMeta?.submittedAt ?? null, + reviewedAt, + reviewerNotes, + }); + } + + async submitForUser(params: { + userId: string; + filename: string; + mimeType: string; + sizeBytes: number; + content: Uint8Array; + }): Promise { + const mapping = await this.mappings.findByUserId(params.userId); + if (!mapping?.sfAccountId) { + throw new Error("No Salesforce mapping found for current user"); + } + const sfAccountId = assertSalesforceId(mapping.sfAccountId, "sfAccountId"); + + const fileBuffer = Buffer.from(params.content as unknown as Uint8Array); + const versionData = fileBuffer.toString("base64"); + const extension = extname(params.filename || "").replace(/^\./, ""); + const title = basename(params.filename || "residence-card", extension ? `.${extension}` : ""); + + const create = this.sf.sobject("ContentVersion")?.create; + if (!create) { + throw new Error("Salesforce ContentVersion create method not available"); + } + + try { + const result = await create({ + Title: title || "residence-card", + PathOnClient: params.filename || "residence-card", + VersionData: versionData, + FirstPublishLocationId: sfAccountId, + }); + const id = (result as { id?: unknown })?.id; + if (typeof id !== "string" || id.trim().length === 0) { + throw new Error("Salesforce did not return a ContentVersion id"); + } + } catch (error) { + this.logger.error("Failed to upload residence card to Salesforce Files", { + userId: params.userId, + sfAccountIdTail: sfAccountId.slice(-4), + error: getErrorMessage(error), + }); + throw new Error("Failed to submit residence card. Please try again later."); + } + + const fields = this.getAccountFieldNames(); + const update = this.sf.sobject("Account")?.update; + if (!update) { + throw new Error("Salesforce Account update method not available"); + } + + await update({ + Id: sfAccountId, + [fields.status]: "Submitted", + [fields.submittedAt]: new Date().toISOString(), + [fields.rejectionMessage]: null, + [fields.note]: null, + }); + + return this.getStatusForUser(params.userId); + } + + private getAccountFieldNames(): { + status: string; + submittedAt: string; + verifiedAt: string; + note: string; + rejectionMessage: string; + } { + return { + status: assertSoqlFieldName( + this.config.get("ACCOUNT_ID_VERIFICATION_STATUS_FIELD") ?? + "Id_Verification_Status__c", + "ACCOUNT_ID_VERIFICATION_STATUS_FIELD" + ), + submittedAt: assertSoqlFieldName( + this.config.get("ACCOUNT_ID_VERIFICATION_SUBMITTED_AT_FIELD") ?? + "Id_Verification_Submitted_Date_Time__c", + "ACCOUNT_ID_VERIFICATION_SUBMITTED_AT_FIELD" + ), + verifiedAt: assertSoqlFieldName( + this.config.get("ACCOUNT_ID_VERIFICATION_VERIFIED_AT_FIELD") ?? + "Id_Verification_Verified_Date_Time__c", + "ACCOUNT_ID_VERIFICATION_VERIFIED_AT_FIELD" + ), + note: assertSoqlFieldName( + this.config.get("ACCOUNT_ID_VERIFICATION_NOTE_FIELD") ?? "Id_Verification_Note__c", + "ACCOUNT_ID_VERIFICATION_NOTE_FIELD" + ), + rejectionMessage: assertSoqlFieldName( + this.config.get("ACCOUNT_ID_VERIFICATION_REJECTION_MESSAGE_FIELD") ?? + "Id_Verification_Rejection_Message__c", + "ACCOUNT_ID_VERIFICATION_REJECTION_MESSAGE_FIELD" + ), + }; + } + + private async getLatestAccountFileMetadata(accountId: string): Promise<{ + filename: string | null; + mimeType: string | null; + sizeBytes: number | null; + submittedAt: string | null; + } | null> { + try { + const linkSoql = ` + SELECT ContentDocumentId + FROM ContentDocumentLink + WHERE LinkedEntityId = '${accountId}' + ORDER BY SystemModstamp DESC + LIMIT 1 + `; + const linkRes = (await this.sf.query(linkSoql, { + label: "verification:residence_card:latest_link", + })) as SalesforceResponse<{ ContentDocumentId?: string }>; + const documentId = linkRes.records?.[0]?.ContentDocumentId; + if (!documentId) return null; + + const versionSoql = ` + SELECT Title, FileExtension, FileType, ContentSize, CreatedDate + FROM ContentVersion + WHERE ContentDocumentId = '${documentId}' + ORDER BY CreatedDate DESC + LIMIT 1 + `; + const versionRes = (await this.sf.query(versionSoql, { + label: "verification:residence_card:latest_version", + })) as SalesforceResponse>; + const version = (versionRes.records?.[0] as Record | undefined) ?? undefined; + if (!version) return null; + + const title = typeof version.Title === "string" ? version.Title.trim() : ""; + const ext = typeof version.FileExtension === "string" ? version.FileExtension.trim() : ""; + const fileType = typeof version.FileType === "string" ? version.FileType.trim() : ""; + const sizeBytes = typeof version.ContentSize === "number" ? version.ContentSize : null; + const createdDateRaw = version.CreatedDate; + const submittedAt = + typeof createdDateRaw === "string" + ? createdDateRaw + : createdDateRaw instanceof Date + ? createdDateRaw.toISOString() + : null; + + const filename = title + ? ext && !title.toLowerCase().endsWith(`.${ext.toLowerCase()}`) + ? `${title}.${ext}` + : title + : null; + + return { + filename, + mimeType: mapFileTypeToMime(fileType) ?? mapFileTypeToMime(ext) ?? null, + sizeBytes, + submittedAt, + }; + } catch (error) { + this.logger.warn("Failed to load residence card file metadata from Salesforce", { + accountIdTail: accountId.slice(-4), + error: getErrorMessage(error), + }); + return null; + } + } +} diff --git a/apps/bff/src/modules/verification/verification.module.ts b/apps/bff/src/modules/verification/verification.module.ts new file mode 100644 index 00000000..7b704b31 --- /dev/null +++ b/apps/bff/src/modules/verification/verification.module.ts @@ -0,0 +1,14 @@ +import { Module } from "@nestjs/common"; +import { ResidenceCardController } from "./residence-card.controller.js"; +import { ResidenceCardService } from "./residence-card.service.js"; +import { IntegrationsModule } from "@bff/integrations/integrations.module.js"; +import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js"; +import { CoreConfigModule } from "@bff/core/config/config.module.js"; + +@Module({ + imports: [IntegrationsModule, MappingsModule, CoreConfigModule], + controllers: [ResidenceCardController], + providers: [ResidenceCardService], + exports: [ResidenceCardService], +}) +export class VerificationModule {} diff --git a/apps/bff/tsconfig.build.json b/apps/bff/tsconfig.build.json index 352367ef..d1129028 100644 --- a/apps/bff/tsconfig.build.json +++ b/apps/bff/tsconfig.build.json @@ -6,6 +6,6 @@ "rootDir": "./src", "sourceMap": true }, - "include": ["src/**/*"], + "include": ["src/**/*.ts"], "exclude": ["node_modules", "dist", "test", "**/*.spec.ts", "**/*.test.ts"] } diff --git a/apps/bff/tsconfig.json b/apps/bff/tsconfig.json index 0d09c85c..a3db0387 100644 --- a/apps/bff/tsconfig.json +++ b/apps/bff/tsconfig.json @@ -15,6 +15,6 @@ "noEmit": true, "types": ["node"] }, - "include": ["src/**/*"], + "include": ["src/**/*.ts"], "exclude": ["node_modules", "dist", "prisma", "test", "**/*.spec.ts", "**/*.test.ts"] } 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/next.config.mjs b/apps/portal/next.config.mjs index 3d5252aa..cb2a54e4 100644 --- a/apps/portal/next.config.mjs +++ b/apps/portal/next.config.mjs @@ -1,4 +1,3 @@ -/* eslint-env node */ import path from "node:path"; import { fileURLToPath } from "node:url"; diff --git a/apps/portal/package.json b/apps/portal/package.json index f47fea34..d3288faa 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -5,8 +5,8 @@ "scripts": { "predev": "node ./scripts/dev-prep.mjs", "dev": "next dev -p ${NEXT_PORT:-3000}", - "build": "next build", - "build:webpack": "next build --webpack", + "build": "next build --webpack", + "build:turbo": "next build", "build:analyze": "ANALYZE=true next build", "analyze": "pnpm run build:analyze", "start": "next start -p ${NEXT_PORT:-3000}", @@ -24,6 +24,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "lucide-react": "^0.562.0", "next": "16.0.10", "react": "19.2.1", "react-dom": "19.2.1", diff --git a/apps/portal/scripts/bundle-monitor.mjs b/apps/portal/scripts/bundle-monitor.mjs index 1fe77fb5..882ca6dd 100644 --- a/apps/portal/scripts/bundle-monitor.mjs +++ b/apps/portal/scripts/bundle-monitor.mjs @@ -1,6 +1,4 @@ #!/usr/bin/env node -/* eslint-env node */ - /** * Bundle size monitoring script * Analyzes bundle size and reports on performance metrics diff --git a/apps/portal/scripts/dev-prep.mjs b/apps/portal/scripts/dev-prep.mjs index 9923b8e2..9b7da345 100644 --- a/apps/portal/scripts/dev-prep.mjs +++ b/apps/portal/scripts/dev-prep.mjs @@ -1,6 +1,4 @@ #!/usr/bin/env node -/* eslint-env node */ - // Ensure dev-time Next.js manifests exist to avoid noisy ENOENT errors import { mkdirSync, existsSync, writeFileSync, rmSync } from "fs"; import { join } from "path"; diff --git a/apps/portal/scripts/test-request-password-reset.cjs b/apps/portal/scripts/test-request-password-reset.cjs index f1503d89..85091ccb 100755 --- a/apps/portal/scripts/test-request-password-reset.cjs +++ b/apps/portal/scripts/test-request-password-reset.cjs @@ -1,6 +1,4 @@ #!/usr/bin/env node -/* eslint-env node */ - const fs = require("node:fs"); const path = require("node:path"); const Module = require("node:module"); diff --git a/apps/portal/src/app/(authenticated)/account/page.tsx b/apps/portal/src/app/(authenticated)/account/page.tsx deleted file mode 100644 index ed663a5b..00000000 --- a/apps/portal/src/app/(authenticated)/account/page.tsx +++ /dev/null @@ -1,7 +0,0 @@ -"use client"; - -import ProfileContainer from "@/features/account/views/ProfileContainer"; - -export default function AccountPage() { - return ; -} diff --git a/apps/portal/src/app/(authenticated)/catalog/internet/configure/page.tsx b/apps/portal/src/app/(authenticated)/catalog/internet/configure/page.tsx deleted file mode 100644 index 6029868b..00000000 --- a/apps/portal/src/app/(authenticated)/catalog/internet/configure/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import InternetConfigureContainer from "@/features/catalog/views/InternetConfigure"; - -export default function InternetConfigurePage() { - return ; -} diff --git a/apps/portal/src/app/(authenticated)/catalog/internet/page.tsx b/apps/portal/src/app/(authenticated)/catalog/internet/page.tsx deleted file mode 100644 index c2181889..00000000 --- a/apps/portal/src/app/(authenticated)/catalog/internet/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { InternetPlansContainer } from "@/features/catalog/views/InternetPlans"; - -export default function InternetPlansPage() { - return ; -} diff --git a/apps/portal/src/app/(authenticated)/catalog/loading.tsx b/apps/portal/src/app/(authenticated)/catalog/loading.tsx deleted file mode 100644 index 388d7699..00000000 --- a/apps/portal/src/app/(authenticated)/catalog/loading.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { RouteLoading } from "@/components/molecules/RouteLoading"; -import { Squares2X2Icon } from "@heroicons/react/24/outline"; -import { LoadingCard } from "@/components/atoms/loading-skeleton"; - -export default function CatalogLoading() { - return ( - } - title="Catalog" - description="Loading catalog..." - mode="content" - > -
- {Array.from({ length: 6 }).map((_, i) => ( - - ))} -
-
- ); -} diff --git a/apps/portal/src/app/(authenticated)/catalog/page.tsx b/apps/portal/src/app/(authenticated)/catalog/page.tsx deleted file mode 100644 index 970a2c73..00000000 --- a/apps/portal/src/app/(authenticated)/catalog/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import CatalogHomeView from "@/features/catalog/views/CatalogHome"; - -export default function CatalogPage() { - return ; -} diff --git a/apps/portal/src/app/(authenticated)/catalog/sim/configure/page.tsx b/apps/portal/src/app/(authenticated)/catalog/sim/configure/page.tsx deleted file mode 100644 index d36bdbbb..00000000 --- a/apps/portal/src/app/(authenticated)/catalog/sim/configure/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import SimConfigureContainer from "@/features/catalog/views/SimConfigure"; - -export default function SimConfigurePage() { - return ; -} diff --git a/apps/portal/src/app/(authenticated)/catalog/sim/page.tsx b/apps/portal/src/app/(authenticated)/catalog/sim/page.tsx deleted file mode 100644 index a04d19d3..00000000 --- a/apps/portal/src/app/(authenticated)/catalog/sim/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import SimPlansView from "@/features/catalog/views/SimPlans"; - -export default function SimCatalogPage() { - return ; -} diff --git a/apps/portal/src/app/(authenticated)/catalog/vpn/page.tsx b/apps/portal/src/app/(authenticated)/catalog/vpn/page.tsx deleted file mode 100644 index 1486c847..00000000 --- a/apps/portal/src/app/(authenticated)/catalog/vpn/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import VpnPlansView from "@/features/catalog/views/VpnPlans"; - -export default function VpnCatalogPage() { - return ; -} diff --git a/apps/portal/src/app/(authenticated)/checkout/loading.tsx b/apps/portal/src/app/(authenticated)/checkout/loading.tsx deleted file mode 100644 index 2a272cfb..00000000 --- a/apps/portal/src/app/(authenticated)/checkout/loading.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { RouteLoading } from "@/components/molecules/RouteLoading"; -import { ShieldCheckIcon } from "@heroicons/react/24/outline"; -import { LoadingCard, Skeleton } from "@/components/atoms/loading-skeleton"; - -export default function CheckoutLoading() { - return ( - } - title="Checkout" - description="Verifying details and preparing your order..." - mode="content" - > -
- - -
- - -
-
-
- ); -} diff --git a/apps/portal/src/app/(authenticated)/checkout/page.tsx b/apps/portal/src/app/(authenticated)/checkout/page.tsx deleted file mode 100644 index dab56689..00000000 --- a/apps/portal/src/app/(authenticated)/checkout/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import CheckoutContainer from "@/features/checkout/views/CheckoutContainer"; - -export default function CheckoutPage() { - return ; -} diff --git a/apps/portal/src/app/(authenticated)/dashboard/loading.tsx b/apps/portal/src/app/(authenticated)/dashboard/loading.tsx deleted file mode 100644 index 72cc930e..00000000 --- a/apps/portal/src/app/(authenticated)/dashboard/loading.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { RouteLoading } from "@/components/molecules/RouteLoading"; -import { HomeIcon } from "@heroicons/react/24/outline"; -import { LoadingStats, LoadingCard } from "@/components/atoms/loading-skeleton"; - -export default function DashboardLoading() { - return ( - } - title="Dashboard" - description="Loading your overview..." - mode="content" - > -
- -
-
- - -
-
- - -
-
-
-
- ); -} diff --git a/apps/portal/src/app/(authenticated)/orders/[id]/loading.tsx b/apps/portal/src/app/(authenticated)/orders/[id]/loading.tsx deleted file mode 100644 index 9645aded..00000000 --- a/apps/portal/src/app/(authenticated)/orders/[id]/loading.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { RouteLoading } from "@/components/molecules/RouteLoading"; -import { ClipboardDocumentCheckIcon, ArrowLeftIcon } from "@heroicons/react/24/outline"; -import { Button } from "@/components/atoms/button"; - -export default function OrderDetailLoading() { - return ( - } - title="Order Details" - description="Loading order details..." - mode="content" - > -
- -
- -
-
- {/* Header Section */} -
-
- {/* Left: Title & Status */} -
-
-
-
-
-
-
- - {/* Right: Pricing Section */} -
-
-
-
-
-
-
-
-
-
-
-
- - {/* Body Section */} -
-
- {/* Order Items Section */} -
-
-
- {/* Item 1 */} -
-
-
-
-
-
-
-
-
-
-
-
-
-
- - {/* Item 2 */} -
-
-
-
-
-
-
-
-
-
-
-
-
-
- - {/* Item 3 */} -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- - ); -} diff --git a/apps/portal/src/app/(authenticated)/subscriptions/[id]/loading.tsx b/apps/portal/src/app/(authenticated)/subscriptions/[id]/loading.tsx deleted file mode 100644 index cf53e385..00000000 --- a/apps/portal/src/app/(authenticated)/subscriptions/[id]/loading.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { RouteLoading } from "@/components/molecules/RouteLoading"; -import { ServerIcon } from "@heroicons/react/24/outline"; -import { LoadingCard } from "@/components/atoms/loading-skeleton"; - -export default function SubscriptionDetailLoading() { - return ( - } - title="Subscription" - description="Subscription details" - mode="content" - > -
- - -
-
- ); -} diff --git a/apps/portal/src/app/(authenticated)/subscriptions/loading.tsx b/apps/portal/src/app/(authenticated)/subscriptions/loading.tsx deleted file mode 100644 index 82717262..00000000 --- a/apps/portal/src/app/(authenticated)/subscriptions/loading.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { RouteLoading } from "@/components/molecules/RouteLoading"; -import { ServerIcon } from "@heroicons/react/24/outline"; -import { LoadingTable } from "@/components/atoms/loading-skeleton"; - -export default function SubscriptionsLoading() { - return ( - } - title="Subscriptions" - description="View and manage your subscriptions" - mode="content" - > - - - ); -} diff --git a/apps/portal/src/app/(authenticated)/support/page.tsx b/apps/portal/src/app/(authenticated)/support/page.tsx deleted file mode 100644 index 5148a44b..00000000 --- a/apps/portal/src/app/(authenticated)/support/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { SupportHomeView } from "@/features/support"; -import { AgentforceWidget } from "@/components"; - -export default function SupportPage() { - return ( - <> - - - - ); -} - diff --git a/apps/portal/src/app/(public)/(site)/about/page.tsx b/apps/portal/src/app/(public)/(site)/about/page.tsx new file mode 100644 index 00000000..f95bf359 --- /dev/null +++ b/apps/portal/src/app/(public)/(site)/about/page.tsx @@ -0,0 +1,11 @@ +/** + * Public About Page + * + * Corporate profile and company information. + */ + +import { AboutUsView } from "@/features/marketing/views/AboutUsView"; + +export default function AboutPage() { + return ; +} diff --git a/apps/portal/src/app/(public)/auth/forgot-password/page.tsx b/apps/portal/src/app/(public)/(site)/auth/forgot-password/page.tsx similarity index 100% rename from apps/portal/src/app/(public)/auth/forgot-password/page.tsx rename to apps/portal/src/app/(public)/(site)/auth/forgot-password/page.tsx diff --git a/apps/portal/src/app/(public)/auth/loading.tsx b/apps/portal/src/app/(public)/(site)/auth/loading.tsx similarity index 100% rename from apps/portal/src/app/(public)/auth/loading.tsx rename to apps/portal/src/app/(public)/(site)/auth/loading.tsx diff --git a/apps/portal/src/app/(public)/auth/login/page.tsx b/apps/portal/src/app/(public)/(site)/auth/login/page.tsx similarity index 100% rename from apps/portal/src/app/(public)/auth/login/page.tsx rename to apps/portal/src/app/(public)/(site)/auth/login/page.tsx diff --git a/apps/portal/src/app/(public)/(site)/auth/migrate/page.tsx b/apps/portal/src/app/(public)/(site)/auth/migrate/page.tsx new file mode 100644 index 00000000..33cf7d4c --- /dev/null +++ b/apps/portal/src/app/(public)/(site)/auth/migrate/page.tsx @@ -0,0 +1,5 @@ +import MigrateAccountView from "@/features/auth/views/MigrateAccountView"; + +export default function MigrateAccountPage() { + return ; +} diff --git a/apps/portal/src/app/(public)/auth/reset-password/page.tsx b/apps/portal/src/app/(public)/(site)/auth/reset-password/page.tsx similarity index 100% rename from apps/portal/src/app/(public)/auth/reset-password/page.tsx rename to apps/portal/src/app/(public)/(site)/auth/reset-password/page.tsx diff --git a/apps/portal/src/app/(public)/auth/set-password/page.tsx b/apps/portal/src/app/(public)/(site)/auth/set-password/page.tsx similarity index 100% rename from apps/portal/src/app/(public)/auth/set-password/page.tsx rename to apps/portal/src/app/(public)/(site)/auth/set-password/page.tsx diff --git a/apps/portal/src/app/(public)/auth/signup/page.tsx b/apps/portal/src/app/(public)/(site)/auth/signup/page.tsx similarity index 100% rename from apps/portal/src/app/(public)/auth/signup/page.tsx rename to apps/portal/src/app/(public)/(site)/auth/signup/page.tsx diff --git a/apps/portal/src/app/(public)/(site)/contact/page.tsx b/apps/portal/src/app/(public)/(site)/contact/page.tsx new file mode 100644 index 00000000..cea3eeee --- /dev/null +++ b/apps/portal/src/app/(public)/(site)/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 ContactPage() { + return ; +} diff --git a/apps/portal/src/app/(public)/(site)/help/page.tsx b/apps/portal/src/app/(public)/(site)/help/page.tsx new file mode 100644 index 00000000..dc45df1d --- /dev/null +++ b/apps/portal/src/app/(public)/(site)/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)/(site)/layout.tsx b/apps/portal/src/app/(public)/(site)/layout.tsx new file mode 100644 index 00000000..a0693a1a --- /dev/null +++ b/apps/portal/src/app/(public)/(site)/layout.tsx @@ -0,0 +1,11 @@ +/** + * Public Site Layout + * + * Landing/auth/help/contact pages using the PublicShell header/footer. + */ + +import { PublicShell } from "@/components/templates"; + +export default function PublicSiteLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/apps/portal/src/app/(public)/loading.tsx b/apps/portal/src/app/(public)/(site)/loading.tsx similarity index 100% rename from apps/portal/src/app/(public)/loading.tsx rename to apps/portal/src/app/(public)/(site)/loading.tsx diff --git a/apps/portal/src/app/(public)/page.tsx b/apps/portal/src/app/(public)/(site)/page.tsx similarity index 100% rename from apps/portal/src/app/(public)/page.tsx rename to apps/portal/src/app/(public)/(site)/page.tsx diff --git a/apps/portal/src/app/(public)/(site)/services/business/page.tsx b/apps/portal/src/app/(public)/(site)/services/business/page.tsx new file mode 100644 index 00000000..dbef92a9 --- /dev/null +++ b/apps/portal/src/app/(public)/(site)/services/business/page.tsx @@ -0,0 +1,92 @@ +import { Button } from "@/components/atoms"; +import { Server, Monitor, Wrench, Globe } from "lucide-react"; + +export default function BusinessSolutionsPage() { + return ( +
+ {/* Header */} +
+

+ Business Solutions +

+

+ We provide comprehensive business solutions including DIA (Dedicated Internet Access) with + SLA and bandwidth guarantees to ensure your business stays connected. +

+
+ +
+ {/* Office LAN Setup */} +
+
+ +
+

Office LAN Setup

+

+ Whether you are upgrading your current LAN for greater bandwidth and reliability or + installing a new LAN for a new facility, Assist Solutions will ensure you make informed + decisions. From cable installation and data switches to configuration of routers and + firewalls, we help you determine a cost-effective and reliable way to do this. +

+
+ + {/* Onsite & Remote Tech Support */} +
+
+ +
+

Onsite & Remote Tech Support

+

+ We provide onsite and remote support to make sure your network is up and running as + quickly as possible. Assist Solutions can help with your IT needs so you can grow your + business with ease and stability. From computer networks to phone and printer + installations, our team will complete your project to your highest satisfaction. +

+
+ + {/* Dedicated Internet Access (DIA) */} +
+
+ +
+

+ Dedicated Internet Access (DIA) +

+

+ Dedicated Internet Access is designed for businesses that need greater Internet capacity + and a dedicated connection between their existing Local Area Network (LAN) and the + public Internet. We are able to provide a bandwidth guarantee with a service level + agreement depending on what is most suitable for your business. +

+
+ + {/* Data Center Service */} +
+
+ +
+

Data Center Service

+

+ Our Data Center Service provides high-quality data center facilities in Equinix (Tokyo + Tennozu Isle) and GDC (Gotenyama) and many value-added network services to help + establish stable infrastructure platforms. This improves both reliability and efficiency + in your company. +

+
+
+ + {/* CTA */} +
+

+ Interested in our Business Solutions? +

+

+ Contact us today to discuss your requirements and how we can help your business grow. +

+ +
+
+ ); +} diff --git a/apps/portal/src/app/(public)/(site)/services/internet/configure/page.tsx b/apps/portal/src/app/(public)/(site)/services/internet/configure/page.tsx new file mode 100644 index 00000000..badebabc --- /dev/null +++ b/apps/portal/src/app/(public)/(site)/services/internet/configure/page.tsx @@ -0,0 +1,17 @@ +/** + * Public Internet Configure Page + * + * Configure internet plan for unauthenticated users. + */ + +import { PublicInternetConfigureView } from "@/features/services/views/PublicInternetConfigure"; +import { RedirectAuthenticatedToAccountServices } from "@/features/services/components/common/RedirectAuthenticatedToAccountServices"; + +export default function PublicInternetConfigurePage() { + return ( + <> + + + + ); +} diff --git a/apps/portal/src/app/(public)/(site)/services/internet/page.tsx b/apps/portal/src/app/(public)/(site)/services/internet/page.tsx new file mode 100644 index 00000000..2e23ef80 --- /dev/null +++ b/apps/portal/src/app/(public)/(site)/services/internet/page.tsx @@ -0,0 +1,17 @@ +/** + * Public Internet Plans Page + * + * Displays internet plans for unauthenticated users. + */ + +import { PublicInternetPlansView } from "@/features/services/views/PublicInternetPlans"; +import { RedirectAuthenticatedToAccountServices } from "@/features/services/components/common/RedirectAuthenticatedToAccountServices"; + +export default function PublicInternetPlansPage() { + return ( + <> + + + + ); +} diff --git a/apps/portal/src/app/(public)/(site)/services/onsite/page.tsx b/apps/portal/src/app/(public)/(site)/services/onsite/page.tsx new file mode 100644 index 00000000..ded38d14 --- /dev/null +++ b/apps/portal/src/app/(public)/(site)/services/onsite/page.tsx @@ -0,0 +1,142 @@ +import { Button } from "@/components/atoms"; +import { Users, Monitor, Tv, Headset } from "lucide-react"; + +export default function OnsiteSupportPage() { + return ( +
+ {/* Header */} +
+

+ Onsite Support +

+

+ We dispatch our skillful in-house tech staff to your residence or office for your needs. +

+
+ + {/* Main Services */} +
+
+

Need Our Technical Support?

+

+ We can provide you with on-site technical support service. If you would like for our + technicians to visit your residence and provide technical assistance, please let us + know. +

+

+ We also provide "Remote Access Services" which allows our technicians to do support via + Remote Access Software over the Internet connection to fix up the issue (depends on what + the issue is). +

+
+ +
+
+
+ +
+
+ + {/* Pricing Cards */} +
+ {/* Onsite Network & Computer Support */} +
+
+ +
+

+ Onsite Network & Computer Support +

+
+
Basic Service Fee
+
15,000 JPY
+
+
+ + {/* Remote Support */} +
+
+ +
+

+ Remote Network & Computer Support +

+
+
Basic Service Fee
+
5,000 JPY
+
+
+ + {/* Onsite TV Support */} +
+
+ +
+

Onsite TV Support Service

+
+
Basic Service Fee
+
15,000 JPY
+
+
+
+ + {/* FAQ Section */} +
+

+ Frequently Asked Questions +

+ +
+
+

+ My home requires multiple Wi-Fi routers. Would you be able to assist with this? +

+

+ Yes, the Assist Solutions technical team is able to visit your residence for device + set up including Wi-Fi routers, printers, Apple TVs etc. Our tech consulting team will + be able to make suggestions based on your residence layout and requirements. Please + contact us for a free consultation. +

+
+ +
+

+ I am already subscribed to a different Internet provider but require more Wi-Fi + coverage. Would I be able to just opt for the Onsite Support service without switching + over my entire home Internet service? +

+

+ Yes, we are able to offer the Onsite Support service as a standalone service. +

+
+ +
+

+ Do you offer this service outside of Tokyo? +

+
+

+ Our In-Home Technical Assistance service can be provided in Tokyo, Saitama and + Kanagawa prefecture. +

+

+ *Please note that this service may not available in some areas within the above + prefectures. For more information, please contact us. +

+
+
+
+
+ + {/* CTA */} +
+

Ready to get started?

+ +
+
+ ); +} diff --git a/apps/portal/src/app/(public)/(site)/services/page.tsx b/apps/portal/src/app/(public)/(site)/services/page.tsx new file mode 100644 index 00000000..f3d8cb70 --- /dev/null +++ b/apps/portal/src/app/(public)/(site)/services/page.tsx @@ -0,0 +1,24 @@ +import { ServicesGrid } from "@/features/services/components/common/ServicesGrid"; + +interface ServicesPageProps { + basePath?: string; +} + +export default function ServicesPage({ basePath = "/services" }: ServicesPageProps) { + return ( +
+ {/* Header */} +
+

+ Our Services +

+

+ From high-speed internet to onsite support, we provide comprehensive solutions for your + home and business. +

+
+ + +
+ ); +} diff --git a/apps/portal/src/app/(public)/(site)/services/sim/configure/page.tsx b/apps/portal/src/app/(public)/(site)/services/sim/configure/page.tsx new file mode 100644 index 00000000..da3da30e --- /dev/null +++ b/apps/portal/src/app/(public)/(site)/services/sim/configure/page.tsx @@ -0,0 +1,17 @@ +/** + * Public SIM Configure Page + * + * Configure SIM plan for unauthenticated users. + */ + +import { PublicSimConfigureView } from "@/features/services/views/PublicSimConfigure"; +import { RedirectAuthenticatedToAccountServices } from "@/features/services/components/common/RedirectAuthenticatedToAccountServices"; + +export default function PublicSimConfigurePage() { + return ( + <> + + + + ); +} diff --git a/apps/portal/src/app/(public)/(site)/services/sim/page.tsx b/apps/portal/src/app/(public)/(site)/services/sim/page.tsx new file mode 100644 index 00000000..365bfbbb --- /dev/null +++ b/apps/portal/src/app/(public)/(site)/services/sim/page.tsx @@ -0,0 +1,17 @@ +/** + * Public SIM Plans Page + * + * Displays SIM plans for unauthenticated users. + */ + +import { PublicSimPlansView } from "@/features/services/views/PublicSimPlans"; +import { RedirectAuthenticatedToAccountServices } from "@/features/services/components/common/RedirectAuthenticatedToAccountServices"; + +export default function PublicSimPlansPage() { + return ( + <> + + + + ); +} diff --git a/apps/portal/src/app/(public)/(site)/services/tv/page.tsx b/apps/portal/src/app/(public)/(site)/services/tv/page.tsx new file mode 100644 index 00000000..7cd0c8ad --- /dev/null +++ b/apps/portal/src/app/(public)/(site)/services/tv/page.tsx @@ -0,0 +1,729 @@ +import { Button } from "@/components/atoms"; +import { + Tv, + Film, + Music, + Trophy, + Newspaper, + Sparkles, + MoreHorizontal, + GraduationCap, + Globe, +} from "lucide-react"; + +export default function TVServicesPage() { + return ( +
+ {/* Header */} +
+

+ TV Services +

+

+ Providing a variety of options for our customers such as Satellite TV, Cable TV and + Optical Fiber TV. +

+
+ + {/* Intro */} +
+

Service Lineup

+

+ We are proud to act as agents for Japan's major paid TV service providers, and we will + arrange your services on your behalf (no service fee required for us to arrange your + services). Usually each building has their pre-assigned main TV service providers. To find + out which TV service you can apply for, please feel free to contact us anytime. +

+ +
+ + {/* Services List */} +
+ {/* Sky PerfecTV Premium Hikari */} + + + + + + + + + + + + + + + {/* Sky PerfecTV Premium (Satellite) */} + + + + + + + + + + + + + + + {/* Sky PerfecTV (Satellite) */} + + + + + + + + + + + + + + + {/* iTSCOM (CATV) */} + + + + + + + + + + + + + + + {/* JCOM (CATV) */} + + + + + + + + + + + + + +
+ + {/* FAQ */} +
+

+ Frequently Asked Questions +

+
+
+

+ Is Assist Solutions directly providing the TV service? +

+

+ As partners, we are able to refer you to each cable TV company available at your home. + However, once the service starts, the cable TV service itself will be directly + provided by each cable TV company. +

+
+ +
+

+ Would I be able to choose any cable TV service that Assist Solutions is partnered + with? +

+

+ In Japan, most cable TV companies have predetermined service areas. We will be able to + check which services are available for your home. Please contact us for a free + consultation. +

+
+
+
+ + {/* CTA */} +
+

+ Find the best TV service for you +

+ +
+
+ ); +} + +// Helper Components + +function TVServiceSection({ + title, + fees, + note, + children, +}: { + title: string; + fees: { type: string; initial: string; monthly: string }[]; + note?: string; + children?: React.ReactNode; +}) { + return ( +
+
+
+
+ +
+

{title}

+
+
+ +
+
+

+ Service Fees +

+
+ + + + + + + + + + {fees.map((fee, i) => ( + + + + + + ))} + +
TypeInitial CostMonthly Cost
{fee.type}{fee.initial}{fee.monthly}
+
+ {note &&

{note}

} +
+ + {children} +
+
+ ); +} + +function ChannelPackage({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

+ {title} +

+
{children}
+
+ ); +} + +function ChannelCategory({ title, channels }: { title: string; channels: string[] }) { + return ( +
+

+ {title === "Movie" && } + {title === "Music" && } + {title === "Sports" && } + {title === "News & Business" && } + {title === "Entertainment" && } + {title === "Kids" && } + {title === "Foreign Drama" && } + {title === "Documentary" && } + {title === "Others" && } + {title} +

+
    + {channels.map(channel => ( +
  • + {channel} +
  • + ))} +
+
+ ); +} diff --git a/apps/portal/src/app/(public)/(site)/services/vpn/page.tsx b/apps/portal/src/app/(public)/(site)/services/vpn/page.tsx new file mode 100644 index 00000000..abb231d5 --- /dev/null +++ b/apps/portal/src/app/(public)/(site)/services/vpn/page.tsx @@ -0,0 +1,17 @@ +/** + * Public VPN Plans Page + * + * Displays VPN plans for unauthenticated users. + */ + +import { PublicVpnPlansView } from "@/features/services/views/PublicVpnPlans"; +import { RedirectAuthenticatedToAccountServices } from "@/features/services/components/common/RedirectAuthenticatedToAccountServices"; + +export default function PublicVpnPlansPage() { + return ( + <> + + + + ); +} diff --git a/apps/portal/src/app/(public)/auth/link-whmcs/page.tsx b/apps/portal/src/app/(public)/auth/link-whmcs/page.tsx deleted file mode 100644 index ab9d883d..00000000 --- a/apps/portal/src/app/(public)/auth/link-whmcs/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import LinkWhmcsView from "@/features/auth/views/LinkWhmcsView"; - -export default function LinkWhmcsPage() { - return ; -} diff --git a/apps/portal/src/app/(public)/layout.tsx b/apps/portal/src/app/(public)/layout.tsx index aa1e7f26..f0fdd1ca 100644 --- a/apps/portal/src/app/(public)/layout.tsx +++ b/apps/portal/src/app/(public)/layout.tsx @@ -1,11 +1,12 @@ /** * Public Layout * - * Shared shell for public-facing pages (landing, auth, etc.) + * Shared wrapper for public route group. + * + * Note: Individual public sections (site, services, checkout) each provide + * their own shells via nested route-group layouts. */ -import { PublicShell } from "@/components/templates"; - export default function PublicLayout({ children }: { children: React.ReactNode }) { - return {children}; + return children; } 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..817f5c48 --- /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 { CheckoutEntry } from "@/features/checkout/components/CheckoutEntry"; + +export default function CheckoutPage() { + return ; +} diff --git a/apps/portal/src/app/account/AccountRouteGuard.tsx b/apps/portal/src/app/account/AccountRouteGuard.tsx new file mode 100644 index 00000000..89c9f0c2 --- /dev/null +++ b/apps/portal/src/app/account/AccountRouteGuard.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { useEffect } from "react"; +import { usePathname, useRouter } from "next/navigation"; +import { useAuthStore } from "@/features/auth/services/auth.store"; + +export function AccountRouteGuard() { + const router = useRouter(); + const pathname = usePathname(); + const isAuthenticated = useAuthStore(state => state.isAuthenticated); + const hasCheckedAuth = useAuthStore(state => state.hasCheckedAuth); + const loading = useAuthStore(state => state.loading); + const checkAuth = useAuthStore(state => state.checkAuth); + + useEffect(() => { + if (!hasCheckedAuth) { + void checkAuth(); + } + }, [checkAuth, hasCheckedAuth]); + + useEffect(() => { + if (!hasCheckedAuth || loading || isAuthenticated) { + return; + } + + const destination = pathname || "/account"; + router.replace(`/auth/login?redirect=${encodeURIComponent(destination)}`); + }, [hasCheckedAuth, isAuthenticated, loading, pathname, router]); + + return null; +} diff --git a/apps/portal/src/app/(authenticated)/billing/invoices/[id]/loading.tsx b/apps/portal/src/app/account/billing/invoices/[id]/loading.tsx similarity index 95% rename from apps/portal/src/app/(authenticated)/billing/invoices/[id]/loading.tsx rename to apps/portal/src/app/account/billing/invoices/[id]/loading.tsx index f9a8afc7..ae7cfe86 100644 --- a/apps/portal/src/app/(authenticated)/billing/invoices/[id]/loading.tsx +++ b/apps/portal/src/app/account/billing/invoices/[id]/loading.tsx @@ -2,7 +2,7 @@ import { RouteLoading } from "@/components/molecules/RouteLoading"; import { CreditCardIcon } from "@heroicons/react/24/outline"; import { LoadingCard, Skeleton } from "@/components/atoms/loading-skeleton"; -export default function InvoiceDetailLoading() { +export default function AccountInvoiceDetailLoading() { return ( } diff --git a/apps/portal/src/app/(authenticated)/billing/invoices/[id]/page.tsx b/apps/portal/src/app/account/billing/invoices/[id]/page.tsx similarity index 68% rename from apps/portal/src/app/(authenticated)/billing/invoices/[id]/page.tsx rename to apps/portal/src/app/account/billing/invoices/[id]/page.tsx index 96b28667..2b8a005e 100644 --- a/apps/portal/src/app/(authenticated)/billing/invoices/[id]/page.tsx +++ b/apps/portal/src/app/account/billing/invoices/[id]/page.tsx @@ -1,5 +1,5 @@ import InvoiceDetailContainer from "@/features/billing/views/InvoiceDetail"; -export default function InvoiceDetailPage() { +export default function AccountInvoiceDetailPage() { return ; } diff --git a/apps/portal/src/app/(authenticated)/billing/invoices/loading.tsx b/apps/portal/src/app/account/billing/invoices/loading.tsx similarity index 89% rename from apps/portal/src/app/(authenticated)/billing/invoices/loading.tsx rename to apps/portal/src/app/account/billing/invoices/loading.tsx index 09027e5a..15bb93e0 100644 --- a/apps/portal/src/app/(authenticated)/billing/invoices/loading.tsx +++ b/apps/portal/src/app/account/billing/invoices/loading.tsx @@ -2,7 +2,7 @@ import { RouteLoading } from "@/components/molecules/RouteLoading"; import { CreditCardIcon } from "@heroicons/react/24/outline"; import { LoadingTable } from "@/components/atoms/loading-skeleton"; -export default function InvoicesLoading() { +export default function AccountInvoicesLoading() { return ( } diff --git a/apps/portal/src/app/(authenticated)/billing/invoices/page.tsx b/apps/portal/src/app/account/billing/invoices/page.tsx similarity index 70% rename from apps/portal/src/app/(authenticated)/billing/invoices/page.tsx rename to apps/portal/src/app/account/billing/invoices/page.tsx index 3544a515..2d60ed00 100644 --- a/apps/portal/src/app/(authenticated)/billing/invoices/page.tsx +++ b/apps/portal/src/app/account/billing/invoices/page.tsx @@ -1,5 +1,5 @@ import InvoicesListContainer from "@/features/billing/views/InvoicesList"; -export default function InvoicesPage() { +export default function AccountInvoicesPage() { return ; } diff --git a/apps/portal/src/app/(authenticated)/billing/payments/loading.tsx b/apps/portal/src/app/account/billing/payments/loading.tsx similarity index 90% rename from apps/portal/src/app/(authenticated)/billing/payments/loading.tsx rename to apps/portal/src/app/account/billing/payments/loading.tsx index 6b364fb7..048a66a1 100644 --- a/apps/portal/src/app/(authenticated)/billing/payments/loading.tsx +++ b/apps/portal/src/app/account/billing/payments/loading.tsx @@ -2,7 +2,7 @@ import { RouteLoading } from "@/components/molecules/RouteLoading"; import { CreditCardIcon } from "@heroicons/react/24/outline"; import { LoadingCard } from "@/components/atoms/loading-skeleton"; -export default function PaymentsLoading() { +export default function AccountPaymentsLoading() { return ( } diff --git a/apps/portal/src/app/(authenticated)/billing/payments/page.tsx b/apps/portal/src/app/account/billing/payments/page.tsx similarity index 68% rename from apps/portal/src/app/(authenticated)/billing/payments/page.tsx rename to apps/portal/src/app/account/billing/payments/page.tsx index b3ab158e..64d907eb 100644 --- a/apps/portal/src/app/(authenticated)/billing/payments/page.tsx +++ b/apps/portal/src/app/account/billing/payments/page.tsx @@ -1,5 +1,5 @@ import PaymentMethodsContainer from "@/features/billing/views/PaymentMethods"; -export default function PaymentMethodsPage() { +export default function AccountPaymentMethodsPage() { return ; } diff --git a/apps/portal/src/app/(authenticated)/layout.tsx b/apps/portal/src/app/account/layout.tsx similarity index 63% rename from apps/portal/src/app/(authenticated)/layout.tsx rename to apps/portal/src/app/account/layout.tsx index 5f6a0e8e..7275d838 100644 --- a/apps/portal/src/app/(authenticated)/layout.tsx +++ b/apps/portal/src/app/account/layout.tsx @@ -1,10 +1,12 @@ import type { ReactNode } from "react"; import { AppShell } from "@/components/organisms"; import { AccountEventsListener } from "@/features/realtime/components/AccountEventsListener"; +import { AccountRouteGuard } from "./AccountRouteGuard"; -export default function PortalLayout({ children }: { children: ReactNode }) { +export default function AccountLayout({ children }: { children: ReactNode }) { return ( + {children} diff --git a/apps/portal/src/app/account/order/page.tsx b/apps/portal/src/app/account/order/page.tsx new file mode 100644 index 00000000..837ebfa0 --- /dev/null +++ b/apps/portal/src/app/account/order/page.tsx @@ -0,0 +1,11 @@ +/** + * Account Checkout Page + * + * Signed-in checkout experience inside the account shell. + */ + +import { CheckoutEntry } from "@/features/checkout/components/CheckoutEntry"; + +export default function AccountOrderPage() { + return ; +} diff --git a/apps/portal/src/app/account/orders/[id]/loading.tsx b/apps/portal/src/app/account/orders/[id]/loading.tsx new file mode 100644 index 00000000..261bf654 --- /dev/null +++ b/apps/portal/src/app/account/orders/[id]/loading.tsx @@ -0,0 +1,83 @@ +import { RouteLoading } from "@/components/molecules/RouteLoading"; +import { ClipboardDocumentCheckIcon, ArrowLeftIcon } from "@heroicons/react/24/outline"; +import { Button } from "@/components/atoms/button"; + +export default function AccountOrderDetailLoading() { + return ( + } + title="Order Details" + description="Loading order details..." + mode="content" + > +
+ +
+ +
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+ {Array.from({ length: 3 }).map((_, idx) => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))} +
+
+
+
+
+
+ + ); +} diff --git a/apps/portal/src/app/(authenticated)/orders/[id]/page.tsx b/apps/portal/src/app/account/orders/[id]/page.tsx similarity index 68% rename from apps/portal/src/app/(authenticated)/orders/[id]/page.tsx rename to apps/portal/src/app/account/orders/[id]/page.tsx index c9f0394c..8582797c 100644 --- a/apps/portal/src/app/(authenticated)/orders/[id]/page.tsx +++ b/apps/portal/src/app/account/orders/[id]/page.tsx @@ -1,5 +1,5 @@ import OrderDetailContainer from "@/features/orders/views/OrderDetail"; -export default function OrderDetailPage() { +export default function AccountOrderDetailPage() { return ; } diff --git a/apps/portal/src/app/(authenticated)/orders/loading.tsx b/apps/portal/src/app/account/orders/loading.tsx similarity index 88% rename from apps/portal/src/app/(authenticated)/orders/loading.tsx rename to apps/portal/src/app/account/orders/loading.tsx index 5fadc1da..4fe46114 100644 --- a/apps/portal/src/app/(authenticated)/orders/loading.tsx +++ b/apps/portal/src/app/account/orders/loading.tsx @@ -2,11 +2,11 @@ import { RouteLoading } from "@/components/molecules/RouteLoading"; import { ClipboardDocumentListIcon } from "@heroicons/react/24/outline"; import { OrderCardSkeleton } from "@/features/orders/components/OrderCardSkeleton"; -export default function OrdersLoading() { +export default function AccountOrdersLoading() { return ( } - title="My Orders" + title="Orders" description="View and track all your orders" mode="content" > diff --git a/apps/portal/src/app/(authenticated)/orders/page.tsx b/apps/portal/src/app/account/orders/page.tsx similarity index 69% rename from apps/portal/src/app/(authenticated)/orders/page.tsx rename to apps/portal/src/app/account/orders/page.tsx index dfda86db..730abc91 100644 --- a/apps/portal/src/app/(authenticated)/orders/page.tsx +++ b/apps/portal/src/app/account/orders/page.tsx @@ -1,5 +1,5 @@ import OrdersListContainer from "@/features/orders/views/OrdersList"; -export default function OrdersPage() { +export default function AccountOrdersPage() { return ; } diff --git a/apps/portal/src/app/(authenticated)/dashboard/page.tsx b/apps/portal/src/app/account/page.tsx similarity index 63% rename from apps/portal/src/app/(authenticated)/dashboard/page.tsx rename to apps/portal/src/app/account/page.tsx index 0871d6d4..d55872f4 100644 --- a/apps/portal/src/app/(authenticated)/dashboard/page.tsx +++ b/apps/portal/src/app/account/page.tsx @@ -1,5 +1,5 @@ import { DashboardView } from "@/features/dashboard"; -export default function DashboardPage() { +export default function AccountDashboardPage() { return ; } diff --git a/apps/portal/src/app/account/services/internet/configure/page.tsx b/apps/portal/src/app/account/services/internet/configure/page.tsx new file mode 100644 index 00000000..34f3a8c0 --- /dev/null +++ b/apps/portal/src/app/account/services/internet/configure/page.tsx @@ -0,0 +1,5 @@ +import { InternetConfigureContainer } from "@/features/services/views/InternetConfigure"; + +export default function AccountInternetConfigurePage() { + return ; +} diff --git a/apps/portal/src/app/account/services/internet/page.tsx b/apps/portal/src/app/account/services/internet/page.tsx new file mode 100644 index 00000000..d38e2fb3 --- /dev/null +++ b/apps/portal/src/app/account/services/internet/page.tsx @@ -0,0 +1,5 @@ +import { InternetPlansContainer } from "@/features/services/views/InternetPlans"; + +export default function AccountInternetPlansPage() { + return ; +} diff --git a/apps/portal/src/app/account/services/internet/request-submitted/page.tsx b/apps/portal/src/app/account/services/internet/request-submitted/page.tsx new file mode 100644 index 00000000..f9e2d880 --- /dev/null +++ b/apps/portal/src/app/account/services/internet/request-submitted/page.tsx @@ -0,0 +1,5 @@ +import InternetEligibilityRequestSubmittedView from "@/features/services/views/InternetEligibilityRequestSubmitted"; + +export default function AccountInternetEligibilityRequestSubmittedPage() { + return ; +} diff --git a/apps/portal/src/app/account/services/layout.tsx b/apps/portal/src/app/account/services/layout.tsx new file mode 100644 index 00000000..6c1b729c --- /dev/null +++ b/apps/portal/src/app/account/services/layout.tsx @@ -0,0 +1,5 @@ +import type { ReactNode } from "react"; + +export default function AccountServicesLayout({ children }: { children: ReactNode }) { + return <>{children}; +} diff --git a/apps/portal/src/app/account/services/page.tsx b/apps/portal/src/app/account/services/page.tsx new file mode 100644 index 00000000..94bc3d1c --- /dev/null +++ b/apps/portal/src/app/account/services/page.tsx @@ -0,0 +1,22 @@ +import { ServicesGrid } from "@/features/services/components/common/ServicesGrid"; + +export default function AccountServicesPage() { + return ( +
+
+ {/* Header */} +
+

+ Our Services +

+

+ From high-speed internet to onsite support, we provide comprehensive solutions for your + home and business. +

+
+ + +
+
+ ); +} diff --git a/apps/portal/src/app/account/services/sim/configure/page.tsx b/apps/portal/src/app/account/services/sim/configure/page.tsx new file mode 100644 index 00000000..37415f7f --- /dev/null +++ b/apps/portal/src/app/account/services/sim/configure/page.tsx @@ -0,0 +1,5 @@ +import { SimConfigureContainer } from "@/features/services/views/SimConfigure"; + +export default function AccountSimConfigurePage() { + return ; +} diff --git a/apps/portal/src/app/account/services/sim/page.tsx b/apps/portal/src/app/account/services/sim/page.tsx new file mode 100644 index 00000000..a791ff47 --- /dev/null +++ b/apps/portal/src/app/account/services/sim/page.tsx @@ -0,0 +1,5 @@ +import { SimPlansContainer } from "@/features/services/views/SimPlans"; + +export default function AccountSimPlansPage() { + return ; +} diff --git a/apps/portal/src/app/account/services/vpn/page.tsx b/apps/portal/src/app/account/services/vpn/page.tsx new file mode 100644 index 00000000..e524d558 --- /dev/null +++ b/apps/portal/src/app/account/services/vpn/page.tsx @@ -0,0 +1,5 @@ +import { VpnPlansView } from "@/features/services/views/VpnPlans"; + +export default function AccountVpnPlansPage() { + return ; +} diff --git a/apps/portal/src/app/(authenticated)/account/loading.tsx b/apps/portal/src/app/account/settings/loading.tsx similarity index 94% rename from apps/portal/src/app/(authenticated)/account/loading.tsx rename to apps/portal/src/app/account/settings/loading.tsx index 2e925894..77d7c570 100644 --- a/apps/portal/src/app/(authenticated)/account/loading.tsx +++ b/apps/portal/src/app/account/settings/loading.tsx @@ -2,11 +2,11 @@ import { RouteLoading } from "@/components/molecules/RouteLoading"; import { UserIcon } from "@heroicons/react/24/outline"; import { LoadingCard, Skeleton } from "@/components/atoms/loading-skeleton"; -export default function AccountLoading() { +export default function AccountSettingsLoading() { return ( } - title="Account" + title="Settings" description="Loading your profile..." mode="content" > diff --git a/apps/portal/src/app/(authenticated)/account/profile/page.tsx b/apps/portal/src/app/account/settings/page.tsx similarity index 69% rename from apps/portal/src/app/(authenticated)/account/profile/page.tsx rename to apps/portal/src/app/account/settings/page.tsx index b97286f7..ff9565c5 100644 --- a/apps/portal/src/app/(authenticated)/account/profile/page.tsx +++ b/apps/portal/src/app/account/settings/page.tsx @@ -1,5 +1,5 @@ import ProfileContainer from "@/features/account/views/ProfileContainer"; -export default function ProfilePage() { +export default function AccountSettingsPage() { return ; } diff --git a/apps/portal/src/app/account/settings/verification/page.tsx b/apps/portal/src/app/account/settings/verification/page.tsx new file mode 100644 index 00000000..ae82dfac --- /dev/null +++ b/apps/portal/src/app/account/settings/verification/page.tsx @@ -0,0 +1,5 @@ +import { ResidenceCardVerificationSettingsView } from "@/features/verification/views/ResidenceCardVerificationSettingsView"; + +export default function AccountResidenceCardVerificationPage() { + return ; +} diff --git a/apps/portal/src/app/account/subscriptions/[id]/internet/cancel/page.tsx b/apps/portal/src/app/account/subscriptions/[id]/internet/cancel/page.tsx new file mode 100644 index 00000000..e1684c84 --- /dev/null +++ b/apps/portal/src/app/account/subscriptions/[id]/internet/cancel/page.tsx @@ -0,0 +1,5 @@ +import InternetCancelContainer from "@/features/subscriptions/views/InternetCancel"; + +export default function AccountInternetCancelPage() { + return ; +} diff --git a/apps/portal/src/app/account/subscriptions/[id]/loading.tsx b/apps/portal/src/app/account/subscriptions/[id]/loading.tsx new file mode 100644 index 00000000..98608c18 --- /dev/null +++ b/apps/portal/src/app/account/subscriptions/[id]/loading.tsx @@ -0,0 +1,60 @@ +import { RouteLoading } from "@/components/molecules/RouteLoading"; +import { Server } from "lucide-react"; +import { Skeleton } from "@/components/atoms/loading-skeleton"; + +export default function AccountServiceDetailLoading() { + return ( + } title="Service" description="Service details" mode="content"> +
+ {/* Main Subscription Card */} +
+ {/* Header */} +
+
+
+ +
+ + +
+
+ +
+
+ {/* Stats Row */} +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ +
+ +
+
+ ))} +
+
+ + {/* Tabs / Billing History Header */} +
+
+ +
+ {/* Invoice List (Table-like) */} +
+
+ {Array.from({ length: 5 }).map((_, i) => ( +
+
+ + +
+ +
+ ))} +
+
+
+
+
+ ); +} diff --git a/apps/portal/src/app/(authenticated)/subscriptions/[id]/page.tsx b/apps/portal/src/app/account/subscriptions/[id]/page.tsx similarity index 72% rename from apps/portal/src/app/(authenticated)/subscriptions/[id]/page.tsx rename to apps/portal/src/app/account/subscriptions/[id]/page.tsx index 4073bbde..2324febc 100644 --- a/apps/portal/src/app/(authenticated)/subscriptions/[id]/page.tsx +++ b/apps/portal/src/app/account/subscriptions/[id]/page.tsx @@ -1,5 +1,5 @@ import SubscriptionDetailContainer from "@/features/subscriptions/views/SubscriptionDetail"; -export default function SubscriptionDetailPage() { +export default function AccountServiceDetailPage() { return ; } diff --git a/apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/call-history/page.tsx b/apps/portal/src/app/account/subscriptions/[id]/sim/call-history/page.tsx similarity index 70% rename from apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/call-history/page.tsx rename to apps/portal/src/app/account/subscriptions/[id]/sim/call-history/page.tsx index efa22ee9..16e52603 100644 --- a/apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/call-history/page.tsx +++ b/apps/portal/src/app/account/subscriptions/[id]/sim/call-history/page.tsx @@ -1,6 +1,5 @@ import SimCallHistoryContainer from "@/features/subscriptions/views/SimCallHistory"; -export default function SimCallHistoryPage() { +export default function AccountSimCallHistoryPage() { return ; } - diff --git a/apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/cancel/page.tsx b/apps/portal/src/app/account/subscriptions/[id]/sim/cancel/page.tsx similarity index 69% rename from apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/cancel/page.tsx rename to apps/portal/src/app/account/subscriptions/[id]/sim/cancel/page.tsx index c103af27..f9aaf9a4 100644 --- a/apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/cancel/page.tsx +++ b/apps/portal/src/app/account/subscriptions/[id]/sim/cancel/page.tsx @@ -1,5 +1,5 @@ import SimCancelContainer from "@/features/subscriptions/views/SimCancel"; -export default function SimCancelPage() { +export default function AccountSimCancelPage() { return ; } diff --git a/apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/change-plan/page.tsx b/apps/portal/src/app/account/subscriptions/[id]/sim/change-plan/page.tsx similarity index 69% rename from apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/change-plan/page.tsx rename to apps/portal/src/app/account/subscriptions/[id]/sim/change-plan/page.tsx index 4ad4d81d..8ff1da30 100644 --- a/apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/change-plan/page.tsx +++ b/apps/portal/src/app/account/subscriptions/[id]/sim/change-plan/page.tsx @@ -1,5 +1,5 @@ import SimChangePlanContainer from "@/features/subscriptions/views/SimChangePlan"; -export default function SimChangePlanPage() { +export default function AccountSimChangePlanPage() { return ; } diff --git a/apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/reissue/page.tsx b/apps/portal/src/app/account/subscriptions/[id]/sim/reissue/page.tsx similarity index 69% rename from apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/reissue/page.tsx rename to apps/portal/src/app/account/subscriptions/[id]/sim/reissue/page.tsx index e99470f2..1936a048 100644 --- a/apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/reissue/page.tsx +++ b/apps/portal/src/app/account/subscriptions/[id]/sim/reissue/page.tsx @@ -1,5 +1,5 @@ import SimReissueContainer from "@/features/subscriptions/views/SimReissue"; -export default function SimReissuePage() { +export default function AccountSimReissuePage() { return ; } diff --git a/apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/top-up/page.tsx b/apps/portal/src/app/account/subscriptions/[id]/sim/top-up/page.tsx similarity index 69% rename from apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/top-up/page.tsx rename to apps/portal/src/app/account/subscriptions/[id]/sim/top-up/page.tsx index 0c7da26a..89629c2e 100644 --- a/apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/top-up/page.tsx +++ b/apps/portal/src/app/account/subscriptions/[id]/sim/top-up/page.tsx @@ -1,5 +1,5 @@ import SimTopUpContainer from "@/features/subscriptions/views/SimTopUp"; -export default function SimTopUpPage() { +export default function AccountSimTopUpPage() { return ; } diff --git a/apps/portal/src/app/account/subscriptions/loading.tsx b/apps/portal/src/app/account/subscriptions/loading.tsx new file mode 100644 index 00000000..781ca24e --- /dev/null +++ b/apps/portal/src/app/account/subscriptions/loading.tsx @@ -0,0 +1,61 @@ +import { RouteLoading } from "@/components/molecules/RouteLoading"; +import { Server } from "lucide-react"; +import { Skeleton } from "@/components/atoms/loading-skeleton"; + +export default function AccountServicesLoading() { + return ( + } + title="Services" + description="View and manage your services" + mode="content" + > +
+ {/* Stats Cards */} +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+ +
+ + +
+
+
+ ))} +
+ + {/* Search and Table */} +
+
+ +
+
+ {/* Custom Table Skeleton to match embedded style */} +
+
+
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+
+
+ {Array.from({ length: 5 }).map((_, rowIndex) => ( +
+
+ {Array.from({ length: 5 }).map((_, colIndex) => ( + + ))} +
+
+ ))} +
+
+
+
+
+
+ ); +} diff --git a/apps/portal/src/app/(authenticated)/subscriptions/page.tsx b/apps/portal/src/app/account/subscriptions/page.tsx similarity index 71% rename from apps/portal/src/app/(authenticated)/subscriptions/page.tsx rename to apps/portal/src/app/account/subscriptions/page.tsx index c6bef54d..e26c9905 100644 --- a/apps/portal/src/app/(authenticated)/subscriptions/page.tsx +++ b/apps/portal/src/app/account/subscriptions/page.tsx @@ -1,5 +1,5 @@ import SubscriptionsListContainer from "@/features/subscriptions/views/SubscriptionsList"; -export default function SubscriptionsPage() { +export default function AccountSubscriptionsPage() { return ; } diff --git a/apps/portal/src/app/(authenticated)/support/cases/[id]/page.tsx b/apps/portal/src/app/account/support/[id]/page.tsx similarity index 70% rename from apps/portal/src/app/(authenticated)/support/cases/[id]/page.tsx rename to apps/portal/src/app/account/support/[id]/page.tsx index 0bcc3feb..bece2764 100644 --- a/apps/portal/src/app/(authenticated)/support/cases/[id]/page.tsx +++ b/apps/portal/src/app/account/support/[id]/page.tsx @@ -4,8 +4,7 @@ interface PageProps { params: Promise<{ id: string }>; } -export default async function SupportCaseDetailPage({ params }: PageProps) { +export default async function AccountSupportCaseDetailPage({ params }: PageProps) { const { id } = await params; return ; } - diff --git a/apps/portal/src/app/(authenticated)/support/cases/loading.tsx b/apps/portal/src/app/account/support/loading.tsx similarity index 90% rename from apps/portal/src/app/(authenticated)/support/cases/loading.tsx rename to apps/portal/src/app/account/support/loading.tsx index 3c933597..833b5692 100644 --- a/apps/portal/src/app/(authenticated)/support/cases/loading.tsx +++ b/apps/portal/src/app/account/support/loading.tsx @@ -2,7 +2,7 @@ import { RouteLoading } from "@/components/molecules/RouteLoading"; import { ChatBubbleLeftRightIcon } from "@heroicons/react/24/outline"; import { LoadingTable } from "@/components/atoms/loading-skeleton"; -export default function SupportCasesLoading() { +export default function AccountSupportLoading() { return ( } diff --git a/apps/portal/src/app/(authenticated)/support/new/loading.tsx b/apps/portal/src/app/account/support/new/loading.tsx similarity index 94% rename from apps/portal/src/app/(authenticated)/support/new/loading.tsx rename to apps/portal/src/app/account/support/new/loading.tsx index 6da32d5c..bad05834 100644 --- a/apps/portal/src/app/(authenticated)/support/new/loading.tsx +++ b/apps/portal/src/app/account/support/new/loading.tsx @@ -2,7 +2,7 @@ import { RouteLoading } from "@/components/molecules/RouteLoading"; import { PencilSquareIcon } from "@heroicons/react/24/outline"; import { LoadingCard, Skeleton } from "@/components/atoms/loading-skeleton"; -export default function NewSupportLoading() { +export default function AccountSupportNewLoading() { return ( } diff --git a/apps/portal/src/app/(authenticated)/support/new/page.tsx b/apps/portal/src/app/account/support/new/page.tsx similarity index 63% rename from apps/portal/src/app/(authenticated)/support/new/page.tsx rename to apps/portal/src/app/account/support/new/page.tsx index 65c960da..730dceac 100644 --- a/apps/portal/src/app/(authenticated)/support/new/page.tsx +++ b/apps/portal/src/app/account/support/new/page.tsx @@ -1,5 +1,5 @@ import { NewSupportCaseView } from "@/features/support"; -export default function NewSupportCasePage() { +export default function AccountNewSupportCasePage() { return ; } diff --git a/apps/portal/src/app/(authenticated)/support/cases/page.tsx b/apps/portal/src/app/account/support/page.tsx similarity index 65% rename from apps/portal/src/app/(authenticated)/support/cases/page.tsx rename to apps/portal/src/app/account/support/page.tsx index 54a27c29..41ef7fa3 100644 --- a/apps/portal/src/app/(authenticated)/support/cases/page.tsx +++ b/apps/portal/src/app/account/support/page.tsx @@ -1,5 +1,5 @@ import { SupportCasesView } from "@/features/support"; -export default function SupportCasesPage() { +export default function AccountSupportPage() { return ; } diff --git a/apps/portal/src/app/globals.css b/apps/portal/src/app/globals.css index 699e9a10..a276c5b8 100644 --- a/apps/portal/src/app/globals.css +++ b/apps/portal/src/app/globals.css @@ -213,6 +213,6 @@ } body { @apply bg-background text-foreground; - font-family: var(--font-geist-sans), system-ui, sans-serif; + font-family: var(--font-geist-sans, system-ui), system-ui, sans-serif; } } diff --git a/apps/portal/src/app/layout.tsx b/apps/portal/src/app/layout.tsx index 50df9a42..6c63b65a 100644 --- a/apps/portal/src/app/layout.tsx +++ b/apps/portal/src/app/layout.tsx @@ -1,20 +1,9 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; import { headers } from "next/headers"; import "./globals.css"; import { QueryProvider } from "@/lib/providers"; import { SessionTimeoutWarning } from "@/features/auth/components/SessionTimeoutWarning"; -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); - export const metadata: Metadata = { title: "Assist Solutions Portal", description: "Manage your subscriptions, billing, and support with Assist Solutions", @@ -22,7 +11,7 @@ export const metadata: Metadata = { // Disable static generation for the entire app since it uses dynamic features extensively // This is the recommended approach for apps with heavy useSearchParams usage -export const dynamic = 'force-dynamic'; +export const dynamic = "force-dynamic"; export default async function RootLayout({ children, @@ -35,7 +24,7 @@ export default async function RootLayout({ return ( - + {children} diff --git a/apps/portal/src/components/atoms/button.tsx b/apps/portal/src/components/atoms/button.tsx index a94a04a0..5ce78b03 100644 --- a/apps/portal/src/components/atoms/button.tsx +++ b/apps/portal/src/components/atoms/button.tsx @@ -1,5 +1,6 @@ import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ReactNode } from "react"; import { forwardRef } from "react"; +import Link from "next/link"; import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/utils"; import { Spinner } from "./Spinner"; @@ -72,8 +73,33 @@ const Button = forwardRef((p if (props.as === "a") { const { className, variant, size, as: _as, href, ...anchorProps } = rest as ButtonAsAnchorProps; void _as; + + const isExternal = href.startsWith("http") || href.startsWith("mailto:"); + + if (isExternal) { + return ( + } + aria-busy={loading || undefined} + {...anchorProps} + > + + {loading ? : leftIcon} + {loading ? (loadingText ?? children) : children} + {!loading && rightIcon ? ( + + {rightIcon} + + ) : null} + + + ); + } + return ( - } @@ -89,7 +115,7 @@ const Button = forwardRef((p ) : null} - + ); } diff --git a/apps/portal/src/components/organisms/AppShell/AppShell.tsx b/apps/portal/src/components/organisms/AppShell/AppShell.tsx index eb3c2a03..a4435c4a 100644 --- a/apps/portal/src/components/organisms/AppShell/AppShell.tsx +++ b/apps/portal/src/components/organisms/AppShell/AppShell.tsx @@ -67,9 +67,10 @@ export function AppShell({ children }: AppShellProps) { useEffect(() => { if (hasCheckedAuth && !isAuthenticated && !loading) { - router.push("/auth/login"); + const destination = pathname || "/account"; + router.push(`/auth/login?redirect=${encodeURIComponent(destination)}`); } - }, [hasCheckedAuth, isAuthenticated, loading, router]); + }, [hasCheckedAuth, isAuthenticated, loading, pathname, router]); // Hydrate full profile once after auth so header name is consistent across pages useEffect(() => { @@ -97,10 +98,10 @@ export function AppShell({ children }: AppShellProps) { useEffect(() => { setExpandedItems(prev => { const next = new Set(prev); - if (pathname.startsWith("/subscriptions")) next.add("Subscriptions"); - if (pathname.startsWith("/billing")) next.add("Billing"); - if (pathname.startsWith("/support")) next.add("Support"); - if (pathname.startsWith("/account")) next.add("Account"); + if (pathname.startsWith("/account/subscriptions")) next.add("Subscriptions"); + if (pathname.startsWith("/account/billing")) next.add("Billing"); + if (pathname.startsWith("/account/support")) next.add("Support"); + if (pathname.startsWith("/account/settings")) next.add("Settings"); const result = Array.from(next); // Avoid state update if unchanged if (result.length === prev.length && result.every(v => prev.includes(v))) return prev; diff --git a/apps/portal/src/components/organisms/AppShell/Header.tsx b/apps/portal/src/components/organisms/AppShell/Header.tsx index 4f1f1d30..db6a9618 100644 --- a/apps/portal/src/components/organisms/AppShell/Header.tsx +++ b/apps/portal/src/components/organisms/AppShell/Header.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { memo } from "react"; import { Bars3Icon, QuestionMarkCircleIcon } from "@heroicons/react/24/outline"; +import { NotificationBell } from "@/features/notifications"; interface HeaderProps { onMenuClick: () => void; @@ -24,7 +25,7 @@ export const Header = memo(function Header({ onMenuClick, user, profileReady }: : displayName.slice(0, 2).toUpperCase(); return ( -
+
+
+ )} + +
+ +
+ + {submitResidenceCard.isError && ( +

+ {submitResidenceCard.error instanceof Error + ? submitResidenceCard.error.message + : "Failed to submit residence card."} +

+ )} + +

+ Accepted formats: JPG, PNG, or PDF (max 5MB). Make sure all text is readable. +

+
+ )} +
+ )} +
+
); } diff --git a/apps/portal/src/features/auth/components/AuthModal/AuthModal.tsx b/apps/portal/src/features/auth/components/AuthModal/AuthModal.tsx new file mode 100644 index 00000000..a95f967e --- /dev/null +++ b/apps/portal/src/features/auth/components/AuthModal/AuthModal.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { XMarkIcon } from "@heroicons/react/24/outline"; +import { SignupForm } from "../SignupForm/SignupForm"; +import { LoginForm } from "../LoginForm/LoginForm"; +import { useAuthStore } from "../../services/auth.store"; +import { useRouter } from "next/navigation"; + +interface AuthModalProps { + isOpen: boolean; + onClose: () => void; + initialMode?: "signup" | "login"; + redirectTo?: string; + title?: string; + description?: string; + showCloseButton?: boolean; +} + +export function AuthModal({ + isOpen, + onClose, + initialMode = "signup", + redirectTo, + title, + description, + showCloseButton = true, +}: AuthModalProps) { + const [mode, setMode] = useState<"signup" | "login">(initialMode); + const router = useRouter(); + const isAuthenticated = useAuthStore(state => state.isAuthenticated); + const hasCheckedAuth = useAuthStore(state => state.hasCheckedAuth); + + // Update mode when initialMode changes + useEffect(() => { + setMode(initialMode); + }, [initialMode]); + + // Close modal and redirect when authenticated + useEffect(() => { + if (isOpen && hasCheckedAuth && isAuthenticated && redirectTo) { + onClose(); + router.push(redirectTo); + } + }, [isOpen, hasCheckedAuth, isAuthenticated, redirectTo, onClose, router]); + + if (!isOpen) return null; + + const defaultTitle = mode === "signup" ? "Create your account" : "Sign in to continue"; + const defaultDescription = + mode === "signup" + ? "Create an account to continue with your order and access personalized plans." + : "Sign in to your account to continue with your order."; + + return ( +
{ + if (e.target === e.currentTarget) { + onClose(); + } + }} + > + {/* Backdrop */} + + ); +} diff --git a/apps/portal/src/features/auth/components/AuthModal/index.ts b/apps/portal/src/features/auth/components/AuthModal/index.ts new file mode 100644 index 00000000..11ee2cc1 --- /dev/null +++ b/apps/portal/src/features/auth/components/AuthModal/index.ts @@ -0,0 +1 @@ +export { AuthModal } from "./AuthModal"; diff --git a/apps/portal/src/features/auth/components/InlineAuthSection/InlineAuthSection.tsx b/apps/portal/src/features/auth/components/InlineAuthSection/InlineAuthSection.tsx new file mode 100644 index 00000000..5b64b706 --- /dev/null +++ b/apps/portal/src/features/auth/components/InlineAuthSection/InlineAuthSection.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/atoms/button"; +import { SignupForm } from "../SignupForm/SignupForm"; +import { LoginForm } from "../LoginForm/LoginForm"; +import { LinkWhmcsForm } from "../LinkWhmcsForm/LinkWhmcsForm"; +import { getSafeRedirect } from "@/features/auth/utils/route-protection"; + +interface HighlightItem { + title: string; + description: string; +} + +interface InlineAuthSectionProps { + title: string; + description?: string; + redirectTo?: string; + initialMode?: "signup" | "login"; + highlights?: HighlightItem[]; + className?: string; +} + +export function InlineAuthSection({ + title, + description, + redirectTo, + initialMode = "signup", + highlights = [], + className = "", +}: InlineAuthSectionProps) { + const router = useRouter(); + const [mode, setMode] = useState<"signup" | "login" | "migrate">(initialMode); + const safeRedirect = getSafeRedirect(redirectTo, "/account"); + + return ( +
+
+

{title}

+ {description && ( +

{description}

+ )} +
+ +
+
+ + + +
+
+ +
+
+ {mode === "signup" && ( + <> +

Create your account

+

+ Set up your portal access in a few simple steps. +

+ + + )} + {mode === "login" && ( + <> +

Sign in

+

Access your account to continue.

+ + + )} + {mode === "migrate" && ( + <> +

Migrate your account

+

+ Use your legacy portal credentials to transfer your account. +

+ { + if (result.needsPasswordSet) { + const params = new URLSearchParams({ + email: result.user.email, + redirect: safeRedirect, + }); + router.push(`/auth/set-password?${params.toString()}`); + return; + } + router.push(safeRedirect); + }} + /> + + )} +
+
+ + {highlights.length > 0 && ( +
+
+ {highlights.map(item => ( +
+
{item.title}
+
{item.description}
+
+ ))} +
+
+ )} +
+ ); +} diff --git a/apps/portal/src/features/auth/components/LoginForm/LoginForm.tsx b/apps/portal/src/features/auth/components/LoginForm/LoginForm.tsx index 217cb5de..1f6cf88a 100644 --- a/apps/portal/src/features/auth/components/LoginForm/LoginForm.tsx +++ b/apps/portal/src/features/auth/components/LoginForm/LoginForm.tsx @@ -7,12 +7,14 @@ import { useCallback } from "react"; import Link from "next/link"; +import { useSearchParams } from "next/navigation"; import { Button, Input, ErrorMessage } from "@/components/atoms"; import { FormField } from "@/components/molecules/FormField/FormField"; import { useLogin } from "../../hooks/use-auth"; import { loginRequestSchema } from "@customer-portal/domain/auth"; import { useZodForm } from "@/hooks/useZodForm"; import { z } from "zod"; +import { getSafeRedirect } from "@/features/auth/utils/route-protection"; interface LoginFormProps { onSuccess?: () => void; @@ -20,6 +22,8 @@ interface LoginFormProps { showSignupLink?: boolean; showForgotPasswordLink?: boolean; className?: string; + redirectTo?: string; + initialEmail?: string; } /** @@ -40,8 +44,15 @@ export function LoginForm({ showSignupLink = true, showForgotPasswordLink = true, className = "", + redirectTo, + initialEmail, }: LoginFormProps) { - const { login, loading, error, clearError } = useLogin(); + const searchParams = useSearchParams(); + const { login, loading, error, clearError } = useLogin({ redirectTo }); + const redirectCandidate = + redirectTo || searchParams?.get("next") || searchParams?.get("redirect"); + const redirect = getSafeRedirect(redirectCandidate, ""); + const redirectQuery = redirect ? `?redirect=${encodeURIComponent(redirect)}` : ""; const handleLogin = useCallback( async ({ rememberMe: _rememberMe, ...formData }: LoginFormValues) => { @@ -64,7 +75,7 @@ export function LoginForm({ useZodForm({ schema: loginFormSchema, initialValues: { - email: "", + email: initialEmail ?? "", password: "", rememberMe: false, }, @@ -72,7 +83,7 @@ export function LoginForm({ }); return ( -
+
void handleSubmit(event)} className="space-y-6"> Don't have an account?{" "} Sign up diff --git a/apps/portal/src/features/auth/components/SignupForm/MultiStepForm.tsx b/apps/portal/src/features/auth/components/SignupForm/MultiStepForm.tsx index 1070078e..850790e9 100644 --- a/apps/portal/src/features/auth/components/SignupForm/MultiStepForm.tsx +++ b/apps/portal/src/features/auth/components/SignupForm/MultiStepForm.tsx @@ -44,14 +44,13 @@ export function MultiStepForm({ onStepChange?.(currentStep); }, [currentStep, onStepChange]); - const totalSteps = steps.length; const step = steps[currentStep] ?? steps[0]; const isFirstStep = currentStep === 0; const disableNext = isSubmitting || (!canProceed && !isLastStep); return (
- {/* Step Indicators */} + {/* Simple Step Indicators */}
{steps.map((s, idx) => { const isCompleted = idx < currentStep; @@ -61,20 +60,20 @@ export function MultiStepForm({
{isCompleted ? : idx + 1}
- {idx < totalSteps - 1 && ( + {idx < steps.length - 1 && (
void; onError?: (error: string) => void; className?: string; + redirectTo?: string; + initialEmail?: string; + showFooterLinks?: boolean; } const STEPS = [ @@ -59,40 +69,24 @@ const STEPS = [ }, { key: "address", - title: "Delivery Address", - description: "Where to ship your SIM card", + title: "Address", + description: "Used for service eligibility and delivery", }, { - key: "password", - title: "Create Password", - description: "Secure your account", - }, - { - key: "review", - title: "Review & Confirm", - description: "Verify your information", + key: "security", + title: "Security & Terms", + description: "Create a password and confirm agreements", }, ] as const; const STEP_FIELD_KEYS: Record<(typeof STEPS)[number]["key"], Array> = { - account: [ - "sfNumber", - "firstName", - "lastName", - "email", - "phone", - "phoneCountryCode", - "dateOfBirth", - "gender", - ], + account: ["firstName", "lastName", "email", "phone", "phoneCountryCode", "dateOfBirth", "gender"], address: ["address"], - password: ["password", "confirmPassword"], - review: ["acceptTerms"], + security: ["password", "confirmPassword", "acceptTerms", "marketingConsent"], }; const STEP_VALIDATION_SCHEMAS: Record<(typeof STEPS)[number]["key"], z.ZodTypeAny | undefined> = { account: signupFormBaseSchema.pick({ - sfNumber: true, firstName: true, lastName: true, email: true, @@ -104,18 +98,15 @@ const STEP_VALIDATION_SCHEMAS: Record<(typeof STEPS)[number]["key"], z.ZodTypeAn address: signupFormBaseSchema.pick({ address: true, }), - password: signupFormBaseSchema + security: signupFormBaseSchema .pick({ password: true, confirmPassword: true, + acceptTerms: true, }) .refine(data => data.password === data.confirmPassword, { message: "Passwords do not match", path: ["confirmPassword"], - }), - review: signupFormBaseSchema - .pick({ - acceptTerms: true, }) .refine(data => data.acceptTerms === true, { message: "You must accept the terms and conditions", @@ -123,22 +114,32 @@ const STEP_VALIDATION_SCHEMAS: Record<(typeof STEPS)[number]["key"], z.ZodTypeAn }), }; -export function SignupForm({ onSuccess, onError, className = "" }: SignupFormProps) { - const { signup, loading, error, clearError } = useSignup(); +export function SignupForm({ + onSuccess, + onError, + className = "", + redirectTo, + initialEmail, + showFooterLinks = true, +}: SignupFormProps) { + const searchParams = useSearchParams(); + const { signup, loading, error, clearError } = useSignupWithRedirect({ redirectTo }); const [step, setStep] = useState(0); + const redirectFromQuery = searchParams?.get("next") || searchParams?.get("redirect"); + const redirect = getSafeRedirect(redirectTo || redirectFromQuery, ""); + const redirectQuery = redirect ? `?redirect=${encodeURIComponent(redirect)}` : ""; const form = useZodForm({ schema: signupFormSchema, initialValues: { - sfNumber: "", firstName: "", lastName: "", - email: "", + email: initialEmail ?? "", phone: "", phoneCountryCode: "+81", company: "", - dateOfBirth: undefined, - gender: undefined, + dateOfBirth: "", + gender: "" as unknown as "male" | "female" | "other", // Will be validated on submit address: { address1: "", address2: "", @@ -253,8 +254,7 @@ export function SignupForm({ onSuccess, onError, className = "" }: SignupFormPro const stepContent = [ , , - , - , + , ]; const steps = STEPS.map((s, i) => ({ @@ -263,29 +263,29 @@ export function SignupForm({ onSuccess, onError, className = "" }: SignupFormPro })); return ( -
-
- +
+ - {error && ( - - {error} - - )} + {error && ( + + {error} + + )} + {showFooterLinks && (

Already have an account?{" "} Sign in @@ -294,14 +294,14 @@ export function SignupForm({ onSuccess, onError, className = "" }: SignupFormPro

Existing customer?{" "} Migrate your account

-
+ )}
); } diff --git a/apps/portal/src/features/auth/components/SignupForm/steps/AccountStep.tsx b/apps/portal/src/features/auth/components/SignupForm/steps/AccountStep.tsx index b5b2ffc9..4cb589bc 100644 --- a/apps/portal/src/features/auth/components/SignupForm/steps/AccountStep.tsx +++ b/apps/portal/src/features/auth/components/SignupForm/steps/AccountStep.tsx @@ -1,5 +1,5 @@ /** - * Account Step - Customer number and contact info + * Account Step - Contact info */ "use client"; @@ -10,7 +10,6 @@ import { FormField } from "@/components/molecules/FormField/FormField"; interface AccountStepProps { form: { values: { - sfNumber: string; firstName: string; lastName: string; email: string; @@ -33,24 +32,6 @@ export function AccountStep({ form }: AccountStepProps) { return (
- {/* Customer Number - Highlighted */} -
- - setValue("sfNumber", e.target.value)} - onBlur={() => setTouchedField("sfNumber")} - placeholder="e.g., AST-123456" - autoFocus - /> - -
- {/* Name Fields */}
@@ -61,6 +42,7 @@ export function AccountStep({ form }: AccountStepProps) { onBlur={() => setTouchedField("firstName")} placeholder="Taro" autoComplete="given-name" + autoFocus /> @@ -128,9 +110,9 @@ export function AccountStep({ form }: AccountStepProps) {
- {/* DOB + Gender (Optional WHMCS custom fields) */} + {/* DOB + Gender (Required) */}
- + - + setValue("acceptTerms", e.target.checked)} + onBlur={() => setTouchedField("acceptTerms")} + className="mt-0.5 h-5 w-5 text-primary border-input rounded focus:ring-ring" + /> + + I accept the{" "} + + Terms of Service + {" "} + and{" "} + + Privacy Policy + + * + + + {touched.acceptTerms && errors.acceptTerms && ( +

{errors.acceptTerms}

+ )} + + +
); } diff --git a/apps/portal/src/features/auth/components/SignupForm/steps/ReviewStep.tsx b/apps/portal/src/features/auth/components/SignupForm/steps/ReviewStep.tsx index 9dcff4f4..2811b7ad 100644 --- a/apps/portal/src/features/auth/components/SignupForm/steps/ReviewStep.tsx +++ b/apps/portal/src/features/auth/components/SignupForm/steps/ReviewStep.tsx @@ -68,7 +68,6 @@ interface ReviewStepProps { email: string; phone: string; phoneCountryCode: string; - sfNumber: string; company?: string; dateOfBirth?: string; gender?: "male" | "female" | "other"; @@ -116,10 +115,6 @@ export function ReviewStep({ form }: ReviewStepProps) { Account Summary
-
-
Customer Number
-
{values.sfNumber}
-
Name
diff --git a/apps/portal/src/features/auth/components/index.ts b/apps/portal/src/features/auth/components/index.ts index 5742e6ad..b98495f1 100644 --- a/apps/portal/src/features/auth/components/index.ts +++ b/apps/portal/src/features/auth/components/index.ts @@ -8,4 +8,5 @@ export { SignupForm } from "./SignupForm/SignupForm"; export { PasswordResetForm } from "./PasswordResetForm/PasswordResetForm"; export { SetPasswordForm } from "./SetPasswordForm/SetPasswordForm"; export { LinkWhmcsForm } from "./LinkWhmcsForm/LinkWhmcsForm"; +export { InlineAuthSection } from "./InlineAuthSection/InlineAuthSection"; export { AuthLayout } from "@/components/templates/AuthLayout"; diff --git a/apps/portal/src/features/auth/hooks/use-auth.ts b/apps/portal/src/features/auth/hooks/use-auth.ts index cc1ff97b..4d35d42f 100644 --- a/apps/portal/src/features/auth/hooks/use-auth.ts +++ b/apps/portal/src/features/auth/hooks/use-auth.ts @@ -41,10 +41,10 @@ export function useAuth() { // Enhanced login with redirect handling const login = useCallback( - async (credentials: LoginRequest) => { + async (credentials: LoginRequest, options?: { redirectTo?: string }) => { await loginAction(credentials); // Keep loading state active during redirect - const redirectTo = getPostLoginRedirect(searchParams); + const redirectTo = getPostLoginRedirect(searchParams, options?.redirectTo); router.push(redirectTo); // Note: loading will be cleared when the new page loads }, @@ -53,10 +53,10 @@ export function useAuth() { // Enhanced signup with redirect handling const signup = useCallback( - async (data: SignupRequest) => { + async (data: SignupRequest, options?: { redirectTo?: string }) => { await signupAction(data); - const redirectTo = getPostLoginRedirect(searchParams); - router.push(redirectTo); + const dest = getPostLoginRedirect(searchParams, options?.redirectTo); + router.push(dest); }, [signupAction, router, searchParams] ); @@ -100,11 +100,11 @@ export function useAuth() { /** * Hook for login functionality */ -export function useLogin() { +export function useLogin(options?: { redirectTo?: string }) { const { login, loading, error, clearError } = useAuth(); return { - login, + login: (credentials: LoginRequest) => login(credentials, options), loading, error, clearError, @@ -115,10 +115,14 @@ export function useLogin() { * Hook for signup functionality */ export function useSignup() { + return useSignupWithRedirect(); +} + +export function useSignupWithRedirect(options?: { redirectTo?: string }) { const { signup, loading, error, clearError } = useAuth(); return { - signup, + signup: (data: SignupRequest) => signup(data, options), loading, error, clearError, diff --git a/apps/portal/src/features/auth/services/auth.store.ts b/apps/portal/src/features/auth/services/auth.store.ts index 5e701a51..11808afd 100644 --- a/apps/portal/src/features/auth/services/auth.store.ts +++ b/apps/portal/src/features/auth/services/auth.store.ts @@ -280,7 +280,7 @@ export const useAuthStore = create()((set, get) => { linkWhmcs: async (linkRequest: LinkWhmcsRequest) => { set({ loading: true, error: null }); try { - const response = await apiClient.POST("/api/auth/link-whmcs", { + const response = await apiClient.POST("/api/auth/migrate", { body: linkRequest, disableCsrf: true, // Public auth endpoint, exempt from CSRF }); diff --git a/apps/portal/src/features/auth/utils/route-protection.ts b/apps/portal/src/features/auth/utils/route-protection.ts index 33408eac..10c4ec8a 100644 --- a/apps/portal/src/features/auth/utils/route-protection.ts +++ b/apps/portal/src/features/auth/utils/route-protection.ts @@ -1,8 +1,18 @@ import type { ReadonlyURLSearchParams } from "next/navigation"; -export function getPostLoginRedirect(searchParams: ReadonlyURLSearchParams): string { - const dest = searchParams.get("redirect") || "/dashboard"; - // prevent open redirects - if (dest.startsWith("http://") || dest.startsWith("https://")) return "/dashboard"; +export function getSafeRedirect(candidate?: string | null, fallback = "/account"): string { + const dest = (candidate ?? "").trim(); + if (!dest) return fallback; + if (!dest.startsWith("/")) return fallback; + if (dest.startsWith("//")) return fallback; + if (dest.startsWith("http://") || dest.startsWith("https://")) return fallback; return dest; } + +export function getPostLoginRedirect( + searchParams: ReadonlyURLSearchParams, + override?: string | null +): string { + const candidate = override || searchParams.get("next") || searchParams.get("redirect"); + return getSafeRedirect(candidate, "/account"); +} diff --git a/apps/portal/src/features/auth/views/LinkWhmcsView.tsx b/apps/portal/src/features/auth/views/MigrateAccountView.tsx similarity index 88% rename from apps/portal/src/features/auth/views/LinkWhmcsView.tsx rename to apps/portal/src/features/auth/views/MigrateAccountView.tsx index 8d63a4e7..41536ca9 100644 --- a/apps/portal/src/features/auth/views/LinkWhmcsView.tsx +++ b/apps/portal/src/features/auth/views/MigrateAccountView.tsx @@ -1,5 +1,5 @@ /** - * Link WHMCS View - Account migration page + * Migrate Account View - Account migration page */ "use client"; @@ -10,7 +10,7 @@ import { AuthLayout } from "../components"; import { LinkWhmcsForm } from "@/features/auth/components"; import { MIGRATION_TRANSFER_ITEMS, MIGRATION_STEPS } from "@customer-portal/domain/auth"; -export function LinkWhmcsView() { +export function MigrateAccountView() { const router = useRouter(); return ( @@ -32,7 +32,7 @@ export function LinkWhmcsView() {
{/* Form */} -
+

Enter Legacy Portal Credentials

@@ -44,14 +44,14 @@ export function LinkWhmcsView() { if (result.needsPasswordSet) { router.push(`/auth/set-password?email=${encodeURIComponent(result.user.email)}`); } else { - router.push("/dashboard"); + router.push("/account"); } }} />
{/* Links */} -
+
New customer?{" "} @@ -83,7 +83,7 @@ export function LinkWhmcsView() {

Need help?{" "} - + Contact support

@@ -92,4 +92,4 @@ export function LinkWhmcsView() { ); } -export default LinkWhmcsView; +export default MigrateAccountView; diff --git a/apps/portal/src/features/auth/views/SetPasswordView.tsx b/apps/portal/src/features/auth/views/SetPasswordView.tsx index 43519b5a..fda8439c 100644 --- a/apps/portal/src/features/auth/views/SetPasswordView.tsx +++ b/apps/portal/src/features/auth/views/SetPasswordView.tsx @@ -11,16 +11,20 @@ function SetPasswordContent() { const router = useRouter(); const searchParams = useSearchParams(); const email = searchParams.get("email") ?? ""; + const redirect = searchParams.get("redirect"); useEffect(() => { if (!email) { - router.replace("/auth/link-whmcs"); + router.replace("/auth/migrate"); } }, [email, router]); const handlePasswordSetSuccess = () => { - // Redirect to dashboard after successful password setup - router.push("/dashboard"); + if (redirect) { + router.push(redirect); + return; + } + router.push("/account"); }; if (!email) { @@ -32,7 +36,7 @@ function SetPasswordContent() { again so we can verify your account.

Go to account transfer diff --git a/apps/portal/src/features/auth/views/SignupView.tsx b/apps/portal/src/features/auth/views/SignupView.tsx index d206f69d..27ca9807 100644 --- a/apps/portal/src/features/auth/views/SignupView.tsx +++ b/apps/portal/src/features/auth/views/SignupView.tsx @@ -13,6 +13,7 @@ export function SignupView() { diff --git a/apps/portal/src/features/auth/views/index.ts b/apps/portal/src/features/auth/views/index.ts index 50a35c81..7e3ecdc2 100644 --- a/apps/portal/src/features/auth/views/index.ts +++ b/apps/portal/src/features/auth/views/index.ts @@ -3,4 +3,4 @@ export { SignupView } from "./SignupView"; export { ForgotPasswordView } from "./ForgotPasswordView"; export { ResetPasswordView } from "./ResetPasswordView"; export { SetPasswordView } from "./SetPasswordView"; -export { LinkWhmcsView } from "./LinkWhmcsView"; +export { MigrateAccountView } from "./MigrateAccountView"; diff --git a/apps/portal/src/features/billing/components/BillingSummary/BillingSummary.tsx b/apps/portal/src/features/billing/components/BillingSummary/BillingSummary.tsx index e446e857..ec19e8c7 100644 --- a/apps/portal/src/features/billing/components/BillingSummary/BillingSummary.tsx +++ b/apps/portal/src/features/billing/components/BillingSummary/BillingSummary.tsx @@ -91,7 +91,7 @@ const BillingSummary = forwardRef(
{!compact && ( View All @@ -158,7 +158,7 @@ const BillingSummary = forwardRef( {compact && (
View All Invoices diff --git a/apps/portal/src/features/billing/components/InvoiceDetail/InvoiceItems.tsx b/apps/portal/src/features/billing/components/InvoiceDetail/InvoiceItems.tsx index 4ebe2837..2c48d90d 100644 --- a/apps/portal/src/features/billing/components/InvoiceDetail/InvoiceItems.tsx +++ b/apps/portal/src/features/billing/components/InvoiceDetail/InvoiceItems.tsx @@ -72,7 +72,7 @@ export function InvoiceItems({ items = [], currency }: InvoiceItemsProps) { clipRule="evenodd" /> - Service #{item.serviceId} + Subscription #{item.serviceId} ) : ( @@ -104,7 +104,7 @@ export function InvoiceItems({ items = [], currency }: InvoiceItemsProps) { if (isLinked) { return ( - + {itemContent} ); diff --git a/apps/portal/src/features/billing/components/InvoiceItemRow.tsx b/apps/portal/src/features/billing/components/InvoiceItemRow.tsx index fe32456e..95b17291 100644 --- a/apps/portal/src/features/billing/components/InvoiceItemRow.tsx +++ b/apps/portal/src/features/billing/components/InvoiceItemRow.tsx @@ -25,7 +25,7 @@ export function InvoiceItemRow({ ? "border-blue-200 bg-blue-50 hover:bg-blue-100 cursor-pointer hover:shadow-sm" : "border-gray-200 bg-gray-50" }`} - onClick={serviceId ? () => router.push(`/subscriptions/${serviceId}`) : undefined} + onClick={serviceId ? () => router.push(`/account/subscriptions/${serviceId}`) : undefined} >
- Service #{serviceId} • Click to view + Subscription #{serviceId} • Click to view
)}
diff --git a/apps/portal/src/features/billing/components/InvoiceTable/InvoiceTable.tsx b/apps/portal/src/features/billing/components/InvoiceTable/InvoiceTable.tsx index f5930187..0207bcd7 100644 --- a/apps/portal/src/features/billing/components/InvoiceTable/InvoiceTable.tsx +++ b/apps/portal/src/features/billing/components/InvoiceTable/InvoiceTable.tsx @@ -65,7 +65,7 @@ export function InvoiceTable({ if (onInvoiceClick) { onInvoiceClick(invoice); } else { - router.push(`/billing/invoices/${invoice.id}`); + router.push(`/account/billing/invoices/${invoice.id}`); } }, [onInvoiceClick, router] diff --git a/apps/portal/src/features/billing/views/InvoiceDetail.tsx b/apps/portal/src/features/billing/views/InvoiceDetail.tsx index 657bcff2..8253f3fc 100644 --- a/apps/portal/src/features/billing/views/InvoiceDetail.tsx +++ b/apps/portal/src/features/billing/views/InvoiceDetail.tsx @@ -91,7 +91,7 @@ export function InvoiceDetailContainer() { variant="page" />
- + ← Back to invoices
@@ -105,8 +105,8 @@ export function InvoiceDetailContainer() { title={`Invoice #${invoice.id}`} description="Invoice details and actions" breadcrumbs={[ - { label: "Billing", href: "/billing/invoices" }, - { label: "Invoices", href: "/billing/invoices" }, + { label: "Billing", href: "/account/billing/invoices" }, + { label: "Invoices", href: "/account/billing/invoices" }, { label: `#${invoice.id}` }, ]} > diff --git a/apps/portal/src/features/catalog/components/sim/SimTypeSelector.tsx b/apps/portal/src/features/catalog/components/sim/SimTypeSelector.tsx deleted file mode 100644 index dfdc3c05..00000000 --- a/apps/portal/src/features/catalog/components/sim/SimTypeSelector.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { DevicePhoneMobileIcon, CpuChipIcon } from "@heroicons/react/24/outline"; - -interface SimTypeSelectorProps { - simType: "Physical SIM" | "eSIM" | ""; - onSimTypeChange: (type: "Physical SIM" | "eSIM") => void; - eid: string; - onEidChange: (eid: string) => void; - errors: Record; -} - -export function SimTypeSelector({ - simType, - onSimTypeChange, - eid, - onEidChange, - errors, -}: SimTypeSelectorProps) { - return ( -
-
- - - -
- - {/* EID Input for eSIM */} -
-
-

eSIM Device Information

-
- - onEidChange(e.target.value)} - className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" - placeholder="32-digit EID number" - maxLength={32} - /> - {errors.eid &&

{errors.eid}

} -

- Find your EID in: Settings → General → About → EID (iOS) or Settings → About Phone → - IMEI (Android) -

-
-
-
-
- ); -} diff --git a/apps/portal/src/features/catalog/hooks/useCatalog.ts b/apps/portal/src/features/catalog/hooks/useCatalog.ts deleted file mode 100644 index 236464ec..00000000 --- a/apps/portal/src/features/catalog/hooks/useCatalog.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Catalog Hooks - * React hooks for catalog functionality - */ - -import { useQuery } from "@tanstack/react-query"; -import { queryKeys } from "@/lib/api"; -import { catalogService } from "../services"; - -/** - * Internet catalog composite hook - * Fetches plans and installations together - */ -export function useInternetCatalog() { - return useQuery({ - queryKey: queryKeys.catalog.internet.combined(), - queryFn: () => catalogService.getInternetCatalog(), - }); -} - -/** - * SIM catalog composite hook - * Fetches plans, activation fees, and addons together - */ -export function useSimCatalog() { - return useQuery({ - queryKey: queryKeys.catalog.sim.combined(), - queryFn: () => catalogService.getSimCatalog(), - }); -} - -/** - * VPN catalog hook - * Fetches VPN plans and activation fees - */ -export function useVpnCatalog() { - return useQuery({ - queryKey: queryKeys.catalog.vpn.combined(), - queryFn: () => catalogService.getVpnCatalog(), - }); -} - -/** - * Lookup helpers by SKU - */ -export function useInternetPlan(sku?: string) { - const { data, ...rest } = useInternetCatalog(); - const plan = (data?.plans || []).find(p => p.sku === sku); - return { plan, ...rest } as const; -} - -export function useSimPlan(sku?: string) { - const { data, ...rest } = useSimCatalog(); - const plan = (data?.plans || []).find(p => p.sku === sku); - return { plan, ...rest } as const; -} - -export function useVpnPlan(sku?: string) { - const { data, ...rest } = useVpnCatalog(); - const plan = (data?.plans || []).find(p => p.sku === sku); - return { plan, ...rest } as const; -} - -/** - * Addon/installation lookup helpers by SKU - */ -export function useInternetInstallation(sku?: string) { - const { data, ...rest } = useInternetCatalog(); - const installation = (data?.installations || []).find(i => i.sku === sku); - return { installation, ...rest } as const; -} - -export function useInternetAddon(sku?: string) { - const { data, ...rest } = useInternetCatalog(); - const addon = (data?.addons || []).find(a => a.sku === sku); - return { addon, ...rest } as const; -} - -export function useSimAddon(sku?: string) { - const { data, ...rest } = useSimCatalog(); - const addon = (data?.addons || []).find(a => a.sku === sku); - return { addon, ...rest } as const; -} diff --git a/apps/portal/src/features/catalog/services/catalog.service.ts b/apps/portal/src/features/catalog/services/catalog.service.ts deleted file mode 100644 index c9035d07..00000000 --- a/apps/portal/src/features/catalog/services/catalog.service.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { apiClient, getDataOrDefault, getDataOrThrow } from "@/lib/api"; -import { - EMPTY_SIM_CATALOG, - EMPTY_VPN_CATALOG, - internetInstallationCatalogItemSchema, - internetAddonCatalogItemSchema, - simActivationFeeCatalogItemSchema, - simCatalogProductSchema, - vpnCatalogProductSchema, - type InternetCatalogCollection, - type InternetAddonCatalogItem, - type InternetInstallationCatalogItem, - type SimActivationFeeCatalogItem, - type SimCatalogCollection, - type SimCatalogProduct, - type VpnCatalogCollection, - type VpnCatalogProduct, -} from "@customer-portal/domain/catalog"; - -export const catalogService = { - async getInternetCatalog(): Promise { - const response = await apiClient.GET("/api/catalog/internet/plans"); - const data = getDataOrThrow( - response, - "Failed to load internet catalog" - ); - return data; // BFF already validated - }, - - async getInternetInstallations(): Promise { - const response = await apiClient.GET( - "/api/catalog/internet/installations" - ); - const data = getDataOrDefault(response, []); - return internetInstallationCatalogItemSchema.array().parse(data); - }, - - async getInternetAddons(): Promise { - const response = await apiClient.GET( - "/api/catalog/internet/addons" - ); - const data = getDataOrDefault(response, []); - return internetAddonCatalogItemSchema.array().parse(data); - }, - - async getSimCatalog(): Promise { - const response = await apiClient.GET("/api/catalog/sim/plans"); - const data = getDataOrDefault(response, EMPTY_SIM_CATALOG); - return data; // BFF already validated - }, - - async getSimActivationFees(): Promise { - const response = await apiClient.GET( - "/api/catalog/sim/activation-fees" - ); - const data = getDataOrDefault(response, []); - return simActivationFeeCatalogItemSchema.array().parse(data); - }, - - async getSimAddons(): Promise { - const response = await apiClient.GET("/api/catalog/sim/addons"); - const data = getDataOrDefault(response, []); - return simCatalogProductSchema.array().parse(data); - }, - - async getVpnCatalog(): Promise { - const response = await apiClient.GET("/api/catalog/vpn/plans"); - const data = getDataOrDefault(response, EMPTY_VPN_CATALOG); - return data; // BFF already validated - }, - - async getVpnActivationFees(): Promise { - const response = await apiClient.GET("/api/catalog/vpn/activation-fees"); - const data = getDataOrDefault(response, []); - return vpnCatalogProductSchema.array().parse(data); - }, -}; diff --git a/apps/portal/src/features/catalog/services/index.ts b/apps/portal/src/features/catalog/services/index.ts deleted file mode 100644 index 14dc64fb..00000000 --- a/apps/portal/src/features/catalog/services/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { catalogService } from "./catalog.service"; diff --git a/apps/portal/src/features/catalog/utils/index.ts b/apps/portal/src/features/catalog/utils/index.ts deleted file mode 100644 index 4f516e0a..00000000 --- a/apps/portal/src/features/catalog/utils/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./catalog.utils"; -export * from "./pricing"; diff --git a/apps/portal/src/features/catalog/views/CatalogHome.tsx b/apps/portal/src/features/catalog/views/CatalogHome.tsx deleted file mode 100644 index f787861c..00000000 --- a/apps/portal/src/features/catalog/views/CatalogHome.tsx +++ /dev/null @@ -1,106 +0,0 @@ -"use client"; - -import React from "react"; -import { PageLayout } from "@/components/templates/PageLayout"; -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"; - -export function CatalogHomeView() { - return ( - } - title="Catalog" - description="Choose services and place new orders" - > -
-
-
- - Services Catalog -
-

- Choose your connectivity solution -

-

- Discover high-speed internet, mobile data/voice options, and secure VPN services. -

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

- Why choose our services? -

-

- Personalized recommendations based on your location and account eligibility. -

-
-
- } - title="Location-Based Plans" - description="Internet plans tailored to your house type and infrastructure" - /> - } - title="Seamless Integration" - description="Manage all services from a single account" - /> -
-
-
-
- ); -} - -export default CatalogHomeView; diff --git a/apps/portal/src/features/catalog/views/InternetConfigure.tsx b/apps/portal/src/features/catalog/views/InternetConfigure.tsx deleted file mode 100644 index 8d85b033..00000000 --- a/apps/portal/src/features/catalog/views/InternetConfigure.tsx +++ /dev/null @@ -1,55 +0,0 @@ -"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"; - -export function InternetConfigureContainer() { - const router = useRouter(); - const vm = useInternetConfigure(); - - // Debug: log current state - logger.debug("InternetConfigure state", { - plan: vm.plan?.sku, - mode: vm.mode, - installation: vm.selectedInstallation?.sku, - addons: vm.selectedAddonSkus, - }); - - const handleConfirm = () => { - logger.debug("handleConfirm called, current state", { - plan: vm.plan?.sku, - mode: vm.mode, - installation: vm.selectedInstallation?.sku, - selectedInstallationSku: 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, - }); - - // Determine what's missing - 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 checkout with params", { - params: params.toString(), - }); - router.push(`/checkout?${params.toString()}`); - }; - - return ; -} - -export default InternetConfigureContainer; diff --git a/apps/portal/src/features/catalog/views/InternetPlans.tsx b/apps/portal/src/features/catalog/views/InternetPlans.tsx deleted file mode 100644 index 4095c17d..00000000 --- a/apps/portal/src/features/catalog/views/InternetPlans.tsx +++ /dev/null @@ -1,213 +0,0 @@ -"use client"; - -import { useState, useEffect, useMemo } from "react"; -import { PageLayout } from "@/components/templates/PageLayout"; -import { WifiIcon, ServerIcon, HomeIcon, BuildingOfficeIcon } from "@heroicons/react/24/outline"; -import { useInternetCatalog } from "@/features/catalog/hooks"; -import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscriptions"; -import type { - InternetPlanCatalogItem, - InternetInstallationCatalogItem, -} from "@customer-portal/domain/catalog"; -import { Skeleton } from "@/components/atoms/loading-skeleton"; -import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock"; -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"; - -export function InternetPlansContainer() { - const { data, isLoading, error } = useInternetCatalog(); - const plans: InternetPlanCatalogItem[] = useMemo(() => data?.plans ?? [], [data?.plans]); - const installations: InternetInstallationCatalogItem[] = useMemo( - () => data?.installations ?? [], - [data?.installations] - ); - const [eligibility, setEligibility] = useState(""); - const { data: activeSubs } = useActiveSubscriptions(); - const hasActiveInternet = useMemo( - () => - Array.isArray(activeSubs) - ? activeSubs.some( - s => - String(s.productName || "") - .toLowerCase() - .includes("sonixnet via ntt optical fiber") && - String(s.status || "").toLowerCase() === "active" - ) - : false, - [activeSubs] - ); - - useEffect(() => { - if (plans.length > 0) { - setEligibility(plans[0].internetOfferingType || "Home 1G"); - } - }, [plans]); - - 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 || error) { - return ( - } - > - -
- - - {/* Title + eligibility */} -
-
-
-
-
-
-
-
- - {/* Active internet warning slot */} -
- - {/* Plans grid */} -
- {Array.from({ length: 6 }).map((_, i) => ( -
- - - -
- ))} -
- - {/* Important Notes */} -
-
- - - ); - } - - return ( - } - > -
- - - - {eligibility && ( -
-
- {getEligibilityIcon(eligibility)} - Available for: {eligibility} -
-

- Plans shown are tailored to your house type and local infrastructure. -

-
- )} -
- - {hasActiveInternet && ( - -

- You already have an Internet subscription with us. If you want another subscription - for a different residence, please{" "} - - contact us - - . -

-
- )} - - {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 for your location at this time. -

- -
-
- )} -
-
- ); -} - -// InternetPlanCard extracted to components/internet/InternetPlanCard diff --git a/apps/portal/src/features/catalog/views/SimPlans.tsx b/apps/portal/src/features/catalog/views/SimPlans.tsx deleted file mode 100644 index ea199657..00000000 --- a/apps/portal/src/features/catalog/views/SimPlans.tsx +++ /dev/null @@ -1,396 +0,0 @@ -"use client"; - -import { useEffect, useMemo, useState } from "react"; -import { PageLayout } from "@/components/templates/PageLayout"; -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[]; -} - -export function SimPlansContainer() { - const { data, isLoading, error } = useSimCatalog(); - const simPlans: SimCatalogProduct[] = useMemo(() => data?.plans ?? [], [data?.plans]); - const [hasExistingSim, setHasExistingSim] = useState(false); - const [activeTab, setActiveTab] = useState<"data-voice" | "data-only" | "voice-only">( - "data-voice" - ); - - useEffect(() => { - setHasExistingSim(simPlans.some(p => p.simHasFamilyDiscount)); - }, [simPlans]); - - if (isLoading) { - return ( -
- } - > -
- - - {/* Title block */} -
-
-
-
- - {/* Family discount banner slot */} -
-
-
- - {/* Tabs */} -
-
-
- - {/* Plans grid */} -
- {Array.from({ length: 6 }).map((_, i) => ( -
- - - -
- ))} -
- - {/* Terms section */} -
-
- {Array.from({ length: 6 }).map((_, i) => ( -
-
-
- - -
-
- ))} -
-
- - {/* Important terms banner */} -
-
- -
- ); - } - - 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 ( -
- } - > -
- - - - - {hasExistingSim && ( - -
-

- You already have a SIM subscription with us. Family discount pricing is - automatically applied to eligible additional lines below. -

-
    -
  • Reduced monthly pricing automatically reflected
  • -
  • Same great features
  • -
  • Easy to manage multiple lines
  • -
-
-
- )} - -
-
- -
-
- -
- {activeTab === "data-voice" && ( -
- } - plans={plansByType.DataSmsVoice} - showFamilyDiscount={hasExistingSim} - /> -
- )} - {activeTab === "data-only" && ( -
- } - plans={plansByType.DataOnly} - showFamilyDiscount={hasExistingSim} - /> -
- )} - {activeTab === "voice-only" && ( -
- } - plans={plansByType.VoiceOnly} - showFamilyDiscount={hasExistingSim} - /> -
- )} -
- -
-

- 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
-
-
-
- -
-
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. -

-
-
-
Cancellation
-

- Can be requested online after 3rd month. Service terminates at end of billing - cycle. -

-
-
-
-
-
Plan Changes
-

- Data plan switching is free and takes effect next month. Voice plan changes - require new SIM and cancellation policies apply. -

-
-
-
Calling/SMS Charges
-

- Pay-per-use charges apply separately. Billed 5-6 weeks after usage within - billing cycle. -

-
-
-
SIM Replacement
-

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

-
-
-
-
-
-
-
- ); -} - -export default SimPlansContainer; diff --git a/apps/portal/src/features/checkout/components/AccountCheckoutContainer.tsx b/apps/portal/src/features/checkout/components/AccountCheckoutContainer.tsx new file mode 100644 index 00000000..183919a5 --- /dev/null +++ b/apps/portal/src/features/checkout/components/AccountCheckoutContainer.tsx @@ -0,0 +1,849 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { ShieldCheck, CreditCard } from "lucide-react"; + +import { PageLayout } from "@/components/templates/PageLayout"; +import { SubCard } from "@/components/molecules/SubCard/SubCard"; +import { Button } from "@/components/atoms/button"; +import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; +import { InlineToast } from "@/components/atoms/inline-toast"; +import { StatusPill } from "@/components/atoms/status-pill"; +import { AddressConfirmation } from "@/features/services/components/base/AddressConfirmation"; +import { useCheckoutStore } from "@/features/checkout/stores/checkout.store"; +import { ordersService } from "@/features/orders/services/orders.service"; +import { usePaymentMethods } from "@/features/billing/hooks/useBilling"; +import { usePaymentRefresh } from "@/features/billing/hooks/usePaymentRefresh"; +import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscriptions"; +import { ACTIVE_INTERNET_SUBSCRIPTION_WARNING } from "@/features/checkout/constants"; +import { useInternetEligibility } from "@/features/services/hooks/useInternetEligibility"; +import { useRequestInternetEligibilityCheck } from "@/features/services/hooks/useInternetEligibility"; +import { + useResidenceCardVerification, + useSubmitResidenceCard, +} from "@/features/verification/hooks/useResidenceCardVerification"; +import { useAuthSession } from "@/features/auth/services/auth.store"; +import { apiClient } from "@/lib/api"; + +import { ORDER_TYPE, type OrderTypeValue } from "@customer-portal/domain/orders"; +import { ssoLinkResponseSchema } from "@customer-portal/domain/auth"; +import { buildPaymentMethodDisplay } from "../utils/checkout-ui-utils"; +import { CheckoutStatusBanners } from "./CheckoutStatusBanners"; + +export function AccountCheckoutContainer() { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const { user } = useAuthSession(); + + const { cartItem, checkoutSessionId, clear } = useCheckoutStore(); + + const [submitting, setSubmitting] = useState(false); + const [addressConfirmed, setAddressConfirmed] = useState(false); + const [submitError, setSubmitError] = useState(null); + const [openingPaymentPortal, setOpeningPaymentPortal] = useState(false); + const paymentToastTimeoutRef = useRef(null); + + const orderType: OrderTypeValue | null = useMemo(() => { + if (!cartItem?.orderType) return null; + switch (cartItem.orderType) { + case "Internet": + return ORDER_TYPE.INTERNET; + case "SIM": + return ORDER_TYPE.SIM; + case "VPN": + return ORDER_TYPE.VPN; + default: + return null; + } + }, [cartItem?.orderType]); + + const isInternetOrder = orderType === ORDER_TYPE.INTERNET; + + const { data: activeSubs } = useActiveSubscriptions(); + const hasActiveInternetSubscription = useMemo(() => { + if (!Array.isArray(activeSubs)) return false; + return activeSubs.some( + subscription => + String(subscription.groupName || subscription.productName || "") + .toLowerCase() + .includes("internet") && String(subscription.status || "").toLowerCase() === "active" + ); + }, [activeSubs]); + + const activeInternetWarning = + isInternetOrder && hasActiveInternetSubscription ? ACTIVE_INTERNET_SUBSCRIPTION_WARNING : null; + + const { + data: paymentMethods, + isLoading: paymentMethodsLoading, + error: paymentMethodsError, + refetch: refetchPaymentMethods, + } = usePaymentMethods(); + + const paymentRefresh = usePaymentRefresh({ + refetch: refetchPaymentMethods, + attachFocusListeners: false, + }); + + const paymentMethodList = paymentMethods?.paymentMethods ?? []; + const hasPaymentMethod = paymentMethodList.length > 0; + + const defaultPaymentMethod = + paymentMethodList.find(method => method.isDefault) ?? paymentMethodList[0] ?? null; + const paymentMethodDisplay = defaultPaymentMethod + ? buildPaymentMethodDisplay(defaultPaymentMethod) + : null; + + const eligibilityQuery = useInternetEligibility({ enabled: isInternetOrder }); + const eligibilityValue = eligibilityQuery.data?.eligibility; + const eligibilityStatus = eligibilityQuery.data?.status; + const eligibilityRequestedAt = eligibilityQuery.data?.requestedAt; + const eligibilityNotes = eligibilityQuery.data?.notes; + const eligibilityRequest = useRequestInternetEligibilityCheck(); + const eligibilityLoading = Boolean(isInternetOrder && eligibilityQuery.isLoading); + const eligibilityNotRequested = Boolean( + isInternetOrder && eligibilityQuery.isSuccess && eligibilityStatus === "not_requested" + ); + const eligibilityPending = Boolean( + isInternetOrder && eligibilityQuery.isSuccess && eligibilityStatus === "pending" + ); + const eligibilityIneligible = Boolean( + isInternetOrder && eligibilityQuery.isSuccess && eligibilityStatus === "ineligible" + ); + const eligibilityError = Boolean(isInternetOrder && eligibilityQuery.isError); + const isEligible = + !isInternetOrder || + (eligibilityStatus === "eligible" && + typeof eligibilityValue === "string" && + eligibilityValue.trim().length > 0); + + const hasServiceAddress = Boolean( + user?.address?.address1 && + user?.address?.city && + user?.address?.postcode && + (user?.address?.country || user?.address?.countryCode) + ); + const addressLabel = useMemo(() => { + const a = user?.address; + if (!a) return ""; + return [a.address1, a.address2, a.city, a.state, a.postcode, a.country || a.countryCode] + .filter(Boolean) + .map(part => String(part).trim()) + .filter(part => part.length > 0) + .join(", "); + }, [user?.address]); + + const residenceCardQuery = useResidenceCardVerification(); + const submitResidenceCard = useSubmitResidenceCard(); + const [residenceFile, setResidenceFile] = useState(null); + const residenceFileInputRef = useRef(null); + + const residenceStatus = residenceCardQuery.data?.status; + const residenceSubmitted = residenceStatus === "pending" || residenceStatus === "verified"; + + const showPaymentToast = useCallback( + (text: string, tone: "info" | "success" | "warning" | "error") => { + if (paymentToastTimeoutRef.current) { + clearTimeout(paymentToastTimeoutRef.current); + paymentToastTimeoutRef.current = null; + } + + paymentRefresh.setToast({ visible: true, text, tone }); + paymentToastTimeoutRef.current = window.setTimeout(() => { + paymentRefresh.setToast(current => ({ ...current, visible: false })); + paymentToastTimeoutRef.current = null; + }, 2200); + }, + [paymentRefresh] + ); + + useEffect(() => { + return () => { + if (paymentToastTimeoutRef.current) { + clearTimeout(paymentToastTimeoutRef.current); + } + }; + }, []); + + const formatDateTime = useCallback((iso?: string | null) => { + if (!iso) return null; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return null; + return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format( + date + ); + }, []); + + const navigateBackToConfigure = useCallback(() => { + const params = new URLSearchParams(searchParams?.toString() ?? ""); + const type = (params.get("type") ?? "").toLowerCase(); + params.delete("type"); + + // Configure flows use `planSku` as the canonical selection. + // Additional params (addons, simType, etc.) may also be present for restore. + const planSku = params.get("planSku")?.trim(); + if (!planSku) { + params.delete("planSku"); + } + + if (type === "sim") { + router.push(`/account/services/sim/configure?${params.toString()}`); + return; + } + if (type === "internet" || type === "") { + router.push(`/account/services/internet/configure?${params.toString()}`); + return; + } + router.push("/account/services"); + }, [router, searchParams]); + + const handleSubmitOrder = useCallback(async () => { + setSubmitError(null); + if (!checkoutSessionId) { + setSubmitError("Checkout session expired. Please restart checkout from the shop."); + return; + } + + try { + setSubmitting(true); + const result = await ordersService.createOrderFromCheckoutSession(checkoutSessionId); + clear(); + router.push(`/account/orders/${encodeURIComponent(result.sfOrderId)}?status=success`); + } catch (error) { + const message = error instanceof Error ? error.message : "Order submission failed"; + if ( + message.toLowerCase().includes("residence card submission required") || + message.toLowerCase().includes("residence card submission was rejected") + ) { + const next = `${pathname}${searchParams?.toString() ? `?${searchParams.toString()}` : ""}`; + router.push(`/account/settings/verification?returnTo=${encodeURIComponent(next)}`); + return; + } + setSubmitError(message); + } finally { + setSubmitting(false); + } + }, [checkoutSessionId, clear, pathname, router, searchParams]); + + const handleManagePayment = useCallback(async () => { + if (openingPaymentPortal) return; + setOpeningPaymentPortal(true); + + try { + const response = await apiClient.POST("/api/auth/sso-link", { + body: { destination: "index.php?rp=/account/paymentmethods" }, + }); + const data = ssoLinkResponseSchema.parse(response.data); + if (!data.url) { + throw new Error("No payment portal URL returned"); + } + window.open(data.url, "_blank", "noopener,noreferrer"); + } catch (error) { + const message = error instanceof Error ? error.message : "Unable to open the payment portal"; + showPaymentToast(message, "error"); + } finally { + setOpeningPaymentPortal(false); + } + }, [openingPaymentPortal, showPaymentToast]); + + if (!cartItem || !orderType) { + const shopHref = pathname.startsWith("/account") ? "/account/services" : "/services"; + return ( +
+ +
+ Checkout data is not available + +
+
+
+ ); + } + + return ( + } + > +
+ + + void eligibilityQuery.refetch(), + }} + eligibilityRequest={eligibilityRequest} + hasServiceAddress={hasServiceAddress} + addressLabel={addressLabel} + userAddress={user?.address} + planSku={cartItem.planSku} + /> + +
+
+ +

Checkout requirements

+
+ +
+ + setAddressConfirmed(true)} + onAddressIncomplete={() => setAddressConfirmed(false)} + orderType={orderType} + /> + + + } + right={ +
+ {hasPaymentMethod ? : undefined} + +
+ } + > + {paymentMethodsLoading ? ( +
Checking payment methods...
+ ) : paymentMethodsError ? ( + +
+ + +
+
+ ) : hasPaymentMethod ? ( +
+ {paymentMethodDisplay ? ( +
+
+
+

+ Default payment method +

+

+ {paymentMethodDisplay.title} +

+ {paymentMethodDisplay.subtitle ? ( +

+ {paymentMethodDisplay.subtitle} +

+ ) : null} +
+
+
+ ) : null} +

+ We securely charge your saved payment method after the order is approved. +

+
+ ) : ( + +
+ + +
+
+ )} +
+ + } + right={ + residenceStatus === "verified" ? ( + + ) : residenceStatus === "pending" ? ( + + ) : residenceStatus === "rejected" ? ( + + ) : ( + + ) + } + > + {residenceCardQuery.isLoading ? ( +
Checking residence card status…
+ ) : residenceCardQuery.isError ? ( + + + + ) : residenceStatus === "verified" ? ( +
+ + Your identity verification is complete. + + + {residenceCardQuery.data?.filename || residenceCardQuery.data?.submittedAt ? ( +
+
+ Submitted document +
+ {residenceCardQuery.data?.filename ? ( +
+ {residenceCardQuery.data.filename} + {typeof residenceCardQuery.data.sizeBytes === "number" && + residenceCardQuery.data.sizeBytes > 0 ? ( + + {" "} + ·{" "} + {Math.round((residenceCardQuery.data.sizeBytes / 1024 / 1024) * 10) / + 10} + {" MB"} + + ) : null} +
+ ) : null} +
+ {formatDateTime(residenceCardQuery.data?.submittedAt) ? ( +
+ Submitted: {formatDateTime(residenceCardQuery.data?.submittedAt)} +
+ ) : null} + {formatDateTime(residenceCardQuery.data?.reviewedAt) ? ( +
Reviewed: {formatDateTime(residenceCardQuery.data?.reviewedAt)}
+ ) : null} +
+
+ ) : null} + +
+ + Replace residence card + +
+

+ Replacing the file restarts the verification process. +

+ setResidenceFile(e.target.files?.[0] ?? null)} + className="block w-full text-sm text-foreground file:mr-4 file:py-2 file:px-3 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-muted file:text-foreground hover:file:bg-muted/80" + /> + + {residenceFile ? ( +
+
+
+ Selected file +
+
+ {residenceFile.name} +
+
+ +
+ ) : null} + +
+ +
+ + {submitResidenceCard.isError && ( +
+ {submitResidenceCard.error instanceof Error + ? submitResidenceCard.error.message + : "Failed to submit residence card."} +
+ )} +
+
+
+ ) : residenceStatus === "pending" ? ( +
+ + We’ll verify your residence card before activating SIM service. + + + {residenceCardQuery.data?.filename || residenceCardQuery.data?.submittedAt ? ( +
+
+ Submitted document +
+ {residenceCardQuery.data?.filename ? ( +
+ {residenceCardQuery.data.filename} + {typeof residenceCardQuery.data.sizeBytes === "number" && + residenceCardQuery.data.sizeBytes > 0 ? ( + + {" "} + ·{" "} + {Math.round((residenceCardQuery.data.sizeBytes / 1024 / 1024) * 10) / + 10} + {" MB"} + + ) : null} +
+ ) : null} +
+ {formatDateTime(residenceCardQuery.data?.submittedAt) ? ( +
+ Submitted: {formatDateTime(residenceCardQuery.data?.submittedAt)} +
+ ) : null} +
+
+ ) : null} + +
+ + Replace residence card + +
+

+ If you uploaded the wrong file, you can replace it. This restarts the + review. +

+ setResidenceFile(e.target.files?.[0] ?? null)} + className="block w-full text-sm text-foreground file:mr-4 file:py-2 file:px-3 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-muted file:text-foreground hover:file:bg-muted/80" + /> + + {residenceFile ? ( +
+
+
+ Selected file +
+
+ {residenceFile.name} +
+
+ +
+ ) : null} + +
+ +
+ + {submitResidenceCard.isError && ( +
+ {submitResidenceCard.error instanceof Error + ? submitResidenceCard.error.message + : "Failed to submit residence card."} +
+ )} +
+
+
+ ) : ( + +
+ {residenceStatus === "rejected" && residenceCardQuery.data?.reviewerNotes ? ( +
+
Rejection note
+
{residenceCardQuery.data.reviewerNotes}
+
+ ) : residenceStatus === "rejected" ? ( +

+ Your document couldn’t be approved. Please upload a new file to continue. +

+ ) : null} +

+ Upload a JPG, PNG, or PDF (max 5MB). We’ll verify it before activating SIM + service. +

+ +
+ setResidenceFile(e.target.files?.[0] ?? null)} + className="block w-full text-sm text-foreground file:mr-4 file:py-2 file:px-3 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-muted file:text-foreground hover:file:bg-muted/80" + /> + + {residenceFile ? ( +
+
+
+ Selected file +
+
+ {residenceFile.name} +
+
+ +
+ ) : null} +
+ +
+ +
+ + {submitResidenceCard.isError && ( +
+ {submitResidenceCard.error instanceof Error + ? submitResidenceCard.error.message + : "Failed to submit residence card."} +
+ )} +
+
+ )} +
+
+
+ +
+
+ +
+

Review & Submit

+

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

+ + {submitError ? ( +
+ + {submitError} + +
+ ) : null} + +
+

What to expect

+
+

• Our team reviews your order and schedules setup if needed

+

• We may contact you to confirm details or availability

+

• We verify your residence card before service activation

+

• We only charge your card after the order is approved

+

• You’ll receive confirmation and next steps by email

+
+
+ +
+
+ Estimated Total +
+
+ ¥{cartItem.pricing.monthlyTotal.toLocaleString()}/mo +
+ {cartItem.pricing.oneTimeTotal > 0 && ( +
+ + ¥{cartItem.pricing.oneTimeTotal.toLocaleString()} one-time +
+ )} +
+
+
+
+ +
+ + +
+
+
+ ); +} + +export default AccountCheckoutContainer; diff --git a/apps/portal/src/features/checkout/components/CheckoutEntry.tsx b/apps/portal/src/features/checkout/components/CheckoutEntry.tsx new file mode 100644 index 00000000..0f6568a7 --- /dev/null +++ b/apps/portal/src/features/checkout/components/CheckoutEntry.tsx @@ -0,0 +1,247 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { usePathname, useSearchParams, useRouter } from "next/navigation"; +import type { CartItem, OrderType as CheckoutOrderType } from "@customer-portal/domain/checkout"; +import type { CheckoutCart, OrderTypeValue } from "@customer-portal/domain/orders"; +import { ORDER_TYPE } from "@customer-portal/domain/orders"; +import { checkoutService } from "@/features/checkout/services/checkout.service"; +import { CheckoutParamsService } from "@/features/checkout/services/checkout-params.service"; +import { useCheckoutStore } from "@/features/checkout/stores/checkout.store"; +import { AccountCheckoutContainer } from "@/features/checkout/components/AccountCheckoutContainer"; +import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; +import { Button } from "@/components/atoms/button"; +import { Spinner } from "@/components/atoms"; +import { EmptyCartRedirect } from "@/features/checkout/components/EmptyCartRedirect"; +import { useAuthSession, useAuthStore } from "@/features/auth/services/auth.store"; + +const signatureFromSearchParams = (params: URLSearchParams): string => { + const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b)); + return entries.map(([key, value]) => `${key}=${value}`).join("&"); +}; + +const mapOrderTypeToCheckout = (orderType: OrderTypeValue): CheckoutOrderType => { + switch (orderType) { + case ORDER_TYPE.SIM: + return "SIM"; + case ORDER_TYPE.VPN: + return "VPN"; + case ORDER_TYPE.INTERNET: + case ORDER_TYPE.OTHER: + default: + return "Internet"; + } +}; + +type CheckoutCartSummary = { items: CheckoutCart["items"]; totals: CheckoutCart["totals"] }; + +const cartItemFromCheckoutCart = ( + cart: CheckoutCartSummary, + orderType: OrderTypeValue +): CartItem => { + const planItem = cart.items.find(item => item.itemType === "plan") ?? cart.items[0]; + const planSku = planItem?.sku; + if (!planSku) { + throw new Error("Checkout cart did not include a plan. Please re-select your plan."); + } + const addonSkus = Array.from( + new Set(cart.items.map(item => item.sku).filter(sku => sku && sku !== planSku)) + ); + + return { + orderType: mapOrderTypeToCheckout(orderType), + planSku, + planName: planItem?.name ?? planSku, + addonSkus, + configuration: {}, + pricing: { + monthlyTotal: cart.totals.monthlyTotal, + oneTimeTotal: cart.totals.oneTimeTotal, + breakdown: cart.items.map(item => ({ + label: item.name, + sku: item.sku, + monthlyPrice: item.monthlyPrice, + oneTimePrice: item.oneTimePrice, + quantity: item.quantity, + })), + }, + }; +}; + +export function CheckoutEntry() { + const router = useRouter(); + const searchParams = useSearchParams(); + const pathname = usePathname(); + const { isAuthenticated } = useAuthSession(); + const hasCheckedAuth = useAuthStore(state => state.hasCheckedAuth); + const paramsKey = useMemo(() => searchParams.toString(), [searchParams]); + const signature = useMemo( + () => signatureFromSearchParams(new URLSearchParams(paramsKey)), + [paramsKey] + ); + + const { + cartItem, + cartParamsSignature, + checkoutSessionId, + setCartItem, + setCartItemFromParams, + setCheckoutSession, + isCartStale, + clear, + } = useCheckoutStore(); + + const [status, setStatus] = useState<"idle" | "loading" | "error">("idle"); + const [errorMessage, setErrorMessage] = useState(null); + + useEffect(() => { + if (!paramsKey) { + return; + } + + let mounted = true; + setStatus("loading"); + setErrorMessage(null); + + void (async () => { + try { + const snapshot = CheckoutParamsService.buildSnapshot(new URLSearchParams(paramsKey)); + if (!snapshot.planReference) { + throw new Error("No plan selected. Please go back and select a plan."); + } + + const session = await checkoutService.createSession( + snapshot.orderType, + snapshot.selections, + snapshot.configuration + ); + if (!mounted) return; + + const nextCartItem = cartItemFromCheckoutCart(session.cart, session.orderType); + setCartItemFromParams(nextCartItem, signature); + setCheckoutSession({ id: session.sessionId, expiresAt: session.expiresAt }); + setStatus("idle"); + } catch (error) { + if (!mounted) return; + setStatus("error"); + setErrorMessage(error instanceof Error ? error.message : "Failed to load checkout"); + } + })(); + + return () => { + mounted = false; + }; + }, [paramsKey, setCartItemFromParams, setCheckoutSession, signature]); + + useEffect(() => { + if (paramsKey) return; + + if (isCartStale()) { + clear(); + return; + } + + if (!checkoutSessionId) { + return; + } + + let mounted = true; + setStatus("loading"); + setErrorMessage(null); + + void (async () => { + try { + const session = await checkoutService.getSession(checkoutSessionId); + if (!mounted) return; + setCheckoutSession({ id: session.sessionId, expiresAt: session.expiresAt }); + const nextCartItem = cartItemFromCheckoutCart(session.cart, session.orderType); + // Session-based entry: don't tie progress to URL params. + setCartItem(nextCartItem); + setStatus("idle"); + } catch (error) { + if (!mounted) return; + setStatus("error"); + setErrorMessage(error instanceof Error ? error.message : "Failed to load checkout"); + } + })(); + + return () => { + mounted = false; + }; + }, [checkoutSessionId, clear, isCartStale, paramsKey, setCartItem, setCheckoutSession]); + + const shouldWaitForCart = + (Boolean(paramsKey) && (!cartItem || cartParamsSignature !== signature)) || + (!paramsKey && Boolean(checkoutSessionId) && !cartItem); + + if (status === "loading" && shouldWaitForCart) { + return ( +
+
+ +

Preparing your checkout…

+
+
+ ); + } + + if (status === "error") { + const shopHref = pathname.startsWith("/account") ? "/account/services" : "/services"; + return ( +
+ +
+
{errorMessage}
+
+ + + Contact support + +
+
+
+
+ ); + } + + if (!paramsKey && !checkoutSessionId) { + return ; + } + + // Redirect unauthenticated users to login + // Cart data is preserved in localStorage, so they can continue after logging in + if (!isAuthenticated && hasCheckedAuth) { + const currentUrl = pathname + (paramsKey ? `?${paramsKey}` : ""); + const returnTo = encodeURIComponent( + pathname.startsWith("/account") + ? currentUrl + : `/account/order${paramsKey ? `?${paramsKey}` : ""}` + ); + router.replace(`/auth/login?returnTo=${returnTo}`); + return ( +
+
+ +

Redirecting to sign in…

+
+
+ ); + } + + // Wait for auth check + if (!hasCheckedAuth) { + return ( +
+
+ +

Loading…

+
+
+ ); + } + + return ; +} 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..3f3b0103 --- /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/CheckoutShell.tsx b/apps/portal/src/features/checkout/components/CheckoutShell.tsx new file mode 100644 index 00000000..7bbced29 --- /dev/null +++ b/apps/portal/src/features/checkout/components/CheckoutShell.tsx @@ -0,0 +1,97 @@ +"use client"; + +import type { ReactNode } from "react"; +import { useEffect } from "react"; +import Link from "next/link"; +import { Logo } from "@/components/atoms/logo"; +import { ShieldCheckIcon } from "@heroicons/react/24/outline"; +import { useAuthStore } from "@/features/auth/services/auth.store"; + +interface CheckoutShellProps { + children: ReactNode; +} + +/** + * CheckoutShell - Minimal shell for checkout flow + * + * Features: + * - Logo linking to homepage + * - Security badge + * - Support link + * - Clean, focused design + */ +export function CheckoutShell({ children }: CheckoutShellProps) { + const hasCheckedAuth = useAuthStore(state => state.hasCheckedAuth); + const checkAuth = useAuthStore(state => state.checkAuth); + + useEffect(() => { + if (!hasCheckedAuth) { + void checkAuth(); + } + }, [checkAuth, hasCheckedAuth]); + + return ( +
+ {/* Subtle background pattern */} +
+
+
+
+ +
+
+ {/* 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/CheckoutStatusBanners.tsx b/apps/portal/src/features/checkout/components/CheckoutStatusBanners.tsx new file mode 100644 index 00000000..0484bf29 --- /dev/null +++ b/apps/portal/src/features/checkout/components/CheckoutStatusBanners.tsx @@ -0,0 +1,138 @@ +import { Button } from "@/components/atoms/button"; +import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; +import type { Address } from "@customer-portal/domain/customer"; + +interface CheckoutStatusBannersProps { + activeInternetWarning: string | null; + eligibility: { + isLoading: boolean; + isError: boolean; + isPending: boolean; + isNotRequested: boolean; + isIneligible: boolean; + notes?: string | null; + requestedAt?: string | null; + refetch: () => void; + }; + eligibilityRequest: { + isPending: boolean; + mutate: (data: { address?: Partial
; notes: string }) => void; + }; + hasServiceAddress: boolean; + addressLabel: string; + userAddress?: Partial
; + planSku?: string; +} + +export function CheckoutStatusBanners({ + activeInternetWarning, + eligibility, + eligibilityRequest, + hasServiceAddress, + addressLabel, + userAddress, + planSku, +}: CheckoutStatusBannersProps) { + return ( + <> + {activeInternetWarning && ( + + {activeInternetWarning} + + )} + + {eligibility.isLoading ? ( + + We’re loading your current eligibility status. + + ) : eligibility.isError ? ( + +
+ + Please try again in a moment. If this continues, contact support. + + +
+
+ ) : eligibility.isPending ? ( + +
+ + We’re verifying whether our service is available at your residence. Once eligibility + is confirmed, you can submit your internet order. + + +
+
+ ) : eligibility.isNotRequested ? ( + +
+ + Request an eligibility review to confirm service availability for your address before + submitting an internet order. + + {hasServiceAddress ? ( + + ) : ( + + )} +
+
+ ) : eligibility.isIneligible ? ( + +
+

+ Our team reviewed your address and determined service isn’t available right now. +

+ {eligibility.notes ? ( +

{eligibility.notes}

+ ) : eligibility.requestedAt ? ( +

+ Last updated: {new Date(eligibility.requestedAt).toLocaleString()} +

+ ) : null} + +
+
+ ) : null} + + ); +} 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..fc924c68 --- /dev/null +++ b/apps/portal/src/features/checkout/components/EmptyCartRedirect.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { ShoppingCartIcon } from "@heroicons/react/24/outline"; +import { Button } from "@/components/atoms/button"; +import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath"; + +/** + * EmptyCartRedirect - Shown when checkout is accessed without a cart + * + * Redirects to shop after a short delay, or user can click to go immediately. + */ +export function EmptyCartRedirect() { + const router = useRouter(); + const servicesBasePath = useServicesBasePath(); + + useEffect(() => { + const timer = setTimeout(() => { + router.push(servicesBasePath); + }, 5000); + + return () => clearTimeout(timer); + }, [router, servicesBasePath]); + + return ( +
+
+
+ +
+

Your cart is empty

+

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

+ +

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

+
+
+ ); +} diff --git a/apps/portal/src/features/checkout/components/OrderConfirmation.tsx b/apps/portal/src/features/checkout/components/OrderConfirmation.tsx new file mode 100644 index 00000000..144ff0d7 --- /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..bde188f0 --- /dev/null +++ b/apps/portal/src/features/checkout/components/index.ts @@ -0,0 +1,7 @@ +export { CheckoutShell } from "./CheckoutShell"; +export { OrderSummaryCard } from "./OrderSummaryCard"; +export { EmptyCartRedirect } from "./EmptyCartRedirect"; +export { OrderConfirmation } from "./OrderConfirmation"; +export { CheckoutErrorBoundary } from "./CheckoutErrorBoundary"; +export { CheckoutEntry } from "./CheckoutEntry"; +export { AccountCheckoutContainer } from "./AccountCheckoutContainer"; diff --git a/apps/portal/src/features/checkout/hooks/useCheckout.ts b/apps/portal/src/features/checkout/hooks/useCheckout.ts deleted file mode 100644 index 0a760a24..00000000 --- a/apps/portal/src/features/checkout/hooks/useCheckout.ts +++ /dev/null @@ -1,229 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useSearchParams, useRouter } from "next/navigation"; -import { logger } from "@/lib/logger"; -import { ordersService } from "@/features/orders/services/orders.service"; -import { checkoutService } from "@/features/checkout/services/checkout.service"; -import { ACTIVE_INTERNET_SUBSCRIPTION_WARNING } from "@/features/checkout/constants"; -import { usePaymentMethods } from "@/features/billing/hooks/useBilling"; -import { usePaymentRefresh } from "@/features/billing/hooks/usePaymentRefresh"; -import { - createLoadingState, - createSuccessState, - createErrorState, -} from "@customer-portal/domain/toolkit"; -import type { AsyncState } from "@customer-portal/domain/toolkit"; -import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscriptions"; -import { - ORDER_TYPE, - orderWithSkuValidationSchema, - prepareOrderFromCart, - type CheckoutCart, -} from "@customer-portal/domain/orders"; -import { CheckoutParamsService } from "@/features/checkout/services/checkout-params.service"; -import { useAuthSession, useAuthStore } from "@/features/auth/services/auth.store"; -import { ZodError } from "zod"; - -// Use domain Address type -import type { Address } from "@customer-portal/domain/customer"; - -export function useCheckout() { - const params = useSearchParams(); - const router = useRouter(); - const { isAuthenticated } = useAuthSession(); - - const [submitting, setSubmitting] = useState(false); - const [addressConfirmed, setAddressConfirmed] = useState(false); - - const [checkoutState, setCheckoutState] = useState>({ - status: "loading", - }); - - // Load active subscriptions to enforce business rules client-side before submission - const { data: activeSubs } = useActiveSubscriptions(); - const hasActiveInternetSubscription = useMemo(() => { - if (!Array.isArray(activeSubs)) return false; - return activeSubs.some( - subscription => - String(subscription.groupName || subscription.productName || "") - .toLowerCase() - .includes("internet") && String(subscription.status || "").toLowerCase() === "active" - ); - }, [activeSubs]); - const [activeInternetWarning, setActiveInternetWarning] = useState(null); - - const { - data: paymentMethods, - isLoading: paymentMethodsLoading, - error: paymentMethodsError, - refetch: refetchPaymentMethods, - } = usePaymentMethods(); - - const paymentRefresh = usePaymentRefresh({ - refetch: refetchPaymentMethods, - attachFocusListeners: true, - }); - - const paramsKey = params.toString(); - const checkoutSnapshot = CheckoutParamsService.buildSnapshot(new URLSearchParams(paramsKey)); - const { orderType, warnings } = checkoutSnapshot; - - const lastWarningSignature = useRef(null); - - useEffect(() => { - if (warnings.length === 0) { - return; - } - - const signature = warnings.join("|"); - if (signature === lastWarningSignature.current) { - return; - } - - lastWarningSignature.current = signature; - warnings.forEach(message => { - logger.warn("Checkout parameter warning", { message }); - }); - }, [warnings]); - - useEffect(() => { - if (orderType !== ORDER_TYPE.INTERNET || !hasActiveInternetSubscription) { - setActiveInternetWarning(null); - return; - } - - setActiveInternetWarning(ACTIVE_INTERNET_SUBSCRIPTION_WARNING); - }, [orderType, hasActiveInternetSubscription]); - - useEffect(() => { - // Wait for authentication before building cart - if (!isAuthenticated) { - return; - } - - let mounted = true; - - void (async () => { - const snapshot = CheckoutParamsService.buildSnapshot(new URLSearchParams(paramsKey)); - const { - orderType: snapshotOrderType, - selections, - configuration, - planReference: snapshotPlan, - } = snapshot; - - try { - setCheckoutState(createLoadingState()); - - if (!snapshotPlan) { - throw new Error("No plan selected. Please go back and select a plan."); - } - - // Build cart using BFF service - const cart = await checkoutService.buildCart(snapshotOrderType, selections, configuration); - - if (!mounted) return; - - setCheckoutState(createSuccessState(cart)); - } catch (error) { - if (mounted) { - const reason = error instanceof Error ? error.message : "Failed to load checkout data"; - setCheckoutState(createErrorState(new Error(reason))); - } - } - })(); - - return () => { - mounted = false; - }; - }, [isAuthenticated, paramsKey]); - - const handleSubmitOrder = useCallback(async () => { - try { - setSubmitting(true); - if (checkoutState.status !== "success") { - throw new Error("Checkout data not loaded"); - } - - const cart = checkoutState.data; - - // Debug logging to check cart contents - console.log("[DEBUG] Cart data:", cart); - console.log("[DEBUG] Cart items:", cart.items); - - // Validate cart before submission - await checkoutService.validateCart(cart); - - // Use domain helper to prepare order data - // This encapsulates SKU extraction and payload formatting - const orderData = prepareOrderFromCart(cart, orderType); - - console.log("[DEBUG] Extracted SKUs from cart:", orderData.skus); - - const currentUserId = useAuthStore.getState().user?.id; - if (currentUserId) { - try { - orderWithSkuValidationSchema.parse({ - ...orderData, - userId: currentUserId, - }); - } catch (validationError) { - if (validationError instanceof ZodError) { - const firstIssue = validationError.issues.at(0); - throw new Error(firstIssue?.message || "Order contains invalid data"); - } - throw validationError; - } - } - - const response = await ordersService.createOrder(orderData); - router.push(`/orders/${response.sfOrderId}?status=success`); - } catch (error) { - let errorMessage = "Order submission failed"; - if (error instanceof Error) errorMessage = error.message; - setCheckoutState(createErrorState(new Error(errorMessage))); - } finally { - setSubmitting(false); - } - }, [checkoutState, orderType, router]); - - const confirmAddress = useCallback((address?: Address) => { - setAddressConfirmed(true); - void address; - }, []); - - const markAddressIncomplete = useCallback(() => { - setAddressConfirmed(false); - }, []); - - const navigateBackToConfigure = useCallback(() => { - // State is already persisted in Zustand store - // Just need to restore params and navigate - const urlParams = new URLSearchParams(paramsKey); - urlParams.delete("type"); // Remove type param as it's not needed - - const configureUrl = - orderType === ORDER_TYPE.INTERNET - ? `/catalog/internet/configure?${urlParams.toString()}` - : `/catalog/sim/configure?${urlParams.toString()}`; - - router.push(configureUrl); - }, [orderType, paramsKey, router]); - - return { - checkoutState, - submitting, - orderType, - addressConfirmed, - paymentMethods, - paymentMethodsLoading, - paymentMethodsError, - paymentRefresh, - confirmAddress, - markAddressIncomplete, - handleSubmitOrder, - navigateBackToConfigure, - activeInternetWarning, - } as const; -} diff --git a/apps/portal/src/features/checkout/services/checkout-params.service.ts b/apps/portal/src/features/checkout/services/checkout-params.service.ts index 9c0c3cbb..93a1ae82 100644 --- a/apps/portal/src/features/checkout/services/checkout-params.service.ts +++ b/apps/portal/src/features/checkout/services/checkout-params.service.ts @@ -1,3 +1,4 @@ +import { normalizeOrderType } from "@customer-portal/domain/checkout"; import { ORDER_TYPE, buildOrderConfigurations, @@ -27,26 +28,31 @@ export class CheckoutParamsService { } static resolveOrderType(params: URLSearchParams): OrderTypeValue { - const type = params.get("type")?.toLowerCase(); - switch (type) { - case "sim": - return ORDER_TYPE.SIM; - case "vpn": - return ORDER_TYPE.VPN; - case "other": - return ORDER_TYPE.OTHER; - case "internet": - default: - return ORDER_TYPE.INTERNET; + const typeParam = params.get("type"); + + // Default to Internet if no type specified + if (!typeParam) { + return ORDER_TYPE.INTERNET; } + + // Try to normalize using domain logic + const normalized = normalizeOrderType(typeParam); + if (normalized) { + return normalized; + } + + // Handle legacy/edge cases not covered by normalization + if (typeParam.toLowerCase() === "other") { + return ORDER_TYPE.OTHER; + } + + // Default fallback + return ORDER_TYPE.INTERNET; } private static coalescePlanReference(selections: OrderSelections): string | null { - // After cleanup, we only use planSku const planSku = selections.planSku; - if (typeof planSku === "string" && planSku.trim().length > 0) { - return planSku.trim(); - } + if (typeof planSku === "string" && planSku.trim().length > 0) return planSku.trim(); return null; } diff --git a/apps/portal/src/features/checkout/services/checkout.service.ts b/apps/portal/src/features/checkout/services/checkout.service.ts index b24f50ac..9c01b37d 100644 --- a/apps/portal/src/features/checkout/services/checkout.service.ts +++ b/apps/portal/src/features/checkout/services/checkout.service.ts @@ -7,6 +7,15 @@ import type { } from "@customer-portal/domain/orders"; import type { ApiSuccessResponse } from "@customer-portal/domain/common"; +type CheckoutCartSummary = { items: CheckoutCart["items"]; totals: CheckoutCart["totals"] }; + +type CheckoutSessionResponse = { + sessionId: string; + expiresAt: string; + orderType: OrderTypeValue; + cart: CheckoutCartSummary; +}; + export const checkoutService = { /** * Build checkout cart from order type and selections @@ -31,6 +40,40 @@ export const checkoutService = { return wrappedResponse.data; }, + async createSession( + orderType: OrderTypeValue, + selections: OrderSelections, + configuration?: OrderConfigurations + ): Promise { + const response = await apiClient.POST>( + "/api/checkout/session", + { + body: { orderType, selections, configuration }, + } + ); + + const wrappedResponse = getDataOrThrow(response, "Failed to create checkout session"); + if (!wrappedResponse.success) { + throw new Error("Failed to create checkout session"); + } + return wrappedResponse.data; + }, + + async getSession(sessionId: string): Promise { + const response = await apiClient.GET>( + "/api/checkout/session/{sessionId}", + { + params: { path: { sessionId } }, + } + ); + + const wrappedResponse = getDataOrThrow(response, "Failed to load checkout session"); + if (!wrappedResponse.success) { + throw new Error("Failed to load checkout session"); + } + return wrappedResponse.data; + }, + /** * Validate checkout cart */ diff --git a/apps/portal/src/features/checkout/stores/checkout.store.ts b/apps/portal/src/features/checkout/stores/checkout.store.ts new file mode 100644 index 00000000..a14208f7 --- /dev/null +++ b/apps/portal/src/features/checkout/stores/checkout.store.ts @@ -0,0 +1,159 @@ +/** + * Checkout Store + * + * Zustand store for checkout flow with localStorage persistence. + * Stores cart data and checkout session for authenticated users. + */ + +import { create } from "zustand"; +import { persist, createJSONStorage } from "zustand/middleware"; +import type { CartItem } from "@customer-portal/domain/checkout"; + +interface CheckoutState { + // Cart data + cartItem: CartItem | null; + cartParamsSignature: string | null; + checkoutSessionId: string | null; + checkoutSessionExpiresAt: string | null; + + // Cart timestamp for staleness detection + cartUpdatedAt: number | null; +} + +interface CheckoutActions { + // Cart actions + setCartItem: (item: CartItem) => void; + setCartItemFromParams: (item: CartItem, signature: string) => void; + setCheckoutSession: (session: { id: string; expiresAt: string }) => void; + clearCart: () => void; + + // Reset + clear: () => void; + + // Cart recovery + isCartStale: (maxAgeMs?: number) => boolean; +} + +type CheckoutStore = CheckoutState & CheckoutActions; + +const initialState: CheckoutState = { + cartItem: null, + cartParamsSignature: null, + checkoutSessionId: null, + checkoutSessionExpiresAt: null, + cartUpdatedAt: null, +}; + +export const useCheckoutStore = create()( + persist( + (set, get) => ({ + ...initialState, + + // Cart actions + setCartItem: (item: CartItem) => + set({ + cartItem: item, + cartUpdatedAt: Date.now(), + }), + + setCartItemFromParams: (item: CartItem, signature: string) => { + const { cartParamsSignature, cartItem } = get(); + const signatureChanged = cartParamsSignature !== signature; + const hasExistingCart = cartItem !== null || cartParamsSignature !== null; + + if (signatureChanged && hasExistingCart) { + set(initialState); + } + + if (!signatureChanged && cartItem) { + // Allow refreshing cart totals without resetting progress + set({ + cartItem: item, + cartUpdatedAt: Date.now(), + }); + return; + } + + set({ + cartItem: item, + cartParamsSignature: signature, + cartUpdatedAt: Date.now(), + }); + }, + + setCheckoutSession: session => + set({ + checkoutSessionId: session.id, + checkoutSessionExpiresAt: session.expiresAt, + }), + + clearCart: () => + set({ + cartItem: null, + cartParamsSignature: null, + checkoutSessionId: null, + checkoutSessionExpiresAt: null, + cartUpdatedAt: null, + }), + + // 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: 3, + storage: createJSONStorage(() => localStorage), + migrate: (persistedState: unknown, version: number) => { + if (!persistedState || typeof persistedState !== "object") { + return initialState; + } + + const state = persistedState as Partial; + + // Migration from v1/v2: strip out removed fields + if (version < 3) { + return { + cartItem: state.cartItem ?? null, + cartParamsSignature: state.cartParamsSignature ?? null, + checkoutSessionId: state.checkoutSessionId ?? null, + checkoutSessionExpiresAt: state.checkoutSessionExpiresAt ?? null, + cartUpdatedAt: state.cartUpdatedAt ?? null, + } as CheckoutState; + } + + return { + ...initialState, + ...state, + } as CheckoutState; + }, + partialize: state => ({ + // Persist only essential data + cartItem: state.cartItem + ? { + ...state.cartItem, + // Avoid persisting potentially sensitive configuration details. + configuration: {}, + } + : null, + cartParamsSignature: state.cartParamsSignature, + checkoutSessionId: state.checkoutSessionId, + checkoutSessionExpiresAt: state.checkoutSessionExpiresAt, + cartUpdatedAt: state.cartUpdatedAt, + }), + } + ) +); + +/** + * Hook to check if cart has items + */ +export function useHasCartItem(): boolean { + return useCheckoutStore(state => state.cartItem !== null); +} diff --git a/apps/portal/src/features/checkout/utils/checkout-ui-utils.ts b/apps/portal/src/features/checkout/utils/checkout-ui-utils.ts new file mode 100644 index 00000000..85e4a87c --- /dev/null +++ b/apps/portal/src/features/checkout/utils/checkout-ui-utils.ts @@ -0,0 +1,81 @@ +import type { PaymentMethod } from "@customer-portal/domain/payments"; + +export function buildPaymentMethodDisplay(method: PaymentMethod): { + title: string; + subtitle?: string; +} { + const descriptor = + method.cardType?.trim() || + method.bankName?.trim() || + method.description?.trim() || + method.gatewayName?.trim() || + "Saved payment method"; + + const trimmedLastFour = + typeof method.cardLastFour === "string" && method.cardLastFour.trim().length > 0 + ? method.cardLastFour.trim().slice(-4) + : null; + + const headline = + trimmedLastFour && method.type?.toLowerCase().includes("card") + ? `${descriptor} · •••• ${trimmedLastFour}` + : descriptor; + + const details = new Set(); + + if (method.bankName && !headline.toLowerCase().includes(method.bankName.trim().toLowerCase())) { + details.add(method.bankName.trim()); + } + + const expiry = normalizeExpiryLabel(method.expiryDate); + if (expiry) { + details.add(`Exp ${expiry}`); + } + + if (!trimmedLastFour && method.cardLastFour && method.cardLastFour.trim().length > 0) { + details.add(`Ends ${method.cardLastFour.trim().slice(-4)}`); + } + + if (method.type?.toLowerCase().includes("bank") && method.description?.trim()) { + details.add(method.description.trim()); + } + + const subtitle = details.size > 0 ? Array.from(details).join(" · ") : undefined; + return { title: headline, subtitle }; +} + +export function normalizeExpiryLabel(expiry?: string | null): string | null { + if (!expiry) return null; + const value = expiry.trim(); + if (!value) return null; + + if (/^\d{4}-\d{2}$/.test(value)) { + const [year, month] = value.split("-"); + return `${month}/${year.slice(-2)}`; + } + + if (/^\d{2}\/\d{4}$/.test(value)) { + const [month, year] = value.split("/"); + return `${month}/${year.slice(-2)}`; + } + + if (/^\d{2}\/\d{2}$/.test(value)) { + return value; + } + + const digits = value.replace(/\D/g, ""); + + if (digits.length === 6) { + const year = digits.slice(2, 4); + const month = digits.slice(4, 6); + return `${month}/${year}`; + } + + if (digits.length === 4) { + const month = digits.slice(0, 2); + const year = digits.slice(2, 4); + return `${month}/${year}`; + } + + return value; +} diff --git a/apps/portal/src/features/checkout/views/CheckoutContainer.tsx b/apps/portal/src/features/checkout/views/CheckoutContainer.tsx deleted file mode 100644 index fbda24b7..00000000 --- a/apps/portal/src/features/checkout/views/CheckoutContainer.tsx +++ /dev/null @@ -1,369 +0,0 @@ -"use client"; -import { useCheckout } from "@/features/checkout/hooks/useCheckout"; -import { PageLayout } from "@/components/templates/PageLayout"; -import { SubCard } from "@/components/molecules/SubCard/SubCard"; -import { Button } from "@/components/atoms/button"; -import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; -import { PageAsync } from "@/components/molecules/AsyncBlock/AsyncBlock"; -import { InlineToast } from "@/components/atoms/inline-toast"; -import { StatusPill } from "@/components/atoms/status-pill"; -import { AddressConfirmation } from "@/features/catalog/components/base/AddressConfirmation"; -import { isLoading, isError, isSuccess } from "@customer-portal/domain/toolkit"; -import { ShieldCheckIcon, CreditCardIcon } from "@heroicons/react/24/outline"; -import type { PaymentMethod } from "@customer-portal/domain/payments"; - -export function CheckoutContainer() { - const { - checkoutState, - submitting, - orderType, - addressConfirmed, - paymentMethods, - paymentMethodsLoading, - paymentMethodsError, - paymentRefresh, - confirmAddress, - markAddressIncomplete, - handleSubmitOrder, - navigateBackToConfigure, - activeInternetWarning, - } = useCheckout(); - - if (isLoading(checkoutState)) { - return ( - } - > - - <> - - - ); - } - - if (isError(checkoutState)) { - return ( - } - > -
- -
- {checkoutState.error.message} - -
-
-
-
- ); - } - - if (!isSuccess(checkoutState)) { - return ( - } - > -
- -
- Checkout data is not available - -
-
-
-
- ); - } - - const { items, totals } = checkoutState.data; - const paymentMethodList = paymentMethods?.paymentMethods ?? []; - const defaultPaymentMethod = - paymentMethodList.find(method => method.isDefault) ?? paymentMethodList[0] ?? null; - const paymentMethodDisplay = defaultPaymentMethod - ? buildPaymentMethodDisplay(defaultPaymentMethod) - : null; - - return ( - } - > -
- - - {activeInternetWarning && ( - - {activeInternetWarning} - - )} - -
-
- -

Confirm Details

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

- Default payment method -

-

- {paymentMethodDisplay.title} -

- {paymentMethodDisplay.subtitle ? ( -

- {paymentMethodDisplay.subtitle} -

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

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

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

Review & Submit

-

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

-
-

What to expect

-
-

• Our team reviews your order and schedules setup if needed

-

• We may contact you to confirm details or availability

-

• We only charge your card after the order is approved

-

• You’ll receive confirmation and next steps by email

-
-
- -
-
- Estimated Total -
-
- ¥{totals.monthlyTotal.toLocaleString()}/mo -
- {totals.oneTimeTotal > 0 && ( -
- + ¥{totals.oneTimeTotal.toLocaleString()} one-time -
- )} -
-
-
-
- -
- - -
-
-
- ); -} - -function buildPaymentMethodDisplay(method: PaymentMethod): { title: string; subtitle?: string } { - const descriptor = - method.cardType?.trim() || - method.bankName?.trim() || - method.description?.trim() || - method.gatewayName?.trim() || - "Saved payment method"; - - const trimmedLastFour = - typeof method.cardLastFour === "string" && method.cardLastFour.trim().length > 0 - ? method.cardLastFour.trim().slice(-4) - : null; - - const headline = - trimmedLastFour && method.type?.toLowerCase().includes("card") - ? `${descriptor} · •••• ${trimmedLastFour}` - : descriptor; - - const details = new Set(); - - if (method.bankName && !headline.toLowerCase().includes(method.bankName.trim().toLowerCase())) { - details.add(method.bankName.trim()); - } - - const expiry = normalizeExpiryLabel(method.expiryDate); - if (expiry) { - details.add(`Exp ${expiry}`); - } - - if (!trimmedLastFour && method.cardLastFour && method.cardLastFour.trim().length > 0) { - details.add(`Ends ${method.cardLastFour.trim().slice(-4)}`); - } - - if (method.type?.toLowerCase().includes("bank") && method.description?.trim()) { - details.add(method.description.trim()); - } - - const subtitle = details.size > 0 ? Array.from(details).join(" · ") : undefined; - return { title: headline, subtitle }; -} - -function normalizeExpiryLabel(expiry?: string | null): string | null { - if (!expiry) return null; - const value = expiry.trim(); - if (!value) return null; - - if (/^\d{4}-\d{2}$/.test(value)) { - const [year, month] = value.split("-"); - return `${month}/${year.slice(-2)}`; - } - - if (/^\d{2}\/\d{4}$/.test(value)) { - const [month, year] = value.split("/"); - return `${month}/${year.slice(-2)}`; - } - - if (/^\d{2}\/\d{2}$/.test(value)) { - return value; - } - - const digits = value.replace(/\D/g, ""); - - if (digits.length === 6) { - const year = digits.slice(2, 4); - const month = digits.slice(4, 6); - return `${month}/${year}`; - } - - if (digits.length === 4) { - const month = digits.slice(0, 2); - const year = digits.slice(2, 4); - return `${month}/${year}`; - } - - return value; -} - -export default CheckoutContainer; diff --git a/apps/portal/src/features/dashboard/components/QuickStats.tsx b/apps/portal/src/features/dashboard/components/QuickStats.tsx index 5f6bfe68..1a9deab3 100644 --- a/apps/portal/src/features/dashboard/components/QuickStats.tsx +++ b/apps/portal/src/features/dashboard/components/QuickStats.tsx @@ -135,7 +135,7 @@ export function QuickStats({ icon={ServerIcon} label="Active Services" value={activeSubscriptions} - href="/subscriptions" + href="/account/services" tone="primary" emptyText="No active services" /> @@ -143,7 +143,7 @@ export function QuickStats({ icon={ChatBubbleLeftRightIcon} label="Open Support Cases" value={openCases} - href="/support/cases" + href="/account/support" tone={openCases > 0 ? "warning" : "info"} emptyText="No open cases" /> @@ -152,7 +152,7 @@ export function QuickStats({ icon={ClipboardDocumentListIcon} label="Recent Orders" value={recentOrders} - href="/orders" + href="/account/orders" tone="success" emptyText="No recent orders" /> diff --git a/apps/portal/src/features/dashboard/components/TaskList.tsx b/apps/portal/src/features/dashboard/components/TaskList.tsx index 36edbc6d..21006232 100644 --- a/apps/portal/src/features/dashboard/components/TaskList.tsx +++ b/apps/portal/src/features/dashboard/components/TaskList.tsx @@ -52,18 +52,18 @@ function AllCaughtUp() { {/* Quick action cards */}
- Browse Catalog + Browse Services
@@ -74,7 +74,7 @@ function AllCaughtUp() {
diff --git a/apps/portal/src/features/dashboard/hooks/index.ts b/apps/portal/src/features/dashboard/hooks/index.ts index 1c598b0c..5dceb40a 100644 --- a/apps/portal/src/features/dashboard/hooks/index.ts +++ b/apps/portal/src/features/dashboard/hooks/index.ts @@ -1,2 +1,3 @@ export * from "./useDashboardSummary"; export * from "./useDashboardTasks"; +export * from "./useMeStatus"; diff --git a/apps/portal/src/features/dashboard/hooks/useDashboardSummary.ts b/apps/portal/src/features/dashboard/hooks/useDashboardSummary.ts index 98f4d778..05aa3b65 100644 --- a/apps/portal/src/features/dashboard/hooks/useDashboardSummary.ts +++ b/apps/portal/src/features/dashboard/hooks/useDashboardSummary.ts @@ -3,80 +3,20 @@ * Provides dashboard data with proper error handling, caching, and loading states */ -import { useQuery } from "@tanstack/react-query"; -import { useAuthSession } from "@/features/auth/services/auth.store"; -import { apiClient, queryKeys } from "@/lib/api"; -import { - dashboardSummarySchema, - type DashboardSummary, - type DashboardError, -} from "@customer-portal/domain/dashboard"; - -class DashboardDataError extends Error { - constructor( - public code: DashboardError["code"], - message: string, - public details?: Record - ) { - super(message); - this.name = "DashboardDataError"; - } -} +import type { DashboardSummary } from "@customer-portal/domain/dashboard"; +import { useMeStatus } from "./useMeStatus"; /** * Hook for fetching dashboard summary data */ export function useDashboardSummary() { - const { isAuthenticated } = useAuthSession(); + const status = useMeStatus(); - return useQuery({ - queryKey: queryKeys.dashboard.summary(), - queryFn: async () => { - if (!isAuthenticated) { - throw new DashboardDataError( - "AUTHENTICATION_REQUIRED", - "Authentication required to fetch dashboard data" - ); - } - - try { - const response = await apiClient.GET("/api/me/summary"); - if (!response.data) { - throw new DashboardDataError("FETCH_ERROR", "Dashboard summary response was empty"); - } - const parsed = dashboardSummarySchema.safeParse(response.data); - if (!parsed.success) { - throw new DashboardDataError( - "FETCH_ERROR", - "Dashboard summary response failed validation", - { issues: parsed.error.issues } - ); - } - return parsed.data; - } catch (error) { - // Transform API errors to DashboardError format - if (error instanceof Error) { - throw new DashboardDataError("FETCH_ERROR", error.message, { - originalError: error, - }); - } - - throw new DashboardDataError( - "UNKNOWN_ERROR", - "An unexpected error occurred while fetching dashboard data", - { originalError: error as Record } - ); - } - }, - enabled: isAuthenticated, - retry: (failureCount, error) => { - // Don't retry authentication errors - if (error?.code === "AUTHENTICATION_REQUIRED") { - return false; - } - // Retry up to 3 times for other errors - return failureCount < 3; - }, - retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000), // Exponential backoff - }); + return { + data: (status.data?.summary ?? undefined) as DashboardSummary | undefined, + isLoading: status.isLoading, + isError: status.isError, + error: status.error, + refetch: status.refetch, + }; } diff --git a/apps/portal/src/features/dashboard/hooks/useDashboardTasks.ts b/apps/portal/src/features/dashboard/hooks/useDashboardTasks.ts index 5569eb61..e94cacfd 100644 --- a/apps/portal/src/features/dashboard/hooks/useDashboardTasks.ts +++ b/apps/portal/src/features/dashboard/hooks/useDashboardTasks.ts @@ -1,165 +1,38 @@ "use client"; import { useMemo } from "react"; -import { formatDistanceToNow, format } from "date-fns"; import { ExclamationCircleIcon, CreditCardIcon, ClockIcon, SparklesIcon, + IdentificationIcon, } from "@heroicons/react/24/outline"; -import type { DashboardSummary } from "@customer-portal/domain/dashboard"; -import type { PaymentMethodList } from "@customer-portal/domain/payments"; -import type { OrderSummary } from "@customer-portal/domain/orders"; import type { TaskTone } from "../components/TaskCard"; -import { useDashboardSummary } from "./useDashboardSummary"; -import { usePaymentMethods } from "@/features/billing/hooks/useBilling"; -import { useOrdersList } from "@/features/orders/hooks/useOrdersList"; -import { useFormatCurrency } from "@/lib/hooks/useFormatCurrency"; +import type { + DashboardTask as DomainDashboardTask, + DashboardTaskType, +} from "@customer-portal/domain/dashboard"; +import { useMeStatus } from "./useMeStatus"; /** * Task type for dashboard actions */ -export type DashboardTaskType = "invoice" | "payment_method" | "order" | "onboarding"; +export type { DashboardTaskType }; -/** - * Dashboard task structure - */ -export interface DashboardTask { - id: string; - priority: 1 | 2 | 3 | 4; - type: DashboardTaskType; - title: string; - description: string; - /** Label for the action button */ - actionLabel: string; - /** Link for card click (navigates to detail page) */ - detailHref?: string; - /** Whether the action opens an external SSO link */ - requiresSsoAction?: boolean; +export interface DashboardTask extends DomainDashboardTask { tone: TaskTone; icon: React.ComponentType>; - metadata?: { - invoiceId?: number; - orderId?: string; - amount?: number; - currency?: string; - }; } -interface ComputeTasksParams { - summary: DashboardSummary | undefined; - paymentMethods: PaymentMethodList | undefined; - orders: OrderSummary[] | undefined; - formatCurrency: (amount: number, options?: { currency?: string }) => string; -} - -/** - * Compute dashboard tasks based on user's account state - */ -function computeTasks({ - summary, - paymentMethods, - orders, - formatCurrency, -}: ComputeTasksParams): DashboardTask[] { - const tasks: DashboardTask[] = []; - - if (!summary) return tasks; - - // Priority 1: Unpaid invoices - if (summary.nextInvoice) { - const dueDate = new Date(summary.nextInvoice.dueDate); - const isOverdue = dueDate < new Date(); - const dueText = isOverdue - ? `Overdue since ${format(dueDate, "MMM d")}` - : `Due ${formatDistanceToNow(dueDate, { addSuffix: true })}`; - - tasks.push({ - id: `invoice-${summary.nextInvoice.id}`, - priority: 1, - type: "invoice", - title: isOverdue ? "Pay overdue invoice" : "Pay upcoming invoice", - description: `Invoice #${summary.nextInvoice.id} · ${formatCurrency(summary.nextInvoice.amount, { currency: summary.nextInvoice.currency })} · ${dueText}`, - actionLabel: "Pay now", - detailHref: `/billing/invoices/${summary.nextInvoice.id}`, - requiresSsoAction: true, - tone: "critical", - icon: ExclamationCircleIcon, - metadata: { - invoiceId: summary.nextInvoice.id, - amount: summary.nextInvoice.amount, - currency: summary.nextInvoice.currency, - }, - }); - } - - // Priority 2: No payment method - if (paymentMethods && paymentMethods.totalCount === 0) { - tasks.push({ - id: "add-payment-method", - priority: 2, - type: "payment_method", - title: "Add a payment method", - description: "Required to place orders and process invoices", - actionLabel: "Add method", - detailHref: "/billing/payments", - requiresSsoAction: true, - tone: "warning", - icon: CreditCardIcon, - }); - } - - // Priority 3: Pending orders (Draft, Pending, or Activated but not yet complete) - if (orders && orders.length > 0) { - const pendingOrders = orders.filter( - o => - o.status === "Draft" || - o.status === "Pending" || - (o.status === "Activated" && o.activationStatus !== "Completed") - ); - - if (pendingOrders.length > 0) { - const order = pendingOrders[0]; - const statusText = - order.status === "Pending" - ? "awaiting review" - : order.status === "Draft" - ? "in draft" - : "being activated"; - - tasks.push({ - id: `order-${order.id}`, - priority: 3, - type: "order", - title: "Order in progress", - description: `${order.orderType || "Your"} order is ${statusText}`, - actionLabel: "View details", - detailHref: `/orders/${order.id}`, - tone: "info", - icon: ClockIcon, - metadata: { orderId: order.id }, - }); - } - } - - // Priority 4: No subscriptions (onboarding) - only show if no other tasks - if (summary.stats.activeSubscriptions === 0 && tasks.length === 0) { - tasks.push({ - id: "start-subscription", - priority: 4, - type: "onboarding", - title: "Start your first service", - description: "Browse our catalog and subscribe to internet, SIM, or VPN", - actionLabel: "Browse catalog", - detailHref: "/catalog", - tone: "neutral", - icon: SparklesIcon, - }); - } - - return tasks.sort((a, b) => a.priority - b.priority); -} +const TASK_ICONS: Record = { + invoice: ExclamationCircleIcon, + payment_method: CreditCardIcon, + order: ClockIcon, + internet_eligibility: ClockIcon, + id_verification: IdentificationIcon, + onboarding: SparklesIcon, +}; export interface UseDashboardTasksResult { tasks: DashboardTask[]; @@ -169,39 +42,25 @@ export interface UseDashboardTasksResult { } /** - * Hook to compute and return prioritized dashboard tasks + * Hook to return prioritized dashboard tasks computed by the BFF. */ export function useDashboardTasks(): UseDashboardTasksResult { - const { formatCurrency } = useFormatCurrency(); + const { data, isLoading, error } = useMeStatus(); - const { data: summary, isLoading: summaryLoading, error: summaryError } = useDashboardSummary(); - - const { - data: paymentMethods, - isLoading: paymentMethodsLoading, - error: paymentMethodsError, - } = usePaymentMethods(); - - const { data: orders, isLoading: ordersLoading, error: ordersError } = useOrdersList(); - - const isLoading = summaryLoading || paymentMethodsLoading || ordersLoading; - const hasError = Boolean(summaryError || paymentMethodsError || ordersError); - - const tasks = useMemo( - () => - computeTasks({ - summary, - paymentMethods, - orders, - formatCurrency, - }), - [summary, paymentMethods, orders, formatCurrency] - ); + const tasks = useMemo(() => { + const raw = data?.tasks ?? []; + return raw.map(task => ({ + ...task, + // Default to neutral when undefined (shouldn't happen due to domain validation) + tone: (task.tone ?? "neutral") as TaskTone, + icon: TASK_ICONS[task.type] ?? SparklesIcon, + })); + }, [data?.tasks]); return { tasks, isLoading, - hasError, + hasError: Boolean(error), taskCount: tasks.length, }; } diff --git a/apps/portal/src/features/dashboard/hooks/useMeStatus.ts b/apps/portal/src/features/dashboard/hooks/useMeStatus.ts new file mode 100644 index 00000000..10828ff8 --- /dev/null +++ b/apps/portal/src/features/dashboard/hooks/useMeStatus.ts @@ -0,0 +1,24 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { useAuthSession } from "@/features/auth/services/auth.store"; +import { queryKeys } from "@/lib/api"; +import { getMeStatus } from "../services/meStatus.service"; +import type { MeStatus } from "@customer-portal/domain/dashboard"; + +/** + * Fetches aggregated customer status used by the dashboard (tasks, summary, gating signals). + */ +export function useMeStatus() { + const { isAuthenticated } = useAuthSession(); + + return useQuery({ + queryKey: queryKeys.me.status(), + queryFn: () => getMeStatus(), + enabled: isAuthenticated, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }); +} + +export type UseMeStatusResult = ReturnType; diff --git a/apps/portal/src/features/dashboard/services/meStatus.service.ts b/apps/portal/src/features/dashboard/services/meStatus.service.ts new file mode 100644 index 00000000..410d6c45 --- /dev/null +++ b/apps/portal/src/features/dashboard/services/meStatus.service.ts @@ -0,0 +1,14 @@ +import { apiClient } from "@/lib/api"; +import { meStatusSchema, type MeStatus } from "@customer-portal/domain/dashboard"; + +export async function getMeStatus(): Promise { + const response = await apiClient.GET("/api/me/status"); + if (!response.data) { + throw new Error("Status response was empty"); + } + const parsed = meStatusSchema.safeParse(response.data); + if (!parsed.success) { + throw new Error("Status response failed validation"); + } + return parsed.data; +} diff --git a/apps/portal/src/features/dashboard/utils/dashboard.utils.ts b/apps/portal/src/features/dashboard/utils/dashboard.utils.ts index 3900aabd..24ea6b7d 100644 --- a/apps/portal/src/features/dashboard/utils/dashboard.utils.ts +++ b/apps/portal/src/features/dashboard/utils/dashboard.utils.ts @@ -11,8 +11,8 @@ import { ACTIVITY_FILTERS, filterActivities, isActivityClickable, - generateDashboardTasks, - type DashboardTask, + generateQuickActions, + type QuickActionTask, type DashboardTaskSummary, } from "@customer-portal/domain/dashboard"; import { formatCurrency as formatCurrencyUtil } from "@customer-portal/domain/toolkit"; @@ -22,8 +22,8 @@ export { ACTIVITY_FILTERS, filterActivities, isActivityClickable, - generateDashboardTasks, - type DashboardTask, + generateQuickActions, + type QuickActionTask, type DashboardTaskSummary, }; @@ -38,12 +38,12 @@ export function getActivityNavigationPath(activity: Activity): string | null { switch (activity.type) { case "invoice_created": case "invoice_paid": - return `/billing/invoices/${activity.relatedId}`; + return `/account/billing/invoices/${activity.relatedId}`; case "service_activated": - return `/subscriptions/${activity.relatedId}`; + return `/account/subscriptions/${activity.relatedId}`; case "case_created": case "case_closed": - return `/support/cases/${activity.relatedId}`; + return `/account/support/${activity.relatedId}`; default: return null; } diff --git a/apps/portal/src/features/dashboard/views/DashboardView.tsx b/apps/portal/src/features/dashboard/views/DashboardView.tsx index caee77d2..2ee9864d 100644 --- a/apps/portal/src/features/dashboard/views/DashboardView.tsx +++ b/apps/portal/src/features/dashboard/views/DashboardView.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect } from "react"; +import { useEffect, useRef, useState } from "react"; import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; import { useAuthStore } from "@/features/auth/services/auth.store"; @@ -9,9 +9,17 @@ import { TaskList, QuickStats, ActivityFeed } from "@/features/dashboard/compone import { ErrorState } from "@/components/atoms/error-state"; import { PageLayout } from "@/components/templates"; import { cn } from "@/lib/utils"; +import { InlineToast } from "@/components/atoms/inline-toast"; +import { useInternetEligibility } from "@/features/services/hooks"; export function DashboardView() { const { user, isAuthenticated, loading: authLoading, clearLoading } = useAuthStore(); + const hideToastTimeout = useRef(null); + const [eligibilityToast, setEligibilityToast] = useState<{ + visible: boolean; + text: string; + tone: "info" | "success" | "warning" | "error"; + }>({ visible: false, text: "", tone: "info" }); // Clear auth loading state when dashboard loads (after successful login) useEffect(() => { @@ -20,10 +28,48 @@ export function DashboardView() { const { data: summary, isLoading: summaryLoading, error } = useDashboardSummary(); const { tasks, isLoading: tasksLoading, taskCount } = useDashboardTasks(); + const { data: eligibility } = useInternetEligibility({ enabled: isAuthenticated }); // Combined loading state const isLoading = authLoading || summaryLoading || !isAuthenticated; + useEffect(() => { + if (!isAuthenticated || !user?.id) return; + const status = eligibility?.status; + if (!status) return; // query not ready yet + + const key = `cp:internet-eligibility:last:${user.id}`; + const last = localStorage.getItem(key); + + if (status === "pending") { + localStorage.setItem(key, "PENDING"); + return; + } + + if (status === "eligible" && typeof eligibility?.eligibility === "string") { + const current = eligibility.eligibility.trim(); + if (last === "PENDING") { + setEligibilityToast({ + visible: true, + text: "We’ve finished reviewing your address — you can now choose personalized internet plans.", + tone: "success", + }); + if (hideToastTimeout.current) window.clearTimeout(hideToastTimeout.current); + hideToastTimeout.current = window.setTimeout(() => { + setEligibilityToast(t => ({ ...t, visible: false })); + hideToastTimeout.current = null; + }, 3500); + } + localStorage.setItem(key, current); + } + }, [eligibility?.eligibility, eligibility?.status, isAuthenticated, user?.id]); + + useEffect(() => { + return () => { + if (hideToastTimeout.current) window.clearTimeout(hideToastTimeout.current); + }; + }, []); + if (isLoading) { return ( @@ -75,6 +121,11 @@ export function DashboardView() { return ( + {/* Greeting Section */}

Welcome back

diff --git a/apps/portal/src/features/landing-page/views/PublicLandingLoadingView.tsx b/apps/portal/src/features/landing-page/views/PublicLandingLoadingView.tsx index 904ea144..38595f30 100644 --- a/apps/portal/src/features/landing-page/views/PublicLandingLoadingView.tsx +++ b/apps/portal/src/features/landing-page/views/PublicLandingLoadingView.tsx @@ -2,38 +2,43 @@ import { Skeleton } from "@/components/atoms/loading-skeleton"; export function PublicLandingLoadingView() { return ( -
-
-
-
- -
- - -
+
+ {/* Hero Section Skeleton */} +
+
+ +
+ +
-
- - +
+ +
-
-
-
- -
- {Array.from({ length: 3 }).map((_, index) => ( -
- - - - +
+ + +
+
+ + {/* Concept Section Skeleton */} +
+
+ + +
+
+ {Array.from({ length: 3 }).map((_, index) => ( +
+ + +
+ +
- ))} -
+
+ ))}
diff --git a/apps/portal/src/features/landing-page/views/PublicLandingView.tsx b/apps/portal/src/features/landing-page/views/PublicLandingView.tsx index 8b0e05b8..1c0fcf42 100644 --- a/apps/portal/src/features/landing-page/views/PublicLandingView.tsx +++ b/apps/portal/src/features/landing-page/views/PublicLandingView.tsx @@ -1,138 +1,212 @@ import Link from "next/link"; -import { Logo } from "@/components/atoms/logo"; import { - UserIcon, - SparklesIcon, - CreditCardIcon, - Cog6ToothIcon, - PhoneIcon, - ArrowRightIcon, -} from "@heroicons/react/24/outline"; + ArrowRight, + Wifi, + Smartphone, + Lock, + BadgeCheck, + Globe, + Wrench, + Building2, + Tv, +} from "lucide-react"; +/** + * PublicLandingView - Marketing-focused landing page + * + * Purpose: Hook visitors, build trust, guide to shop + * Contains: + * - Hero with tagline + * - Value props (One Stop Solution, English Support, Onsite Support) - ONLY here + * - Brief service tease (links to /services) + * - CTA to contact + */ export function PublicLandingView() { return ( -
- {/* Hero */} -
-
- {/* Subtle glow behind logo */} -
-
- +
+ {/* Hero Section */} +
+
+ +
+
+ + + + + Reliable Connectivity in Japan
-
-
-

- Customer Portal +

+ A One Stop Solution + + for Your IT Needs +

-

- Manage your services, billing, and support in one place. +

+ Serving Japan's international community with reliable, English-supported internet, + mobile, and VPN solutions.

+
+ + Browse Services + + + + Contact Us + +
- {/* Primary actions */} -
-
-
-
-
- -
-
-

Existing customers

-

- Sign in or migrate your account from the old system. -

-
- - Sign in - - - - Migrate account - -
-
-
+ {/* CONCEPT Section - Value Propositions (ONLY on homepage) */} +
+
+

+ Our Concept +

+

+ Why customers choose us +

+
+ {/* One Stop Solution */} +
+
+ +
+

One Stop Solution

+

+ All you need is just to contact us and we will take care of everything. +

+
-
-
-
-
- + {/* English Support */} +
+
+
-
-

New customers

-

- Create an account to get started with our services. -

-
- - Create account - - -
+

English Support

+

+ We always assist you in English. No language barrier to worry about. +

+
+ + {/* Onsite Support */} +
+
+
+

Onsite Support

+

+ Our tech staff can visit your residence for setup and troubleshooting. +

- {/* Feature highlights */} -
-
-
-
-

Everything you need

-

Powerful tools to manage your account

-
- - Need help? - - -
-
-
-
- -
-
Billing
-
- View invoices, payments, and billing history. -
-
+ {/* Services Teaser - Brief preview linking to /services */} +
+

+ Our Services +

+

What we offer

+
+ + + + Internet + + + + + + SIM & eSIM + + + + + + VPN + + + + + + Business + + + + + + Onsite Support + + + + + + TV Services + + +
+ + Browse all services + + +
-
-
- -
-
Services
-
- Manage subscriptions and service details. -
-
- -
-
- -
-
Support
-
- Create cases and track responses in one place. -
+ {/* CTA Section */} +
+
+
+
+
+

+ Ready to get connected? +

+

+ Contact us anytime — our bilingual team is here to help you find the right solution. +

+
+ + Contact Us + + + + Browse Services +
diff --git a/apps/portal/src/features/marketing/views/AboutUsView.tsx b/apps/portal/src/features/marketing/views/AboutUsView.tsx new file mode 100644 index 00000000..8d8df5f1 --- /dev/null +++ b/apps/portal/src/features/marketing/views/AboutUsView.tsx @@ -0,0 +1,204 @@ +"use client"; + +import { + Building2, + Users, + Calendar, + CircleDollarSign, + Phone, + MapPin, + Clock, + CheckCircle, +} from "lucide-react"; + +/** + * AboutUsView - Corporate profile and company information + * + * Displays company background, corporate data, business activities, + * and mission statement for Assist Solutions. + */ +export function AboutUsView() { + return ( +
+ {/* Header */} +
+

About Us

+

+ We specialize in serving Japan's international community with the most reliable and + cost-efficient IT solutions available. +

+
+ + {/* Who We Are Section */} +
+
+
+ +
+

Who We Are

+
+
+

+ Assist Solutions Corp. is a privately-owned entrepreneurial IT service company. We + specialize in serving Japan's international community with the most reliable and + cost-efficient IT & TV solutions available. +

+

+ We are dedicated to providing comfortable support for our customer's diverse needs + in both English and Japanese. We believe that our excellent bi-lingual support and + flexible service along with our knowledge and experience in the field are what sets us + apart from the rest of the information technology and broadcasting industry. +

+
+
+ + {/* Corporate Data Section */} +
+
+
+ +
+

Corporate Data

+
+

+ Assist Solutions is a privately-owned entrepreneurial IT supporting company, focused on + the international community in Japan. +

+ +
+ {/* Company Name */} +
+
Name
+
+ Assist Solutions Corp. +
+ (Notified Telecommunication Carrier: A-19-9538) +
+
+ + {/* Address */} +
+
+ + Address +
+
+ 3F Azabu Maruka Bldg., 3-8-2 Higashi Azabu, +
+ Minato-ku, Tokyo 106-0044 +
+
+ + {/* Phone/Fax */} +
+
+ + Tel / Fax +
+
+ Tel: 03-3560-1006 +
+ Fax: 03-3560-1007 +
+
+ + {/* Business Hours */} +
+
+ + Business Hours +
+
+
Mon - Fri 9:30AM - 6:00PM — Customer Support Team
+
Mon - Fri 9:30AM - 6:00PM — In-office Tech Support Team
+
Mon - Sat 10:00AM - 9:00PM — Onsite Tech Support Team
+
+
+ + {/* Representative */} +
+
Representative Director
+
Daisuke Nagakawa
+
+ + {/* Employees */} +
+
Employees
+
+ 21 Staff Members (as of March 31st, 2025) +
+
+ + {/* Established */} +
+
+ + Established +
+
March 8, 2002
+
+ + {/* Capital */} +
+
+ + Paid-in Capital +
+
40,000,000 JPY
+
+
+
+ + {/* Business Activities Section */} +
+

Business Activities

+
+ {[ + "IT Consulting Services", + "TV Consulting Services", + "Internet Connection Service Provision (SonixNet ISP)", + "VPN Connection Service Provision (SonixNet US/UK Remote Access)", + "Agent for Telecommunication Services", + "Agent for Internet Services", + "Agent for TV Services", + "Onsite Support Service for IT", + "Onsite Support Service for TV", + "Server Management Service", + "Network Management Service", + ].map((activity, index) => ( +
+ + {activity} +
+ ))} +
+
+ + {/* Mission Statement Section */} +
+

Mission Statement

+

+ We will achieve business success by pursuing the following: +

+
    + {[ + "Provide the most customer-oriented service in this industry in Japan.", + "Through our service, we save client's time and enrich customers' lives.", + "We always have the latest and most efficient knowledge required for our service.", + "Be a responsible participant in Japan's international community.", + "Maintain high ethical standards in all business activities.", + ].map((mission, index) => ( +
  • + + {index + 1} + + {mission} +
  • + ))} +
+
+
+ ); +} + +export default AboutUsView; diff --git a/apps/portal/src/features/marketing/views/PublicLandingView.tsx b/apps/portal/src/features/marketing/views/PublicLandingView.tsx index e2721127..6f3e0186 100644 --- a/apps/portal/src/features/marketing/views/PublicLandingView.tsx +++ b/apps/portal/src/features/marketing/views/PublicLandingView.tsx @@ -72,7 +72,7 @@ export function PublicLandingView() { Login to Portal Migrate Account diff --git a/apps/portal/src/features/notifications/components/NotificationBell.tsx b/apps/portal/src/features/notifications/components/NotificationBell.tsx new file mode 100644 index 00000000..c21127fa --- /dev/null +++ b/apps/portal/src/features/notifications/components/NotificationBell.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { memo, useState, useRef, useCallback, useEffect } from "react"; +import { BellIcon } from "@heroicons/react/24/outline"; +import { useUnreadNotificationCount } from "../hooks/useNotifications"; +import { NotificationDropdown } from "./NotificationDropdown"; +import { cn } from "@/lib/utils"; + +interface NotificationBellProps { + className?: string; +} + +export const NotificationBell = memo(function NotificationBell({ + className, +}: NotificationBellProps) { + const [isOpen, setIsOpen] = useState(false); + const containerRef = useRef(null); + const { data: unreadCount = 0 } = useUnreadNotificationCount(); + + const toggleDropdown = useCallback(() => { + setIsOpen(prev => !prev); + }, []); + + const closeDropdown = useCallback(() => { + setIsOpen(false); + }, []); + + // Close dropdown when clicking outside + useEffect(() => { + if (!isOpen) return; + + const handleClickOutside = (event: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [isOpen]); + + // Close on escape + useEffect(() => { + if (!isOpen) return; + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setIsOpen(false); + } + }; + + document.addEventListener("keydown", handleEscape); + return () => { + document.removeEventListener("keydown", handleEscape); + }; + }, [isOpen]); + + return ( +
+ + + +
+ ); +}); diff --git a/apps/portal/src/features/notifications/components/NotificationDropdown.tsx b/apps/portal/src/features/notifications/components/NotificationDropdown.tsx new file mode 100644 index 00000000..6f1c7b0c --- /dev/null +++ b/apps/portal/src/features/notifications/components/NotificationDropdown.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { memo } from "react"; +import Link from "next/link"; +import { CheckIcon } from "@heroicons/react/24/outline"; +import { BellSlashIcon } from "@heroicons/react/24/solid"; +import { + useNotifications, + useMarkNotificationAsRead, + useMarkAllNotificationsAsRead, + useDismissNotification, +} from "../hooks/useNotifications"; +import { NotificationItem } from "./NotificationItem"; +import { cn } from "@/lib/utils"; + +interface NotificationDropdownProps { + isOpen: boolean; + onClose: () => void; +} + +export const NotificationDropdown = memo(function NotificationDropdown({ + isOpen, + onClose, +}: NotificationDropdownProps) { + const { data, isLoading } = useNotifications({ + limit: 10, + includeRead: true, + enabled: isOpen, + }); + + const markAsRead = useMarkNotificationAsRead(); + const markAllAsRead = useMarkAllNotificationsAsRead(); + const dismiss = useDismissNotification(); + + const notifications = data?.notifications ?? []; + const hasUnread = (data?.unreadCount ?? 0) > 0; + + if (!isOpen) return null; + + return ( +
+ {/* Header */} +
+

Notifications

+ {hasUnread && ( + + )} +
+ + {/* Notification list */} +
+ {isLoading ? ( +
+
+
+ ) : notifications.length === 0 ? ( +
+ +

No notifications yet

+

+ We'll notify you when something important happens +

+
+ ) : ( +
+ {notifications.map(notification => ( + markAsRead.mutate(id)} + onDismiss={id => dismiss.mutate(id)} + /> + ))} +
+ )} +
+ + {/* Footer */} + {notifications.length > 0 && ( +
+ + View all notifications + +
+ )} +
+ ); +}); diff --git a/apps/portal/src/features/notifications/components/NotificationItem.tsx b/apps/portal/src/features/notifications/components/NotificationItem.tsx new file mode 100644 index 00000000..e23198e9 --- /dev/null +++ b/apps/portal/src/features/notifications/components/NotificationItem.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { memo, useCallback } from "react"; +import Link from "next/link"; +import { formatDistanceToNow } from "date-fns"; +import { XMarkIcon } from "@heroicons/react/24/outline"; +import { + CheckCircleIcon, + ExclamationCircleIcon, + InformationCircleIcon, +} from "@heroicons/react/24/solid"; +import type { Notification } from "@customer-portal/domain/notifications"; +import { NOTIFICATION_TYPE } from "@customer-portal/domain/notifications"; +import { cn } from "@/lib/utils"; + +interface NotificationItemProps { + notification: Notification; + onMarkAsRead?: (id: string) => void; + onDismiss?: (id: string) => void; +} + +const getNotificationIcon = (type: Notification["type"]) => { + switch (type) { + case NOTIFICATION_TYPE.ELIGIBILITY_ELIGIBLE: + case NOTIFICATION_TYPE.VERIFICATION_VERIFIED: + case NOTIFICATION_TYPE.ORDER_ACTIVATED: + return ; + case NOTIFICATION_TYPE.ELIGIBILITY_INELIGIBLE: + case NOTIFICATION_TYPE.VERIFICATION_REJECTED: + case NOTIFICATION_TYPE.ORDER_FAILED: + return ; + default: + return ; + } +}; + +export const NotificationItem = memo(function NotificationItem({ + notification, + onMarkAsRead, + onDismiss, +}: NotificationItemProps) { + const handleClick = useCallback(() => { + if (!notification.read && onMarkAsRead) { + onMarkAsRead(notification.id); + } + }, [notification.id, notification.read, onMarkAsRead]); + + const handleDismiss = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + onDismiss?.(notification.id); + }, + [notification.id, onDismiss] + ); + + const content = ( +
+ {/* Icon */} +
{getNotificationIcon(notification.type)}
+ + {/* Content */} +
+

+ {notification.title} +

+ {notification.message && ( +

{notification.message}

+ )} +

+ {formatDistanceToNow(new Date(notification.createdAt), { + addSuffix: true, + })} +

+
+ + {/* Dismiss button */} + + + {/* Unread indicator */} + {!notification.read && ( +
+ )} +
+ ); + + if (notification.actionUrl) { + return ( + + {content} + + ); + } + + return content; +}); diff --git a/apps/portal/src/features/notifications/hooks/useNotifications.ts b/apps/portal/src/features/notifications/hooks/useNotifications.ts new file mode 100644 index 00000000..8eb51697 --- /dev/null +++ b/apps/portal/src/features/notifications/hooks/useNotifications.ts @@ -0,0 +1,90 @@ +/** + * Notification Hooks + * + * React Query hooks for managing notifications. + */ + +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { notificationService } from "../services/notification.service"; + +const NOTIFICATION_QUERY_KEY = ["notifications"]; +const UNREAD_COUNT_QUERY_KEY = ["notifications", "unread-count"]; + +/** + * Hook to fetch notifications + */ +export function useNotifications(options?: { + limit?: number; + includeRead?: boolean; + enabled?: boolean; +}) { + return useQuery({ + queryKey: [...NOTIFICATION_QUERY_KEY, "list", options?.limit, options?.includeRead], + queryFn: () => + notificationService.getNotifications({ + limit: options?.limit ?? 10, + includeRead: options?.includeRead ?? true, + }), + staleTime: 30 * 1000, // 30 seconds + enabled: options?.enabled ?? true, + }); +} + +/** + * Hook to get unread notification count + */ +export function useUnreadNotificationCount(enabled = true) { + return useQuery({ + queryKey: UNREAD_COUNT_QUERY_KEY, + queryFn: () => notificationService.getUnreadCount(), + staleTime: 30 * 1000, // 30 seconds + refetchInterval: 60 * 1000, // Refetch every minute + enabled, + }); +} + +/** + * Hook to mark a notification as read + */ +export function useMarkNotificationAsRead() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (notificationId: string) => notificationService.markAsRead(notificationId), + onSuccess: () => { + // Invalidate both queries + void queryClient.invalidateQueries({ queryKey: NOTIFICATION_QUERY_KEY }); + void queryClient.invalidateQueries({ queryKey: UNREAD_COUNT_QUERY_KEY }); + }, + }); +} + +/** + * Hook to mark all notifications as read + */ +export function useMarkAllNotificationsAsRead() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => notificationService.markAllAsRead(), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: NOTIFICATION_QUERY_KEY }); + void queryClient.invalidateQueries({ queryKey: UNREAD_COUNT_QUERY_KEY }); + }, + }); +} + +/** + * Hook to dismiss a notification + */ +export function useDismissNotification() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (notificationId: string) => notificationService.dismiss(notificationId), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: NOTIFICATION_QUERY_KEY }); + void queryClient.invalidateQueries({ queryKey: UNREAD_COUNT_QUERY_KEY }); + }, + }); +} diff --git a/apps/portal/src/features/notifications/index.ts b/apps/portal/src/features/notifications/index.ts new file mode 100644 index 00000000..bb39c34a --- /dev/null +++ b/apps/portal/src/features/notifications/index.ts @@ -0,0 +1,22 @@ +/** + * Notifications Feature + * + * In-app notification components, hooks, and services. + */ + +// Services +export { notificationService } from "./services/notification.service"; + +// Hooks +export { + useNotifications, + useUnreadNotificationCount, + useMarkNotificationAsRead, + useMarkAllNotificationsAsRead, + useDismissNotification, +} from "./hooks/useNotifications"; + +// Components +export { NotificationBell } from "./components/NotificationBell"; +export { NotificationDropdown } from "./components/NotificationDropdown"; +export { NotificationItem } from "./components/NotificationItem"; diff --git a/apps/portal/src/features/notifications/services/notification.service.ts b/apps/portal/src/features/notifications/services/notification.service.ts new file mode 100644 index 00000000..55fa98a0 --- /dev/null +++ b/apps/portal/src/features/notifications/services/notification.service.ts @@ -0,0 +1,61 @@ +/** + * Notification Service + * + * Handles API calls for in-app notifications. + */ + +import { apiClient, getDataOrThrow } from "@/lib/api"; +import type { NotificationListResponse } from "@customer-portal/domain/notifications"; + +const BASE_PATH = "/api/notifications"; + +export const notificationService = { + /** + * Get notifications for the current user + */ + async getNotifications(params?: { + limit?: number; + offset?: number; + includeRead?: boolean; + }): Promise { + const query: Record = {}; + if (params?.limit) query.limit = String(params.limit); + if (params?.offset) query.offset = String(params.offset); + if (params?.includeRead !== undefined) query.includeRead = String(params.includeRead); + + const response = await apiClient.GET(BASE_PATH, { + params: { query }, + }); + return getDataOrThrow(response); + }, + + /** + * Get unread notification count + */ + async getUnreadCount(): Promise { + const response = await apiClient.GET<{ count: number }>(`${BASE_PATH}/unread-count`); + const data = getDataOrThrow(response); + return data.count; + }, + + /** + * Mark a notification as read + */ + async markAsRead(notificationId: string): Promise { + await apiClient.POST<{ success: boolean }>(`${BASE_PATH}/${notificationId}/read`); + }, + + /** + * Mark all notifications as read + */ + async markAllAsRead(): Promise { + await apiClient.POST<{ success: boolean }>(`${BASE_PATH}/read-all`); + }, + + /** + * Dismiss a notification + */ + async dismiss(notificationId: string): Promise { + await apiClient.POST<{ success: boolean }>(`${BASE_PATH}/${notificationId}/dismiss`); + }, +}; diff --git a/apps/portal/src/features/orders/services/orders.service.ts b/apps/portal/src/features/orders/services/orders.service.ts index 17416ae5..7704549a 100644 --- a/apps/portal/src/features/orders/services/orders.service.ts +++ b/apps/portal/src/features/orders/services/orders.service.ts @@ -30,18 +30,27 @@ async function createOrder(payload: CreateOrderRequest): Promise<{ sfOrderId: st ); return { sfOrderId: parsed.data.sfOrderId }; } catch (error) { - log.error( - "Order creation failed", - error instanceof Error ? error : undefined, - { - orderType: body.orderType, - skuCount: body.skus.length, - } - ); + log.error("Order creation failed", error instanceof Error ? error : undefined, { + orderType: body.orderType, + skuCount: body.skus.length, + }); throw error; } } +async function createOrderFromCheckoutSession( + checkoutSessionId: string +): Promise<{ sfOrderId: string }> { + const response = await apiClient.POST("/api/orders/from-checkout-session", { + body: { checkoutSessionId }, + }); + + const parsed = assertSuccess<{ sfOrderId: string; status: string; message: string }>( + response.data as DomainApiResponse<{ sfOrderId: string; status: string; message: string }> + ); + return { sfOrderId: parsed.data.sfOrderId }; +} + async function getMyOrders(): Promise { const response = await apiClient.GET("/api/orders/user"); const data = Array.isArray(response.data) ? response.data : []; @@ -68,6 +77,7 @@ async function getOrderById( export const ordersService = { createOrder, + createOrderFromCheckoutSession, getMyOrders, getOrderById, } as const; diff --git a/apps/portal/src/features/orders/views/OrderDetail.tsx b/apps/portal/src/features/orders/views/OrderDetail.tsx index 70adf445..afc39a2d 100644 --- a/apps/portal/src/features/orders/views/OrderDetail.tsx +++ b/apps/portal/src/features/orders/views/OrderDetail.tsx @@ -275,7 +275,7 @@ export function OrderDetailContainer() { title={data ? `${data.orderType} Service Order` : "Order Details"} description={data ? `Order #${orderNumber}` : "Loading order details..."} breadcrumbs={[ - { label: "Orders", href: "/orders" }, + { label: "Orders", href: "/account/orders" }, { label: data ? `Order #${orderNumber}` : "Order Details" }, ]} > diff --git a/apps/portal/src/features/orders/views/OrdersList.tsx b/apps/portal/src/features/orders/views/OrdersList.tsx index d34cb331..8aee7691 100644 --- a/apps/portal/src/features/orders/views/OrdersList.tsx +++ b/apps/portal/src/features/orders/views/OrdersList.tsx @@ -90,7 +90,7 @@ export function OrdersListContainer() { icon={} title="No orders yet" description="You haven't placed any orders yet." - action={{ label: "Browse Catalog", onClick: () => router.push("/catalog") }} + action={{ label: "Browse Services", onClick: () => router.push("/account/services") }} /> ) : ( @@ -99,7 +99,7 @@ export function OrdersListContainer() { router.push(`/orders/${order.id}`)} + onClick={() => router.push(`/account/orders/${order.id}`)} /> ))}
diff --git a/apps/portal/src/features/realtime/components/AccountEventsListener.tsx b/apps/portal/src/features/realtime/components/AccountEventsListener.tsx index 570f2ea6..d7b84985 100644 --- a/apps/portal/src/features/realtime/components/AccountEventsListener.tsx +++ b/apps/portal/src/features/realtime/components/AccountEventsListener.tsx @@ -42,15 +42,15 @@ export function AccountEventsListener() { const parsed = JSON.parse(event.data) as RealtimeEventEnvelope; if (!parsed || typeof parsed !== "object") return; - if (parsed.event === "catalog.eligibility.changed") { - logger.info("Received catalog.eligibility.changed; invalidating catalog queries"); - void queryClient.invalidateQueries({ queryKey: queryKeys.catalog.all() }); + if (parsed.event === "services.eligibility.changed") { + logger.info("Received services.eligibility.changed; invalidating services queries"); + void queryClient.invalidateQueries({ queryKey: queryKeys.services.all() }); return; } - if (parsed.event === "catalog.changed") { - logger.info("Received catalog.changed; invalidating catalog queries"); - void queryClient.invalidateQueries({ queryKey: queryKeys.catalog.all() }); + if (parsed.event === "services.changed") { + logger.info("Received services.changed; invalidating services queries"); + void queryClient.invalidateQueries({ queryKey: queryKeys.services.all() }); return; } @@ -59,6 +59,7 @@ export function AccountEventsListener() { void queryClient.invalidateQueries({ queryKey: queryKeys.orders.list() }); // Dashboard summary often depends on orders/subscriptions; cheap to keep in sync. void queryClient.invalidateQueries({ queryKey: queryKeys.dashboard.summary() }); + void queryClient.invalidateQueries({ queryKey: queryKeys.me.status() }); return; } } catch (error) { diff --git a/apps/portal/src/features/catalog/components/base/AddonGroup.tsx b/apps/portal/src/features/services/components/base/AddonGroup.tsx similarity index 99% rename from apps/portal/src/features/catalog/components/base/AddonGroup.tsx rename to apps/portal/src/features/services/components/base/AddonGroup.tsx index 7d890288..0b6b84a4 100644 --- a/apps/portal/src/features/catalog/components/base/AddonGroup.tsx +++ b/apps/portal/src/features/services/components/base/AddonGroup.tsx @@ -1,7 +1,7 @@ "use client"; import { CheckCircleIcon } from "@heroicons/react/24/solid"; -import type { CatalogProductBase } from "@customer-portal/domain/catalog"; +import type { CatalogProductBase } from "@customer-portal/domain/services"; interface AddonGroupProps { addons: Array; selectedAddonSkus: string[]; diff --git a/apps/portal/src/features/catalog/components/base/AddressConfirmation.tsx b/apps/portal/src/features/services/components/base/AddressConfirmation.tsx similarity index 91% rename from apps/portal/src/features/catalog/components/base/AddressConfirmation.tsx rename to apps/portal/src/features/services/components/base/AddressConfirmation.tsx index db5cd99e..6de19600 100644 --- a/apps/portal/src/features/catalog/components/base/AddressConfirmation.tsx +++ b/apps/portal/src/features/services/components/base/AddressConfirmation.tsx @@ -155,8 +155,8 @@ export function AddressConfirmation({ // Persist to server (WHMCS via BFF) const updatedAddress = await accountService.updateAddress(sanitizedAddress); - // Address changes can affect server-personalized catalog results (eligibility). - await queryClient.invalidateQueries({ queryKey: queryKeys.catalog.all() }); + // Address changes can affect server-personalized services results (eligibility). + await queryClient.invalidateQueries({ queryKey: queryKeys.services.all() }); // Rebuild BillingInfo from updated address const updatedInfo: BillingInfo = { @@ -246,9 +246,13 @@ export function AddressConfirmation({ ? (getCountryName(address.country) ?? address.country) : null; + const showConfirmAction = + isInternetOrder && !addressConfirmed && !editing && billingInfo.isComplete; + const showEditAction = billingInfo.isComplete && !editing; + return wrap( <> -
+
@@ -261,12 +265,37 @@ export function AddressConfirmation({
-
- {/* Consistent status pill placement (right side) */} +
+ {showConfirmAction ? ( + + ) : null} + {showEditAction ? ( + + ) : null}
@@ -415,38 +444,6 @@ export function AddressConfirmation({ Please confirm this is the correct installation address for your internet service. )} - - {/* Action buttons */} -
- {/* Primary action when pending for Internet orders */} - {isInternetOrder && !addressConfirmed && !editing && ( - - )} - - {/* Edit button */} - {billingInfo.isComplete && !editing && ( - - )} -
) : (
diff --git a/apps/portal/src/features/catalog/components/base/AddressForm.tsx b/apps/portal/src/features/services/components/base/AddressForm.tsx similarity index 100% rename from apps/portal/src/features/catalog/components/base/AddressForm.tsx rename to apps/portal/src/features/services/components/base/AddressForm.tsx diff --git a/apps/portal/src/features/catalog/components/base/CardBadge.tsx b/apps/portal/src/features/services/components/base/CardBadge.tsx similarity index 100% rename from apps/portal/src/features/catalog/components/base/CardBadge.tsx rename to apps/portal/src/features/services/components/base/CardBadge.tsx diff --git a/apps/portal/src/features/catalog/components/base/CardPricing.tsx b/apps/portal/src/features/services/components/base/CardPricing.tsx similarity index 100% rename from apps/portal/src/features/catalog/components/base/CardPricing.tsx rename to apps/portal/src/features/services/components/base/CardPricing.tsx diff --git a/apps/portal/src/features/catalog/components/base/ConfigurationStep.tsx b/apps/portal/src/features/services/components/base/ConfigurationStep.tsx similarity index 100% rename from apps/portal/src/features/catalog/components/base/ConfigurationStep.tsx rename to apps/portal/src/features/services/components/base/ConfigurationStep.tsx diff --git a/apps/portal/src/features/catalog/components/base/EnhancedOrderSummary.tsx b/apps/portal/src/features/services/components/base/EnhancedOrderSummary.tsx similarity index 99% rename from apps/portal/src/features/catalog/components/base/EnhancedOrderSummary.tsx rename to apps/portal/src/features/services/components/base/EnhancedOrderSummary.tsx index 5dbbe283..db195a6e 100644 --- a/apps/portal/src/features/catalog/components/base/EnhancedOrderSummary.tsx +++ b/apps/portal/src/features/services/components/base/EnhancedOrderSummary.tsx @@ -9,8 +9,8 @@ const { formatCurrency } = Formatting; import { Button } from "@/components/atoms/button"; import { useRouter } from "next/navigation"; -// Align with shared catalog contracts -import type { CatalogProductBase } from "@customer-portal/domain/catalog"; +// Align with shared services contracts +import type { CatalogProductBase } from "@customer-portal/domain/services"; import type { CheckoutTotals } from "@customer-portal/domain/orders"; // Enhanced order item representation for UI summary diff --git a/apps/portal/src/features/catalog/components/base/OrderSummary.tsx b/apps/portal/src/features/services/components/base/OrderSummary.tsx similarity index 99% rename from apps/portal/src/features/catalog/components/base/OrderSummary.tsx rename to apps/portal/src/features/services/components/base/OrderSummary.tsx index a5756a89..04b0958c 100644 --- a/apps/portal/src/features/catalog/components/base/OrderSummary.tsx +++ b/apps/portal/src/features/services/components/base/OrderSummary.tsx @@ -1,5 +1,5 @@ import { ArrowLeftIcon, ArrowRightIcon } from "@heroicons/react/24/outline"; -import type { CatalogProductBase } from "@customer-portal/domain/catalog"; +import type { CatalogProductBase } from "@customer-portal/domain/services"; import { useRouter } from "next/navigation"; import { Button } from "@/components/atoms/button"; diff --git a/apps/portal/src/features/catalog/components/base/PaymentForm.tsx b/apps/portal/src/features/services/components/base/PaymentForm.tsx similarity index 100% rename from apps/portal/src/features/catalog/components/base/PaymentForm.tsx rename to apps/portal/src/features/services/components/base/PaymentForm.tsx diff --git a/apps/portal/src/features/catalog/components/base/PricingDisplay.tsx b/apps/portal/src/features/services/components/base/PricingDisplay.tsx similarity index 99% rename from apps/portal/src/features/catalog/components/base/PricingDisplay.tsx rename to apps/portal/src/features/services/components/base/PricingDisplay.tsx index 79ab096c..da9a24d8 100644 --- a/apps/portal/src/features/catalog/components/base/PricingDisplay.tsx +++ b/apps/portal/src/features/services/components/base/PricingDisplay.tsx @@ -3,7 +3,7 @@ import { ReactNode } from "react"; import { CurrencyYenIcon, InformationCircleIcon } from "@heroicons/react/24/outline"; import { Formatting } from "@customer-portal/domain/toolkit"; -import type { PricingTier } from "@customer-portal/domain/catalog"; +import type { PricingTier } from "@customer-portal/domain/services"; const { formatCurrency } = Formatting; diff --git a/apps/portal/src/features/catalog/components/base/ProductCard.tsx b/apps/portal/src/features/services/components/base/ProductCard.tsx similarity index 100% rename from apps/portal/src/features/catalog/components/base/ProductCard.tsx rename to apps/portal/src/features/services/components/base/ProductCard.tsx diff --git a/apps/portal/src/features/catalog/components/base/ProductComparison.tsx b/apps/portal/src/features/services/components/base/ProductComparison.tsx similarity index 100% rename from apps/portal/src/features/catalog/components/base/ProductComparison.tsx rename to apps/portal/src/features/services/components/base/ProductComparison.tsx diff --git a/apps/portal/src/features/services/components/base/ServiceHighlights.tsx b/apps/portal/src/features/services/components/base/ServiceHighlights.tsx new file mode 100644 index 00000000..c35d1fa1 --- /dev/null +++ b/apps/portal/src/features/services/components/base/ServiceHighlights.tsx @@ -0,0 +1,50 @@ +import { CheckCircle } from "lucide-react"; + +export interface HighlightFeature { + icon: React.ReactNode; + title: string; + description: string; + highlight?: string; +} + +interface ServiceHighlightsProps { + features: HighlightFeature[]; + className?: string; +} + +function HighlightItem({ icon, title, description, highlight }: HighlightFeature) { + return ( +
+
+
+ {icon} +
+ {highlight && ( + + + {highlight} + + )} +
+ +

{title}

+

{description}

+
+ ); +} + +/** + * ServiceHighlights + * + * A clean, grid-based layout for displaying service features/highlights. + * Replaces the old boxed "Why Choose Us" sections. + */ +export function ServiceHighlights({ features, className = "" }: ServiceHighlightsProps) { + return ( +
+ {features.map((feature, index) => ( + + ))} +
+ ); +} diff --git a/apps/portal/src/features/catalog/components/base/CatalogBackLink.tsx b/apps/portal/src/features/services/components/base/ServicesBackLink.tsx similarity index 87% rename from apps/portal/src/features/catalog/components/base/CatalogBackLink.tsx rename to apps/portal/src/features/services/components/base/ServicesBackLink.tsx index 4d087a3b..98b6f2d1 100644 --- a/apps/portal/src/features/catalog/components/base/CatalogBackLink.tsx +++ b/apps/portal/src/features/services/components/base/ServicesBackLink.tsx @@ -7,7 +7,7 @@ import type { ReactNode } from "react"; type Alignment = "left" | "center" | "right"; -interface CatalogBackLinkProps { +interface ServicesBackLinkProps { href: string; label?: string; align?: Alignment; @@ -22,14 +22,14 @@ const alignmentMap: Record = { right: "justify-end", }; -export function CatalogBackLink({ +export function ServicesBackLink({ href, label = "Back", align = "left", className, buttonClassName, icon = , -}: CatalogBackLinkProps) { +}: ServicesBackLinkProps) { return (
diff --git a/apps/portal/src/features/services/components/common/ServicesGrid.tsx b/apps/portal/src/features/services/components/common/ServicesGrid.tsx new file mode 100644 index 00000000..e55965a3 --- /dev/null +++ b/apps/portal/src/features/services/components/common/ServicesGrid.tsx @@ -0,0 +1,132 @@ +import Link from "next/link"; +import { Building2, Wrench, Tv, ArrowRight, Wifi, Smartphone, Lock } from "lucide-react"; + +interface ServicesGridProps { + basePath?: string; +} + +export function ServicesGrid({ basePath = "/services" }: ServicesGridProps) { + return ( +
+ {/* Internet */} + +
+
+ +
+

+ Internet +

+

+ NTT fiber with speeds up to 10Gbps and professional installation support. Fast and + reliable connectivity. +

+
+ View Plans{" "} + +
+
+ + + {/* SIM & eSIM */} + +
+
+ +
+

+ SIM & eSIM +

+

+ Data, voice & SMS on NTT Docomo's nationwide network. Available as physical SIM or + instant eSIM. +

+
+ View Plans{" "} + +
+
+ + + {/* VPN */} + +
+
+ +
+

+ VPN +

+

+ Access US/UK content with a pre-configured router. Easy plug & play setup for seamless + streaming. +

+
+ View Plans{" "} + +
+
+ + + {/* Business Solutions */} + +
+
+ +
+

+ Business Solutions +

+

+ Dedicated Internet Access (DIA), Office LAN setup, Data Center services, and + onsite/remote tech support. +

+
+ Learn more{" "} + +
+
+ + + {/* Onsite Support */} + +
+
+ +
+

+ Onsite Support +

+

+ Professional technical support at your residence or office. Network setup, device + configuration, and troubleshooting. +

+
+ Learn more{" "} + +
+
+ + + {/* TV Services */} + +
+
+ +
+

+ TV Services +

+

+ Satellite, Cable, and Optical Fiber TV services. We arrange subscriptions for major + Japanese TV providers. +

+
+ Learn more{" "} + +
+
+ +
+ ); +} diff --git a/apps/portal/src/features/catalog/components/index.ts b/apps/portal/src/features/services/components/index.ts similarity index 79% rename from apps/portal/src/features/catalog/components/index.ts rename to apps/portal/src/features/services/components/index.ts index cc5786d7..46cc9c44 100644 --- a/apps/portal/src/features/catalog/components/index.ts +++ b/apps/portal/src/features/services/components/index.ts @@ -1,9 +1,9 @@ /** - * Catalog Feature Components - * Business-specific components for product catalog functionality + * Services Feature Components + * Business-specific components for services browsing/ordering functionality */ -// Main catalog components (base) +// Main services components (base) export { ProductCard } from "./base/ProductCard"; export { PricingDisplay } from "./base/PricingDisplay"; export { ProductComparison } from "./base/ProductComparison"; @@ -41,3 +41,8 @@ export type { export type { ConfigurationStepProps, StepValidation } from "./base/ConfigurationStep"; export type { AddressFormProps } from "./base/AddressForm"; export type { PaymentFormProps } from "./base/PaymentForm"; + +// Common components +export { RedirectAuthenticatedToAccountServices } from "./common/RedirectAuthenticatedToAccountServices"; +export { FeatureCard } from "./common/FeatureCard"; +export { ServiceHeroCard } from "./common/ServiceHeroCard"; diff --git a/apps/portal/src/features/services/components/internet/HowItWorksSection.tsx b/apps/portal/src/features/services/components/internet/HowItWorksSection.tsx new file mode 100644 index 00000000..c187e162 --- /dev/null +++ b/apps/portal/src/features/services/components/internet/HowItWorksSection.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { + UserPlusIcon, + MagnifyingGlassIcon, + CheckBadgeIcon, + RocketLaunchIcon, +} from "@heroicons/react/24/outline"; + +interface StepProps { + number: number; + icon: React.ReactNode; + title: string; + description: string; + isLast?: boolean; +} + +function Step({ number, icon, title, description, isLast = false }: StepProps) { + return ( +
+ {/* Step number with icon */} +
+
+ {icon} +
+ {/* Connector line */} + {!isLast && ( +
+ )} +
+ + {/* Content */} +
+
+ + Step {number} + +
+

{title}

+

{description}

+
+
+ ); +} + +export function HowItWorksSection() { + const steps = [ + { + icon: , + title: "Create your account", + description: + "Sign up with your email and provide your service address. This only takes a minute.", + }, + { + icon: , + title: "We verify with NTT", + description: + "Our team checks what service is available at your address. This takes 1-2 business days.", + }, + { + icon: , + title: "Choose your plan", + description: + "Once verified, you'll see exactly which plans are available and can select your tier (Silver, Gold, or Platinum).", + }, + { + icon: , + title: "Get connected", + description: + "We coordinate NTT installation and set up your service. You'll be online in no time.", + }, + ]; + + return ( +
+
+

How it works

+

+ Getting connected is simple. Here's what to expect. +

+
+ +
+ {steps.map((step, index) => ( + + ))} +
+
+ ); +} diff --git a/apps/portal/src/features/catalog/components/internet/InstallationOptions.tsx b/apps/portal/src/features/services/components/internet/InstallationOptions.tsx similarity index 97% rename from apps/portal/src/features/catalog/components/internet/InstallationOptions.tsx rename to apps/portal/src/features/services/components/internet/InstallationOptions.tsx index 949f5417..4c685cec 100644 --- a/apps/portal/src/features/catalog/components/internet/InstallationOptions.tsx +++ b/apps/portal/src/features/services/components/internet/InstallationOptions.tsx @@ -1,7 +1,7 @@ "use client"; -import type { InternetInstallationCatalogItem } from "@customer-portal/domain/catalog"; -import { CardPricing } from "@/features/catalog/components/base/CardPricing"; +import type { InternetInstallationCatalogItem } from "@customer-portal/domain/services"; +import { CardPricing } from "@/features/services/components/base/CardPricing"; interface InstallationOptionsProps { installations: InternetInstallationCatalogItem[]; diff --git a/apps/portal/src/features/catalog/components/internet/InternetConfigureView.tsx b/apps/portal/src/features/services/components/internet/InternetConfigureView.tsx similarity index 88% rename from apps/portal/src/features/catalog/components/internet/InternetConfigureView.tsx rename to apps/portal/src/features/services/components/internet/InternetConfigureView.tsx index 982f690e..e8e4a696 100644 --- a/apps/portal/src/features/catalog/components/internet/InternetConfigureView.tsx +++ b/apps/portal/src/features/services/components/internet/InternetConfigureView.tsx @@ -1,7 +1,7 @@ "use client"; import { InternetConfigureContainer } from "./configure"; -import type { UseInternetConfigureResult } from "@/features/catalog/hooks/useInternetConfigure"; +import type { UseInternetConfigureResult } from "@/features/services/hooks/useInternetConfigure"; interface Props extends UseInternetConfigureResult { onConfirm: () => void; diff --git a/apps/portal/src/features/services/components/internet/InternetImportantNotes.tsx b/apps/portal/src/features/services/components/internet/InternetImportantNotes.tsx new file mode 100644 index 00000000..f773d186 --- /dev/null +++ b/apps/portal/src/features/services/components/internet/InternetImportantNotes.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { InformationCircleIcon } from "@heroicons/react/24/outline"; + +export function InternetImportantNotes() { + return ( +
+
+ +
+

Important notes & fees

+

+ A few things to keep in mind when selecting your internet service. +

+
+
+ +
    +
  • + + + Same speeds across tiers + —Silver, Gold, and Platinum all provide the same connection speed. The difference is in + equipment and support level. + +
  • +
  • + + + Flexible installation payment + —The ¥22,800 setup fee can be paid upfront or spread across 12 or 24 monthly + installments. + +
  • +
  • + + + Home phone available + —Hikari Denwa (IP phone) can be added to Gold or Platinum plans for ¥450/month + + one-time setup (¥1,000–¥3,000). + +
  • +
  • + + + On-site help if needed + —Our technicians can visit your home for setup or troubleshooting (¥15,000 per visit). + +
  • +
+ +

+ All prices shown exclude 10% consumption tax. Final pricing confirmed after address + verification. +

+
+ ); +} diff --git a/apps/portal/src/features/services/components/internet/InternetModalShell.tsx b/apps/portal/src/features/services/components/internet/InternetModalShell.tsx new file mode 100644 index 00000000..f8d39ccd --- /dev/null +++ b/apps/portal/src/features/services/components/internet/InternetModalShell.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { useEffect, useId, useRef } from "react"; +import { XMarkIcon } from "@heroicons/react/24/outline"; +import { cn } from "@/lib/utils"; + +interface InternetModalShellProps { + isOpen: boolean; + onClose: () => void; + title: string; + description?: string; + children: React.ReactNode; + size?: "md" | "lg"; +} + +const sizeMap: Record, string> = { + md: "max-w-lg", + lg: "max-w-3xl", +}; + +/** + * Lightweight modal shell (overlay + card) used by the Internet shop experience. + * Implements: + * - Backdrop click to close + * - Escape to close + * - Simple focus trap + focus restore (pattern aligned with SessionTimeoutWarning) + */ +export function InternetModalShell({ + isOpen, + onClose, + title, + description, + children, + size = "lg", +}: InternetModalShellProps) { + const titleId = useId(); + const descriptionId = useId(); + const dialogRef = useRef(null); + const previouslyFocusedElement = useRef(null); + + useEffect(() => { + if (!isOpen) return; + + previouslyFocusedElement.current = document.activeElement as HTMLElement | null; + + const focusTimer = window.setTimeout(() => { + dialogRef.current?.focus(); + }, 0); + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + onClose(); + } + + if (event.key === "Tab") { + const focusableElements = dialogRef.current?.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + + if (!focusableElements || focusableElements.length === 0) { + event.preventDefault(); + return; + } + + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + + if (!event.shiftKey && document.activeElement === lastElement) { + event.preventDefault(); + firstElement.focus(); + } + + if (event.shiftKey && document.activeElement === firstElement) { + event.preventDefault(); + lastElement.focus(); + } + } + }; + + document.addEventListener("keydown", handleKeyDown); + + return () => { + clearTimeout(focusTimer); + document.removeEventListener("keydown", handleKeyDown); + previouslyFocusedElement.current?.focus(); + }; + }, [isOpen, onClose]); + + if (!isOpen) return null; + + return ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > + + ); +} diff --git a/apps/portal/src/features/services/components/internet/InternetOfferingCard.tsx b/apps/portal/src/features/services/components/internet/InternetOfferingCard.tsx new file mode 100644 index 00000000..841639ce --- /dev/null +++ b/apps/portal/src/features/services/components/internet/InternetOfferingCard.tsx @@ -0,0 +1,205 @@ +"use client"; + +import { Home, Building2, Zap } from "lucide-react"; +import { Button } from "@/components/atoms/button"; +import { CardBadge } from "@/features/services/components/base/CardBadge"; +import { cn } from "@/lib/utils"; + +interface TierInfo { + tier: "Silver" | "Gold" | "Platinum"; + planSku: string; + monthlyPrice: number; + description: string; + features: string[]; + recommended?: boolean; + pricingNote?: string; +} + +interface InternetOfferingCardProps { + offeringType: string; + title: string; + speedBadge: string; + description: string; + iconType: "home" | "apartment"; + startingPrice: number; + setupFee: number; + tiers: TierInfo[]; + isPremium?: boolean; + ctaPath: string; + // defaultExpanded is no longer used but kept for prop compatibility if needed upstream + defaultExpanded?: boolean; + disabled?: boolean; + disabledReason?: string; + previewMode?: boolean; +} + +const tierStyles = { + Silver: { + card: "border-muted-foreground/20 bg-card", + accent: "text-muted-foreground", + }, + Gold: { + card: "border-warning/30 bg-warning-soft/20", + accent: "text-warning", + }, + Platinum: { + card: "border-primary/30 bg-info-soft/20", + accent: "text-primary", + }, +} as const; + +export function InternetOfferingCard({ + title, + speedBadge, + description, + iconType, + startingPrice, + setupFee, + tiers, + isPremium = false, + ctaPath, + disabled = false, + disabledReason, + previewMode = false, +}: InternetOfferingCardProps) { + const Icon = iconType === "home" ? Home : Building2; + const resolveTierHref = (basePath: string, planSku: string): string => { + const joiner = basePath.includes("?") ? "&" : "?"; + return `${basePath}${joiner}planSku=${encodeURIComponent(planSku)}`; + }; + + return ( +
+ {/* Header - Always visible */} +
+
+
+ +
+ +
+
+

{title}

+ + {isPremium && (select areas)} +
+

{description}

+
+ From + + ¥{startingPrice.toLocaleString()} + + /mo + + + ¥{setupFee.toLocaleString()} setup + +
+
+
+
+ + {/* Tiers - Always expanded */} +
+
+ {tiers.map(tier => ( +
+ {/* Header */} +
+ + {tier.tier} + + {tier.recommended && ( + + )} +
+ + {/* Price */} + {!previewMode && ( +
+
+ + ¥{tier.monthlyPrice.toLocaleString()} + + /mo + {tier.pricingNote && ( + {tier.pricingNote} + )} +
+
+ )} + + {/* Description */} +

{tier.description}

+ + {/* Features */} +
    + {tier.features.slice(0, 3).map((feature, index) => ( +
  • + + {feature} +
  • + ))} +
+ + {/* Button */} + {previewMode ? ( +
+

+ See pricing after verification +

+
+ ) : disabled ? ( +
+ + {disabledReason && ( +

+ {disabledReason} +

+ )} +
+ ) : ( + + )} +
+ ))} +
+ + {/* Footer */} +

+ + ¥{setupFee.toLocaleString()} one-time installation (or 12/24-month installment) +

+
+
+ ); +} + +export type { InternetOfferingCardProps, TierInfo }; diff --git a/apps/portal/src/features/catalog/components/internet/InternetPlanCard.tsx b/apps/portal/src/features/services/components/internet/InternetPlanCard.tsx similarity index 66% rename from apps/portal/src/features/catalog/components/internet/InternetPlanCard.tsx rename to apps/portal/src/features/services/components/internet/InternetPlanCard.tsx index 28ccdcc3..dbd788c5 100644 --- a/apps/portal/src/features/catalog/components/internet/InternetPlanCard.tsx +++ b/apps/portal/src/features/services/components/internet/InternetPlanCard.tsx @@ -6,20 +6,35 @@ import { ArrowRightIcon, CheckIcon } from "@heroicons/react/24/outline"; import type { InternetPlanCatalogItem, InternetInstallationCatalogItem, -} from "@customer-portal/domain/catalog"; +} from "@customer-portal/domain/services"; import { useRouter } from "next/navigation"; -import { CardPricing } from "@/features/catalog/components/base/CardPricing"; -import { CardBadge } from "@/features/catalog/components/base/CardBadge"; -import type { BadgeVariant } from "@/features/catalog/components/base/CardBadge"; -import { useCatalogStore } from "@/features/catalog/services/catalog.store"; +import { CardPricing } from "@/features/services/components/base/CardPricing"; +import { CardBadge } from "@/features/services/components/base/CardBadge"; +import type { BadgeVariant } from "@/features/services/components/base/CardBadge"; +import { useCatalogStore } from "@/features/services/services/services.store"; import { IS_DEVELOPMENT } from "@/config/environment"; -import { parsePlanName } from "@/features/catalog/components/internet/utils/planName"; +import { parsePlanName } from "@/features/services/components/internet/utils/planName"; +import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath"; interface InternetPlanCardProps { plan: InternetPlanCatalogItem; installations: InternetInstallationCatalogItem[]; disabled?: boolean; disabledReason?: string; + /** Override the default configure href (default: /services/internet/configure?planSku=...) */ + configureHref?: string; + /** Override default "Configure Plan" action (used for public browse-only flows) */ + action?: { label: string; href: string }; + /** Optional small prefix above pricing (e.g. "Starting from") */ + pricingPrefix?: string; + /** Show tier badge (default: true) */ + showTierBadge?: boolean; + /** Show plan subtitle (default: true) */ + showPlanSubtitle?: boolean; + /** Show features list (default: true) */ + showFeatures?: boolean; + /** Prefer which label becomes the title when details exist */ + titlePriority?: "detail" | "base"; } // Tier-based styling using design tokens @@ -47,14 +62,27 @@ export function InternetPlanCard({ installations, disabled, disabledReason, + configureHref, + action, + pricingPrefix, + showTierBadge = true, + showPlanSubtitle = true, + showFeatures = true, + titlePriority = "detail", }: InternetPlanCardProps) { const router = useRouter(); + const servicesBasePath = useServicesBasePath(); const tier = plan.internetPlanTier; const isGold = tier === "Gold"; const isPlatinum = tier === "Platinum"; const isSilver = tier === "Silver"; const isDisabled = disabled && !IS_DEVELOPMENT; const { baseName: planBaseName, detail: planDetail } = parsePlanName(plan); + const hasDetail = Boolean(planDetail); + const showDetailAsTitle = titlePriority === "detail" && hasDetail; + const planTitle = showDetailAsTitle ? planDetail : planBaseName; + const planSubtitle = showDetailAsTitle ? planBaseName : hasDetail ? planDetail : null; + const planDescription = plan.catalogMetadata?.tierDescription || plan.description || null; const installationPrices = installations .map(installation => { @@ -152,29 +180,40 @@ export function InternetPlanCard({ {/* Header with badges */}
- - {isGold && } - {planDetail && } + {showTierBadge && ( + + )} + {showTierBadge && isGold && ( + + )}
{/* Plan name and description - Full width */}

- {planBaseName} + {planTitle}

- {plan.catalogMetadata?.tierDescription || plan.description ? ( -

- {plan.catalogMetadata?.tierDescription || plan.description} + {showPlanSubtitle && planSubtitle && ( +

+ {planSubtitle}

+ )} + {planDescription ? ( +

{planDescription}

) : null}
{/* Pricing - Full width below */}
+ {pricingPrefix ? ( +
+ {pricingPrefix} +
+ ) : null} {/* Features */} -
-

- Your Plan Includes: -

-
    {renderPlanFeatures()}
-
+ {showFeatures && ( +
+

+ Your Plan Includes: +

+
    {renderPlanFeatures()}
+
+ )} {/* Action Button */}
diff --git a/apps/portal/src/features/services/components/internet/InternetTierPricingModal.tsx b/apps/portal/src/features/services/components/internet/InternetTierPricingModal.tsx new file mode 100644 index 00000000..9a8f27b6 --- /dev/null +++ b/apps/portal/src/features/services/components/internet/InternetTierPricingModal.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { BoltIcon } from "@heroicons/react/24/outline"; +import { Button } from "@/components/atoms/button"; +import { CardBadge } from "@/features/services/components/base/CardBadge"; +import { cn } from "@/lib/utils"; +import type { TierInfo } from "@/features/services/components/internet/InternetOfferingCard"; +import { InternetModalShell } from "@/features/services/components/internet/InternetModalShell"; + +interface InternetTierPricingModalProps { + isOpen: boolean; + onClose: () => void; + offeringTitle: string; + offeringSubtitle?: string; + tiers: TierInfo[]; + setupFee: number; + ctaHref: string; +} + +const tierStyles = { + Silver: { + card: "border-muted-foreground/20 bg-card", + accent: "text-muted-foreground", + }, + Gold: { + card: "border-warning/30 bg-warning-soft/20", + accent: "text-warning", + }, + Platinum: { + card: "border-primary/30 bg-info-soft/20", + accent: "text-primary", + }, +} as const; + +export function InternetTierPricingModal({ + isOpen, + onClose, + offeringTitle, + offeringSubtitle, + tiers, + setupFee, + ctaHref, +}: InternetTierPricingModalProps) { + return ( + +
+ {offeringSubtitle ? ( +
+
{offeringTitle}
+
{offeringSubtitle}
+
+ ) : null} + +
+ {tiers.map(tier => ( +
+
+ {tier.tier} + {tier.recommended ? ( + + ) : null} +
+ +
+
+ + ¥{tier.monthlyPrice.toLocaleString()} + + /mo +
+ {tier.pricingNote ? ( +

{tier.pricingNote}

+ ) : null} +
+ +

{tier.description}

+ +
    + {tier.features.map((feature, index) => ( +
  • + + {feature} +
  • + ))} +
+ +
+

+ + ¥{setupFee.toLocaleString()} setup +

+
+
+ ))} +
+ +
+ + +
+
+
+ ); +} diff --git a/apps/portal/src/features/services/components/internet/PlanComparisonGuide.tsx b/apps/portal/src/features/services/components/internet/PlanComparisonGuide.tsx new file mode 100644 index 00000000..658b9017 --- /dev/null +++ b/apps/portal/src/features/services/components/internet/PlanComparisonGuide.tsx @@ -0,0 +1,157 @@ +"use client"; + +import { useState } from "react"; +import { Wrench, Sparkles, Network, ChevronDown, ChevronUp, HelpCircle } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface PlanGuideItemProps { + tier: "Silver" | "Gold" | "Platinum"; + icon: React.ReactNode; + title: string; + idealFor: string; + description: string; + highlight?: boolean; +} + +const tierColors = { + Silver: { + bg: "bg-muted/30", + border: "border-muted-foreground/20", + icon: "bg-muted text-muted-foreground border-muted-foreground/20", + title: "text-muted-foreground", + }, + Gold: { + bg: "bg-warning-soft/30", + border: "border-warning/30", + icon: "bg-warning-soft text-warning border-warning/30", + title: "text-warning", + }, + Platinum: { + bg: "bg-info-soft/30", + border: "border-primary/30", + icon: "bg-info-soft text-primary border-primary/30", + title: "text-primary", + }, +}; + +function PlanGuideItem({ + tier, + icon, + title, + idealFor, + description, + highlight, +}: PlanGuideItemProps) { + const colors = tierColors[tier]; + + return ( +
+
+
+ {icon} +
+
+
+

{title}

+ {highlight && ( + + Most Popular + + )} +
+

{idealFor}

+

{description}

+
+
+
+ ); +} + +export function PlanComparisonGuide() { + const [isExpanded, setIsExpanded] = useState(false); + + return ( +
+ {/* Collapsible header */} + + + {/* Expandable content */} + {isExpanded && ( +
+
+ } + title="Silver" + idealFor="Tech-savvy users with their own router" + description="You get the NTT modem and ISP connection. Bring your own WiFi router and configure the network yourself." + /> + + } + title="Gold" + idealFor="Most customers—hassle-free setup" + description="We provide everything: NTT modem, WiFi router, and pre-configured ISP. Just plug in and connect. Optional range extender available." + highlight + /> + + } + title="Platinum" + idealFor="Larger homes needing custom coverage" + description="For residences where one router isn't enough. We design a custom mesh network with Netgear INSIGHT routers, cloud management, and professional setup." + /> +
+ +
+

+ About Platinum: After verifying + your address, we'll assess your space and create a tailored proposal. Final pricing + depends on your specific setup requirements. +

+
+
+ )} +
+ ); +} diff --git a/apps/portal/src/features/services/components/internet/PlanHeader.tsx b/apps/portal/src/features/services/components/internet/PlanHeader.tsx new file mode 100644 index 00000000..6dbc6882 --- /dev/null +++ b/apps/portal/src/features/services/components/internet/PlanHeader.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { ArrowLeftIcon } from "@heroicons/react/24/outline"; +import { Button } from "@/components/atoms/button"; +import { CardBadge } from "@/features/services/components/base/CardBadge"; +import type { BadgeVariant } from "@/features/services/components/base/CardBadge"; +import { parsePlanName } from "@/features/services/components/internet/utils/planName"; +import type { InternetPlanCatalogItem } from "@customer-portal/domain/services"; + +interface PlanHeaderProps { + plan: InternetPlanCatalogItem; + backHref?: string; + backLabel?: string; + title?: string; + className?: string; +} + +export function PlanHeader({ + plan, + backHref, + backLabel = "Back to Internet Plans", + title = "Configure your plan", + className = "", +}: PlanHeaderProps) { + const { baseName: planBaseName, detail: planDetail } = parsePlanName(plan); + + return ( +
+ {backHref && ( + + )} + +

{title}

+ + {planBaseName} + {planDetail ? ` (${planDetail})` : ""} + + +
+ {plan.internetPlanTier ? ( + + ) : null} + {planDetail ? : null} + {plan.monthlyPrice && plan.monthlyPrice > 0 ? ( + + ¥{plan.monthlyPrice.toLocaleString()}/month + + ) : null} +
+
+ ); +} + +function getTierBadgeVariant(tier?: string | null): BadgeVariant { + switch (tier) { + case "Gold": + return "gold"; + case "Platinum": + return "platinum"; + case "Silver": + return "silver"; + case "Recommended": + return "recommended"; + default: + return "default"; + } +} diff --git a/apps/portal/src/features/services/components/internet/PublicOfferingCard.tsx b/apps/portal/src/features/services/components/internet/PublicOfferingCard.tsx new file mode 100644 index 00000000..1b7a6acd --- /dev/null +++ b/apps/portal/src/features/services/components/internet/PublicOfferingCard.tsx @@ -0,0 +1,274 @@ +"use client"; + +import { useState } from "react"; +import { ChevronDown, ChevronUp, Home, Building2, Zap, Info, X } from "lucide-react"; +import { Button } from "@/components/atoms/button"; +import { CardBadge } from "@/features/services/components/base/CardBadge"; +import { cn } from "@/lib/utils"; + +interface TierInfo { + tier: "Silver" | "Gold" | "Platinum"; + monthlyPrice: number; + description: string; + features: string[]; + pricingNote?: string; +} + +interface PublicOfferingCardProps { + offeringType: string; + title: string; + speedBadge: string; + description: string; + iconType: "home" | "apartment"; + startingPrice: number; + setupFee: number; + tiers: TierInfo[]; + isPremium?: boolean; + ctaPath: string; + defaultExpanded?: boolean; + /** Show info tooltip explaining connection types (for Apartment) */ + showConnectionInfo?: boolean; + customCtaLabel?: string; + onCtaClick?: (e: React.MouseEvent) => void; +} + +const tierStyles = { + Silver: { + card: "border-muted-foreground/20 bg-card", + accent: "text-muted-foreground", + }, + Gold: { + card: "border-warning/30 bg-warning-soft/20", + accent: "text-warning", + }, + Platinum: { + card: "border-primary/30 bg-info-soft/20", + accent: "text-primary", + }, +} as const; + +/** + * Info panel explaining apartment connection types + */ +function ConnectionTypeInfo({ onClose }: { onClose: () => void }) { + return ( +
+
+
+ +

+ Why does speed vary by building? +

+
+ +
+
+

+ Apartment buildings in Japan have different fiber infrastructure installed by NTT. Your + available speed depends on what your building supports: +

+
+
+ FTTH (1Gbps) + + — Fiber directly to your unit. Fastest option, available in newer buildings. + +
+
+ VDSL (100Mbps) + + — Fiber to building, then phone line to your unit. Most common in older buildings. + +
+
+ LAN (100Mbps) + + — Fiber to building, then ethernet to your unit. Common in some mansion types. + +
+
+

+ Good news: All types have the same monthly price (¥4,800~). We'll check what's + available at your address. +

+
+
+ ); +} + +/** + * Public-facing offering card that shows pricing inline + * No modals - all information is visible or expandable within the card + */ +export function PublicOfferingCard({ + title, + speedBadge, + description, + iconType, + startingPrice, + setupFee, + tiers, + isPremium = false, + ctaPath, + defaultExpanded = false, + showConnectionInfo = false, + customCtaLabel, + onCtaClick, +}: PublicOfferingCardProps) { + const [isExpanded, setIsExpanded] = useState(defaultExpanded); + const [showInfo, setShowInfo] = useState(false); + + const Icon = iconType === "home" ? Home : Building2; + + return ( +
+ {/* Header - Always visible */} + + + {/* Expanded content - Tier pricing shown inline */} + {isExpanded && ( +
+ {/* Connection type info button (for Apartment) */} + {showConnectionInfo && !showInfo && ( + + )} + + {/* Connection type info panel */} + {showConnectionInfo && showInfo && ( + setShowInfo(false)} /> + )} + + {/* Tier cards - 3 columns on desktop */} +
+ {tiers.map(tier => ( +
+ {/* Header */} +
+ + {tier.tier} + +
+ + {/* Price - Always visible */} +
+
+ + ¥{tier.monthlyPrice.toLocaleString()} + + /mo + {tier.pricingNote && ( + {tier.pricingNote} + )} +
+
+ + {/* Description */} +

{tier.description}

+ + {/* Features */} +
    + {tier.features.slice(0, 3).map((feature, index) => ( +
  • + + {feature} +
  • + ))} +
+
+ ))} +
+ + {/* Footer with setup fee and CTA */} +
+

+ + + ¥{setupFee.toLocaleString()} one-time setup + {" "} + (or 12/24-month installment) +

+ {onCtaClick ? ( + + ) : ( + + )} +
+
+ )} +
+ ); +} + +export type { PublicOfferingCardProps, TierInfo }; diff --git a/apps/portal/src/features/catalog/components/internet/configure/InternetConfigureContainer.tsx b/apps/portal/src/features/services/components/internet/configure/InternetConfigureContainer.tsx similarity index 74% rename from apps/portal/src/features/catalog/components/internet/configure/InternetConfigureContainer.tsx rename to apps/portal/src/features/services/components/internet/configure/InternetConfigureContainer.tsx index 335bbfc3..9a1c01b4 100644 --- a/apps/portal/src/features/catalog/components/internet/configure/InternetConfigureContainer.tsx +++ b/apps/portal/src/features/services/components/internet/configure/InternetConfigureContainer.tsx @@ -3,15 +3,12 @@ import { useEffect, useState, type ReactElement } from "react"; import { PageLayout } from "@/components/templates/PageLayout"; import { ProgressSteps } from "@/components/molecules"; -import { Button } from "@/components/atoms/button"; -import { CardBadge } from "@/features/catalog/components/base/CardBadge"; -import type { BadgeVariant } from "@/features/catalog/components/base/CardBadge"; -import { ServerIcon, ArrowLeftIcon } from "@heroicons/react/24/outline"; +import { ServerIcon } from "@heroicons/react/24/outline"; import type { InternetPlanCatalogItem, InternetInstallationCatalogItem, InternetAddonCatalogItem, -} from "@customer-portal/domain/catalog"; +} from "@customer-portal/domain/services"; import type { AccessModeValue } from "@customer-portal/domain/orders"; import { ConfigureLoadingSkeleton } from "./components/ConfigureLoadingSkeleton"; import { ServiceConfigurationStep } from "./steps/ServiceConfigurationStep"; @@ -19,7 +16,8 @@ import { InstallationStep } from "./steps/InstallationStep"; import { AddonsStep } from "./steps/AddonsStep"; import { ReviewOrderStep } from "./steps/ReviewOrderStep"; import { useConfigureState } from "./hooks/useConfigureState"; -import { parsePlanName } from "@/features/catalog/components/internet/utils/planName"; +import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath"; +import { PlanHeader } from "@/features/services/components/internet/PlanHeader"; interface Props { plan: InternetPlanCatalogItem | null; @@ -60,6 +58,7 @@ export function InternetConfigureContainer({ currentStep, setCurrentStep, }: Props) { + const servicesBasePath = useServicesBasePath(); const [renderedStep, setRenderedStep] = useState(currentStep); const [transitionPhase, setTransitionPhase] = useState<"idle" | "enter" | "exit">("idle"); // Use local state ONLY for step validation, step management now in Zustand @@ -213,7 +212,11 @@ export function InternetConfigureContainer({
{/* Plan Header */} - + {/* Progress Steps */}
@@ -229,59 +232,3 @@ export function InternetConfigureContainer({ ); } - -function PlanHeader({ plan }: { plan: InternetPlanCatalogItem }) { - const { baseName: planBaseName, detail: planDetail } = parsePlanName(plan); - - return ( -
- - -

Configure your plan

- - {planBaseName} - {planDetail ? ` (${planDetail})` : ""} - - -
- {plan.internetPlanTier ? ( - - ) : null} - {planDetail ? : null} - {plan.monthlyPrice && plan.monthlyPrice > 0 ? ( - - ¥{plan.monthlyPrice.toLocaleString()}/month - - ) : null} -
-
- ); -} - -function getTierBadgeVariant(tier?: string | null): BadgeVariant { - switch (tier) { - case "Gold": - return "gold"; - case "Platinum": - return "platinum"; - case "Silver": - return "silver"; - case "Recommended": - return "recommended"; - default: - return "default"; - } -} diff --git a/apps/portal/src/features/catalog/components/internet/configure/components/ConfigureLoadingSkeleton.tsx b/apps/portal/src/features/services/components/internet/configure/components/ConfigureLoadingSkeleton.tsx similarity index 100% rename from apps/portal/src/features/catalog/components/internet/configure/components/ConfigureLoadingSkeleton.tsx rename to apps/portal/src/features/services/components/internet/configure/components/ConfigureLoadingSkeleton.tsx diff --git a/apps/portal/src/features/catalog/components/internet/configure/hooks/useConfigureState.ts b/apps/portal/src/features/services/components/internet/configure/hooks/useConfigureState.ts similarity index 97% rename from apps/portal/src/features/catalog/components/internet/configure/hooks/useConfigureState.ts rename to apps/portal/src/features/services/components/internet/configure/hooks/useConfigureState.ts index d2ebd37b..2dbbab3a 100644 --- a/apps/portal/src/features/catalog/components/internet/configure/hooks/useConfigureState.ts +++ b/apps/portal/src/features/services/components/internet/configure/hooks/useConfigureState.ts @@ -5,7 +5,7 @@ import type { InternetPlanCatalogItem, InternetInstallationCatalogItem, InternetAddonCatalogItem, -} from "@customer-portal/domain/catalog"; +} from "@customer-portal/domain/services"; import type { AccessModeValue } from "@customer-portal/domain/orders"; /** diff --git a/apps/portal/src/features/catalog/components/internet/configure/index.ts b/apps/portal/src/features/services/components/internet/configure/index.ts similarity index 100% rename from apps/portal/src/features/catalog/components/internet/configure/index.ts rename to apps/portal/src/features/services/components/internet/configure/index.ts diff --git a/apps/portal/src/features/catalog/components/internet/configure/steps/AddonsStep.tsx b/apps/portal/src/features/services/components/internet/configure/steps/AddonsStep.tsx similarity index 94% rename from apps/portal/src/features/catalog/components/internet/configure/steps/AddonsStep.tsx rename to apps/portal/src/features/services/components/internet/configure/steps/AddonsStep.tsx index e68717db..f86f0f31 100644 --- a/apps/portal/src/features/catalog/components/internet/configure/steps/AddonsStep.tsx +++ b/apps/portal/src/features/services/components/internet/configure/steps/AddonsStep.tsx @@ -2,9 +2,9 @@ import { Button } from "@/components/atoms/button"; import { StepHeader } from "@/components/atoms"; -import { AddonGroup } from "@/features/catalog/components/base/AddonGroup"; +import { AddonGroup } from "@/features/services/components/base/AddonGroup"; import { ArrowLeftIcon, ArrowRightIcon } from "@heroicons/react/24/outline"; -import type { InternetAddonCatalogItem } from "@customer-portal/domain/catalog"; +import type { InternetAddonCatalogItem } from "@customer-portal/domain/services"; interface Props { addons: InternetAddonCatalogItem[]; diff --git a/apps/portal/src/features/catalog/components/internet/configure/steps/InstallationStep.tsx b/apps/portal/src/features/services/components/internet/configure/steps/InstallationStep.tsx similarity index 98% rename from apps/portal/src/features/catalog/components/internet/configure/steps/InstallationStep.tsx rename to apps/portal/src/features/services/components/internet/configure/steps/InstallationStep.tsx index 1eb8c7ae..96f514ee 100644 --- a/apps/portal/src/features/catalog/components/internet/configure/steps/InstallationStep.tsx +++ b/apps/portal/src/features/services/components/internet/configure/steps/InstallationStep.tsx @@ -4,7 +4,7 @@ import { Button } from "@/components/atoms/button"; import { StepHeader } from "@/components/atoms"; import { InstallationOptions } from "../../InstallationOptions"; import { ArrowLeftIcon, ArrowRightIcon } from "@heroicons/react/24/outline"; -import type { InternetInstallationCatalogItem } from "@customer-portal/domain/catalog"; +import type { InternetInstallationCatalogItem } from "@customer-portal/domain/services"; interface Props { installations: InternetInstallationCatalogItem[]; diff --git a/apps/portal/src/features/catalog/components/internet/configure/steps/ReviewOrderStep.tsx b/apps/portal/src/features/services/components/internet/configure/steps/ReviewOrderStep.tsx similarity index 98% rename from apps/portal/src/features/catalog/components/internet/configure/steps/ReviewOrderStep.tsx rename to apps/portal/src/features/services/components/internet/configure/steps/ReviewOrderStep.tsx index be527a3a..24427972 100644 --- a/apps/portal/src/features/catalog/components/internet/configure/steps/ReviewOrderStep.tsx +++ b/apps/portal/src/features/services/components/internet/configure/steps/ReviewOrderStep.tsx @@ -8,7 +8,7 @@ import type { InternetPlanCatalogItem, InternetInstallationCatalogItem, InternetAddonCatalogItem, -} from "@customer-portal/domain/catalog"; +} from "@customer-portal/domain/services"; import type { AccessModeValue } from "@customer-portal/domain/orders"; interface Props { @@ -34,7 +34,7 @@ export function ReviewOrderStep({ }: Props) { const selectedAddons = addons.filter(addon => selectedAddonSkus.includes(addon.sku)); - // Calculate display totals from catalog prices + // Calculate display totals from services prices // Note: BFF will recalculate authoritative pricing const monthlyTotal = (plan.monthlyPrice ?? 0) + diff --git a/apps/portal/src/features/catalog/components/internet/configure/steps/ServiceConfigurationStep.tsx b/apps/portal/src/features/services/components/internet/configure/steps/ServiceConfigurationStep.tsx similarity index 99% rename from apps/portal/src/features/catalog/components/internet/configure/steps/ServiceConfigurationStep.tsx rename to apps/portal/src/features/services/components/internet/configure/steps/ServiceConfigurationStep.tsx index b0def944..a97390a1 100644 --- a/apps/portal/src/features/catalog/components/internet/configure/steps/ServiceConfigurationStep.tsx +++ b/apps/portal/src/features/services/components/internet/configure/steps/ServiceConfigurationStep.tsx @@ -4,7 +4,7 @@ import { Button } from "@/components/atoms/button"; import { StepHeader } from "@/components/atoms"; import { ArrowRightIcon } from "@heroicons/react/24/outline"; import type { ReactNode } from "react"; -import type { InternetPlanCatalogItem } from "@customer-portal/domain/catalog"; +import type { InternetPlanCatalogItem } from "@customer-portal/domain/services"; import type { AccessModeValue } from "@customer-portal/domain/orders"; interface Props { diff --git a/apps/portal/src/features/services/components/internet/utils/groupPlansByOfferingType.ts b/apps/portal/src/features/services/components/internet/utils/groupPlansByOfferingType.ts new file mode 100644 index 00000000..469bed5c --- /dev/null +++ b/apps/portal/src/features/services/components/internet/utils/groupPlansByOfferingType.ts @@ -0,0 +1,31 @@ +import type { InternetPlanCatalogItem } from "@customer-portal/domain/services"; + +export type InternetOfferingTypeGroup = { + offeringType: string; + plans: InternetPlanCatalogItem[]; +}; + +/** + * Group plans by `internetOfferingType`, preserving input order. + * If the offering type is missing, plans are grouped under "Other". + */ +export function groupPlansByOfferingType( + plans: InternetPlanCatalogItem[] +): InternetOfferingTypeGroup[] { + const groups: InternetOfferingTypeGroup[] = []; + const indexByKey = new Map(); + + for (const plan of plans) { + const offeringType = String(plan.internetOfferingType || "").trim() || "Other"; + const key = offeringType.toLowerCase(); + const idx = indexByKey.get(key); + if (typeof idx === "number") { + groups[idx]?.plans.push(plan); + continue; + } + indexByKey.set(key, groups.length); + groups.push({ offeringType, plans: [plan] }); + } + + return groups; +} diff --git a/apps/portal/src/features/catalog/components/internet/utils/planName.ts b/apps/portal/src/features/services/components/internet/utils/planName.ts similarity index 98% rename from apps/portal/src/features/catalog/components/internet/utils/planName.ts rename to apps/portal/src/features/services/components/internet/utils/planName.ts index 3d152385..1913a678 100644 --- a/apps/portal/src/features/catalog/components/internet/utils/planName.ts +++ b/apps/portal/src/features/services/components/internet/utils/planName.ts @@ -1,6 +1,6 @@ "use client"; -import type { InternetPlanCatalogItem } from "@customer-portal/domain/catalog"; +import type { InternetPlanCatalogItem } from "@customer-portal/domain/services"; type ParsedPlanName = { baseName: string; diff --git a/apps/portal/src/features/catalog/components/sim/ActivationForm.tsx b/apps/portal/src/features/services/components/sim/ActivationForm.tsx similarity index 98% rename from apps/portal/src/features/catalog/components/sim/ActivationForm.tsx rename to apps/portal/src/features/services/components/sim/ActivationForm.tsx index 9e5d6f24..894e5acb 100644 --- a/apps/portal/src/features/catalog/components/sim/ActivationForm.tsx +++ b/apps/portal/src/features/services/components/sim/ActivationForm.tsx @@ -1,6 +1,6 @@ "use client"; -import { CardPricing } from "@/features/catalog/components/base/CardPricing"; +import { CardPricing } from "@/features/services/components/base/CardPricing"; interface ActivationFeeDetails { amount: number; diff --git a/apps/portal/src/features/catalog/components/sim/MnpForm.tsx b/apps/portal/src/features/services/components/sim/MnpForm.tsx similarity index 100% rename from apps/portal/src/features/catalog/components/sim/MnpForm.tsx rename to apps/portal/src/features/services/components/sim/MnpForm.tsx diff --git a/apps/portal/src/features/services/components/sim/SimCallingRates.tsx b/apps/portal/src/features/services/components/sim/SimCallingRates.tsx new file mode 100644 index 00000000..9c7625da --- /dev/null +++ b/apps/portal/src/features/services/components/sim/SimCallingRates.tsx @@ -0,0 +1,168 @@ +"use client"; + +import { useState } from "react"; +import { PhoneIcon, ChatBubbleLeftIcon, GlobeAltIcon } from "@heroicons/react/24/outline"; +import { ChevronDownIcon } from "@heroicons/react/24/solid"; + +const domesticRates = { + calling: { rate: 10, unit: "30 sec" }, + sms: { rate: 3, unit: "message" }, +}; + +const internationalSmsRate = 100; // per message + +const internationalCallingRates = [ + { country: "United States", code: "US", rate: "31-34" }, + { country: "United Kingdom", code: "UK", rate: "78-108" }, + { country: "Australia", code: "AU", rate: "63-68" }, + { country: "China", code: "CN", rate: "49-57" }, + { country: "India", code: "IN", rate: "98-148" }, + { country: "Singapore", code: "SG", rate: "63-68" }, + { country: "France", code: "FR", rate: "78-108" }, + { country: "Germany", code: "DE", rate: "78-108" }, +]; + +export function SimCallingRates() { + const [isExpanded, setIsExpanded] = useState(false); + + return ( +
+ {/* Header */} +
+

+ + Calling & SMS Rates +

+

+ Pay-per-use charges apply. Billed 5-6 weeks after usage. +

+
+ + {/* Domestic Rates */} +
+

+ + + + Domestic (Japan) +

+ +
+
+
+ + Voice Calls +
+
+ ¥{domesticRates.calling.rate} + + /{domesticRates.calling.unit} + +
+
+ +
+
+ + SMS +
+
+ ¥{domesticRates.sms.rate} + + /{domesticRates.sms.unit} + +
+
+
+ +

Incoming calls and SMS are free.

+
+ + {/* International Rates (Collapsible) */} +
+ + + {isExpanded && ( +
+
+ + + + + + + + + {internationalCallingRates.map((rate, index) => ( + + + + + ))} + +
Country + Rate (¥/30sec) +
+ {rate.country} + ({rate.code}) + ¥{rate.rate}
+
+ +
+

• International SMS: ¥{internationalSmsRate}/message

+

• Rates vary by time of day and day of week

+

+ • For full rate details, visit{" "} + + NTT Docomo's website + +

+
+
+ )} +
+ + {/* Unlimited Calling Option */} +
+
+
+ +
+
+

Unlimited Domestic Calling

+

+ Add unlimited domestic calls to any Data+Voice plan for{" "} + ¥3,000/month +

+

+ Available as an add-on during checkout. International calls not included. +

+
+
+
+
+ ); +} diff --git a/apps/portal/src/features/catalog/components/sim/SimConfigureView.tsx b/apps/portal/src/features/services/components/sim/SimConfigureView.tsx similarity index 92% rename from apps/portal/src/features/catalog/components/sim/SimConfigureView.tsx rename to apps/portal/src/features/services/components/sim/SimConfigureView.tsx index 348bb154..4d3a943b 100644 --- a/apps/portal/src/features/catalog/components/sim/SimConfigureView.tsx +++ b/apps/portal/src/features/services/components/sim/SimConfigureView.tsx @@ -3,13 +3,14 @@ import { PageLayout } from "@/components/templates/PageLayout"; import { Button } from "@/components/atoms/button"; import { AnimatedCard } from "@/components/molecules"; -import { AddonGroup } from "@/features/catalog/components/base/AddonGroup"; +import { AddonGroup } from "@/features/services/components/base/AddonGroup"; import { StepHeader } from "@/components/atoms"; -import { SimTypeSelector } from "@/features/catalog/components/sim/SimTypeSelector"; -import { ActivationForm } from "@/features/catalog/components/sim/ActivationForm"; -import { MnpForm } from "@/features/catalog/components/sim/MnpForm"; +import { SimTypeSelector } from "@/features/services/components/sim/SimTypeSelector"; +import { ActivationForm } from "@/features/services/components/sim/ActivationForm"; +import { MnpForm } from "@/features/services/components/sim/MnpForm"; import { ProgressSteps } from "@/components/molecules"; -import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink"; +import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink"; +import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath"; import { ArrowLeftIcon, ArrowRightIcon, @@ -17,8 +18,8 @@ import { ExclamationTriangleIcon, UsersIcon, } from "@heroicons/react/24/outline"; -import type { UseSimConfigureResult } from "@/features/catalog/hooks/useSimConfigure"; -import type { SimActivationFeeCatalogItem } from "@customer-portal/domain/catalog"; +import type { UseSimConfigureResult } from "@/features/services/hooks/useSimConfigure"; +import type { SimActivationFeeCatalogItem } from "@customer-portal/domain/services"; type Props = UseSimConfigureResult & { onConfirm: () => void; @@ -48,6 +49,7 @@ export function SimConfigureView({ setCurrentStep, onConfirm, }: Props) { + const servicesBasePath = useServicesBasePath(); const getRequiredActivationFee = ( fees: SimActivationFeeCatalogItem[] ): SimActivationFeeCatalogItem | undefined => { @@ -80,7 +82,7 @@ export function SimConfigureView({ } : undefined; - // Calculate display totals from catalog prices (for display only) + // Calculate display totals from services prices (for display only) // Note: BFF will recalculate authoritative pricing const monthlyTotal = (plan?.monthlyPrice ?? 0) + @@ -161,7 +163,10 @@ export function SimConfigureView({

Plan Not Found

The selected plan could not be found

- + ← Return to SIM Plans
@@ -185,7 +190,7 @@ export function SimConfigureView({ icon={} >
- +
@@ -530,10 +535,24 @@ export function SimConfigureView({
)} +

+ Prices exclude 10% consumption tax +

+ {/* Verification notice */} +
+

+ Next steps after checkout:{" "} + + We'll review your order and ID verification within 1-2 business days. You'll + receive an email once approved. + +

+
+
); diff --git a/apps/portal/src/features/catalog/components/sim/SimPlanTypeSection.tsx b/apps/portal/src/features/services/components/sim/SimPlanTypeSection.tsx similarity index 71% rename from apps/portal/src/features/catalog/components/sim/SimPlanTypeSection.tsx rename to apps/portal/src/features/services/components/sim/SimPlanTypeSection.tsx index f56ad849..1a50119e 100644 --- a/apps/portal/src/features/catalog/components/sim/SimPlanTypeSection.tsx +++ b/apps/portal/src/features/services/components/sim/SimPlanTypeSection.tsx @@ -2,8 +2,8 @@ import React from "react"; import { UsersIcon } from "@heroicons/react/24/outline"; -import type { SimCatalogProduct } from "@customer-portal/domain/catalog"; -import { SimPlanCard } from "./SimPlanCard"; +import type { SimCatalogProduct } from "@customer-portal/domain/services"; +import { SimPlanCard, type SimPlanCardActionResolver } from "./SimPlanCard"; export function SimPlanTypeSection({ title, @@ -11,12 +11,18 @@ export function SimPlanTypeSection({ icon, plans, showFamilyDiscount, + cardAction, + cardDisabled, + cardDisabledReason, }: { title: string; description: string; icon: React.ReactNode; plans: SimCatalogProduct[]; showFamilyDiscount: boolean; + cardAction?: SimPlanCardActionResolver; + cardDisabled?: boolean; + cardDisabledReason?: string; }) { if (plans.length === 0) return null; const regularPlans = plans.filter(p => !p.simHasFamilyDiscount); @@ -33,7 +39,13 @@ export function SimPlanTypeSection({
{regularPlans.map(plan => ( - + ))}
{showFamilyDiscount && familyPlans.length > 0 && ( @@ -47,7 +59,14 @@ export function SimPlanTypeSection({
{familyPlans.map(plan => ( - + ))}
diff --git a/apps/portal/src/features/services/components/sim/SimPlansContent.tsx b/apps/portal/src/features/services/components/sim/SimPlansContent.tsx new file mode 100644 index 00000000..32340ae6 --- /dev/null +++ b/apps/portal/src/features/services/components/sim/SimPlansContent.tsx @@ -0,0 +1,622 @@ +"use client"; + +import { useMemo, useState, type ElementType, type ReactNode } from "react"; +import { + Smartphone, + Check, + Phone, + Globe, + ArrowLeft, + Signal, + Sparkles, + CreditCard, + ChevronDown, + Info, + CircleDollarSign, + TriangleAlert, + Calendar, + ArrowRightLeft, + ArrowRight, + Users, +} from "lucide-react"; +import { Skeleton } from "@/components/atoms/loading-skeleton"; +import { Button } from "@/components/atoms/button"; +import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; +import type { SimCatalogProduct } from "@customer-portal/domain/services"; +import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink"; +import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath"; +import { CardPricing } from "@/features/services/components/base/CardPricing"; +import { + ServiceHighlights, + type HighlightFeature, +} from "@/features/services/components/base/ServiceHighlights"; + +export type SimPlansTab = "data-voice" | "data-only" | "voice-only"; + +interface PlansByType { + DataOnly: SimCatalogProduct[]; + DataSmsVoice: SimCatalogProduct[]; + VoiceOnly: SimCatalogProduct[]; +} + +function CollapsibleSection({ + title, + icon: Icon, + defaultOpen = false, + children, +}: { + title: string; + icon: ElementType; + defaultOpen?: boolean; + children: ReactNode; +}) { + const [isOpen, setIsOpen] = useState(defaultOpen); + + return ( +
+ +
+
{children}
+
+
+ ); +} + +function SimPlanCardCompact({ + plan, + isFamily, + onSelect, +}: { + plan: SimCatalogProduct; + isFamily?: boolean; + onSelect: (sku: string) => void; +}) { + const displayPrice = plan.monthlyPrice ?? plan.unitPrice ?? plan.oneTimePrice ?? 0; + + return ( +
+ {isFamily && ( +
+ + Family Discount +
+ )} + +
+
+
+ +
+ {plan.simDataSize} +
+
+ +
+ + {isFamily && ( +
Discounted price applied
+ )} +
+ +

{plan.name}

+ + +
+ ); +} + +export function SimPlansContent({ + variant, + plans, + isLoading, + error, + activeTab, + onTabChange, + onSelectPlan, +}: { + variant: "public" | "account"; + plans: SimCatalogProduct[]; + isLoading: boolean; + error: unknown; + activeTab: SimPlansTab; + onTabChange: (tab: SimPlansTab) => void; + onSelectPlan: (sku: string) => void; +}) { + const servicesBasePath = useServicesBasePath(); + + const simPlans: SimCatalogProduct[] = useMemo(() => plans ?? [], [plans]); + const hasExistingSim = useMemo(() => simPlans.some(p => p.simHasFamilyDiscount), [simPlans]); + + const simFeatures: HighlightFeature[] = [ + { + icon: , + title: "NTT Docomo Network", + description: "Best area coverage among the main three carriers in Japan", + highlight: "Nationwide coverage", + }, + { + icon: , + title: "First Month Free", + description: "Basic fee waived on signup to get you started risk-free", + highlight: "Great value", + }, + { + icon: , + title: "Foreign Cards Accepted", + description: "We accept both foreign and Japanese credit cards", + highlight: "No hassle", + }, + { + icon: , + title: "No Binding Contract", + description: "Minimum 4 months service (1st month free + 3 billing months)", + highlight: "Flexible contract", + }, + { + icon: , + title: "Number Portability", + description: "Easily switch to us keeping your current Japanese number", + highlight: "Keep your number", + }, + { + icon: , + title: "Free Plan Changes", + description: "Switch data plans anytime for the next billing cycle", + highlight: "Flexibility", + }, + ]; + + if (isLoading) { + return ( +
+ +
+ + +
+
+ {Array.from({ length: 4 }).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: [] } + ); + + const getCurrentPlans = () => { + const tabPlans = + activeTab === "data-voice" + ? plansByType.DataSmsVoice + : activeTab === "data-only" + ? plansByType.DataOnly + : plansByType.VoiceOnly; + + const regularPlans = tabPlans.filter(p => !p.simHasFamilyDiscount); + const familyPlans = tabPlans.filter(p => p.simHasFamilyDiscount); + + return { regularPlans, familyPlans }; + }; + + const { regularPlans, familyPlans } = getCurrentPlans(); + + return ( +
+ + +
+
+ + Powered by NTT DOCOMO +
+

+ Choose Your SIM Plan +

+

+ Get connected with Japan's best network coverage. Choose eSIM for quick digital + delivery or physical SIM shipped to your door. +

+
+ + {variant === "account" && hasExistingSim && ( +
+ +

+ You already have a SIM subscription. Discounted pricing is automatically shown for + additional lines. +

+
+
+ )} + + + +
+
+ + + +
+
+ +
+ {regularPlans.length > 0 || familyPlans.length > 0 ? ( +
+ {regularPlans.length > 0 && ( +
+ {regularPlans.map(plan => ( + + ))} +
+ )} + + {variant === "account" && hasExistingSim && familyPlans.length > 0 && ( +
+
+ +

Family Discount Plans

+
+
+ {familyPlans.map(plan => ( + + ))} +
+
+ )} +
+ ) : ( +
+ No plans available in this category. +
+ )} +
+ +
+ +
+
+

+ + + + Domestic (Japan) +

+
+
+
Voice Calls
+
+ ¥10/30 sec +
+
+
+
SMS
+
+ ¥3/message +
+
+
+

+ Incoming calls and SMS are free. Pay-per-use charges billed 5-6 weeks after usage. +

+
+ +
+
+ +
+

Unlimited Domestic Calling

+

+ Add unlimited domestic calls for{" "} + ¥3,000/month (available at + checkout) +

+
+
+
+ +
+

+ International calling rates vary by country (¥31-148/30 sec). See{" "} + + NTT Docomo's website + {" "} + for full details. +

+
+
+
+ + +
+
+

One-time Fees

+
+
+ Activation Fee + ¥1,500 +
+
+ SIM Replacement (lost/damaged) + ¥1,500 +
+
+ eSIM Re-download + ¥1,500 +
+
+
+ +
+

Family Discount

+

+ ¥300/month off per additional + Voice SIM on your account +

+
+ +

All prices exclude 10% consumption tax.

+
+
+ + +
+
+

+ + Important Notices +

+
    +
  • + + + ID verification with official documents (name, date of birth, address, photo) is + required during checkout. + +
  • +
  • + + + A compatible unlocked device is required. Check compatibility on our website. + +
  • +
  • + + + Service may not be available in areas with weak signal. See{" "} + + NTT Docomo coverage map + + . + +
  • +
  • + + + SIM is activated as 4G by default. 5G can be requested via your account portal. + +
  • +
  • + + + International data roaming is not available. Voice/SMS roaming can be enabled + upon request (¥50,000/month limit). + +
  • +
+
+ +
+

Contract Terms

+
    +
  • + + + Minimum contract: 3 full billing + months. First month (sign-up to end of month) is free and doesn't count. + +
  • +
  • + + + Billing cycle: 1st to end of month. + Regular billing starts the 1st of the following month after sign-up. + +
  • +
  • + + + Cancellation: Can be requested + after 3rd month via cancellation form. Monthly fee is incurred in full for + cancellation month. + +
  • +
  • + + + SIM return: SIM card must be + returned after service termination. + +
  • +
+
+ +
+

Additional Options

+
    +
  • + + Call waiting and voice mail available as separate paid options. +
  • +
  • + + Data plan changes are free and take effect next billing month. +
  • +
  • + + + Voice plan changes require new SIM issuance and standard policies apply. + +
  • +
+
+ +
+

+ Payment is by credit card only. Data service is not suitable for activities + requiring continuous large data transfers. See full Terms of Service for complete + details. +

+
+
+
+
+ +
+

+ All prices exclude 10% consumption tax.{" "} + + View full Terms of Service + +

+
+
+ ); +} + +export default SimPlansContent; diff --git a/apps/portal/src/features/services/components/sim/SimTypeComparison.tsx b/apps/portal/src/features/services/components/sim/SimTypeComparison.tsx new file mode 100644 index 00000000..bea3cf3f --- /dev/null +++ b/apps/portal/src/features/services/components/sim/SimTypeComparison.tsx @@ -0,0 +1,157 @@ +"use client"; + +import { useState } from "react"; +import { + DevicePhoneMobileIcon, + SignalIcon, + TruckIcon, + EnvelopeIcon, + QuestionMarkCircleIcon, + CheckIcon, + InformationCircleIcon, +} from "@heroicons/react/24/outline"; + +const esimFeatures = [ + { text: "No physical card needed", included: true }, + { text: "Delivered via email after approval", included: true }, + { text: "EID number required", included: true, note: true }, + { text: "Can be transferred between devices", included: false }, +]; + +const physicalSimFeatures = [ + { text: "Works with any unlocked device", included: true }, + { text: "Ships after approval (1-3 days)", included: true }, + { text: "3-in-1 size (Nano/Micro/Standard)", included: true }, + { text: "No EID required", included: true }, +]; + +const compatibleEidPrefixes = ["89049032", "89033023", "89033024", "89043051", "89043052"]; + +export function SimTypeComparison() { + const [showEidInfo, setShowEidInfo] = useState(false); + + return ( +
+

+ eSIM vs Physical SIM +

+

+ Choose the right option for your device +

+ +
+ {/* eSIM Card */} +
+
+
+ +
+
+

eSIM

+

Digital SIM card

+
+
+ +
    + {esimFeatures.map((feature, index) => ( +
  • + {feature.included ? ( + + ) : ( + + ✕ + + )} + + {feature.text} + {feature.note && ( + + )} + +
  • + ))} +
+ +
+
+ + + Delivery: Email after approval + +
+
+
+ + {/* Physical SIM Card */} +
+
+
+ +
+
+

Physical SIM

+

Traditional SIM card

+
+
+ +
    + {physicalSimFeatures.map((feature, index) => ( +
  • + + {feature.text} +
  • + ))} +
+ +
+
+ + + Delivery: 1-3 business days + +
+
+
+
+ + {/* EID Info Panel */} + {showEidInfo && ( +
+
+ +
+

What is an EID?

+

+ An EID (Embedded Identity Document) is a 32-digit number unique to your device's + eSIM chip. You can find it in your phone's settings under "About" or "SIM status". +

+
+

Compatible EID prefixes:

+
+ {compatibleEidPrefixes.map(prefix => ( + + {prefix}... + + ))} +
+
+
+
+
+ )} + + {/* Note */} +

+ Both options require ID verification before activation (1-2 business days) +

+
+ ); +} diff --git a/apps/portal/src/features/services/components/sim/SimTypeSelector.tsx b/apps/portal/src/features/services/components/sim/SimTypeSelector.tsx new file mode 100644 index 00000000..def836c0 --- /dev/null +++ b/apps/portal/src/features/services/components/sim/SimTypeSelector.tsx @@ -0,0 +1,229 @@ +"use client"; + +import { useState } from "react"; +import { + DevicePhoneMobileIcon, + SignalIcon, + TruckIcon, + EnvelopeIcon, + InformationCircleIcon, + CheckIcon, +} from "@heroicons/react/24/outline"; + +interface SimTypeSelectorProps { + simType: "Physical SIM" | "eSIM" | ""; + onSimTypeChange: (type: "Physical SIM" | "eSIM") => void; + eid: string; + onEidChange: (eid: string) => void; + errors: Record; +} + +const compatibleEidPrefixes = ["89049032", "89033023", "89033024", "89043051", "89043052"]; + +export function SimTypeSelector({ + simType, + onSimTypeChange, + eid, + onEidChange, + errors, +}: SimTypeSelectorProps) { + const [showEidInfo, setShowEidInfo] = useState(false); + + return ( +
+ {/* SIM Type Selection Cards */} +
+ {/* eSIM Option */} + + + {/* Physical SIM Option */} + +
+ + {/* EID Input for eSIM */} +
+
+
+ +
+

eSIM Device Information

+

+ Your EID (Embedded Identity Document) is required to provision the eSIM to your + device. +

+
+
+ +
+ + onEidChange(e.target.value)} + className={`w-full px-4 py-3 bg-card border rounded-lg text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition-colors ${ + errors.eid ? "border-destructive" : "border-border" + }`} + placeholder="32-digit EID number" + maxLength={32} + /> + {errors.eid &&

{errors.eid}

} + + + + {showEidInfo && ( +
+

+ Find your EID in your phone's settings: +

+
    +
  • + iOS: Settings → General → About → + EID +
  • +
  • + Android: Settings → About Phone → + SIM status → EID +
  • +
+
+

Compatible EID prefixes:

+
+ {compatibleEidPrefixes.map(prefix => ( + + {prefix}... + + ))} +
+
+
+ )} +
+
+
+ + {/* Note about verification */} +
+ +

+ Both eSIM and Physical SIM require ID verification before activation (1-2 business days). +

+
+
+ ); +} diff --git a/apps/portal/src/features/catalog/components/vpn/VpnPlanCard.tsx b/apps/portal/src/features/services/components/vpn/VpnPlanCard.tsx similarity index 75% rename from apps/portal/src/features/catalog/components/vpn/VpnPlanCard.tsx rename to apps/portal/src/features/services/components/vpn/VpnPlanCard.tsx index bcea8cdc..036233ea 100644 --- a/apps/portal/src/features/catalog/components/vpn/VpnPlanCard.tsx +++ b/apps/portal/src/features/services/components/vpn/VpnPlanCard.tsx @@ -2,9 +2,9 @@ import { AnimatedCard } from "@/components/molecules"; import { Button } from "@/components/atoms/button"; -import { ArrowRightIcon, ShieldCheckIcon } from "@heroicons/react/24/outline"; -import type { VpnCatalogProduct } from "@customer-portal/domain/catalog"; -import { CardPricing } from "@/features/catalog/components/base/CardPricing"; +import { ArrowRight, ShieldCheck } from "lucide-react"; +import type { VpnCatalogProduct } from "@customer-portal/domain/services"; +import { CardPricing } from "@/features/services/components/base/CardPricing"; interface VpnPlanCardProps { plan: VpnCatalogProduct; @@ -16,7 +16,7 @@ export function VpnPlanCard({ plan }: VpnPlanCardProps) { {/* Header with icon and name */}
- +

{plan.name}

@@ -32,11 +32,11 @@ export function VpnPlanCard({ plan }: VpnPlanCardProps) {
diff --git a/apps/portal/src/features/catalog/containers/InternetConfigure.tsx b/apps/portal/src/features/services/containers/InternetConfigure.tsx similarity index 60% rename from apps/portal/src/features/catalog/containers/InternetConfigure.tsx rename to apps/portal/src/features/services/containers/InternetConfigure.tsx index 4358d6a9..6414556e 100644 --- a/apps/portal/src/features/catalog/containers/InternetConfigure.tsx +++ b/apps/portal/src/features/services/containers/InternetConfigure.tsx @@ -1,8 +1,8 @@ "use client"; import { useRouter } from "next/navigation"; -import { useInternetConfigure } from "@/features/catalog/hooks/useInternetConfigure"; -import { InternetConfigureView } from "@/features/catalog/components/internet/InternetConfigureView"; +import { useInternetConfigure } from "@/features/services/hooks/useInternetConfigure"; +import { InternetConfigureView } from "@/features/services/components/internet/InternetConfigureView"; export function InternetConfigureContainer() { const router = useRouter(); @@ -11,7 +11,7 @@ export function InternetConfigureContainer() { const handleConfirm = () => { const params = vm.buildCheckoutSearchParams(); if (!params) return; - router.push(`/checkout?${params.toString()}`); + router.push(`/order?${params.toString()}`); }; return ; diff --git a/apps/portal/src/features/catalog/containers/SimConfigure.tsx b/apps/portal/src/features/services/containers/SimConfigure.tsx similarity index 62% rename from apps/portal/src/features/catalog/containers/SimConfigure.tsx rename to apps/portal/src/features/services/containers/SimConfigure.tsx index e74c296d..25c817a9 100644 --- a/apps/portal/src/features/catalog/containers/SimConfigure.tsx +++ b/apps/portal/src/features/services/containers/SimConfigure.tsx @@ -1,13 +1,13 @@ "use client"; import { useSearchParams, useRouter } from "next/navigation"; -import { useSimConfigure } from "@/features/catalog/hooks/useSimConfigure"; -import { SimConfigureView } from "@/features/catalog/components/sim/SimConfigureView"; +import { useSimConfigure } from "@/features/services/hooks/useSimConfigure"; +import { SimConfigureView } from "@/features/services/components/sim/SimConfigureView"; export function SimConfigureContainer() { const searchParams = useSearchParams(); const router = useRouter(); - const planId = searchParams.get("plan") || undefined; + const planId = searchParams.get("planSku") || undefined; const vm = useSimConfigure(planId); @@ -15,7 +15,7 @@ export function SimConfigureContainer() { if (!vm.plan || !vm.validate()) return; const params = vm.buildCheckoutSearchParams(); if (!params) return; - router.push(`/checkout?${params.toString()}`); + router.push(`/order?${params.toString()}`); }; return ; diff --git a/apps/portal/src/features/catalog/hooks/index.ts b/apps/portal/src/features/services/hooks/index.ts similarity index 50% rename from apps/portal/src/features/catalog/hooks/index.ts rename to apps/portal/src/features/services/hooks/index.ts index 13663186..0febb2a5 100644 --- a/apps/portal/src/features/catalog/hooks/index.ts +++ b/apps/portal/src/features/services/hooks/index.ts @@ -1,4 +1,6 @@ -export * from "./useCatalog"; +export * from "./useServices"; export * from "./useConfigureParams"; export * from "./useSimConfigure"; export * from "./useInternetConfigure"; +export * from "./useInternetEligibility"; +export * from "./useServicesBasePath"; diff --git a/apps/portal/src/features/catalog/hooks/useConfigureParams.ts b/apps/portal/src/features/services/hooks/useConfigureParams.ts similarity index 98% rename from apps/portal/src/features/catalog/hooks/useConfigureParams.ts rename to apps/portal/src/features/services/hooks/useConfigureParams.ts index 50ab1b1d..093041f5 100644 --- a/apps/portal/src/features/catalog/hooks/useConfigureParams.ts +++ b/apps/portal/src/features/services/hooks/useConfigureParams.ts @@ -8,7 +8,7 @@ import type { AccessModeValue } from "@customer-portal/domain/orders"; * Parse URL parameters for configuration deep linking * * Note: These params are only used for initial page load/deep linking. - * State management is handled by Zustand store (catalog.store.ts). + * State management is handled by Zustand store (services.store.ts). * The store's restore functions handle parsing these params into state. */ diff --git a/apps/portal/src/features/catalog/hooks/useInternetConfigure.ts b/apps/portal/src/features/services/hooks/useInternetConfigure.ts similarity index 82% rename from apps/portal/src/features/catalog/hooks/useInternetConfigure.ts rename to apps/portal/src/features/services/hooks/useInternetConfigure.ts index a4f8c725..89d45b45 100644 --- a/apps/portal/src/features/catalog/hooks/useInternetConfigure.ts +++ b/apps/portal/src/features/services/hooks/useInternetConfigure.ts @@ -2,14 +2,15 @@ import { useEffect, useMemo, useRef } from "react"; import { useRouter, useSearchParams } from "next/navigation"; -import { useInternetCatalog, useInternetPlan } from "."; -import { useCatalogStore } from "../services/catalog.store"; +import { useAccountInternetCatalog } from "."; +import { useCatalogStore } from "../services/services.store"; +import { useServicesBasePath } from "./useServicesBasePath"; import type { AccessModeValue } from "@customer-portal/domain/orders"; import type { InternetPlanCatalogItem, InternetInstallationCatalogItem, InternetAddonCatalogItem, -} from "@customer-portal/domain/catalog"; +} from "@customer-portal/domain/services"; type InstallationTerm = NonNullable< NonNullable["installationTerm"] @@ -41,9 +42,10 @@ export type UseInternetConfigureResult = { */ export function useInternetConfigure(): UseInternetConfigureResult { const router = useRouter(); + const servicesBasePath = useServicesBasePath(); const searchParams = useSearchParams(); const paramsSignature = useMemo(() => searchParams.toString(), [searchParams]); - const urlPlanSku = searchParams.get("plan"); + const urlPlanSku = searchParams.get("planSku"); // Get state from Zustand store (persisted) const configState = useCatalogStore(state => state.internet); @@ -52,9 +54,13 @@ export function useInternetConfigure(): UseInternetConfigureResult { const buildParams = useCatalogStore(state => state.buildInternetCheckoutParams); const lastRestoredSignatureRef = useRef(null); - // Fetch catalog data from BFF - const { data: internetData, isLoading: internetLoading } = useInternetCatalog(); - const { plan: selectedPlan } = useInternetPlan(configState.planSku || urlPlanSku || undefined); + // Fetch services data from BFF + const { data: internetData, isLoading: internetLoading } = useAccountInternetCatalog(); + const selectedPlanSku = configState.planSku || urlPlanSku || undefined; + const selectedPlan = useMemo(() => { + if (!selectedPlanSku) return null; + return (internetData?.plans ?? []).find(p => p.sku === selectedPlanSku) ?? null; + }, [internetData?.plans, selectedPlanSku]); // Initialize/restore state on mount useEffect(() => { @@ -65,7 +71,7 @@ export function useInternetConfigure(): UseInternetConfigureResult { // If URL has configuration params (back navigation from checkout), restore them const params = new URLSearchParams(paramsSignature); - const hasConfigParams = params.has("plan") ? params.size > 1 : params.size > 0; + const hasConfigParams = params.has("planSku") ? params.size > 1 : params.size > 0; const shouldRestore = hasConfigParams && lastRestoredSignatureRef.current !== paramsSignature && paramsSignature; if (shouldRestore) { @@ -75,9 +81,17 @@ export function useInternetConfigure(): UseInternetConfigureResult { // Redirect if no plan selected if (!urlPlanSku && !configState.planSku) { - router.push("/catalog/internet"); + router.push(`${servicesBasePath}/internet`); } - }, [configState.planSku, paramsSignature, restoreFromParams, router, setConfig, urlPlanSku]); + }, [ + configState.planSku, + paramsSignature, + restoreFromParams, + router, + setConfig, + servicesBasePath, + urlPlanSku, + ]); // Auto-set default mode for Gold/Platinum plans if not already set useEffect(() => { @@ -91,7 +105,7 @@ export function useInternetConfigure(): UseInternetConfigureResult { } }, [selectedPlan, configState.accessMode, setConfig]); - // Derive catalog items + // Derive services items const addons = useMemo(() => internetData?.addons ?? [], [internetData]); const installations = useMemo(() => internetData?.installations ?? [], [internetData]); diff --git a/apps/portal/src/features/services/hooks/useInternetEligibility.ts b/apps/portal/src/features/services/hooks/useInternetEligibility.ts new file mode 100644 index 00000000..ca399e3e --- /dev/null +++ b/apps/portal/src/features/services/hooks/useInternetEligibility.ts @@ -0,0 +1,27 @@ +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { queryKeys } from "@/lib/api"; +import { servicesService } from "@/features/services/services"; +import type { Address } from "@customer-portal/domain/customer"; + +export function useInternetEligibility(options?: { enabled?: boolean }) { + return useQuery({ + queryKey: queryKeys.services.internet.eligibility(), + queryFn: () => servicesService.getInternetEligibility(), + enabled: options?.enabled, + }); +} + +export function useRequestInternetEligibilityCheck() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (body?: { notes?: string; address?: Partial
}) => + servicesService.requestInternetEligibilityCheck(body), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: queryKeys.services.internet.eligibility() }); + await queryClient.invalidateQueries({ queryKey: queryKeys.services.internet.combined() }); + }, + }); +} diff --git a/apps/portal/src/features/services/hooks/useServices.ts b/apps/portal/src/features/services/hooks/useServices.ts new file mode 100644 index 00000000..40cb9997 --- /dev/null +++ b/apps/portal/src/features/services/hooks/useServices.ts @@ -0,0 +1,132 @@ +/** + * Services Hooks + * React hooks for services functionality + */ + +import { useQuery } from "@tanstack/react-query"; +import { queryKeys } from "@/lib/api"; +import { servicesService } from "../services"; +import { useAuthSession } from "@/features/auth/services/auth.store"; + +type ServicesCatalogScope = "public" | "account"; + +function withScope(key: T, scope: ServicesCatalogScope) { + return [...key, scope] as const; +} + +/** + * Internet catalog (public vs account) + */ +export function usePublicInternetCatalog() { + return useQuery({ + queryKey: withScope(queryKeys.services.internet.combined(), "public"), + queryFn: () => servicesService.getPublicInternetCatalog(), + }); +} + +export function useAccountInternetCatalog() { + const { isAuthenticated } = useAuthSession(); + return useQuery({ + queryKey: withScope(queryKeys.services.internet.combined(), "account"), + enabled: isAuthenticated, + queryFn: () => servicesService.getAccountInternetCatalog(), + }); +} + +/** + * SIM catalog (public vs account) + */ +export function usePublicSimCatalog() { + return useQuery({ + queryKey: withScope(queryKeys.services.sim.combined(), "public"), + queryFn: () => servicesService.getPublicSimCatalog(), + }); +} + +export function useAccountSimCatalog() { + const { isAuthenticated } = useAuthSession(); + return useQuery({ + queryKey: withScope(queryKeys.services.sim.combined(), "account"), + enabled: isAuthenticated, + queryFn: () => servicesService.getAccountSimCatalog(), + }); +} + +/** + * VPN catalog (public vs account) + */ +export function usePublicVpnCatalog() { + return useQuery({ + queryKey: withScope(queryKeys.services.vpn.combined(), "public"), + queryFn: () => servicesService.getPublicVpnCatalog(), + }); +} + +export function useAccountVpnCatalog() { + const { isAuthenticated } = useAuthSession(); + return useQuery({ + queryKey: withScope(queryKeys.services.vpn.combined(), "account"), + enabled: isAuthenticated, + queryFn: () => servicesService.getAccountVpnCatalog(), + }); +} + +/** + * Lookup helpers by SKU (explicit scope) + */ +export function usePublicInternetPlan(sku?: string) { + const { data, ...rest } = usePublicInternetCatalog(); + const plan = (data?.plans || []).find(p => p.sku === sku); + return { plan, ...rest } as const; +} + +export function useAccountInternetPlan(sku?: string) { + const { data, ...rest } = useAccountInternetCatalog(); + const plan = (data?.plans || []).find(p => p.sku === sku); + return { plan, ...rest } as const; +} + +export function usePublicSimPlan(sku?: string) { + const { data, ...rest } = usePublicSimCatalog(); + const plan = (data?.plans || []).find(p => p.sku === sku); + return { plan, ...rest } as const; +} + +export function useAccountSimPlan(sku?: string) { + const { data, ...rest } = useAccountSimCatalog(); + const plan = (data?.plans || []).find(p => p.sku === sku); + return { plan, ...rest } as const; +} + +export function usePublicVpnPlan(sku?: string) { + const { data, ...rest } = usePublicVpnCatalog(); + const plan = (data?.plans || []).find(p => p.sku === sku); + return { plan, ...rest } as const; +} + +export function useAccountVpnPlan(sku?: string) { + const { data, ...rest } = useAccountVpnCatalog(); + const plan = (data?.plans || []).find(p => p.sku === sku); + return { plan, ...rest } as const; +} + +/** + * Addon/installation lookup helpers by SKU + */ +export function useAccountInternetInstallation(sku?: string) { + const { data, ...rest } = useAccountInternetCatalog(); + const installation = (data?.installations || []).find(i => i.sku === sku); + return { installation, ...rest } as const; +} + +export function useAccountInternetAddon(sku?: string) { + const { data, ...rest } = useAccountInternetCatalog(); + const addon = (data?.addons || []).find(a => a.sku === sku); + return { addon, ...rest } as const; +} + +export function useAccountSimAddon(sku?: string) { + const { data, ...rest } = useAccountSimCatalog(); + const addon = (data?.addons || []).find(a => a.sku === sku); + return { addon, ...rest } as const; +} diff --git a/apps/portal/src/features/services/hooks/useServicesBasePath.ts b/apps/portal/src/features/services/hooks/useServicesBasePath.ts new file mode 100644 index 00000000..fdca9889 --- /dev/null +++ b/apps/portal/src/features/services/hooks/useServicesBasePath.ts @@ -0,0 +1,17 @@ +"use client"; + +import { usePathname } from "next/navigation"; + +/** + * Returns the active services base path for the current shell. + * + * - Public services: `/services` + * - Account shop (inside AppShell): `/account/services` + */ +export function useServicesBasePath(): "/services" | "/account/services" { + const pathname = usePathname(); + if (pathname.startsWith("/account/services")) { + return "/account/services"; + } + return "/services"; +} diff --git a/apps/portal/src/features/catalog/hooks/useSimConfigure.ts b/apps/portal/src/features/services/hooks/useSimConfigure.ts similarity index 88% rename from apps/portal/src/features/catalog/hooks/useSimConfigure.ts rename to apps/portal/src/features/services/hooks/useSimConfigure.ts index 6980d91c..8f30f5d7 100644 --- a/apps/portal/src/features/catalog/hooks/useSimConfigure.ts +++ b/apps/portal/src/features/services/hooks/useSimConfigure.ts @@ -2,8 +2,9 @@ import { useEffect, useCallback, useMemo, useRef } from "react"; import { useRouter, useSearchParams } from "next/navigation"; -import { useSimCatalog, useSimPlan } from "."; -import { useCatalogStore } from "../services/catalog.store"; +import { useAccountSimCatalog } from "."; +import { useCatalogStore } from "../services/services.store"; +import { useServicesBasePath } from "./useServicesBasePath"; import { simConfigureFormSchema, type SimConfigureFormData, @@ -14,7 +15,7 @@ import { import type { SimCatalogProduct, SimActivationFeeCatalogItem, -} from "@customer-portal/domain/catalog"; +} from "@customer-portal/domain/services"; export type UseSimConfigureResult = { // data @@ -54,8 +55,9 @@ export type UseSimConfigureResult = { */ export function useSimConfigure(planId?: string): UseSimConfigureResult { const router = useRouter(); + const servicesBasePath = useServicesBasePath(); const searchParams = useSearchParams(); - const urlPlanSku = searchParams.get("plan"); + const urlPlanSku = searchParams.get("planSku"); const paramsSignature = useMemo(() => searchParams.toString(), [searchParams]); // Get state from Zustand store (persisted) @@ -65,9 +67,13 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult { const buildParams = useCatalogStore(state => state.buildSimCheckoutParams); const lastRestoredSignatureRef = useRef(null); - // Fetch catalog data from BFF - const { data: simData, isLoading: simLoading } = useSimCatalog(); - const { plan: selectedPlan } = useSimPlan(configState.planSku || urlPlanSku || planId); + // Fetch services data from BFF + const { data: simData, isLoading: simLoading } = useAccountSimCatalog(); + const selectedPlanSku = configState.planSku || urlPlanSku || planId; + const selectedPlan = useMemo(() => { + if (!selectedPlanSku) return null; + return (simData?.plans ?? []).find(p => p.sku === selectedPlanSku) ?? null; + }, [simData?.plans, selectedPlanSku]); // Initialize/restore state on mount useEffect(() => { @@ -79,7 +85,7 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult { // If URL has configuration params (back navigation from checkout), restore them const params = new URLSearchParams(paramsSignature); - const hasConfigParams = params.has("plan") ? params.size > 1 : params.size > 0; + const hasConfigParams = params.has("planSku") ? params.size > 1 : params.size > 0; const shouldRestore = hasConfigParams && lastRestoredSignatureRef.current !== paramsSignature && paramsSignature; if (shouldRestore) { @@ -89,7 +95,7 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult { // Redirect if no plan selected if (!effectivePlanSku && !configState.planSku) { - router.push("/catalog/sim"); + router.push(`${servicesBasePath}/sim`); } }, [ configState.planSku, @@ -98,10 +104,11 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult { restoreFromParams, router, setConfig, + servicesBasePath, urlPlanSku, ]); - // Derive catalog items + // Derive services items const addons = simData?.addons ?? []; const activationFees = simData?.activationFees ?? []; diff --git a/apps/portal/src/features/catalog/index.ts b/apps/portal/src/features/services/index.ts similarity index 69% rename from apps/portal/src/features/catalog/index.ts rename to apps/portal/src/features/services/index.ts index 21b163f0..03d25e6d 100644 --- a/apps/portal/src/features/catalog/index.ts +++ b/apps/portal/src/features/services/index.ts @@ -1,6 +1,6 @@ /** - * Catalog Feature Module - * Product catalog and ordering functionality including components, hooks, and services + * Services Feature Module + * Service browsing and ordering functionality including components, hooks, and services */ // Components diff --git a/apps/portal/src/features/services/services/index.ts b/apps/portal/src/features/services/services/index.ts new file mode 100644 index 00000000..b79c1caa --- /dev/null +++ b/apps/portal/src/features/services/services/index.ts @@ -0,0 +1 @@ +export { servicesService } from "./services.service"; diff --git a/apps/portal/src/features/services/services/services.service.ts b/apps/portal/src/features/services/services/services.service.ts new file mode 100644 index 00000000..e76ada16 --- /dev/null +++ b/apps/portal/src/features/services/services/services.service.ts @@ -0,0 +1,155 @@ +import { apiClient, getDataOrDefault, getDataOrThrow } from "@/lib/api"; +import { + EMPTY_SIM_CATALOG, + EMPTY_VPN_CATALOG, + internetInstallationCatalogItemSchema, + internetAddonCatalogItemSchema, + internetEligibilityDetailsSchema, + simActivationFeeCatalogItemSchema, + simCatalogProductSchema, + vpnCatalogProductSchema, + type InternetCatalogCollection, + type InternetAddonCatalogItem, + type InternetInstallationCatalogItem, + type InternetEligibilityDetails, + type SimActivationFeeCatalogItem, + type SimCatalogCollection, + type SimCatalogProduct, + type VpnCatalogCollection, + type VpnCatalogProduct, +} from "@customer-portal/domain/services"; +import type { Address } from "@customer-portal/domain/customer"; + +export const servicesService = { + // ============================================================================ + // Public (non-personalized) catalog endpoints + // ============================================================================ + + async getPublicInternetCatalog(): Promise { + const response = await apiClient.GET( + "/api/public/services/internet/plans" + ); + const data = getDataOrThrow( + response, + "Failed to load internet services" + ); + return data; // BFF already validated + }, + + /** + * @deprecated Use getPublicInternetCatalog() or getAccountInternetCatalog() for clear separation. + */ + async getInternetCatalog(): Promise { + return this.getPublicInternetCatalog(); + }, + + async getPublicSimCatalog(): Promise { + const response = await apiClient.GET("/api/public/services/sim/plans"); + const data = getDataOrDefault(response, EMPTY_SIM_CATALOG); + return data; // BFF already validated + }, + + async getPublicVpnCatalog(): Promise { + const response = await apiClient.GET("/api/public/services/vpn/plans"); + const data = getDataOrDefault(response, EMPTY_VPN_CATALOG); + return data; // BFF already validated + }, + + // ============================================================================ + // Account (authenticated + personalized) catalog endpoints + // ============================================================================ + + async getAccountInternetCatalog(): Promise { + const response = await apiClient.GET( + "/api/account/services/internet/plans" + ); + const data = getDataOrThrow( + response, + "Failed to load internet services" + ); + return data; // BFF already validated + }, + + async getAccountSimCatalog(): Promise { + const response = await apiClient.GET("/api/account/services/sim/plans"); + const data = getDataOrDefault(response, EMPTY_SIM_CATALOG); + return data; // BFF already validated + }, + + async getAccountVpnCatalog(): Promise { + const response = await apiClient.GET("/api/account/services/vpn/plans"); + const data = getDataOrDefault(response, EMPTY_VPN_CATALOG); + return data; // BFF already validated + }, + + async getInternetInstallations(): Promise { + const response = await apiClient.GET( + "/api/services/internet/installations" + ); + const data = getDataOrDefault(response, []); + return internetInstallationCatalogItemSchema.array().parse(data); + }, + + async getInternetAddons(): Promise { + const response = await apiClient.GET( + "/api/services/internet/addons" + ); + const data = getDataOrDefault(response, []); + return internetAddonCatalogItemSchema.array().parse(data); + }, + + /** + * @deprecated Use getPublicSimCatalog() or getAccountSimCatalog() for clear separation. + */ + async getSimCatalog(): Promise { + return this.getPublicSimCatalog(); + }, + + async getSimActivationFees(): Promise { + const response = await apiClient.GET( + "/api/services/sim/activation-fees" + ); + const data = getDataOrDefault(response, []); + return simActivationFeeCatalogItemSchema.array().parse(data); + }, + + async getSimAddons(): Promise { + const response = await apiClient.GET("/api/services/sim/addons"); + const data = getDataOrDefault(response, []); + return simCatalogProductSchema.array().parse(data); + }, + + /** + * @deprecated Use getPublicVpnCatalog() or getAccountVpnCatalog() for clear separation. + */ + async getVpnCatalog(): Promise { + return this.getPublicVpnCatalog(); + }, + + async getVpnActivationFees(): Promise { + const response = await apiClient.GET("/api/services/vpn/activation-fees"); + const data = getDataOrDefault(response, []); + return vpnCatalogProductSchema.array().parse(data); + }, + + async getInternetEligibility(): Promise { + const response = await apiClient.GET( + "/api/services/internet/eligibility" + ); + const data = getDataOrThrow(response, "Failed to load internet eligibility"); + return internetEligibilityDetailsSchema.parse(data); + }, + + async requestInternetEligibilityCheck(body?: { + notes?: string; + address?: Partial
; + }): Promise<{ requestId: string }> { + const response = await apiClient.POST<{ requestId: string }>( + "/api/services/internet/eligibility-request", + { + body: body ?? {}, + } + ); + return getDataOrThrow(response, "Failed to request availability check"); + }, +}; diff --git a/apps/portal/src/features/catalog/services/catalog.store.ts b/apps/portal/src/features/services/services/services.store.ts similarity index 97% rename from apps/portal/src/features/catalog/services/catalog.store.ts rename to apps/portal/src/features/services/services/services.store.ts index f300e668..1390b55c 100644 --- a/apps/portal/src/features/catalog/services/catalog.store.ts +++ b/apps/portal/src/features/services/services/services.store.ts @@ -1,7 +1,7 @@ /** - * Centralized Catalog Configuration Store + * Centralized Services Configuration Store * - * Manages all catalog configuration state (Internet, SIM) with localStorage persistence. + * Manages all services configuration state (Internet, SIM) with localStorage persistence. * This store serves as the single source of truth for configuration state, * eliminating URL param coupling and enabling reliable navigation. */ @@ -332,7 +332,7 @@ export const useCatalogStore = create()( }, }), { - name: "catalog-config-store", + name: "services-config-store", storage: createJSONStorage(() => localStorage), // Only persist configuration state, not transient UI state partialize: state => ({ @@ -357,11 +357,11 @@ export const selectSimStep = (state: CatalogStore) => state.sim.currentStep; // ============================================================================ /** - * Clear all catalog configuration from localStorage + * Clear all services configuration from localStorage * Useful for testing or debugging */ export const clearCatalogStore = () => { if (typeof window !== "undefined") { - localStorage.removeItem("catalog-config-store"); + localStorage.removeItem("services-config-store"); } }; diff --git a/apps/portal/src/features/services/utils/index.ts b/apps/portal/src/features/services/utils/index.ts new file mode 100644 index 00000000..317073c5 --- /dev/null +++ b/apps/portal/src/features/services/utils/index.ts @@ -0,0 +1,2 @@ +export * from "./services.utils"; +export * from "./pricing"; diff --git a/apps/portal/src/features/catalog/utils/pricing.ts b/apps/portal/src/features/services/utils/pricing.ts similarity index 90% rename from apps/portal/src/features/catalog/utils/pricing.ts rename to apps/portal/src/features/services/utils/pricing.ts index ec9e4e57..3cff36bb 100644 --- a/apps/portal/src/features/catalog/utils/pricing.ts +++ b/apps/portal/src/features/services/utils/pricing.ts @@ -7,7 +7,7 @@ import { getCatalogProductPriceDisplay, type CatalogProductBase, type CatalogPriceInfo, -} from "@customer-portal/domain/catalog"; +} from "@customer-portal/domain/services"; // Re-export domain type for compatibility export type PriceInfo = CatalogPriceInfo; diff --git a/apps/portal/src/features/catalog/utils/catalog.utils.ts b/apps/portal/src/features/services/utils/services.utils.ts similarity index 91% rename from apps/portal/src/features/catalog/utils/catalog.utils.ts rename to apps/portal/src/features/services/utils/services.utils.ts index 9033a926..2fe338c9 100644 --- a/apps/portal/src/features/catalog/utils/catalog.utils.ts +++ b/apps/portal/src/features/services/utils/services.utils.ts @@ -1,6 +1,6 @@ /** - * Catalog Utilities - * Helper functions for catalog operations + * Services Utilities + * Helper functions for services operations */ import type { @@ -9,8 +9,8 @@ import type { InternetInstallationCatalogItem, SimCatalogProduct, VpnCatalogProduct, -} from "@customer-portal/domain/catalog"; -import { calculateSavingsPercentage } from "@customer-portal/domain/catalog"; +} from "@customer-portal/domain/services"; +import { calculateSavingsPercentage } from "@customer-portal/domain/services"; type CatalogProduct = | InternetPlanCatalogItem diff --git a/apps/portal/src/features/services/views/InternetConfigure.tsx b/apps/portal/src/features/services/views/InternetConfigure.tsx new file mode 100644 index 00000000..b65c9576 --- /dev/null +++ b/apps/portal/src/features/services/views/InternetConfigure.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { useEffect } from "react"; +import { usePathname, useRouter } from "next/navigation"; +import { logger } from "@/lib/logger"; +import { useInternetConfigure } from "@/features/services/hooks/useInternetConfigure"; +import { useInternetEligibility } from "@/features/services/hooks/useInternetEligibility"; +import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath"; +import { InternetConfigureView as InternetConfigureInnerView } from "@/features/services/components/internet/InternetConfigureView"; +import { Spinner } from "@/components/atoms/Spinner"; + +export function InternetConfigureContainer() { + const router = useRouter(); + const pathname = usePathname(); + const servicesBasePath = useServicesBasePath(); + const eligibilityQuery = useInternetEligibility(); + const vm = useInternetConfigure(); + + // Keep /internet/configure strictly in the post-eligibility path. + useEffect(() => { + if (!pathname.startsWith("/account")) return; + if (!eligibilityQuery.isSuccess) return; + if (eligibilityQuery.data.status === "eligible") return; + router.replace(`${servicesBasePath}/internet`); + }, [ + eligibilityQuery.data?.status, + eligibilityQuery.isSuccess, + pathname, + router, + servicesBasePath, + ]); + + if (pathname.startsWith("/account")) { + if (eligibilityQuery.isLoading) { + return ( +
+
+ +

Checking availability…

+
+
+ ); + } + + if (eligibilityQuery.isSuccess && eligibilityQuery.data.status !== "eligible") { + return ( +
+
+ +

Redirecting…

+
+
+ ); + } + } + + // Debug: log current state + logger.debug("InternetConfigure state", { + plan: vm.plan?.sku, + mode: vm.mode, + installation: vm.selectedInstallation?.sku, + addons: vm.selectedAddonSkus, + }); + + const handleConfirm = () => { + logger.debug("handleConfirm called, current state", { + plan: vm.plan?.sku, + mode: vm.mode, + installation: vm.selectedInstallation?.sku, + selectedInstallationSku: 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, + }); + + // Determine what's missing + 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 checkout with params", { + params: params.toString(), + }); + const orderBasePath = pathname.startsWith("/account") ? "/account/order" : "/order"; + router.push(`${orderBasePath}?${params.toString()}`); + }; + + return ; +} + +export default InternetConfigureContainer; diff --git a/apps/portal/src/features/services/views/InternetEligibilityRequestSubmitted.tsx b/apps/portal/src/features/services/views/InternetEligibilityRequestSubmitted.tsx new file mode 100644 index 00000000..1444a74a --- /dev/null +++ b/apps/portal/src/features/services/views/InternetEligibilityRequestSubmitted.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { useMemo } from "react"; +import { useSearchParams } from "next/navigation"; +import { CheckCircle, Clock } from "lucide-react"; +import { Button } from "@/components/atoms/button"; +import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; +import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink"; +import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath"; +import { useInternetEligibility } from "@/features/services/hooks"; +import { useAuthSession } from "@/features/auth/services/auth.store"; + +export function InternetEligibilityRequestSubmittedView() { + const servicesBasePath = useServicesBasePath(); + const searchParams = useSearchParams(); + const requestIdFromQuery = searchParams?.get("requestId")?.trim() || null; + + const { user } = useAuthSession(); + const eligibilityQuery = useInternetEligibility(); + + const addressLabel = useMemo(() => { + const a = user?.address; + if (!a) return ""; + return [a.address1, a.address2, a.city, a.state, a.postcode, a.country || a.countryCode] + .filter(Boolean) + .map(part => String(part).trim()) + .filter(part => part.length > 0) + .join(", "); + }, [user?.address]); + + const requestId = requestIdFromQuery ?? eligibilityQuery.data?.requestId ?? null; + const status = eligibilityQuery.data?.status; + + const isPending = status === "pending"; + const isEligible = status === "eligible"; + const isIneligible = status === "ineligible"; + + return ( +
+ + +
+
+
+ +
+
+

Availability request submitted

+

+ We'll verify NTT service availability for your address. This typically takes 1-2 + business days. +

+ + {addressLabel && ( +
+
+ Address on file +
+
{addressLabel}
+
+ )} + + {requestId && ( +
+
+ Request ID +
+
{requestId}
+
+ )} + +
+ + +
+
+
+ + {(isPending || isEligible || isIneligible) && ( +
+ {isPending && ( + +
+ + + We'll email you once our team completes the manual serviceability check. + +
+
+ )} + {isEligible && ( + + Your address is eligible. You can now choose a plan and complete your order. + + )} + {isIneligible && ( + + It looks like service isn't available at your address. Please contact support + if you think this is incorrect. + + )} +
+ )} +
+
+ ); +} + +export default InternetEligibilityRequestSubmittedView; diff --git a/apps/portal/src/features/services/views/InternetPlans.tsx b/apps/portal/src/features/services/views/InternetPlans.tsx new file mode 100644 index 00000000..1a729b31 --- /dev/null +++ b/apps/portal/src/features/services/views/InternetPlans.tsx @@ -0,0 +1,753 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Server, CheckCircle, Clock, TriangleAlert, MapPin } from "lucide-react"; +import { useAccountInternetCatalog } from "@/features/services/hooks"; +import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscriptions"; +import type { + InternetPlanCatalogItem, + InternetInstallationCatalogItem, +} from "@customer-portal/domain/services"; +import { Skeleton } from "@/components/atoms/loading-skeleton"; +import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock"; +import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; +import { Button } from "@/components/atoms/button"; +import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink"; +import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath"; +import { InternetImportantNotes } from "@/features/services/components/internet/InternetImportantNotes"; +import { + InternetOfferingCard, + type TierInfo, +} from "@/features/services/components/internet/InternetOfferingCard"; +import { PublicInternetPlansContent } from "@/features/services/views/PublicInternetPlans"; +import { PlanComparisonGuide } from "@/features/services/components/internet/PlanComparisonGuide"; +import { + useInternetEligibility, + useRequestInternetEligibilityCheck, +} from "@/features/services/hooks"; +import { useAuthSession } from "@/features/auth/services/auth.store"; +import { cn } from "@/lib/utils"; + +type AutoRequestStatus = "idle" | "submitting" | "submitted" | "failed" | "missing_address"; + +// Offering configuration for display +interface OfferingConfig { + offeringType: string; + title: string; + speedBadge: string; + description: string; + iconType: "home" | "apartment"; + isPremium: boolean; + displayOrder: number; + isAlternative?: boolean; + alternativeNote?: string; +} + +const OFFERING_CONFIGS: Record> = { + "Home 10G": { + title: "Home 10Gbps", + speedBadge: "10 Gbps", + description: "Ultra-fast fiber with the highest speeds available in Japan.", + iconType: "home", + isPremium: true, + displayOrder: 1, + }, + "Home 1G": { + title: "Home 1Gbps", + speedBadge: "1 Gbps", + description: "High-speed fiber. The most popular choice for home internet.", + iconType: "home", + isPremium: false, + displayOrder: 2, + }, + "Apartment 1G": { + title: "Apartment 1Gbps", + speedBadge: "1 Gbps", + description: "High-speed fiber-to-the-unit for mansions and apartment buildings.", + iconType: "apartment", + isPremium: false, + displayOrder: 1, + }, + "Apartment 100M": { + title: "Apartment 100Mbps", + speedBadge: "100 Mbps", + description: "Standard speed via VDSL or LAN for apartment buildings.", + iconType: "apartment", + isPremium: false, + displayOrder: 2, + }, +}; + +function getTierInfo(plans: InternetPlanCatalogItem[], offeringType: string): TierInfo[] { + const filtered = plans.filter(p => p.internetOfferingType === offeringType); + const tierOrder: ("Silver" | "Gold" | "Platinum")[] = ["Silver", "Gold", "Platinum"]; + + const tierDescriptions: Record< + string, + { description: string; features: string[]; pricingNote?: string } + > = { + Silver: { + description: "Essential setup—bring your own router", + features: ["NTT modem + ISP connection", "IPoE or PPPoE protocols", "Self-configuration"], + }, + Gold: { + description: "All-inclusive with router rental", + features: [ + "Everything in Silver", + "WiFi router included", + "Auto-configured", + "Range extender option", + ], + }, + Platinum: { + description: "Tailored setup for larger homes", + features: [ + "Netgear INSIGHT mesh routers", + "Cloud-managed WiFi", + "Remote support", + "Custom setup", + ], + pricingNote: "+ equipment fees", + }, + }; + + const result: TierInfo[] = []; + for (const tier of tierOrder) { + const plan = filtered.find(p => p.internetPlanTier?.toLowerCase() === tier.toLowerCase()); + if (!plan) continue; + const config = tierDescriptions[tier]; + result.push({ + tier, + planSku: plan.sku, + monthlyPrice: plan.monthlyPrice ?? 0, + description: config.description, + features: config.features, + recommended: tier === "Gold", + pricingNote: config.pricingNote, + }); + } + return result; +} + +function getSetupFee(installations: InternetInstallationCatalogItem[]): number { + const basic = installations.find(i => i.sku?.toLowerCase().includes("basic")); + return basic?.oneTimePrice ?? 22800; +} + +function getAvailableOfferings( + eligibility: string | null, + plans: InternetPlanCatalogItem[] +): OfferingConfig[] { + if (!eligibility) return []; + + const results: OfferingConfig[] = []; + const eligibilityLower = eligibility.toLowerCase(); + + if (eligibilityLower.includes("home 10g")) { + const config10g = OFFERING_CONFIGS["Home 10G"]; + const config1g = OFFERING_CONFIGS["Home 1G"]; + if (config10g && plans.some(p => p.internetOfferingType === "Home 10G")) { + results.push({ offeringType: "Home 10G", ...config10g }); + } + if (config1g && plans.some(p => p.internetOfferingType === "Home 1G")) { + results.push({ + offeringType: "Home 1G", + ...config1g, + isAlternative: true, + alternativeNote: "Lower monthly cost option", + }); + } + } else if (eligibilityLower.includes("home 1g")) { + const config = OFFERING_CONFIGS["Home 1G"]; + if (config && plans.some(p => p.internetOfferingType === "Home 1G")) { + results.push({ offeringType: "Home 1G", ...config }); + } + } else if (eligibilityLower.includes("apartment 1g")) { + const config = OFFERING_CONFIGS["Apartment 1G"]; + if (config && plans.some(p => p.internetOfferingType === "Apartment 1G")) { + results.push({ offeringType: "Apartment 1G", ...config }); + } + } else if (eligibilityLower.includes("apartment 100m")) { + const config = OFFERING_CONFIGS["Apartment 100M"]; + if (config && plans.some(p => p.internetOfferingType === "Apartment 100M")) { + results.push({ offeringType: "Apartment 100M", ...config }); + } + } + + return results.sort((a, b) => a.displayOrder - b.displayOrder); +} + +function formatEligibilityDisplay(eligibility: string): { + residenceType: "home" | "apartment"; + speed: string; + label: string; + description: string; +} { + const lower = eligibility.toLowerCase(); + + if (lower.includes("home 10g")) { + return { + residenceType: "home", + speed: "10 Gbps", + label: "Standalone House (10Gbps available)", + description: + "Your address supports our fastest 10Gbps service. You can also choose 1Gbps for lower monthly cost.", + }; + } + if (lower.includes("home 1g")) { + return { + residenceType: "home", + speed: "1 Gbps", + label: "Standalone House (1Gbps)", + description: "Your address supports high-speed 1Gbps fiber connection.", + }; + } + if (lower.includes("apartment 1g")) { + return { + residenceType: "apartment", + speed: "1 Gbps", + label: "Apartment/Mansion (1Gbps FTTH)", + description: "Your building has fiber-to-the-unit infrastructure supporting 1Gbps speeds.", + }; + } + if (lower.includes("apartment 100m")) { + return { + residenceType: "apartment", + speed: "100 Mbps", + label: "Apartment/Mansion (100Mbps)", + description: "Your building uses VDSL or LAN infrastructure with up to 100Mbps speeds.", + }; + } + + return { + residenceType: "home", + speed: eligibility, + label: eligibility, + description: "Service is available at your address.", + }; +} + +// Status badge component +function EligibilityStatusBadge({ + status, + speed, +}: { + status: "eligible" | "pending" | "not_requested" | "ineligible"; + speed?: string; +}) { + const configs = { + eligible: { + icon: CheckCircle, + bg: "bg-success-soft", + border: "border-success/30", + text: "text-success", + label: "Service Available", + }, + pending: { + icon: Clock, + bg: "bg-info-soft", + border: "border-info/30", + text: "text-info", + label: "Review in Progress", + }, + not_requested: { + icon: MapPin, + bg: "bg-muted", + border: "border-border", + text: "text-muted-foreground", + label: "Verification Required", + }, + ineligible: { + icon: TriangleAlert, + bg: "bg-warning/10", + border: "border-warning/30", + text: "text-warning", + label: "Not Available", + }, + }; + + const config = configs[status]; + const Icon = config.icon; + + return ( +
+ + {config.label} + {status === "eligible" && speed && ( + <> + · + Up to {speed} + + )} +
+ ); +} + +export function InternetPlansContainer() { + const router = useRouter(); + const servicesBasePath = useServicesBasePath(); + const searchParams = useSearchParams(); + const { user } = useAuthSession(); + const { data, isLoading, error } = useAccountInternetCatalog(); + const eligibilityQuery = useInternetEligibility(); + const eligibilityLoading = eligibilityQuery.isLoading; + const refetchEligibility = eligibilityQuery.refetch; + const eligibilityRequest = useRequestInternetEligibilityCheck(); + const submitEligibilityRequest = eligibilityRequest.mutateAsync; + const plans: InternetPlanCatalogItem[] = useMemo(() => data?.plans ?? [], [data?.plans]); + const installations: InternetInstallationCatalogItem[] = useMemo( + () => data?.installations ?? [], + [data?.installations] + ); + const { data: activeSubs } = useActiveSubscriptions(); + const hasActiveInternet = useMemo( + () => + Array.isArray(activeSubs) + ? activeSubs.some( + s => + String(s.productName || "") + .toLowerCase() + .includes("sonixnet via ntt optical fiber") && + String(s.status || "").toLowerCase() === "active" + ) + : false, + [activeSubs] + ); + + const eligibilityValue = eligibilityQuery.data?.eligibility; + const eligibilityStatus = eligibilityQuery.data?.status; + const requestedAt = eligibilityQuery.data?.requestedAt; + const rejectionNotes = eligibilityQuery.data?.notes; + + const isEligible = + eligibilityStatus === "eligible" && + typeof eligibilityValue === "string" && + eligibilityValue.trim().length > 0; + const isPending = eligibilityStatus === "pending"; + const isNotRequested = eligibilityStatus === "not_requested"; + const isIneligible = eligibilityStatus === "ineligible"; + + const hasServiceAddress = Boolean( + user?.address?.address1 && + user?.address?.city && + user?.address?.postcode && + (user?.address?.country || user?.address?.countryCode) + ); + const autoEligibilityRequest = searchParams?.get("autoEligibilityRequest") === "1"; + const autoPlanSku = searchParams?.get("planSku"); + const [autoRequestStatus, setAutoRequestStatus] = useState("idle"); + const [autoRequestId, setAutoRequestId] = useState(null); + const addressLabel = useMemo(() => { + const a = user?.address; + if (!a) return ""; + return [a.address1, a.address2, a.city, a.state, a.postcode, a.country || a.countryCode] + .filter(Boolean) + .map(part => String(part).trim()) + .filter(part => part.length > 0) + .join(", "); + }, [user?.address]); + + const eligibility = useMemo(() => { + if (!isEligible) return null; + return eligibilityValue?.trim() ?? null; + }, [eligibilityValue, isEligible]); + + const setupFee = useMemo(() => getSetupFee(installations), [installations]); + + const availableOfferings = useMemo(() => { + if (!eligibility) return []; + return getAvailableOfferings(eligibility, plans); + }, [eligibility, plans]); + + const eligibilityDisplay = useMemo(() => { + if (!eligibility) return null; + return formatEligibilityDisplay(eligibility); + }, [eligibility]); + + const offeringCards = useMemo(() => { + return availableOfferings + .map(config => { + const tiers = getTierInfo(plans, config.offeringType); + const startingPrice = tiers.length > 0 ? Math.min(...tiers.map(t => t.monthlyPrice)) : 0; + return { + ...config, + tiers, + startingPrice, + setupFee, + ctaPath: `${servicesBasePath}/internet/configure`, + }; + }) + .filter(card => card.tiers.length > 0); + }, [availableOfferings, plans, setupFee, servicesBasePath]); + + // Logic to handle check availability click + const handleCheckAvailability = async (e?: React.MouseEvent) => { + if (e) e.preventDefault(); + if (!hasServiceAddress) { + // Should redirect to address page if not handled by parent UI + router.push("/account/settings"); + return; + } + + // Trigger eligibility check + const confirmed = + typeof window === "undefined" || + window.confirm(`Request availability check for:\n\n${addressLabel}`); + if (!confirmed) return; + + setAutoRequestId(null); + setAutoRequestStatus("submitting"); + try { + const result = await submitEligibilityRequest({ address: user?.address ?? undefined }); + setAutoRequestId(result.requestId ?? null); + setAutoRequestStatus("submitted"); + await refetchEligibility(); + const query = result.requestId ? `?requestId=${encodeURIComponent(result.requestId)}` : ""; + router.push(`${servicesBasePath}/internet/request-submitted${query}`); + } catch { + setAutoRequestStatus("failed"); + } + }; + + // Auto eligibility request effect + useEffect(() => { + if (!autoEligibilityRequest) return; + if (autoRequestStatus !== "idle") return; + if (eligibilityLoading) return; + if (!isNotRequested) { + router.replace(`${servicesBasePath}/internet`); + return; + } + if (!hasServiceAddress) { + setAutoRequestStatus("missing_address"); + router.replace(`${servicesBasePath}/internet`); + return; + } + + const submit = async () => { + setAutoRequestStatus("submitting"); + try { + const notes = autoPlanSku + ? `Requested after signup. Selected plan SKU: ${autoPlanSku}` + : "Requested after signup."; + const result = await submitEligibilityRequest({ + address: user?.address ?? undefined, + notes, + }); + setAutoRequestId(result.requestId ?? null); + setAutoRequestStatus("submitted"); + await refetchEligibility(); + const query = result.requestId ? `?requestId=${encodeURIComponent(result.requestId)}` : ""; + router.replace(`${servicesBasePath}/internet/request-submitted${query}`); + return; + } catch { + setAutoRequestStatus("failed"); + } + router.replace(`${servicesBasePath}/internet`); + }; + + void submit(); + }, [ + autoEligibilityRequest, + autoPlanSku, + autoRequestStatus, + eligibilityLoading, + refetchEligibility, + submitEligibilityRequest, + hasServiceAddress, + isNotRequested, + servicesBasePath, + user?.address, + router, + ]); + + // Loading state + if (isLoading || error) { + return ( +
+ +
+ +
+ + +
+
+ {[1, 2].map(i => ( +
+
+ +
+ + + +
+
+
+ ))} +
+
+
+
+ ); + } + + // Determine current status for the badge + const currentStatus = isEligible + ? "eligible" + : isPending + ? "pending" + : isIneligible + ? "ineligible" + : "not_requested"; + + // Case 1: Unverified / Not Requested - Show Public Content exactly + if (isNotRequested && autoRequestStatus !== "submitting" && autoRequestStatus !== "submitted") { + return ( +
+ {/* Already has internet warning */} + {hasActiveInternet && ( + + You already have an internet subscription. For additional residences, please{" "} + + contact support + + . + + )} + + {/* Auto-request status alerts - only show for errors/success */} + {autoRequestStatus === "failed" && ( + + Please try again below or contact support. + + )} + + {autoRequestStatus === "missing_address" && ( + +
+ Add your service address to request availability verification. + +
+
+ )} + + +
+ ); + } + + // Case 2: Standard Portal View (Pending, Eligible, Ineligible, Loading) + return ( +
+ + + {/* Hero section - compact (for portal view) */} +
+

+ Your Internet Options +

+

+ Plans tailored to your residence and available infrastructure +

+ + {/* Status badge */} + {!eligibilityLoading && autoRequestStatus !== "submitting" && ( + + )} + + {/* Loading states */} + {(eligibilityLoading || autoRequestStatus === "submitting") && ( +
+
+ + {autoRequestStatus === "submitting" ? "Submitting request..." : "Checking status..."} + +
+ )} +
+ + {/* Already has internet warning */} + {hasActiveInternet && ( + + You already have an internet subscription. For additional residences, please{" "} + + contact support + + . + + )} + + {/* Auto-request status alerts - only show for errors/success */} + {autoRequestStatus === "submitted" && ( + + We'll verify your address and notify you when complete. + {autoRequestId && ( + ID: {autoRequestId} + )} + + )} + + {autoRequestStatus === "failed" && ( + + Please try again below or contact support. + + )} + + {autoRequestStatus === "missing_address" && ( + +
+ Add your service address to request availability verification. + +
+
+ )} + + {/* ELIGIBLE STATE - Clean & Personalized */} + {isEligible && eligibilityDisplay && offeringCards.length > 0 && ( + <> + {/* Plan comparison guide */} +
+ +
+ + {/* Speed options header (only if multiple) */} + {offeringCards.length > 1 && ( +
+

Choose your speed

+

+ Your address supports multiple options +

+
+ )} + + {/* Offering cards */} +
+ {offeringCards.map(card => ( +
+ {card.isAlternative && ( +
+
+ + Alternative option + +
+
+ )} + +
+ ))} +
+ + {/* Important notes - collapsed by default */} + + + + + )} + + {/* PENDING STATE - Clean Status View */} + {isPending && ( + <> +
+ +

+ Verification in Progress +

+

+ We're currently verifying NTT service availability at your registered address. +
+ This manual check ensures we offer you the correct fiber connection type. +

+ +
+ Estimated time + 1-2 business days +
+ + {requestedAt && ( +

+ Request submitted: {new Date(requestedAt).toLocaleDateString()} +

+ )} +
+ +
+ +
+ + )} + + {/* INELIGIBLE STATE */} + {isIneligible && ( +
+ +

Service not available

+

+ {rejectionNotes || + "Our review determined that NTT fiber service isn't available at your address."} +

+ +
+ )} + + {/* No plans available */} + {plans.length === 0 && !isLoading && ( +
+
+ +

No Plans Available

+

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

+ +
+
+ )} +
+ ); +} + +export default InternetPlansContainer; diff --git a/apps/portal/src/features/services/views/PublicInternetConfigure.tsx b/apps/portal/src/features/services/views/PublicInternetConfigure.tsx new file mode 100644 index 00000000..47c70209 --- /dev/null +++ b/apps/portal/src/features/services/views/PublicInternetConfigure.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; +import { WifiIcon, ClockIcon, EnvelopeIcon, CheckCircleIcon } from "@heroicons/react/24/outline"; +import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink"; +import { InlineAuthSection } from "@/features/auth/components/InlineAuthSection/InlineAuthSection"; +import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath"; +import { usePublicInternetPlan } from "@/features/services/hooks"; +import { CardPricing } from "@/features/services/components/base/CardPricing"; +import { Skeleton } from "@/components/atoms/loading-skeleton"; + +/** + * Public Internet Configure View + * + * Clean signup flow - auth form is the focus, "what happens next" is secondary info. + */ +export function PublicInternetConfigureView() { + const servicesBasePath = useServicesBasePath(); + const searchParams = useSearchParams(); + const planSku = searchParams?.get("planSku"); + const { plan, isLoading } = usePublicInternetPlan(planSku || undefined); + + const redirectTo = planSku + ? `/account/services/internet?autoEligibilityRequest=1&planSku=${encodeURIComponent(planSku)}` + : "/account/services/internet?autoEligibilityRequest=1"; + + if (isLoading) { + return ( +
+ +
+ + +
+
+ ); + } + + return ( +
+ + + {/* Header */} +
+
+
+ +
+
+

+ Check Internet Service Availability +

+

+ Create an account to see what's available at your address +

+
+ + {/* Plan Summary Card - only if plan is selected */} + {plan && ( +
+
+
+ +
+
+
+
+

Selected plan

+

{plan.name}

+
+ +
+
+
+
+ )} + + {/* Auth Section - Primary focus */} + + + {/* What happens next - Below auth, secondary info */} +
+

What happens next

+
+
+
+ 1 +
+
+

We verify your address

+

+ + 1-2 business days +

+
+
+
+
+ 2 +
+
+

You get notified

+

+ + Email when ready +

+
+
+
+
+ 3 +
+
+

Complete your order

+

+ + Choose plan & schedule +

+
+
+
+
+
+ ); +} + +export default PublicInternetConfigureView; diff --git a/apps/portal/src/features/services/views/PublicInternetPlans.tsx b/apps/portal/src/features/services/views/PublicInternetPlans.tsx new file mode 100644 index 00000000..9e8423d8 --- /dev/null +++ b/apps/portal/src/features/services/views/PublicInternetPlans.tsx @@ -0,0 +1,480 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { + ArrowRight, + Sparkles, + ChevronDown, + ChevronUp, + Wifi, + Zap, + Languages, + FileText, + Wrench, + Globe, +} from "lucide-react"; +import { usePublicInternetCatalog } from "@/features/services/hooks"; +import type { + InternetPlanCatalogItem, + InternetInstallationCatalogItem, +} from "@customer-portal/domain/services"; +import { Skeleton } from "@/components/atoms/loading-skeleton"; +import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; +import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink"; +import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath"; +import { Button } from "@/components/atoms/button"; +import { PublicOfferingCard } from "@/features/services/components/internet/PublicOfferingCard"; +import type { TierInfo } from "@/features/services/components/internet/PublicOfferingCard"; +import { + ServiceHighlights, + HighlightFeature, +} from "@/features/services/components/base/ServiceHighlights"; + +// Types +interface GroupedOffering { + offeringType: string; + title: string; + speedBadge: string; + description: string; + iconType: "home" | "apartment"; + startingPrice: number; + setupFee: number; + tiers: TierInfo[]; + isPremium?: boolean; + showConnectionInfo?: boolean; +} + +// FAQ data +const faqItems = [ + { + question: "How can I check if 10Gbps service is available at my address?", + answer: + "10Gbps service is currently available in select areas, primarily in Tokyo and surrounding regions. When you check availability with your address, we'll show you exactly which speed options are available at your location.", + }, + { + question: "Why do apartment speeds vary by building?", + answer: + "Apartment buildings have different NTT fiber infrastructure. Newer buildings often have FTTH (fiber-to-the-home) supporting up to 1Gbps, while older buildings may use VDSL or LAN connections at 100Mbps. The good news: all apartment types have the same monthly price.", + }, + { + question: "My home needs multiple WiFi routers for full coverage. Can you help?", + answer: + "Yes! Our Platinum tier includes a mesh WiFi system designed for larger homes. During setup, our team will assess your space and recommend the best equipment configuration for full coverage.", + }, + { + question: "Can I transfer my existing internet service to Assist Solutions?", + answer: + "In most cases, yes. If you already have an NTT line, we can often take over the service without a new installation. Contact us with your current provider details and we'll guide you through the process.", + }, + { + question: "What is the contract period?", + answer: + "Our standard contract is 2 years. Early termination fees may apply if you cancel before the contract ends. The setup fee can be paid upfront or spread across 12 or 24 monthly installments.", + }, + { + question: "How are invoices sent?", + answer: + "E-statements (available only in English) will be sent to your primary email address. The service fee will be charged automatically to your registered credit card on file. For corporate plans, please contact us with your requests.", + }, +]; + +/** + * FAQ Item component with expand/collapse + */ +function FAQItem({ + question, + answer, + isOpen, + onToggle, +}: { + question: string; + answer: string; + isOpen: boolean; + onToggle: () => void; +}) { + return ( +
+ + {isOpen && ( +
+

{answer}

+
+ )} +
+ ); +} + +export interface PublicInternetPlansContentProps { + onCtaClick?: (e: React.MouseEvent) => void; + ctaPath?: string; + ctaLabel?: string; + heroTitle?: string; + heroDescription?: string; +} + +/** + * Public Internet Plans Content - Reusable component + */ +export function PublicInternetPlansContent({ + onCtaClick, + ctaPath: propCtaPath, + ctaLabel = "Check Availability", + heroTitle = "Internet Service Plans", + heroDescription = "NTT Optical Fiber with full English support", +}: PublicInternetPlansContentProps) { + const { data: servicesCatalog, isLoading, error } = usePublicInternetCatalog(); + const servicesBasePath = useServicesBasePath(); + const defaultCtaPath = `${servicesBasePath}/internet/configure`; + const ctaPath = propCtaPath ?? defaultCtaPath; + const [openFaqIndex, setOpenFaqIndex] = useState(null); + + const internetFeatures: HighlightFeature[] = [ + { + icon: , + title: "NTT Optical Fiber", + description: "Japan's most reliable network with speeds up to 10Gbps", + highlight: "99.9% uptime", + }, + { + icon: , + title: "IPv6/IPoE Ready", + description: "Next-gen protocol for congestion-free browsing", + highlight: "No peak-hour slowdowns", + }, + { + icon: , + title: "Full English Support", + description: "Native English service for setup, billing & technical help", + highlight: "No language barriers", + }, + { + icon: , + title: "One Bill, One Provider", + description: "NTT line + ISP + equipment bundled with simple billing", + highlight: "No hidden fees", + }, + { + icon: , + title: "On-site Support", + description: "Technicians can visit for installation & troubleshooting", + highlight: "Professional setup", + }, + { + icon: , + title: "Flexible Options", + description: "Multiple ISP configs available, IPv4/PPPoE if needed", + highlight: "Customizable", + }, + ]; + + // Group services items by offering type + const groupedOfferings = useMemo(() => { + if (!servicesCatalog?.plans) return []; + + const plansByType = servicesCatalog.plans.reduce( + (acc, plan) => { + const key = plan.internetOfferingType ?? "unknown"; + if (!acc[key]) acc[key] = []; + acc[key].push(plan); + return acc; + }, + {} as Record + ); + + // Get installation item for setup fee + const installationItem = servicesCatalog.installations?.[0] as + | InternetInstallationCatalogItem + | undefined; + const setupFee = installationItem?.oneTimePrice ?? 22800; + + // Create grouped offerings + const offerings: GroupedOffering[] = []; + + // Consolidate apartment types (they all have the same price) + // Connection type (FTTH, VDSL, LAN) depends on building infrastructure + const apartmentTypes = ["Apartment 1G", "Apartment 100M"]; + const apartmentPlans: InternetPlanCatalogItem[] = []; + + for (const type of apartmentTypes) { + if (plansByType[type]) { + apartmentPlans.push(...plansByType[type]); + } + } + + // Define offering metadata + // Order: Home 10G first (premium), then Home 1G, then consolidated Apartment + const offeringMeta: Record< + string, + { + title: string; + description: string; + iconType: "home" | "apartment"; + order: number; + isPremium?: boolean; + } + > = { + "Home 10G": { + title: "Home 10Gbps", + description: "Ultra-fast fiber with the highest speeds available in Japan.", + iconType: "home", + order: 1, + isPremium: true, + }, + "Home 1G": { + title: "Home 1Gbps", + description: "High-speed fiber. The most popular choice for home internet.", + iconType: "home", + order: 2, + }, + Apartment: { + title: "Apartment", + description: + "For mansions and apartment buildings. Speed depends on your building (up to 1Gbps).", + iconType: "apartment", + order: 3, + }, + }; + + // Process Home offerings + for (const [offeringType, plans] of Object.entries(plansByType)) { + // Skip apartment types - we'll handle them separately + if (apartmentTypes.includes(offeringType)) continue; + + const meta = offeringMeta[offeringType]; + if (!meta) continue; + + // Sort plans by tier: Silver, Gold, Platinum + const tierOrder: Record = { Silver: 0, Gold: 1, Platinum: 2 }; + const sortedPlans = [...plans].sort( + (a, b) => + (tierOrder[a.internetPlanTier ?? ""] ?? 99) - (tierOrder[b.internetPlanTier ?? ""] ?? 99) + ); + + // Calculate starting price + const startingPrice = Math.min(...sortedPlans.map(p => p.monthlyPrice ?? 0)); + + // Get speed from offering type + const speedBadge = getSpeedBadge(offeringType); + + // Build tier info (no recommended badge in public view) + const tiers: TierInfo[] = sortedPlans.map(plan => ({ + tier: (plan.internetPlanTier ?? "Silver") as TierInfo["tier"], + monthlyPrice: plan.monthlyPrice ?? 0, + description: getTierDescription(plan.internetPlanTier ?? ""), + features: plan.catalogMetadata?.features ?? getTierFeatures(plan.internetPlanTier ?? ""), + pricingNote: plan.internetPlanTier === "Platinum" ? "+ equipment fees" : undefined, + })); + + offerings.push({ + offeringType, + title: meta.title, + speedBadge, + description: meta.description, + iconType: meta.iconType, + startingPrice, + setupFee, + tiers, + isPremium: meta.isPremium, + }); + } + + // Add consolidated Apartment offering (use any apartment plan for tiers - prices are the same) + if (apartmentPlans.length > 0) { + const meta = offeringMeta["Apartment"]; + + // Get unique tiers from apartment plans (they all have same prices) + const tierOrder: Record = { Silver: 0, Gold: 1, Platinum: 2 }; + const uniqueTiers = new Map(); + + for (const plan of apartmentPlans) { + const tier = plan.internetPlanTier ?? "Silver"; + // Keep first occurrence of each tier (prices are same across apartment types) + if (!uniqueTiers.has(tier)) { + uniqueTiers.set(tier, plan); + } + } + + const sortedTierPlans = Array.from(uniqueTiers.values()).sort( + (a, b) => + (tierOrder[a.internetPlanTier ?? ""] ?? 99) - (tierOrder[b.internetPlanTier ?? ""] ?? 99) + ); + + const startingPrice = Math.min(...sortedTierPlans.map(p => p.monthlyPrice ?? 0)); + + const tiers: TierInfo[] = sortedTierPlans.map(plan => ({ + tier: (plan.internetPlanTier ?? "Silver") as TierInfo["tier"], + monthlyPrice: plan.monthlyPrice ?? 0, + description: getTierDescription(plan.internetPlanTier ?? ""), + features: plan.catalogMetadata?.features ?? getTierFeatures(plan.internetPlanTier ?? ""), + pricingNote: plan.internetPlanTier === "Platinum" ? "+ equipment fees" : undefined, + })); + + offerings.push({ + offeringType: "Apartment", + title: meta.title, + speedBadge: "Up to 1Gbps", + description: meta.description, + iconType: meta.iconType, + startingPrice, + setupFee, + tiers, + showConnectionInfo: true, // Show the info tooltip for Apartment + }); + } + + // Sort by order + return offerings.sort((a, b) => { + const orderA = offeringMeta[a.offeringType]?.order ?? 99; + const orderB = offeringMeta[b.offeringType]?.order ?? 99; + return orderA - orderB; + }); + }, [servicesCatalog]); + + // Error state + if (error) { + return ( +
+ + + We couldn't load internet plans. Please try again later. + +
+ ); + } + + return ( +
+ {/* Back link */} + + + {/* Hero - Clean and impactful */} +
+

+ {heroTitle} +

+

{heroDescription}

+
+ + {/* Service Highlights */} + + + {/* Connection types - no extra header text */} +
+ {isLoading ? ( +
+ {[1, 2, 3].map(i => ( + + ))} +
+ ) : ( +
+ {groupedOfferings.map((offering, index) => ( + + ))} +
+ )} +
+ + {/* Final CTA - Polished */} +
+
+ + Get started in minutes +
+

Ready to get connected?

+

+ Enter your address to see what's available at your location +

+ {onCtaClick ? ( + + ) : ( + + )} +
+ + {/* FAQ Section */} +
+

Frequently Asked Questions

+
+ {faqItems.map((item, index) => ( + setOpenFaqIndex(openFaqIndex === index ? null : index)} + /> + ))} +
+
+
+ ); +} + +/** + * Public Internet Plans page - Marketing/Conversion focused + * Clean, polished design optimized for conversion + */ +export function PublicInternetPlansView() { + return ; +} + +// Helper functions +function getSpeedBadge(offeringType: string): string { + const speeds: Record = { + "Apartment 100M": "100Mbps", + "Apartment 1G": "1Gbps", + "Home 1G": "1Gbps", + "Home 10G": "10Gbps", + }; + return speeds[offeringType] ?? "1Gbps"; +} + +function getTierDescription(tier: string): string { + const descriptions: Record = { + Silver: "Use your own router. Best for tech-savvy users.", + Gold: "Includes WiFi router rental. Our most popular choice.", + Platinum: "Premium equipment with mesh WiFi for larger homes.", + }; + return descriptions[tier] ?? ""; +} + +function getTierFeatures(tier: string): string[] { + const features: Record = { + Silver: ["NTT modem + ISP connection", "Use your own router", "Email/ticket support"], + Gold: ["NTT modem + ISP connection", "WiFi router included", "Priority phone support"], + Platinum: ["NTT modem + ISP connection", "Mesh WiFi system included", "Dedicated support line"], + }; + return features[tier] ?? []; +} diff --git a/apps/portal/src/features/services/views/PublicSimConfigure.tsx b/apps/portal/src/features/services/views/PublicSimConfigure.tsx new file mode 100644 index 00000000..681f6090 --- /dev/null +++ b/apps/portal/src/features/services/views/PublicSimConfigure.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; +import { DevicePhoneMobileIcon, CheckIcon, BoltIcon } from "@heroicons/react/24/outline"; +import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; +import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink"; +import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath"; +import { usePublicSimPlan } from "@/features/services/hooks"; +import { InlineAuthSection } from "@/features/auth/components/InlineAuthSection/InlineAuthSection"; +import { CardPricing } from "@/features/services/components/base/CardPricing"; +import { Skeleton } from "@/components/atoms/loading-skeleton"; + +/** + * Public SIM Configure View + * + * Shows selected plan information and prompts for authentication. + * Simplified design focused on quick signup-to-order flow. + */ +export function PublicSimConfigureView() { + const servicesBasePath = useServicesBasePath(); + const searchParams = useSearchParams(); + const planSku = searchParams?.get("planSku"); + const { plan, isLoading } = usePublicSimPlan(planSku || undefined); + + const redirectTarget = planSku + ? `/account/services/sim/configure?planSku=${encodeURIComponent(planSku)}` + : "/account/services/sim"; + + if (isLoading) { + return ( +
+ +
+ + +
+
+ ); + } + + if (!plan) { + return ( +
+ + + The selected plan could not be found. Please go back and select a plan. + +
+ ); + } + + return ( +
+ + + {/* Header */} +
+
+
+ +
+
+

Order Your SIM

+

+ Create an account to complete your order. Physical SIMs ship next business day. +

+
+ + {/* Plan Summary Card */} +
+
+ Selected Plan +
+
+
+
+ +
+
+
+
+
+

{plan.name}

+ {plan.description && ( +

{plan.description}

+ )} +
+ {plan.simPlanType && ( + + {plan.simPlanType} + + )} + {plan.simDataSize && ( + + {plan.simDataSize} + + )} +
+
+
+ +
+
+
+
+ + {/* Plan Details */} + {(plan.simDataSize || plan.simHasFamilyDiscount || plan.billingCycle) && ( +
+
    + {plan.simDataSize && ( +
  • + + + {plan.simDataSize} data + +
  • + )} + {plan.simPlanType && ( +
  • + + {plan.simPlanType} +
  • + )} + {plan.simHasFamilyDiscount && ( +
  • + + Family discount +
  • + )} + {plan.billingCycle && ( +
  • + + {plan.billingCycle} billing +
  • + )} +
+
+ )} +
+ + {/* Order process info */} +
+
+ +
+

How ordering works

+

+ After signup, add a payment method and upload your residence card for verification. + We'll review your application within 1-2 business days and notify you once approved. +

+
+
+
+ + {/* Auth Section */} + +
+ ); +} + +export default PublicSimConfigureView; diff --git a/apps/portal/src/features/services/views/PublicSimPlans.tsx b/apps/portal/src/features/services/views/PublicSimPlans.tsx new file mode 100644 index 00000000..2b30f103 --- /dev/null +++ b/apps/portal/src/features/services/views/PublicSimPlans.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import type { SimCatalogProduct } from "@customer-portal/domain/services"; +import { usePublicSimCatalog } from "@/features/services/hooks"; +import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath"; +import { + SimPlansContent, + type SimPlansTab, +} from "@/features/services/components/sim/SimPlansContent"; + +/** + * Public SIM Plans View + * + * Displays SIM plans for unauthenticated users. + * Uses the shared plans UI, with a public navigation handler. + */ +export function PublicSimPlansView() { + const router = useRouter(); + const servicesBasePath = useServicesBasePath(); + const { data, isLoading, error } = usePublicSimCatalog(); + const plans: SimCatalogProduct[] = useMemo(() => data?.plans ?? [], [data?.plans]); + const [activeTab, setActiveTab] = useState("data-voice"); + + const handleSelectPlan = (planSku: string) => { + router.push(`${servicesBasePath}/sim/configure?planSku=${encodeURIComponent(planSku)}`); + }; + + return ( + + ); +} + +export default PublicSimPlansView; diff --git a/apps/portal/src/features/services/views/PublicVpnPlans.tsx b/apps/portal/src/features/services/views/PublicVpnPlans.tsx new file mode 100644 index 00000000..ba86dc7c --- /dev/null +++ b/apps/portal/src/features/services/views/PublicVpnPlans.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { ShieldCheck, Zap } from "lucide-react"; +import { usePublicVpnCatalog } from "@/features/services/hooks"; +import { LoadingCard } from "@/components/atoms"; +import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock"; +import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; +import { VpnPlanCard } from "@/features/services/components/vpn/VpnPlanCard"; +import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink"; +import { ServicesHero } from "@/features/services/components/base/ServicesHero"; +import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath"; + +/** + * Public VPN Plans View + * + * Displays VPN plans for unauthenticated users. + */ +export function PublicVpnPlansView() { + const servicesBasePath = useServicesBasePath(); + const { data, isLoading, error } = usePublicVpnCatalog(); + const vpnPlans = data?.plans || []; + const activationFees = data?.activationFees || []; + + if (isLoading || error) { + return ( +
+ + + +
+ {Array.from({ length: 4 }).map((_, index) => ( + + ))} +
+
+
+ ); + } + + return ( +
+ + + + {/* Order info banner */} +
+
+ +

+ Order today + + {" "} + — create account, add payment, and your router ships upon confirmation. + +

+
+
+
+ + {vpnPlans.length > 0 ? ( +
+

Choose Your Region

+

+ Select one region per router rental +

+ +
+ {vpnPlans.map(plan => ( + + ))} +
+ + {activationFees.length > 0 && ( + + A one-time activation fee of ¥3,000 applies per router rental. 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 need to do is plug the VPN router into your existing internet connection. +

+

+ Connect your network media players to the VPN Wi-Fi network to access content from the + selected region. For regular internet usage, use your normal home Wi-Fi. +

+
+
+ + +

+ Content subscriptions are NOT included in the VPN package. Our VPN service establishes a + network connection that virtually locates you in the designated server location. Not all + services can be unblocked. We do not guarantee access to any specific website or streaming + service quality. +

+
+
+ ); +} + +export default PublicVpnPlansView; diff --git a/apps/portal/src/features/catalog/views/SimConfigure.tsx b/apps/portal/src/features/services/views/SimConfigure.tsx similarity index 53% rename from apps/portal/src/features/catalog/views/SimConfigure.tsx rename to apps/portal/src/features/services/views/SimConfigure.tsx index 1e1f8ce0..e0420aa8 100644 --- a/apps/portal/src/features/catalog/views/SimConfigure.tsx +++ b/apps/portal/src/features/services/views/SimConfigure.tsx @@ -1,13 +1,14 @@ "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"; +import { usePathname, useSearchParams, useRouter } from "next/navigation"; +import { useSimConfigure } from "@/features/services/hooks/useSimConfigure"; +import { SimConfigureView as SimConfigureInnerView } from "@/features/services/components/sim/SimConfigureView"; export function SimConfigureContainer() { const searchParams = useSearchParams(); const router = useRouter(); - const planId = searchParams.get("plan") || undefined; + const pathname = usePathname(); + const planId = searchParams.get("planSku") || undefined; const vm = useSimConfigure(planId); @@ -15,7 +16,8 @@ export function SimConfigureContainer() { if (!vm.plan || !vm.validate()) return; const params = vm.buildCheckoutSearchParams(); if (!params) return; - router.push(`/checkout?${params.toString()}`); + const orderBasePath = pathname.startsWith("/account") ? "/account/order" : "/order"; + router.push(`${orderBasePath}?${params.toString()}`); }; return ; diff --git a/apps/portal/src/features/services/views/SimPlans.tsx b/apps/portal/src/features/services/views/SimPlans.tsx new file mode 100644 index 00000000..0881df75 --- /dev/null +++ b/apps/portal/src/features/services/views/SimPlans.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import type { SimCatalogProduct } from "@customer-portal/domain/services"; +import { useAccountSimCatalog } from "@/features/services/hooks"; +import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath"; +import { + SimPlansContent, + type SimPlansTab, +} from "@/features/services/components/sim/SimPlansContent"; + +/** + * Account SIM Plans Container + * + * Fetches account context (payment methods + personalised catalog) and + * renders the shared SIM plans UI. + */ +export function SimPlansContainer() { + const router = useRouter(); + const servicesBasePath = useServicesBasePath(); + const { data, isLoading, error } = useAccountSimCatalog(); + const plans: SimCatalogProduct[] = useMemo(() => data?.plans ?? [], [data?.plans]); + const [activeTab, setActiveTab] = useState("data-voice"); + + const handleSelectPlan = (planSku: string) => { + router.push(`${servicesBasePath}/sim/configure?planSku=${encodeURIComponent(planSku)}`); + }; + + return ( + + ); +} + +export default SimPlansContainer; diff --git a/apps/portal/src/features/catalog/views/VpnPlans.tsx b/apps/portal/src/features/services/views/VpnPlans.tsx similarity index 86% rename from apps/portal/src/features/catalog/views/VpnPlans.tsx rename to apps/portal/src/features/services/views/VpnPlans.tsx index de887e50..7cebfcdd 100644 --- a/apps/portal/src/features/catalog/views/VpnPlans.tsx +++ b/apps/portal/src/features/services/views/VpnPlans.tsx @@ -2,16 +2,18 @@ import { PageLayout } from "@/components/templates/PageLayout"; import { ShieldCheckIcon } from "@heroicons/react/24/outline"; -import { useVpnCatalog } from "@/features/catalog/hooks"; +import { useAccountVpnCatalog } from "@/features/services/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"; +import { VpnPlanCard } from "@/features/services/components/vpn/VpnPlanCard"; +import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink"; +import { ServicesHero } from "@/features/services/components/base/ServicesHero"; +import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath"; export function VpnPlansView() { - const { data, isLoading, error } = useVpnCatalog(); + const servicesBasePath = useServicesBasePath(); + const { data, isLoading, error } = useAccountVpnCatalog(); const vpnPlans = data?.plans || []; const activationFees = data?.activationFees || []; @@ -24,7 +26,7 @@ export function VpnPlansView() { icon={} >
- + } >
- + - @@ -88,8 +90,8 @@ export function VpnPlansView() {

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

- { setActiveInfo("topup"); try { - router.push(`/subscriptions/${subscriptionId}/sim/top-up`); + router.push(`/account/subscriptions/${subscriptionId}/sim/top-up`); } catch { setShowTopUpModal(true); } @@ -177,7 +177,7 @@ export function SimActions({ onClick={() => { setActiveInfo("changePlan"); try { - router.push(`/subscriptions/${subscriptionId}/sim/change-plan`); + router.push(`/account/subscriptions/${subscriptionId}/sim/change-plan`); } catch { setShowChangePlanModal(true); } @@ -236,7 +236,7 @@ export function SimActions({ onClick={() => { setActiveInfo("cancel"); try { - router.push(`/subscriptions/${subscriptionId}/sim/cancel`); + router.push(`/account/subscriptions/${subscriptionId}/sim/cancel`); } catch { // Fallback to inline confirmation modal if navigation is unavailable setShowCancelConfirm(true); diff --git a/apps/portal/src/features/sim-management/components/SimManagementSection.tsx b/apps/portal/src/features/sim-management/components/SimManagementSection.tsx index ae748958..ebb0b65f 100644 --- a/apps/portal/src/features/sim-management/components/SimManagementSection.tsx +++ b/apps/portal/src/features/sim-management/components/SimManagementSection.tsx @@ -56,13 +56,14 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro const [error, setError] = useState(null); // Navigation handlers - const navigateToTopUp = () => router.push(`/subscriptions/${subscriptionId}/sim/top-up`); + const navigateToTopUp = () => router.push(`/account/subscriptions/${subscriptionId}/sim/top-up`); const navigateToChangePlan = () => - router.push(`/subscriptions/${subscriptionId}/sim/change-plan`); - const navigateToReissue = () => router.push(`/subscriptions/${subscriptionId}/sim/reissue`); - const navigateToCancel = () => router.push(`/subscriptions/${subscriptionId}/sim/cancel`); + router.push(`/account/subscriptions/${subscriptionId}/sim/change-plan`); + const navigateToReissue = () => + router.push(`/account/subscriptions/${subscriptionId}/sim/reissue`); + const navigateToCancel = () => router.push(`/account/subscriptions/${subscriptionId}/sim/cancel`); const navigateToCallHistory = () => - router.push(`/subscriptions/${subscriptionId}/sim/call-history`); + router.push(`/account/subscriptions/${subscriptionId}/sim/call-history`); // Fetch subscription data const { data: subscription } = useSubscription(subscriptionId); diff --git a/apps/portal/src/features/subscriptions/components/SubscriptionTable/SubscriptionTable.tsx b/apps/portal/src/features/subscriptions/components/SubscriptionTable/SubscriptionTable.tsx index ba0fe934..059f6ff9 100644 --- a/apps/portal/src/features/subscriptions/components/SubscriptionTable/SubscriptionTable.tsx +++ b/apps/portal/src/features/subscriptions/components/SubscriptionTable/SubscriptionTable.tsx @@ -101,7 +101,7 @@ export function SubscriptionTable({ if (onSubscriptionClick) { onSubscriptionClick(subscription); } else { - router.push(`/subscriptions/${subscription.id}`); + router.push(`/account/subscriptions/${subscription.id}`); } }, [onSubscriptionClick, router] diff --git a/apps/portal/src/features/subscriptions/containers/SimCancel.tsx b/apps/portal/src/features/subscriptions/containers/SimCancel.tsx index f96ba73f..357cfd7f 100644 --- a/apps/portal/src/features/subscriptions/containers/SimCancel.tsx +++ b/apps/portal/src/features/subscriptions/containers/SimCancel.tsx @@ -108,7 +108,10 @@ export function SimCancelContainer() { try { await simActionsService.cancel(subscriptionId, { scheduledAt: runDate }); setMessage("Cancellation request submitted. You will receive a confirmation email."); - setTimeout(() => router.push(`/subscriptions/${subscriptionId}#sim-management`), 1500); + setTimeout( + () => router.push(`/account/subscriptions/${subscriptionId}#sim-management`), + 1500 + ); } catch (e: unknown) { setError(e instanceof Error ? e.message : "Failed to submit cancellation"); } finally { @@ -120,7 +123,7 @@ export function SimCancelContainer() {
← Back to SIM Management diff --git a/apps/portal/src/features/subscriptions/services/internet-actions.service.ts b/apps/portal/src/features/subscriptions/services/internet-actions.service.ts new file mode 100644 index 00000000..324e6a90 --- /dev/null +++ b/apps/portal/src/features/subscriptions/services/internet-actions.service.ts @@ -0,0 +1,52 @@ +import { apiClient } from "@/lib/api"; +import type { + InternetCancellationPreview, + InternetCancelRequest, +} from "@customer-portal/domain/subscriptions"; + +export interface InternetCancellationMonth { + value: string; + label: string; +} + +export interface InternetCancellationPreviewResponse { + productName: string; + billingAmount: number; + nextDueDate?: string; + registrationDate?: string; + availableMonths: InternetCancellationMonth[]; + customerEmail: string; + customerName: string; +} + +export const internetActionsService = { + /** + * Get cancellation preview (available months, service details) + */ + async getCancellationPreview( + subscriptionId: string + ): Promise { + const response = await apiClient.GET<{ + success: boolean; + data: InternetCancellationPreview; + }>("/api/subscriptions/{subscriptionId}/internet/cancellation-preview", { + params: { path: { subscriptionId } }, + }); + + if (!response.data?.data) { + throw new Error("Failed to load cancellation information"); + } + + return response.data.data; + }, + + /** + * Submit Internet cancellation request + */ + async submitCancellation(subscriptionId: string, request: InternetCancelRequest): Promise { + await apiClient.POST("/api/subscriptions/{subscriptionId}/internet/cancel", { + params: { path: { subscriptionId } }, + body: request, + }); + }, +}; diff --git a/apps/portal/src/features/subscriptions/views/InternetCancel.tsx b/apps/portal/src/features/subscriptions/views/InternetCancel.tsx new file mode 100644 index 00000000..d478c104 --- /dev/null +++ b/apps/portal/src/features/subscriptions/views/InternetCancel.tsx @@ -0,0 +1,408 @@ +"use client"; + +import Link from "next/link"; +import { useParams, useRouter } from "next/navigation"; +import { useEffect, useState, type ReactNode } from "react"; +import { + internetActionsService, + type InternetCancellationPreviewResponse, +} from "@/features/subscriptions/services/internet-actions.service"; +import { PageLayout } from "@/components/templates/PageLayout"; +import { SubCard } from "@/components/molecules/SubCard/SubCard"; +import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; +import { Button } from "@/components/atoms"; +import { GlobeAltIcon } from "@heroicons/react/24/outline"; + +type Step = 1 | 2 | 3; + +function Notice({ title, children }: { title: string; children: ReactNode }) { + return ( +
+
{title}
+
{children}
+
+ ); +} + +function InfoRow({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +export function InternetCancelContainer() { + const params = useParams(); + const router = useRouter(); + const subscriptionId = params.id as string; + + const [step, setStep] = useState(1); + const [loading, setLoading] = useState(false); + const [preview, setPreview] = useState(null); + const [error, setError] = useState(null); + const [message, setMessage] = useState(null); + const [acceptTerms, setAcceptTerms] = useState(false); + const [confirmMonthEnd, setConfirmMonthEnd] = useState(false); + const [selectedMonth, setSelectedMonth] = useState(""); + const [alternativeEmail, setAlternativeEmail] = useState(""); + const [alternativeEmail2, setAlternativeEmail2] = useState(""); + const [comments, setComments] = useState(""); + const [loadingPreview, setLoadingPreview] = useState(true); + + useEffect(() => { + const fetchPreview = async () => { + try { + const data = await internetActionsService.getCancellationPreview(subscriptionId); + setPreview(data); + } catch (e: unknown) { + setError( + process.env.NODE_ENV === "development" + ? e instanceof Error + ? e.message + : "Failed to load cancellation information" + : "Unable to load cancellation information right now. Please try again." + ); + } finally { + setLoadingPreview(false); + } + }; + void fetchPreview(); + }, [subscriptionId]); + + const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + const emailProvided = alternativeEmail.trim().length > 0 || alternativeEmail2.trim().length > 0; + const emailValid = + !emailProvided || + (emailPattern.test(alternativeEmail.trim()) && emailPattern.test(alternativeEmail2.trim())); + const emailsMatch = !emailProvided || alternativeEmail.trim() === alternativeEmail2.trim(); + const canProceedStep2 = !!preview && !!selectedMonth; + const canProceedStep3 = acceptTerms && confirmMonthEnd && emailValid && emailsMatch; + + const selectedMonthInfo = preview?.availableMonths.find(m => m.value === selectedMonth); + + const formatCurrency = (amount: number) => { + return `¥${amount.toLocaleString()}`; + }; + + const submit = async () => { + setLoading(true); + setError(null); + setMessage(null); + + if (!selectedMonth) { + setError("Please select a cancellation month before submitting."); + setLoading(false); + return; + } + + try { + await internetActionsService.submitCancellation(subscriptionId, { + cancellationMonth: selectedMonth, + confirmRead: acceptTerms, + confirmCancel: confirmMonthEnd, + alternativeEmail: alternativeEmail.trim() || undefined, + comments: comments.trim() || undefined, + }); + setMessage("Cancellation request submitted. You will receive a confirmation email."); + setTimeout(() => router.push(`/account/subscriptions/${subscriptionId}`), 2000); + } catch (e: unknown) { + setError( + process.env.NODE_ENV === "development" + ? e instanceof Error + ? e.message + : "Failed to submit cancellation" + : "Unable to submit your cancellation right now. Please try again." + ); + } finally { + setLoading(false); + } + }; + + const isBlockingError = !loadingPreview && !preview && Boolean(error); + const pageError = isBlockingError ? error : null; + + return ( + } + title="Cancel Internet" + description="Cancel your Internet subscription" + breadcrumbs={[ + { label: "Subscriptions", href: "/account/subscriptions" }, + { + label: preview?.productName || "Internet", + href: `/account/subscriptions/${subscriptionId}`, + }, + { label: "Cancel" }, + ]} + loading={loadingPreview} + error={pageError} + > + {preview ? ( +
+
+ + ← Back to Subscription Details + +
+ {[1, 2, 3].map(s => ( +
+ ))} +
+
Step {step} of 3
+
+ + {error && !isBlockingError ? ( + + {error} + + ) : null} + {message ? ( + + {message} + + ) : null} + + +

Cancel Internet Service

+

+ Cancel your Internet subscription. Please read all the information carefully before + proceeding. +

+ + {step === 1 && ( +
+ {/* Service Info */} +
+ + + +
+ + {/* Month Selection */} +
+ + +

+ Your subscription will be cancelled at the end of the selected month. +

+
+ +
+ +
+
+ )} + + {step === 2 && ( +
+
+ + Online cancellations must be submitted by the 25th of the desired cancellation + month. Once your cancellation request is accepted, a confirmation email will be + sent to your registered email address. Our team will contact you regarding + equipment return (ONU/router) if applicable. + + + + Internet equipment (ONU, router) is typically rental hardware and must be + returned to Assist Solutions or NTT upon cancellation. Our team will provide + instructions for equipment return after processing your request. + + + + You will be billed for service through the end of your cancellation month. Any + outstanding balance or prorated charges will be processed according to your + billing cycle. + +
+ +
+
+ setAcceptTerms(e.target.checked)} + className="h-4 w-4 text-primary border-input rounded mt-0.5 focus:ring-2 focus:ring-ring" + /> + +
+ +
+ setConfirmMonthEnd(e.target.checked)} + disabled={!selectedMonth} + className="h-4 w-4 text-primary border-input rounded mt-0.5 focus:ring-2 focus:ring-ring" + /> + +
+
+ +
+ + +
+
+ )} + + {step === 3 && ( +
+ {/* Confirmation Summary */} +
+
+ Cancellation Summary +
+
+
+ Service: {preview?.productName} +
+
+ Cancellation effective: End of{" "} + {selectedMonthInfo?.label || selectedMonth} +
+
+
+ + {/* Registered Email */} +
+ Your registered email address is:{" "} + {preview?.customerEmail || "—"} +
+
+ You will receive a cancellation confirmation email. If you would like to receive + this email on a different address, please enter the address below. +
+ + {/* Alternative Email */} +
+
+ + setAlternativeEmail(e.target.value)} + placeholder="you@example.com" + /> +
+
+ + setAlternativeEmail2(e.target.value)} + placeholder="you@example.com" + /> +
+
+ + {emailProvided && !emailValid && ( +
+ Please enter a valid email address in both fields. +
+ )} + {emailProvided && emailValid && !emailsMatch && ( +
Email addresses do not match.
+ )} + + {/* Comments */} +
+ +