diff --git a/apps/bff/src/modules/auth/auth.service.ts b/apps/bff/src/modules/auth/auth.service.ts index 81d40275..45129d95 100644 --- a/apps/bff/src/modules/auth/auth.service.ts +++ b/apps/bff/src/modules/auth/auth.service.ts @@ -23,7 +23,9 @@ import { getErrorMessage } from "@bff/core/utils/error.util"; import { Logger } from "nestjs-pino"; import { sanitizeWhmcsRedirectPath } from "@bff/core/utils/sso.util"; import { EmailService } from "@bff/infra/email/email.service"; + import { User as SharedUser, type AuthTokens } from "@customer-portal/domain"; + import type { User as PrismaUser } from "@prisma/client"; import type { Request } from "express"; import { WhmcsClientResponse } from "@bff/integrations/whmcs/types/whmcs-api.types"; @@ -34,6 +36,8 @@ import { calculateExpiryDate } from "./utils/jwt-expiry.util"; export class AuthService { private readonly MAX_LOGIN_ATTEMPTS = 5; private readonly LOCKOUT_DURATION_MINUTES = 15; + private readonly DEFAULT_TOKEN_TYPE = "Bearer"; + private readonly DEFAULT_TOKEN_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; constructor( private usersService: UsersService, @@ -803,6 +807,61 @@ export class AuthService { return sanitizeWhmcsRedirectPath(path); } + private resolveAccessTokenExpiry(accessToken: string): string { + try { + const decoded = this.jwtService.decode(accessToken); + if (decoded && typeof decoded === "object" && typeof decoded.exp === "number") { + return new Date(decoded.exp * 1000).toISOString(); + } + } catch (error) { + this.logger.debug("Failed to decode JWT for expiry", { error: getErrorMessage(error) }); + } + + const configuredExpiry = this.configService.get("JWT_EXPIRES_IN", "7d"); + const fallbackMs = this.parseExpiresInToMs(configuredExpiry); + return new Date(Date.now() + fallbackMs).toISOString(); + } + + private parseExpiresInToMs(expiresIn: string | number | undefined): number { + if (typeof expiresIn === "number" && Number.isFinite(expiresIn)) { + return expiresIn * 1000; + } + + if (!expiresIn) { + return this.DEFAULT_TOKEN_EXPIRY_MS; + } + + const raw = expiresIn.toString().trim(); + if (!raw) { + return this.DEFAULT_TOKEN_EXPIRY_MS; + } + + const unit = raw.slice(-1); + const magnitude = Number(raw.slice(0, -1)); + + if (Number.isFinite(magnitude)) { + switch (unit) { + case "s": + return magnitude * 1000; + case "m": + return magnitude * 60 * 1000; + case "h": + return magnitude * 60 * 60 * 1000; + case "d": + return magnitude * 24 * 60 * 60 * 1000; + default: + break; + } + } + + const numericValue = Number(raw); + if (Number.isFinite(numericValue)) { + return numericValue * 1000; + } + + return this.DEFAULT_TOKEN_EXPIRY_MS; + } + async requestPasswordReset(email: string): Promise { const user = await this.usersService.findByEmailInternal(email); // Always act as if successful to avoid account enumeration diff --git a/apps/portal/ARCHITECTURE.md b/apps/portal/ARCHITECTURE.md index 4b725daf..c2d3f421 100644 --- a/apps/portal/ARCHITECTURE.md +++ b/apps/portal/ARCHITECTURE.md @@ -6,26 +6,31 @@ This document outlines the new feature-driven architecture implemented for the c ``` apps/portal/src/ -├── app/ # Next.js App Router pages -├── components/ # Shared UI components (Design System) -│ ├── ui/ # Base UI components (atoms) -│ ├── layout/ # Layout components (organisms) -│ └── common/ # Shared business components (molecules) -├── features/ # Feature-specific modules -│ ├── auth/ # Authentication feature -│ ├── dashboard/ # Dashboard feature -│ ├── billing/ # Billing feature -│ ├── subscriptions/ # Subscriptions feature -│ ├── catalog/ # Product catalog feature -│ └── support/ # Support feature -├── lib/ # Core utilities and services (replaces core/shared) -│ ├── api/ # API client and base services -│ ├── query.ts # Query client and keys -│ ├── env.ts # Runtime env parsing -│ ├── types/ # Shared TypeScript types -│ └── utils/ # Utility functions (cn, currency, error-display, ...) -├── providers/ # React context providers (e.g., QueryProvider) -└── styles/ # Global styles and design tokens +├── app/ # Next.js App Router entry points (route groups only) +│ ├── (public)/ # Marketing + auth routes, pages import feature views +│ ├── (authenticated)/ # Signed-in portal routes, thin wrappers around features +│ ├── api/ # App Router API routes +│ ├── favicon.ico / globals.css # Global assets +│ └── layout.tsx # Root layout/providers +├── components/ # Shared UI components (design system atoms/molecules) +│ ├── ui/ +│ ├── layout/ +│ └── common/ +├── core/ # App-wide configuration (env, logger, providers) +├── features/ # Feature-specific modules composed by routes +│ ├── account/ +│ ├── auth/ +│ ├── billing/ +│ ├── catalog/ +│ ├── dashboard/ +│ ├── marketing/ +│ ├── orders/ +│ ├── service-management/ +│ ├── subscriptions/ +│ └── support/ +├── shared/ # Cross-feature helpers (e.g., constants, locale data) +├── styles/ # Global styles and design tokens +└── types/ # Portal-specific TypeScript types ``` ## Design Principles @@ -48,7 +53,7 @@ Each feature module contains: - `utils/`: Utility functions ### 4. Centralized Shared Resources -Common utilities, types, and components are centralized in the `lib/` and `components/` directories. +Common utilities, types, and components are centralized in the `core/`, `shared/`, and `components/` directories. ## Feature Module Structure @@ -118,20 +123,21 @@ import { DataTable } from '@/components/common'; import type { User, ApiResponse } from '@/types'; // Utility imports -import { designSystem } from '@/lib/design-system'; -// Prefer feature services/hooks over direct apiClient usage in pages -import { apiClient } from '@/lib/api/client'; +import { QueryProvider } from '@/core/providers'; +// Prefer feature services/hooks over direct api usage in pages +import { logger } from '@/core/config'; ``` ### Path Mappings - `@/*` - Root src directory - `@/components/*` - Component library +- `@/core/*` - App-wide configuration and providers - `@/features/*` - Feature modules -- `@/lib/*` - Core utilities -- `@/types` - Type definitions +- `@/shared/*` - Shared helpers/constants - `@/styles/*` - Style files -- `@shared/*` - Shared package +- `@/types/*` - Portal-specific types +- `@shared/*` - Shared package exports ## Migration Strategy @@ -179,6 +185,14 @@ The migration to this new architecture will be done incrementally: This ensures pages remain declarative and the feature layer encapsulates logic. +### Route Layering + +- `(public)`: marketing landing and authentication flows. These routes render feature views such as `marketing/PublicLandingView` and `auth` screens while remaining server components by default. +- `(authenticated)`: signed-in portal experience. Pages import dashboard, billing, subscriptions, etc. from the feature layer and rely on the shared route-group layout to provide navigation. +- `api/`: App Router API endpoints remain colocated under `src/app/api` and can reuse feature services for data access. + +Only `layout.tsx`, `page.tsx`, and `loading.tsx` files live inside the route groups. All reusable UI, hooks, and services live under `src/features/**` to keep routing concerns thin. + ### Current Feature Hooks/Services - Catalog @@ -191,3 +205,7 @@ This ensures pages remain declarative and the feature layer encapsulates logic. - Service: `ordersService` (list/detail/create) - Account - Service: `accountService` (`/me/address`) +- Support + - Views: `SupportCasesView`, `NewSupportCaseView` (mock data, ready for API wiring) +- Marketing + - Views: `PublicLandingView`, `PublicLandingLoadingView` diff --git a/apps/portal/scripts/stubs/core-api.ts b/apps/portal/scripts/stubs/core-api.ts new file mode 100644 index 00000000..b1a5efd9 --- /dev/null +++ b/apps/portal/scripts/stubs/core-api.ts @@ -0,0 +1,16 @@ +export type PostCall = [path: string, options?: unknown]; + +export const postCalls: PostCall[] = []; + +export const apiClient = { + POST: async (path: string, options?: unknown) => { + postCalls.push([path, options]); + return { data: null } as const; + }, + GET: async () => ({ data: null } as const), + PUT: async () => ({ data: null } as const), + PATCH: async () => ({ data: null } as const), + DELETE: async () => ({ data: null } as const), +}; + +export const configureApiClientAuth = () => undefined; diff --git a/apps/portal/scripts/test-request-password-reset.cjs b/apps/portal/scripts/test-request-password-reset.cjs new file mode 100755 index 00000000..cc35a88c --- /dev/null +++ b/apps/portal/scripts/test-request-password-reset.cjs @@ -0,0 +1,119 @@ +#!/usr/bin/env node + +const fs = require("node:fs"); +const path = require("node:path"); +const Module = require("node:module"); +const ts = require("typescript"); + +const srcRoot = path.join(path.resolve(__dirname, ".."), "src"); + +const registerTsCompiler = extension => { + require.extensions[extension] = (module, filename) => { + const source = fs.readFileSync(filename, "utf8"); + const { outputText } = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + jsx: ts.JsxEmit.ReactJSX, + esModuleInterop: true, + moduleResolution: ts.ModuleResolutionKind.NodeNext, + resolveJsonModule: true, + skipLibCheck: true, + }, + fileName: filename, + }); + + module._compile(outputText, filename); + }; +}; + +registerTsCompiler(".ts"); +registerTsCompiler(".tsx"); + +const originalResolveFilename = Module._resolveFilename; +Module._resolveFilename = function resolve(request, parent, isMain, options) { + if (request === "@/core/api") { + const stubPath = path.resolve(__dirname, "stubs/core-api.ts"); + return originalResolveFilename.call(this, stubPath, parent, isMain, options); + } + + if (request.startsWith("@/")) { + const resolved = path.resolve(srcRoot, request.slice(2)); + return originalResolveFilename.call(this, resolved, parent, isMain, options); + } + + return originalResolveFilename.call(this, request, parent, isMain, options); +}; + +class LocalStorageMock { + constructor() { + this._store = new Map(); + } + + clear() { + this._store.clear(); + } + + getItem(key) { + return this._store.has(key) ? this._store.get(key) : null; + } + + key(index) { + return Array.from(this._store.keys())[index] ?? null; + } + + removeItem(key) { + this._store.delete(key); + } + + setItem(key, value) { + this._store.set(key, String(value)); + } + + get length() { + return this._store.size; + } +} + +global.localStorage = new LocalStorageMock(); + +const coreApiStub = require("./stubs/core-api.ts"); +coreApiStub.postCalls.length = 0; + +const { useAuthStore } = require("../src/features/auth/services/auth.store.ts"); + +(async () => { + try { + const payload = { email: "tester@example.com" }; + await useAuthStore.getState().requestPasswordReset(payload); + + if (coreApiStub.postCalls.length !== 1) { + throw new Error(`Expected 1 POST call, received ${coreApiStub.postCalls.length}`); + } + + const [endpoint, options] = coreApiStub.postCalls[0]; + if (endpoint !== "/auth/request-password-reset") { + throw new Error(`Expected endpoint \"/auth/request-password-reset\" but received \"${endpoint}\"`); + } + + if (!options || typeof options !== "object") { + throw new Error("Password reset request did not include options payload"); + } + + const body = options.body; + if (!body || typeof body !== "object") { + throw new Error("Password reset request did not include a body"); + } + + if (body.email !== payload.email) { + throw new Error( + `Expected request body email to be \"${payload.email}\" but received \"${body.email}\"` + ); + } + + console.log("Password reset request forwarded correctly:", { endpoint, body }); + } catch (error) { + console.error("Password reset request verification failed:", error); + process.exitCode = 1; + } +})(); diff --git a/apps/portal/src/app/(portal)/account/loading.tsx b/apps/portal/src/app/(authenticated)/account/loading.tsx similarity index 100% rename from apps/portal/src/app/(portal)/account/loading.tsx rename to apps/portal/src/app/(authenticated)/account/loading.tsx diff --git a/apps/portal/src/app/(portal)/account/page.tsx b/apps/portal/src/app/(authenticated)/account/page.tsx similarity index 100% rename from apps/portal/src/app/(portal)/account/page.tsx rename to apps/portal/src/app/(authenticated)/account/page.tsx diff --git a/apps/portal/src/app/(portal)/account/profile/page.tsx b/apps/portal/src/app/(authenticated)/account/profile/page.tsx similarity index 100% rename from apps/portal/src/app/(portal)/account/profile/page.tsx rename to apps/portal/src/app/(authenticated)/account/profile/page.tsx diff --git a/apps/portal/src/app/(portal)/billing/invoices/[id]/loading.tsx b/apps/portal/src/app/(authenticated)/billing/invoices/[id]/loading.tsx similarity index 100% rename from apps/portal/src/app/(portal)/billing/invoices/[id]/loading.tsx rename to apps/portal/src/app/(authenticated)/billing/invoices/[id]/loading.tsx diff --git a/apps/portal/src/app/(portal)/billing/invoices/[id]/page.tsx b/apps/portal/src/app/(authenticated)/billing/invoices/[id]/page.tsx similarity index 100% rename from apps/portal/src/app/(portal)/billing/invoices/[id]/page.tsx rename to apps/portal/src/app/(authenticated)/billing/invoices/[id]/page.tsx diff --git a/apps/portal/src/app/(portal)/billing/invoices/loading.tsx b/apps/portal/src/app/(authenticated)/billing/invoices/loading.tsx similarity index 100% rename from apps/portal/src/app/(portal)/billing/invoices/loading.tsx rename to apps/portal/src/app/(authenticated)/billing/invoices/loading.tsx diff --git a/apps/portal/src/app/(portal)/billing/invoices/page.tsx b/apps/portal/src/app/(authenticated)/billing/invoices/page.tsx similarity index 100% rename from apps/portal/src/app/(portal)/billing/invoices/page.tsx rename to apps/portal/src/app/(authenticated)/billing/invoices/page.tsx diff --git a/apps/portal/src/app/(portal)/billing/payments/loading.tsx b/apps/portal/src/app/(authenticated)/billing/payments/loading.tsx similarity index 100% rename from apps/portal/src/app/(portal)/billing/payments/loading.tsx rename to apps/portal/src/app/(authenticated)/billing/payments/loading.tsx diff --git a/apps/portal/src/app/(portal)/billing/payments/page.tsx b/apps/portal/src/app/(authenticated)/billing/payments/page.tsx similarity index 100% rename from apps/portal/src/app/(portal)/billing/payments/page.tsx rename to apps/portal/src/app/(authenticated)/billing/payments/page.tsx diff --git a/apps/portal/src/app/(portal)/catalog/internet/configure/page.tsx b/apps/portal/src/app/(authenticated)/catalog/internet/configure/page.tsx similarity index 100% rename from apps/portal/src/app/(portal)/catalog/internet/configure/page.tsx rename to apps/portal/src/app/(authenticated)/catalog/internet/configure/page.tsx diff --git a/apps/portal/src/app/(portal)/catalog/internet/page.tsx b/apps/portal/src/app/(authenticated)/catalog/internet/page.tsx similarity index 100% rename from apps/portal/src/app/(portal)/catalog/internet/page.tsx rename to apps/portal/src/app/(authenticated)/catalog/internet/page.tsx diff --git a/apps/portal/src/app/(portal)/catalog/loading.tsx b/apps/portal/src/app/(authenticated)/catalog/loading.tsx similarity index 100% rename from apps/portal/src/app/(portal)/catalog/loading.tsx rename to apps/portal/src/app/(authenticated)/catalog/loading.tsx diff --git a/apps/portal/src/app/(portal)/catalog/page.tsx b/apps/portal/src/app/(authenticated)/catalog/page.tsx similarity index 100% rename from apps/portal/src/app/(portal)/catalog/page.tsx rename to apps/portal/src/app/(authenticated)/catalog/page.tsx diff --git a/apps/portal/src/app/(portal)/catalog/sim/configure/page.tsx b/apps/portal/src/app/(authenticated)/catalog/sim/configure/page.tsx similarity index 100% rename from apps/portal/src/app/(portal)/catalog/sim/configure/page.tsx rename to apps/portal/src/app/(authenticated)/catalog/sim/configure/page.tsx diff --git a/apps/portal/src/app/(portal)/catalog/sim/page.tsx b/apps/portal/src/app/(authenticated)/catalog/sim/page.tsx similarity index 100% rename from apps/portal/src/app/(portal)/catalog/sim/page.tsx rename to apps/portal/src/app/(authenticated)/catalog/sim/page.tsx diff --git a/apps/portal/src/app/(portal)/catalog/vpn/page.tsx b/apps/portal/src/app/(authenticated)/catalog/vpn/page.tsx similarity index 100% rename from apps/portal/src/app/(portal)/catalog/vpn/page.tsx rename to apps/portal/src/app/(authenticated)/catalog/vpn/page.tsx diff --git a/apps/portal/src/app/(portal)/checkout/loading.tsx b/apps/portal/src/app/(authenticated)/checkout/loading.tsx similarity index 100% rename from apps/portal/src/app/(portal)/checkout/loading.tsx rename to apps/portal/src/app/(authenticated)/checkout/loading.tsx diff --git a/apps/portal/src/app/(portal)/checkout/page.tsx b/apps/portal/src/app/(authenticated)/checkout/page.tsx similarity index 100% rename from apps/portal/src/app/(portal)/checkout/page.tsx rename to apps/portal/src/app/(authenticated)/checkout/page.tsx diff --git a/apps/portal/src/app/(portal)/dashboard/loading.tsx b/apps/portal/src/app/(authenticated)/dashboard/loading.tsx similarity index 100% rename from apps/portal/src/app/(portal)/dashboard/loading.tsx rename to apps/portal/src/app/(authenticated)/dashboard/loading.tsx diff --git a/apps/portal/src/app/(authenticated)/dashboard/page.tsx b/apps/portal/src/app/(authenticated)/dashboard/page.tsx new file mode 100644 index 00000000..0871d6d4 --- /dev/null +++ b/apps/portal/src/app/(authenticated)/dashboard/page.tsx @@ -0,0 +1,5 @@ +import { DashboardView } from "@/features/dashboard"; + +export default function DashboardPage() { + return ; +} diff --git a/apps/portal/src/app/(portal)/layout.tsx b/apps/portal/src/app/(authenticated)/layout.tsx similarity index 100% rename from apps/portal/src/app/(portal)/layout.tsx rename to apps/portal/src/app/(authenticated)/layout.tsx diff --git a/apps/portal/src/app/(portal)/orders/[id]/loading.tsx b/apps/portal/src/app/(authenticated)/orders/[id]/loading.tsx similarity index 100% rename from apps/portal/src/app/(portal)/orders/[id]/loading.tsx rename to apps/portal/src/app/(authenticated)/orders/[id]/loading.tsx diff --git a/apps/portal/src/app/(portal)/orders/[id]/page.tsx b/apps/portal/src/app/(authenticated)/orders/[id]/page.tsx similarity index 100% rename from apps/portal/src/app/(portal)/orders/[id]/page.tsx rename to apps/portal/src/app/(authenticated)/orders/[id]/page.tsx diff --git a/apps/portal/src/app/(portal)/orders/loading.tsx b/apps/portal/src/app/(authenticated)/orders/loading.tsx similarity index 100% rename from apps/portal/src/app/(portal)/orders/loading.tsx rename to apps/portal/src/app/(authenticated)/orders/loading.tsx diff --git a/apps/portal/src/app/(portal)/orders/page.tsx b/apps/portal/src/app/(authenticated)/orders/page.tsx similarity index 100% rename from apps/portal/src/app/(portal)/orders/page.tsx rename to apps/portal/src/app/(authenticated)/orders/page.tsx diff --git a/apps/portal/src/app/(portal)/subscriptions/[id]/loading.tsx b/apps/portal/src/app/(authenticated)/subscriptions/[id]/loading.tsx similarity index 100% rename from apps/portal/src/app/(portal)/subscriptions/[id]/loading.tsx rename to apps/portal/src/app/(authenticated)/subscriptions/[id]/loading.tsx diff --git a/apps/portal/src/app/(portal)/subscriptions/[id]/page.tsx b/apps/portal/src/app/(authenticated)/subscriptions/[id]/page.tsx similarity index 100% rename from apps/portal/src/app/(portal)/subscriptions/[id]/page.tsx rename to apps/portal/src/app/(authenticated)/subscriptions/[id]/page.tsx diff --git a/apps/portal/src/app/(portal)/subscriptions/[id]/sim/cancel/page.tsx b/apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/cancel/page.tsx similarity index 100% rename from apps/portal/src/app/(portal)/subscriptions/[id]/sim/cancel/page.tsx rename to apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/cancel/page.tsx diff --git a/apps/portal/src/app/(portal)/subscriptions/[id]/sim/change-plan/page.tsx b/apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/change-plan/page.tsx similarity index 100% rename from apps/portal/src/app/(portal)/subscriptions/[id]/sim/change-plan/page.tsx rename to apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/change-plan/page.tsx diff --git a/apps/portal/src/app/(portal)/subscriptions/[id]/sim/reissue/page.tsx b/apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/reissue/page.tsx similarity index 100% rename from apps/portal/src/app/(portal)/subscriptions/[id]/sim/reissue/page.tsx rename to apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/reissue/page.tsx diff --git a/apps/portal/src/app/(portal)/subscriptions/[id]/sim/top-up/page.tsx b/apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/top-up/page.tsx similarity index 100% rename from apps/portal/src/app/(portal)/subscriptions/[id]/sim/top-up/page.tsx rename to apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/top-up/page.tsx diff --git a/apps/portal/src/app/(portal)/subscriptions/loading.tsx b/apps/portal/src/app/(authenticated)/subscriptions/loading.tsx similarity index 100% rename from apps/portal/src/app/(portal)/subscriptions/loading.tsx rename to apps/portal/src/app/(authenticated)/subscriptions/loading.tsx diff --git a/apps/portal/src/app/(portal)/subscriptions/page.tsx b/apps/portal/src/app/(authenticated)/subscriptions/page.tsx similarity index 100% rename from apps/portal/src/app/(portal)/subscriptions/page.tsx rename to apps/portal/src/app/(authenticated)/subscriptions/page.tsx diff --git a/apps/portal/src/app/(portal)/support/cases/loading.tsx b/apps/portal/src/app/(authenticated)/support/cases/loading.tsx similarity index 100% rename from apps/portal/src/app/(portal)/support/cases/loading.tsx rename to apps/portal/src/app/(authenticated)/support/cases/loading.tsx diff --git a/apps/portal/src/app/(authenticated)/support/cases/page.tsx b/apps/portal/src/app/(authenticated)/support/cases/page.tsx new file mode 100644 index 00000000..54a27c29 --- /dev/null +++ b/apps/portal/src/app/(authenticated)/support/cases/page.tsx @@ -0,0 +1,5 @@ +import { SupportCasesView } from "@/features/support"; + +export default function SupportCasesPage() { + return ; +} diff --git a/apps/portal/src/app/(portal)/support/new/loading.tsx b/apps/portal/src/app/(authenticated)/support/new/loading.tsx similarity index 100% rename from apps/portal/src/app/(portal)/support/new/loading.tsx rename to apps/portal/src/app/(authenticated)/support/new/loading.tsx diff --git a/apps/portal/src/app/(authenticated)/support/new/page.tsx b/apps/portal/src/app/(authenticated)/support/new/page.tsx new file mode 100644 index 00000000..65c960da --- /dev/null +++ b/apps/portal/src/app/(authenticated)/support/new/page.tsx @@ -0,0 +1,5 @@ +import { NewSupportCaseView } from "@/features/support"; + +export default function NewSupportCasePage() { + return ; +} diff --git a/apps/portal/src/app/(portal)/support/new/page.tsx b/apps/portal/src/app/(portal)/support/new/page.tsx deleted file mode 100644 index aa113065..00000000 --- a/apps/portal/src/app/(portal)/support/new/page.tsx +++ /dev/null @@ -1,260 +0,0 @@ -"use client"; -import { logger } from "@/core/config"; - -import { useState } from "react"; -import { useRouter } from "next/navigation"; -import Link from "next/link"; -import { - ArrowLeftIcon, - PaperAirplaneIcon, - ExclamationCircleIcon, - InformationCircleIcon, -} from "@heroicons/react/24/outline"; - -export default function NewSupportCasePage() { - const router = useRouter(); - const [isSubmitting, setIsSubmitting] = useState(false); - const [formData, setFormData] = useState({ - subject: "", - category: "Technical", - priority: "Medium", - description: "", - }); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - setIsSubmitting(true); - - // Mock submission - would normally send to API - void (async () => { - try { - await new Promise(resolve => setTimeout(resolve, 2000)); - - // Redirect to cases list with success message - router.push("/support/cases?created=true"); - } catch (error) { - logger.error(error, "Error creating case"); - } finally { - setIsSubmitting(false); - } - })(); - }; - - const handleInputChange = (field: string, value: string) => { - setFormData(prev => ({ - ...prev, - [field]: value, - })); - }; - - const isFormValid = formData.subject.trim() && formData.description.trim(); - - return ( -
-
- {/* Header */} -
-
- -
- -
-

Create Support Case

-

Get help from our support team

-
-
- - {/* Help Tips */} -
-
-
- -
-
-

Before creating a case

-
-
    -
  • Check our knowledge base for common solutions
  • -
  • Include relevant error messages or screenshots
  • -
  • Provide detailed steps to reproduce the issue
  • -
  • Mention your service or subscription if applicable
  • -
-
-
-
-
- - {/* Form */} -
-
- {/* Subject */} -
- - handleInputChange("subject", e.target.value)} - placeholder="Brief description of your issue" - className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500" - required - /> -
- - {/* Category and Priority */} -
-
- - -
- -
- - -
-
- - {/* Description */} -
- -