Merge branch 'ver2' into codex/update-auth-token-structure-and-consumers
This commit is contained in:
commit
69aee3b08e
@ -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<string | number>("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<void> {
|
||||
const user = await this.usersService.findByEmailInternal(email);
|
||||
// Always act as if successful to avoid account enumeration
|
||||
|
||||
@ -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`
|
||||
|
||||
16
apps/portal/scripts/stubs/core-api.ts
Normal file
16
apps/portal/scripts/stubs/core-api.ts
Normal file
@ -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;
|
||||
119
apps/portal/scripts/test-request-password-reset.cjs
Executable file
119
apps/portal/scripts/test-request-password-reset.cjs
Executable file
@ -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;
|
||||
}
|
||||
})();
|
||||
5
apps/portal/src/app/(authenticated)/dashboard/page.tsx
Normal file
5
apps/portal/src/app/(authenticated)/dashboard/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { DashboardView } from "@/features/dashboard";
|
||||
|
||||
export default function DashboardPage() {
|
||||
return <DashboardView />;
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import { SupportCasesView } from "@/features/support";
|
||||
|
||||
export default function SupportCasesPage() {
|
||||
return <SupportCasesView />;
|
||||
}
|
||||
5
apps/portal/src/app/(authenticated)/support/new/page.tsx
Normal file
5
apps/portal/src/app/(authenticated)/support/new/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { NewSupportCaseView } from "@/features/support";
|
||||
|
||||
export default function NewSupportCasePage() {
|
||||
return <NewSupportCaseView />;
|
||||
}
|
||||
@ -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 (
|
||||
<div className="py-6">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 md:px-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center space-x-4 mb-4">
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="inline-flex items-center text-sm text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<ArrowLeftIcon className="h-4 w-4 mr-1" />
|
||||
Back to Support
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Create Support Case</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">Get help from our support team</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Help Tips */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<InformationCircleIcon className="h-5 w-5 text-blue-400" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-blue-800">Before creating a case</h3>
|
||||
<div className="mt-2 text-sm text-blue-700">
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>Check our knowledge base for common solutions</li>
|
||||
<li>Include relevant error messages or screenshots</li>
|
||||
<li>Provide detailed steps to reproduce the issue</li>
|
||||
<li>Mention your service or subscription if applicable</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||
{/* Subject */}
|
||||
<div>
|
||||
<label htmlFor="subject" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Subject *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="subject"
|
||||
value={formData.subject}
|
||||
onChange={e => 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
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category and Priority */}
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="category"
|
||||
className="block text-sm font-medium text-gray-700 mb-2"
|
||||
>
|
||||
Category
|
||||
</label>
|
||||
<select
|
||||
id="category"
|
||||
value={formData.category}
|
||||
onChange={e => handleInputChange("category", e.target.value)}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="Technical">Technical Support</option>
|
||||
<option value="Billing">Billing Question</option>
|
||||
<option value="General">General Inquiry</option>
|
||||
<option value="Feature Request">Feature Request</option>
|
||||
<option value="Bug Report">Bug Report</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="priority"
|
||||
className="block text-sm font-medium text-gray-700 mb-2"
|
||||
>
|
||||
Priority
|
||||
</label>
|
||||
<select
|
||||
id="priority"
|
||||
value={formData.priority}
|
||||
onChange={e => handleInputChange("priority", e.target.value)}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="Low">Low - General question</option>
|
||||
<option value="Medium">Medium - Issue affecting work</option>
|
||||
<option value="High">High - Service disruption</option>
|
||||
<option value="Critical">Critical - Complete outage</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="description"
|
||||
className="block text-sm font-medium text-gray-700 mb-2"
|
||||
>
|
||||
Description *
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
rows={6}
|
||||
value={formData.description}
|
||||
onChange={e => handleInputChange("description", e.target.value)}
|
||||
placeholder="Please provide a detailed description of your issue, including any error messages and steps to reproduce the problem..."
|
||||
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
|
||||
/>
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
The more details you provide, the faster we can help you.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Priority Warning */}
|
||||
{formData.priority === "Critical" && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<ExclamationCircleIcon className="h-5 w-5 text-red-400" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800">
|
||||
Critical Priority Selected
|
||||
</h3>
|
||||
<div className="mt-2 text-sm text-red-700">
|
||||
<p>
|
||||
Critical priority should only be used for complete service outages. For
|
||||
urgent issues that aren't complete outages, please use High priority.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between pt-6 border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isFormValid || isSubmitting}
|
||||
className="inline-flex items-center px-6 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Creating Case...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PaperAirplaneIcon className="h-4 w-4 mr-2" />
|
||||
Create Case
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Additional Help */}
|
||||
<div className="mt-8 bg-gray-50 rounded-lg p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Need immediate help?</h3>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900">Phone Support</h4>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
9:30-18:00 JST
|
||||
<br />
|
||||
<span className="font-medium text-blue-600">0120-660-470</span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900">Knowledge Base</h4>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Search our help articles for quick solutions
|
||||
<br />
|
||||
<Link
|
||||
href="/support/kb"
|
||||
className="font-medium text-blue-600 hover:text-blue-500"
|
||||
>
|
||||
Browse Knowledge Base
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
apps/portal/src/app/(public)/loading.tsx
Normal file
5
apps/portal/src/app/(public)/loading.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { PublicLandingLoadingView } from "@/features/marketing";
|
||||
|
||||
export default function PublicHomeLoading() {
|
||||
return <PublicLandingLoadingView />;
|
||||
}
|
||||
5
apps/portal/src/app/(public)/page.tsx
Normal file
5
apps/portal/src/app/(public)/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { PublicLandingView } from "@/features/marketing";
|
||||
|
||||
export default function PublicHomePage() {
|
||||
return <PublicLandingView />;
|
||||
}
|
||||
@ -1,288 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { Logo } from "@/components/ui/logo";
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
UserIcon,
|
||||
SparklesIcon,
|
||||
CreditCardIcon,
|
||||
Cog6ToothIcon,
|
||||
PhoneIcon,
|
||||
ChartBarIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-blue-100 to-blue-900">
|
||||
{/* Header */}
|
||||
<header className="bg-white/90 backdrop-blur-sm shadow-sm border-b border-blue-100">
|
||||
<div className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-page-padding)] sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Logo size={40} />
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-gray-900">Assist Solutions</h1>
|
||||
<p className="text-xs text-gray-500">Customer Portal</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<Link
|
||||
href="/auth/login"
|
||||
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 transition-colors duration-200 font-medium text-sm"
|
||||
>
|
||||
Login
|
||||
</Link>
|
||||
<Link
|
||||
href="/support"
|
||||
className="text-gray-600 hover:text-gray-900 px-4 py-2 text-sm transition-colors duration-200"
|
||||
>
|
||||
Support
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="relative py-20 overflow-hidden">
|
||||
{/* Abstract background elements */}
|
||||
<div className="absolute inset-0">
|
||||
<div className="absolute top-20 left-10 w-72 h-72 bg-blue-300 rounded-full mix-blend-multiply filter blur-xl opacity-20 animate-pulse"></div>
|
||||
<div
|
||||
className="absolute top-40 right-10 w-72 h-72 bg-indigo-300 rounded-full mix-blend-multiply filter blur-xl opacity-20 animate-pulse"
|
||||
style={{ animationDelay: "2s" }}
|
||||
></div>
|
||||
<div
|
||||
className="absolute -bottom-8 left-20 w-72 h-72 bg-purple-300 rounded-full mix-blend-multiply filter blur-xl opacity-20 animate-pulse"
|
||||
style={{ animationDelay: "4s" }}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div className="relative max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-page-padding)] sm:px-6 lg:px-8">
|
||||
<div className="text-center">
|
||||
<h1 className="text-5xl md:text-6xl font-bold text-gray-900 mb-[var(--cp-space-2xl)]">
|
||||
New Assist Solutions Customer Portal
|
||||
</h1>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<a
|
||||
href="#portal-access"
|
||||
className="bg-blue-600 text-white px-10 py-4 rounded-xl hover:bg-blue-700 transition-all duration-300 font-semibold text-lg shadow-lg hover:shadow-xl transform hover:-translate-y-1"
|
||||
>
|
||||
Get Started
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Customer Portal Access Section */}
|
||||
<section id="portal-access" className="py-20">
|
||||
<div className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-page-padding)] sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-4">Access Your Portal</h2>
|
||||
<p className="text-lg text-gray-600">Choose the option that applies to you</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-[var(--cp-space-3xl)] items-stretch">
|
||||
{/* Existing Customers - Migration */}
|
||||
<div className="bg-white rounded-[var(--cp-card-radius-lg)] p-[var(--cp-space-3xl)] shadow-[var(--cp-card-shadow)] hover:shadow-[var(--cp-card-shadow-lg)] transition-all duration-[var(--cp-transition-slow)] transform hover:-translate-y-2 border-[var(--cp-card-border)]">
|
||||
<div className="text-center flex-1 flex flex-col">
|
||||
<div className="flex items-center justify-center w-16 h-16 bg-blue-600 rounded-full mb-6 mx-auto">
|
||||
<ArrowPathIcon className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-4">Existing Customers</h3>
|
||||
<p className="text-gray-600 mb-6 leading-relaxed">
|
||||
Migrate to our new portal and enjoy enhanced security with modern interface.
|
||||
</p>
|
||||
|
||||
<div className="mt-auto">
|
||||
<Link
|
||||
href="/auth/link-whmcs"
|
||||
className="block bg-blue-600 text-white px-8 py-4 rounded-xl hover:bg-blue-700 transition-all duration-300 font-semibold text-lg mb-3 shadow-md hover:shadow-lg"
|
||||
>
|
||||
Migrate Your Account
|
||||
</Link>
|
||||
<p className="text-sm text-gray-500">Takes just a few minutes</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Portal Users */}
|
||||
<div className="bg-white rounded-2xl p-8 shadow-lg hover:shadow-xl transition-all duration-300 transform hover:-translate-y-2 border border-gray-100">
|
||||
<div className="text-center flex-1 flex flex-col">
|
||||
<div className="flex items-center justify-center w-16 h-16 bg-blue-600 rounded-full mb-6 mx-auto">
|
||||
<UserIcon className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-4">Portal Users</h3>
|
||||
<p className="text-gray-600 mb-6 leading-relaxed">
|
||||
Sign in to access your dashboard and manage all your services efficiently.
|
||||
</p>
|
||||
|
||||
<div className="mt-auto">
|
||||
<Link
|
||||
href="/auth/login"
|
||||
className="block border-2 border-blue-600 text-blue-600 px-8 py-4 rounded-xl hover:bg-blue-600 hover:text-white transition-all duration-300 font-semibold text-lg mb-3"
|
||||
>
|
||||
Login to Portal
|
||||
</Link>
|
||||
<p className="text-sm text-gray-500">Secure access to your account</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* New Customers */}
|
||||
<div className="bg-white rounded-2xl p-8 shadow-lg hover:shadow-xl transition-all duration-300 transform hover:-translate-y-2 border border-gray-100">
|
||||
<div className="text-center flex-1 flex flex-col">
|
||||
<div className="flex items-center justify-center w-16 h-16 bg-blue-600 rounded-full mb-6 mx-auto">
|
||||
<SparklesIcon className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-4">New Customers</h3>
|
||||
<p className="text-gray-600 mb-6 leading-relaxed">
|
||||
Create your account and access our full range of IT solutions and services.
|
||||
</p>
|
||||
|
||||
<div className="mt-auto">
|
||||
<Link
|
||||
href="/auth/signup"
|
||||
className="block border-2 border-blue-600 text-blue-600 px-8 py-4 rounded-xl hover:bg-blue-600 hover:text-white transition-all duration-300 font-semibold text-lg mb-3"
|
||||
>
|
||||
Create Account
|
||||
</Link>
|
||||
<p className="text-sm text-gray-500">Start your journey with us</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Portal Features Section */}
|
||||
<section className="py-16">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-4">Portal Features</h2>
|
||||
<p className="text-lg text-gray-600">
|
||||
Everything you need to manage your Assist Solutions services
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="bg-white rounded-lg p-6 text-center shadow-lg hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1">
|
||||
<div className="flex justify-center mb-3">
|
||||
<CreditCardIcon className="w-8 h-8 text-blue-600" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2 text-gray-900">Billing & Payments</h3>
|
||||
<p className="text-gray-600 text-sm">
|
||||
View invoices, payment history, and manage billing
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg p-6 text-center shadow-lg hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1">
|
||||
<div className="flex justify-center mb-3">
|
||||
<Cog6ToothIcon className="w-8 h-8 text-purple-600" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2 text-gray-900">Service Management</h3>
|
||||
<p className="text-gray-600 text-sm">Control and configure your active services</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg p-6 text-center shadow-lg hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1">
|
||||
<div className="flex justify-center mb-3">
|
||||
<PhoneIcon className="w-8 h-8 text-pink-600" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2 text-gray-900">Support Tickets</h3>
|
||||
<p className="text-gray-600 text-sm">Create and track support requests</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg p-6 text-center shadow-lg hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1">
|
||||
<div className="flex justify-center mb-3">
|
||||
<ChartBarIcon className="w-8 h-8 text-blue-600" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2 text-gray-900">Usage Reports</h3>
|
||||
<p className="text-gray-600 text-sm">Monitor service usage and performance</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Support Section */}
|
||||
<section className="py-16">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-4">Need Help?</h2>
|
||||
<p className="text-lg text-gray-600">Our support team is here to assist you</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Contact Details Box */}
|
||||
<div className="bg-white rounded-lg p-8 shadow-lg hover:shadow-xl transition-all duration-300">
|
||||
<h3 className="text-xl font-semibold mb-6 text-gray-900">Contact Details</h3>
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="text-center">
|
||||
<h4 className="font-semibold text-gray-900 mb-2">Phone Support</h4>
|
||||
<p className="text-gray-600 text-sm mb-1">9:30-18:00 JST</p>
|
||||
<p className="text-blue-600 font-medium">0120-660-470</p>
|
||||
<p className="text-gray-500 text-sm">Toll Free within Japan</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<h4 className="font-semibold text-gray-900 mb-2">Email Support</h4>
|
||||
<p className="text-gray-600 text-sm mb-1">Response within 24h</p>
|
||||
<Link
|
||||
href="/contact"
|
||||
className="text-purple-600 font-medium hover:text-purple-700"
|
||||
>
|
||||
Send Message
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live Chat & Business Hours Box */}
|
||||
<div className="bg-white rounded-lg p-8 shadow-lg hover:shadow-xl transition-all duration-300">
|
||||
<h3 className="text-xl font-semibold mb-6 text-gray-900">
|
||||
Live Chat & Business Hours
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="text-center">
|
||||
<h4 className="font-semibold text-gray-900 mb-2">Live Chat</h4>
|
||||
<p className="text-gray-600 text-sm mb-1">Available 24/7</p>
|
||||
<Link href="/chat" className="text-green-600 font-medium hover:text-green-700">
|
||||
Start Chat
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900 mb-3">Business Hours</h4>
|
||||
<div className="text-gray-600 text-sm space-y-1">
|
||||
<p>
|
||||
<strong>Monday - Saturday:</strong>
|
||||
<br />
|
||||
10:00 AM - 6:00 PM JST
|
||||
</p>
|
||||
<p>
|
||||
<strong>Sunday:</strong>
|
||||
<br />
|
||||
Closed
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-white/90 backdrop-blur-sm text-gray-900 py-8 border-t border-gray-200">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center">
|
||||
<p className="text-gray-600 text-sm">
|
||||
© 2025 Assist Solutions Corp. All Rights Reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,174 +1,27 @@
|
||||
/**
|
||||
* Core API Client
|
||||
* Instantiates the shared OpenAPI client with portal-specific configuration.
|
||||
|
||||
* Core API client configuration
|
||||
* Wraps the shared generated client to inject portal-specific behavior like auth headers.
|
||||
*/
|
||||
|
||||
import { createClient as createOpenApiClient } from "@customer-portal/api-client";
|
||||
import type { ApiClient as GeneratedApiClient } from "@customer-portal/api-client";
|
||||
import {
|
||||
createClient as createOpenApiClient,
|
||||
type ApiClient as GeneratedApiClient,
|
||||
type AuthHeaderResolver,
|
||||
} from "@customer-portal/api-client";
|
||||
import { env } from "../config/env";
|
||||
|
||||
const DEFAULT_JSON_CONTENT_TYPE = "application/json";
|
||||
|
||||
export type AuthHeaderGetter = () => string | undefined;
|
||||
|
||||
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
|
||||
export type ApiRequestOptions = Omit<RequestInit, "method" | "body" | "headers"> & {
|
||||
body?: unknown;
|
||||
headers?: HeadersInit;
|
||||
};
|
||||
|
||||
export interface HttpError extends Error {
|
||||
status: number;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
type RestMethods = {
|
||||
request: <T = unknown>(method: HttpMethod, path: string, options?: ApiRequestOptions) => Promise<T>;
|
||||
get: <T = unknown>(path: string, options?: Omit<ApiRequestOptions, "body">) => Promise<T>;
|
||||
post: <T = unknown, B = unknown>(
|
||||
path: string,
|
||||
body?: B,
|
||||
options?: Omit<ApiRequestOptions, "body">
|
||||
) => Promise<T>;
|
||||
put: <T = unknown, B = unknown>(
|
||||
path: string,
|
||||
body?: B,
|
||||
options?: Omit<ApiRequestOptions, "body">
|
||||
) => Promise<T>;
|
||||
patch: <T = unknown, B = unknown>(
|
||||
path: string,
|
||||
body?: B,
|
||||
options?: Omit<ApiRequestOptions, "body">
|
||||
) => Promise<T>;
|
||||
delete: <T = unknown, B = unknown>(
|
||||
path: string,
|
||||
options?: Omit<ApiRequestOptions, "body"> & { body?: B }
|
||||
) => Promise<T>;
|
||||
};
|
||||
|
||||
export type ApiClient = GeneratedApiClient & RestMethods;
|
||||
|
||||
const baseUrl = env.NEXT_PUBLIC_API_BASE;
|
||||
|
||||
let authHeaderGetter: AuthHeaderGetter | undefined;
|
||||
let authHeaderResolver: AuthHeaderResolver | undefined;
|
||||
|
||||
const resolveAuthHeader = () => authHeaderGetter?.();
|
||||
const resolveAuthHeader: AuthHeaderResolver = () => authHeaderResolver?.();
|
||||
|
||||
const joinUrl = (base: string, path: string) => {
|
||||
if (/^https?:\/\//.test(path)) {
|
||||
return path;
|
||||
}
|
||||
|
||||
const trimmedBase = base.endsWith("/") ? base.slice(0, -1) : base;
|
||||
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
||||
return `${trimmedBase}${normalizedPath}`;
|
||||
};
|
||||
|
||||
const applyAuthHeader = (headersInit?: HeadersInit) => {
|
||||
const headers =
|
||||
headersInit instanceof Headers ? headersInit : new Headers(headersInit ?? undefined);
|
||||
|
||||
const headerValue = resolveAuthHeader();
|
||||
|
||||
if (headerValue && !headers.has("Authorization")) {
|
||||
headers.set("Authorization", headerValue);
|
||||
}
|
||||
|
||||
return headers;
|
||||
};
|
||||
|
||||
const serializeBody = (body: unknown, headers: Headers): BodyInit | undefined => {
|
||||
if (body === undefined || body === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (
|
||||
body instanceof FormData ||
|
||||
body instanceof Blob ||
|
||||
body instanceof URLSearchParams ||
|
||||
typeof body === "string"
|
||||
) {
|
||||
return body;
|
||||
}
|
||||
|
||||
if (!headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", DEFAULT_JSON_CONTENT_TYPE);
|
||||
}
|
||||
|
||||
return JSON.stringify(body);
|
||||
};
|
||||
|
||||
const parseResponseBody = async (response: Response) => {
|
||||
if (response.status === 204) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (!text) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
};
|
||||
|
||||
const createHttpError = (response: Response, data: unknown): HttpError => {
|
||||
const message =
|
||||
typeof data === "object" && data !== null && "message" in data &&
|
||||
typeof (data as Record<string, unknown>).message === "string"
|
||||
? (data as { message: string }).message
|
||||
: `Request failed with status ${response.status}`;
|
||||
|
||||
const error = new Error(message) as HttpError;
|
||||
error.status = response.status;
|
||||
if (data !== undefined) {
|
||||
error.data = data;
|
||||
}
|
||||
return error;
|
||||
};
|
||||
|
||||
const request = async <T = unknown>(
|
||||
method: HttpMethod,
|
||||
path: string,
|
||||
options: ApiRequestOptions = {}
|
||||
): Promise<T> => {
|
||||
const { body, headers: headersInit, ...rest } = options;
|
||||
const headers = applyAuthHeader(headersInit);
|
||||
const serializedBody = serializeBody(body, headers);
|
||||
const response = await fetch(joinUrl(baseUrl, path), {
|
||||
...rest,
|
||||
method,
|
||||
headers,
|
||||
body: serializedBody,
|
||||
});
|
||||
|
||||
const parsedBody = await parseResponseBody(response);
|
||||
if (!response.ok) {
|
||||
throw createHttpError(response, parsedBody);
|
||||
}
|
||||
|
||||
return parsedBody as T;
|
||||
};
|
||||
|
||||
const restMethods: RestMethods = {
|
||||
request,
|
||||
get: (path, options) => request("GET", path, options as ApiRequestOptions),
|
||||
post: (path, body, options) => request("POST", path, { ...options, body }),
|
||||
put: (path, body, options) => request("PUT", path, { ...options, body }),
|
||||
patch: (path, body, options) => request("PATCH", path, { ...options, body }),
|
||||
delete: (path, options) => request("DELETE", path, options as ApiRequestOptions),
|
||||
};
|
||||
|
||||
const openApiClient = createOpenApiClient(baseUrl, {
|
||||
export const apiClient: GeneratedApiClient = createOpenApiClient(baseUrl, {
|
||||
getAuthHeader: resolveAuthHeader,
|
||||
});
|
||||
|
||||
export const apiClient = Object.assign(openApiClient, restMethods) as ApiClient;
|
||||
|
||||
export const configureApiClientAuth = (getter?: AuthHeaderGetter) => {
|
||||
authHeaderGetter = getter;
|
||||
export const configureApiClientAuth = (resolver?: AuthHeaderResolver) => {
|
||||
authHeaderResolver = resolver;
|
||||
};
|
||||
|
||||
export type ApiClient = GeneratedApiClient;
|
||||
@ -1,3 +1,5 @@
|
||||
export { apiClient, configureApiClientAuth } from "./client";
|
||||
export { queryKeys } from "./query-keys";
|
||||
export type { ApiClient, ApiRequestOptions, AuthHeaderGetter, HttpError } from "./client";
|
||||
|
||||
export type { ApiClient } from "./client";
|
||||
export type { AuthHeaderResolver } from "@customer-portal/api-client";
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
import { logger } from "@customer-portal/logging";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useAuthStore } from "@/features/auth/services/auth.store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
@ -15,70 +15,70 @@ export function SessionTimeoutWarning({
|
||||
const { isAuthenticated, tokens, logout, checkAuth } = useAuthStore();
|
||||
const [showWarning, setShowWarning] = useState(false);
|
||||
const [timeLeft, setTimeLeft] = useState<number>(0);
|
||||
const expiryRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !tokens?.accessToken) {
|
||||
if (!isAuthenticated || !tokens?.expiresAt) {
|
||||
expiryRef.current = null;
|
||||
setShowWarning(false);
|
||||
setTimeLeft(0);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Parse JWT to get expiry time
|
||||
try {
|
||||
const parts = tokens.accessToken.split(".");
|
||||
if (parts.length !== 3) {
|
||||
throw new Error("Invalid token format");
|
||||
}
|
||||
const expiryTime = Date.parse(tokens.expiresAt);
|
||||
if (Number.isNaN(expiryTime)) {
|
||||
logger.warn("Invalid expiresAt on auth tokens", { expiresAt: tokens.expiresAt });
|
||||
expiryRef.current = null;
|
||||
setShowWarning(false);
|
||||
setTimeLeft(0);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const payload = JSON.parse(atob(parts[1])) as { exp?: number };
|
||||
if (!payload.exp) {
|
||||
logger.warn("Token does not have expiration time");
|
||||
return undefined;
|
||||
}
|
||||
expiryRef.current = expiryTime;
|
||||
|
||||
const expiryTime = payload.exp * 1000; // Convert to milliseconds
|
||||
const currentTime = Date.now();
|
||||
const warningThreshold = warningTime * 60 * 1000; // Convert to milliseconds
|
||||
|
||||
const timeUntilExpiry = expiryTime - currentTime;
|
||||
const timeUntilWarning = timeUntilExpiry - warningThreshold;
|
||||
|
||||
if (timeUntilExpiry <= 0) {
|
||||
// Token already expired
|
||||
void logout();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (timeUntilWarning <= 0) {
|
||||
// Should show warning immediately
|
||||
setShowWarning(true);
|
||||
setTimeLeft(Math.ceil(timeUntilExpiry / 1000 / 60)); // Minutes left
|
||||
return undefined;
|
||||
} else {
|
||||
// Set timeout to show warning
|
||||
const warningTimeout = setTimeout(() => {
|
||||
setShowWarning(true);
|
||||
setTimeLeft(warningTime);
|
||||
}, timeUntilWarning);
|
||||
|
||||
return () => clearTimeout(warningTimeout);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error, "Error parsing JWT token");
|
||||
if (Date.now() >= expiryTime) {
|
||||
void logout();
|
||||
return undefined;
|
||||
}
|
||||
}, [isAuthenticated, tokens, warningTime, logout]);
|
||||
|
||||
const warningThreshold = warningTime * 60 * 1000;
|
||||
const now = Date.now();
|
||||
const timeUntilExpiry = expiryTime - now;
|
||||
const timeUntilWarning = timeUntilExpiry - warningThreshold;
|
||||
|
||||
if (timeUntilWarning <= 0) {
|
||||
setShowWarning(true);
|
||||
setTimeLeft(Math.max(1, Math.ceil(timeUntilExpiry / (60 * 1000))));
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const warningTimeout = setTimeout(() => {
|
||||
setShowWarning(true);
|
||||
setTimeLeft(
|
||||
Math.max(1, Math.ceil((expiryRef.current! - Date.now()) / (60 * 1000)))
|
||||
);
|
||||
}, timeUntilWarning);
|
||||
|
||||
return () => clearTimeout(warningTimeout);
|
||||
}, [isAuthenticated, tokens?.expiresAt, warningTime, logout]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showWarning) return undefined;
|
||||
if (!showWarning || !expiryRef.current) return undefined;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setTimeLeft(prev => {
|
||||
if (prev <= 1) {
|
||||
void logout();
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
const expiryTime = expiryRef.current;
|
||||
if (!expiryTime) {
|
||||
return;
|
||||
}
|
||||
|
||||
const remaining = expiryTime - Date.now();
|
||||
if (remaining <= 0) {
|
||||
setTimeLeft(0);
|
||||
void logout();
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeLeft(Math.max(1, Math.ceil(remaining / (60 * 1000))));
|
||||
}, 60000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
|
||||
@ -53,8 +53,8 @@ export function useAuth() {
|
||||
|
||||
// Password reset request
|
||||
const passwordResetMutation = useMutation({
|
||||
mutationFn: (data: ForgotPasswordRequest) =>
|
||||
apiClient.POST('/auth/forgot-password', { body: data }),
|
||||
mutationFn: (data: ForgotPasswordRequest) =>
|
||||
apiClient.POST('/auth/request-password-reset', { body: data }),
|
||||
});
|
||||
|
||||
// Password reset
|
||||
|
||||
@ -15,6 +15,7 @@ import type {
|
||||
ResetPasswordRequest,
|
||||
ChangePasswordRequest,
|
||||
AuthError,
|
||||
IsoDateTimeString,
|
||||
} from "@customer-portal/domain";
|
||||
|
||||
const DEFAULT_TOKEN_TYPE = "Bearer";
|
||||
@ -31,10 +32,12 @@ type LegacyAuthTokens = {
|
||||
|
||||
type RawAuthTokens =
|
||||
| AuthTokens
|
||||
|
||||
| (Omit<AuthTokens, "expiresAt"> & { expiresAt: string | number | Date })
|
||||
| LegacyAuthTokens;
|
||||
|
||||
const toIsoString = (value: string | number | Date | null | undefined) => {
|
||||
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
@ -43,12 +46,14 @@ const toIsoString = (value: string | number | Date | null | undefined) => {
|
||||
return new Date(value).toISOString();
|
||||
}
|
||||
|
||||
|
||||
if (typeof value === "string" && value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
// Treat missing expiry as already expired to force re-authentication
|
||||
return new Date(0).toISOString();
|
||||
|
||||
};
|
||||
|
||||
const isLegacyTokens = (tokens: RawAuthTokens): tokens is LegacyAuthTokens =>
|
||||
@ -242,7 +247,7 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
requestPasswordReset: async (data: ForgotPasswordRequest) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
await apiClient.POST('/auth/forgot-password', { body: data });
|
||||
await apiClient.POST('/auth/request-password-reset', { body: data });
|
||||
set({ loading: false });
|
||||
} catch (error) {
|
||||
const authError = error as AuthError;
|
||||
@ -377,7 +382,16 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
if (user) {
|
||||
set({ user, isAuthenticated: true, loading: false, error: null, hasCheckedAuth: true });
|
||||
} else {
|
||||
set({ loading: false, hasCheckedAuth: true });
|
||||
// No user data returned, clear auth state
|
||||
set({
|
||||
user: null,
|
||||
tokens: null,
|
||||
isAuthenticated: false,
|
||||
loading: false,
|
||||
error: null,
|
||||
hasCheckedAuth: true,
|
||||
});
|
||||
|
||||
}
|
||||
} catch (error) {
|
||||
// Token is invalid, clear auth state
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
export * from "./components";
|
||||
export * from "./hooks";
|
||||
export * from "./views";
|
||||
|
||||
@ -1,23 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuthStore } from "@/features/auth/services/auth.store";
|
||||
import { useDashboardSummary } from "@/features/dashboard/hooks/useDashboard";
|
||||
|
||||
import type { Activity } from "@customer-portal/domain";
|
||||
import type { Activity, DashboardSummary } from "@customer-portal/domain";
|
||||
import {
|
||||
CreditCardIcon,
|
||||
ServerIcon,
|
||||
ChatBubbleLeftRightIcon,
|
||||
ExclamationTriangleIcon,
|
||||
ChevronRightIcon,
|
||||
PlusIcon,
|
||||
DocumentTextIcon,
|
||||
ArrowTrendingUpIcon,
|
||||
CalendarDaysIcon,
|
||||
BellIcon,
|
||||
ClipboardDocumentListIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import {
|
||||
CreditCardIcon as CreditCardIconSolid,
|
||||
@ -26,15 +19,19 @@ import {
|
||||
ClipboardDocumentListIcon as ClipboardDocumentListIconSolid,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
|
||||
import { useAuthStore } from "@/features/auth/services/auth.store";
|
||||
import { useDashboardSummary } from "@/features/dashboard/hooks";
|
||||
import { StatCard, QuickAction, DashboardActivityItem } from "@/features/dashboard/components";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-skeleton";
|
||||
import { ErrorState } from "@/components/ui/error-state";
|
||||
import { formatCurrency, getCurrencyLocale } from "@customer-portal/domain";
|
||||
|
||||
export default function DashboardPage() {
|
||||
export function DashboardView() {
|
||||
const router = useRouter();
|
||||
const { user, isAuthenticated, loading: authLoading } = useAuthStore();
|
||||
const { data: summary, isLoading: summaryLoading, error } = useDashboardSummary();
|
||||
const upcomingInvoice = summary?.nextInvoice ?? null;
|
||||
|
||||
const [paymentLoading, setPaymentLoading] = useState(false);
|
||||
const [paymentError, setPaymentError] = useState<string | null>(null);
|
||||
@ -115,7 +112,7 @@ export default function DashboardPage() {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-[var(--cp-space-2xl)] mb-[var(--cp-space-3xl)]">
|
||||
<StatCard
|
||||
title="Recent Orders"
|
||||
value={((summary?.stats as Record<string, unknown>)?.recentOrders as number) || 0}
|
||||
value={summary?.stats?.recentOrders ?? 0}
|
||||
icon={ClipboardDocumentListIconSolid}
|
||||
gradient="from-gray-500 to-gray-600"
|
||||
href="/orders"
|
||||
@ -157,7 +154,7 @@ export default function DashboardPage() {
|
||||
{/* Main Content Area */}
|
||||
<div className="lg:col-span-2 space-y-[var(--cp-space-3xl)]">
|
||||
{/* Upcoming Payment - compressed attention banner */}
|
||||
{summary?.nextInvoice && (
|
||||
{upcomingInvoice && (
|
||||
<div
|
||||
id="attention"
|
||||
className="bg-white rounded-xl border border-orange-200 shadow-sm p-4"
|
||||
@ -172,29 +169,28 @@ export default function DashboardPage() {
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-gray-700">
|
||||
<span className="font-semibold text-gray-900">Upcoming Payment</span>
|
||||
<span className="text-gray-400">•</span>
|
||||
<span>Invoice #{summary.nextInvoice.id}</span>
|
||||
<span>Invoice #{upcomingInvoice.id}</span>
|
||||
<span className="text-gray-400">•</span>
|
||||
<span title={format(new Date(summary.nextInvoice.dueDate), "MMMM d, yyyy")}>
|
||||
<span title={format(new Date(upcomingInvoice.dueDate), "MMMM d, yyyy")}>
|
||||
Due{" "}
|
||||
{formatDistanceToNow(new Date(summary.nextInvoice.dueDate), {
|
||||
{formatDistanceToNow(new Date(upcomingInvoice.dueDate), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-2xl font-bold text-gray-900">
|
||||
{formatCurrency(summary.nextInvoice.amount, {
|
||||
currency: summary.nextInvoice.currency || "JPY",
|
||||
locale: getCurrencyLocale(summary.nextInvoice.currency || "JPY"),
|
||||
{formatCurrency(upcomingInvoice.amount, {
|
||||
currency: upcomingInvoice.currency || "JPY",
|
||||
locale: getCurrencyLocale(upcomingInvoice.currency || "JPY"),
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
Exact due date:{" "}
|
||||
{format(new Date(summary.nextInvoice.dueDate), "MMMM d, yyyy")}
|
||||
Exact due date: {format(new Date(upcomingInvoice.dueDate), "MMMM d, yyyy")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<button
|
||||
onClick={() => handlePayNow(summary.nextInvoice!.id)}
|
||||
onClick={() => handlePayNow(upcomingInvoice.id)}
|
||||
disabled={paymentLoading}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
@ -205,7 +201,7 @@ export default function DashboardPage() {
|
||||
{!paymentLoading && <ChevronRightIcon className="ml-2 h-4 w-4" />}
|
||||
</button>
|
||||
<Link
|
||||
href={`/billing/invoices/${summary.nextInvoice.id}`}
|
||||
href={`/billing/invoices/${upcomingInvoice.id}`}
|
||||
className="text-blue-600 hover:text-blue-700 font-medium text-sm"
|
||||
>
|
||||
View invoice
|
||||
@ -276,12 +272,13 @@ export default function DashboardPage() {
|
||||
}
|
||||
|
||||
// Helpers and small components (local to dashboard)
|
||||
function truncateName(name: string, len = 28) {
|
||||
if (name.length <= len) return name;
|
||||
return name.slice(0, Math.max(0, len - 1)) + "…";
|
||||
}
|
||||
|
||||
function TasksChip({ summaryLoading, summary }: { summaryLoading: boolean; summary: any }) {
|
||||
function TasksChip({
|
||||
summaryLoading,
|
||||
summary,
|
||||
}: {
|
||||
summaryLoading: boolean;
|
||||
summary: DashboardSummary | undefined;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
if (summaryLoading) return null;
|
||||
const tasks: Array<{ label: string; href: string }> = [];
|
||||
@ -339,7 +336,11 @@ function RecentActivityCard({
|
||||
<button
|
||||
key={opt.k}
|
||||
onClick={() => setFilter(opt.k)}
|
||||
className={`px-2.5 py-1 text-xs rounded-md font-medium ${filter === opt.k ? "bg-white text-gray-900 shadow" : "text-gray-600 hover:text-gray-900"}`}
|
||||
className={`px-2.5 py-1 text-xs rounded-md font-medium ${
|
||||
filter === opt.k
|
||||
? "bg-white text-gray-900 shadow"
|
||||
: "text-gray-600 hover:text-gray-900"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
1
apps/portal/src/features/dashboard/views/index.ts
Normal file
1
apps/portal/src/features/dashboard/views/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./DashboardView";
|
||||
@ -1,3 +1,5 @@
|
||||
export * as billing from "./billing";
|
||||
export * as subscriptions from "./subscriptions";
|
||||
export * as dashboard from "./dashboard";
|
||||
export * as marketing from "./marketing";
|
||||
export * as support from "./support";
|
||||
|
||||
2
apps/portal/src/features/marketing/index.ts
Normal file
2
apps/portal/src/features/marketing/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./views/PublicLandingView";
|
||||
export * from "./views/PublicLandingLoadingView";
|
||||
@ -1,6 +1,6 @@
|
||||
import { Skeleton } from "@/components/ui/loading-skeleton";
|
||||
|
||||
export default function RootLoading() {
|
||||
export function PublicLandingLoadingView() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-blue-100 to-blue-900">
|
||||
<header className="bg-white/90 backdrop-blur-sm shadow-sm border-b border-blue-100">
|
||||
@ -22,8 +22,11 @@ export default function RootLoading() {
|
||||
<div className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-page-padding)] sm:px-6 lg:px-8 space-y-8">
|
||||
<Skeleton className="h-12 w-2/3" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="bg-white rounded-2xl p-8 shadow-lg border border-gray-100 space-y-4">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-white rounded-2xl p-8 shadow-lg border border-gray-100 space-y-4"
|
||||
>
|
||||
<Skeleton className="h-16 w-16 rounded-full mx-auto" />
|
||||
<Skeleton className="h-6 w-1/2 mx-auto" />
|
||||
<Skeleton className="h-4 w-3/4 mx-auto" />
|
||||
@ -36,5 +39,3 @@ export default function RootLoading() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
368
apps/portal/src/features/marketing/views/PublicLandingView.tsx
Normal file
368
apps/portal/src/features/marketing/views/PublicLandingView.tsx
Normal file
@ -0,0 +1,368 @@
|
||||
import Link from "next/link";
|
||||
import { Logo } from "@/components/ui/logo";
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
UserIcon,
|
||||
SparklesIcon,
|
||||
CreditCardIcon,
|
||||
Cog6ToothIcon,
|
||||
PhoneIcon,
|
||||
ChartBarIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
|
||||
export function PublicLandingView() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-blue-100 to-blue-900">
|
||||
{/* Header */}
|
||||
<header className="bg-white/90 backdrop-blur-sm shadow-sm border-b border-blue-100">
|
||||
<div className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-page-padding)] sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Logo size={40} />
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-gray-900">Assist Solutions</h1>
|
||||
<p className="text-xs text-gray-500">Customer Portal</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<Link
|
||||
href="/auth/login"
|
||||
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 transition-colors duration-200 font-medium text-sm"
|
||||
>
|
||||
Login
|
||||
</Link>
|
||||
<Link
|
||||
href="/support"
|
||||
className="text-gray-600 hover:text-gray-900 px-4 py-2 text-sm transition-colors duration-200"
|
||||
>
|
||||
Support
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="relative py-20 overflow-hidden">
|
||||
{/* Abstract background elements */}
|
||||
<div className="absolute inset-0">
|
||||
<div className="absolute top-20 left-10 w-72 h-72 bg-blue-300 rounded-full mix-blend-multiply filter blur-xl opacity-20 animate-pulse"></div>
|
||||
<div
|
||||
className="absolute top-40 right-10 w-72 h-72 bg-indigo-300 rounded-full mix-blend-multiply filter blur-xl opacity-20 animate-pulse"
|
||||
style={{ animationDelay: "2s" }}
|
||||
></div>
|
||||
<div
|
||||
className="absolute -bottom-8 left-20 w-72 h-72 bg-purple-300 rounded-full mix-blend-multiply filter blur-xl opacity-20 animate-pulse"
|
||||
style={{ animationDelay: "4s" }}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div className="relative max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-page-padding)] sm:px-6 lg:px-8">
|
||||
<div className="text-center">
|
||||
<h1 className="text-5xl md:text-6xl font-bold text-gray-900 mb-[var(--cp-space-2xl)]">
|
||||
New Assist Solutions Customer Portal
|
||||
</h1>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<a
|
||||
href="#portal-access"
|
||||
className="bg-blue-600 text-white px-10 py-4 rounded-xl hover:bg-blue-700 transition-all duration-300 font-semibold text-lg shadow-lg hover:shadow-xl transform hover:-translate-y-1"
|
||||
>
|
||||
Get Started
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Customer Portal Access Section */}
|
||||
<section id="portal-access" className="py-20">
|
||||
<div className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-page-padding)] sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-4">Access Your Portal</h2>
|
||||
<p className="text-lg text-gray-600">Choose the option that applies to you</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-[var(--cp-space-3xl)] items-stretch">
|
||||
{/* Existing Customers - Migration */}
|
||||
<div className="bg-white rounded-[var(--cp-card-radius-lg)] p-[var(--cp-space-3xl)] shadow-[var(--cp-card-shadow)] hover:shadow-[var(--cp-card-shadow-lg)] transition-all duration-[var(--cp-transition-slow)] transform hover:-translate-y-2 border-[var(--cp-card-border)]">
|
||||
<div className="text-center flex-1 flex flex-col">
|
||||
<div className="flex items-center justify-center w-16 h-16 bg-blue-600 rounded-full mb-6 mx-auto">
|
||||
<ArrowPathIcon className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-4">Existing Customers</h3>
|
||||
<p className="text-gray-600 mb-6 leading-relaxed">
|
||||
Migrate to our new portal and enjoy enhanced security with modern interface.
|
||||
</p>
|
||||
|
||||
<div className="mt-auto">
|
||||
<Link
|
||||
href="/auth/link-whmcs"
|
||||
className="block bg-blue-600 text-white px-8 py-4 rounded-xl hover:bg-blue-700 transition-all duration-300 font-semibold text-lg mb-3 shadow-md hover:shadow-lg"
|
||||
>
|
||||
Migrate Your Account
|
||||
</Link>
|
||||
<p className="text-sm text-gray-500">Takes just a few minutes</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Portal Users */}
|
||||
<div className="bg-white rounded-2xl p-8 shadow-lg hover:shadow-xl transition-all duration-300 transform hover:-translate-y-2 border border-gray-100">
|
||||
<div className="text-center flex-1 flex flex-col">
|
||||
<div className="flex items-center justify-center w-16 h-16 bg-blue-600 rounded-full mb-6 mx-auto">
|
||||
<UserIcon className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-4">Portal Users</h3>
|
||||
<p className="text-gray-600 mb-6 leading-relaxed">
|
||||
Sign in to access your dashboard and manage all your services efficiently.
|
||||
</p>
|
||||
|
||||
<div className="mt-auto">
|
||||
<Link
|
||||
href="/auth/login"
|
||||
className="block border-2 border-blue-600 text-blue-600 px-8 py-4 rounded-xl hover:bg-blue-600 hover:text-white transition-all duration-300 font-semibold text-lg mb-3"
|
||||
>
|
||||
Login to Portal
|
||||
</Link>
|
||||
<p className="text-sm text-gray-500">Secure access to your account</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* New Customers */}
|
||||
<div className="bg-white rounded-2xl p-8 shadow-lg hover:shadow-xl transition-all duration-300 transform hover:-translate-y-2 border border-gray-100">
|
||||
<div className="text-center flex-1 flex flex-col">
|
||||
<div className="flex items-center justify-center w-16 h-16 bg-blue-600 rounded-full mb-6 mx-auto">
|
||||
<SparklesIcon className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-4">New Customers</h3>
|
||||
<p className="text-gray-600 mb-6 leading-relaxed">
|
||||
Create your account and access our full range of IT solutions and services.
|
||||
</p>
|
||||
|
||||
<div className="mt-auto">
|
||||
<Link
|
||||
href="/auth/signup"
|
||||
className="block border-2 border-blue-600 text-blue-600 px-8 py-4 rounded-xl hover:bg-blue-600 hover:text-white transition-all duration-300 font-semibold text-lg mb-3"
|
||||
>
|
||||
Create Account
|
||||
</Link>
|
||||
<p className="text-sm text-gray-500">Start your journey with us</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Portal Features Section */}
|
||||
<section className="bg-white py-20">
|
||||
<div className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-page-padding)] sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-4">Why Choose Assist Solutions</h2>
|
||||
<p className="text-lg text-gray-600">
|
||||
Modern tools to manage your IT services with confidence
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-[var(--cp-space-3xl)]">
|
||||
<div className="bg-white border border-gray-100 rounded-[var(--cp-card-radius-lg)] p-[var(--cp-space-3xl)] shadow-[var(--cp-card-shadow)] hover:shadow-[var(--cp-card-shadow-lg)] transition-shadow duration-300">
|
||||
<div className="flex items-center justify-center w-14 h-14 bg-blue-100 rounded-full mb-6">
|
||||
<CreditCardIcon className="w-7 h-7 text-blue-600" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-3">Automated Billing</h3>
|
||||
<p className="text-gray-600">
|
||||
Transparent invoicing, automated payments, and flexible billing options tailored to
|
||||
your business.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-gray-100 rounded-[var(--cp-card-radius-lg)] p-[var(--cp-space-3xl)] shadow-[var(--cp-card-shadow)] hover:shadow-[var(--cp-card-shadow-lg)] transition-shadow duration-300">
|
||||
<div className="flex items-center justify-center w-14 h-14 bg-blue-100 rounded-full mb-6">
|
||||
<Cog6ToothIcon className="w-7 h-7 text-blue-600" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-3">Service Management</h3>
|
||||
<p className="text-gray-600">
|
||||
Control subscriptions, manage network services, and track usage from a single pane
|
||||
of glass.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-gray-100 rounded-[var(--cp-card-radius-lg)] p-[var(--cp-space-3xl)] shadow-[var(--cp-card-shadow)] hover:shadow-[var(--cp-card-shadow-lg)] transition-shadow duration-300">
|
||||
<div className="flex items-center justify-center w-14 h-14 bg-blue-100 rounded-full mb-6">
|
||||
<PhoneIcon className="w-7 h-7 text-blue-600" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-3">Expert Support</h3>
|
||||
<p className="text-gray-600">
|
||||
Dedicated support team with SLA-backed response times and proactive service
|
||||
monitoring.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-gray-100 rounded-[var(--cp-card-radius-lg)] p-[var(--cp-space-3xl)] shadow-[var(--cp-card-shadow)] hover:shadow-[var(--cp-card-shadow-lg)] transition-shadow duration-300">
|
||||
<div className="flex items-center justify-center w-14 h-14 bg-blue-100 rounded-full mb-6">
|
||||
<ChartBarIcon className="w-7 h-7 text-blue-600" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-3">Actionable Insights</h3>
|
||||
<p className="text-gray-600">
|
||||
Real-time analytics and reporting to help you optimize resource usage and forecast
|
||||
demand.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-gray-100 rounded-[var(--cp-card-radius-lg)] p-[var(--cp-space-3xl)] shadow-[var(--cp-card-shadow)] hover:shadow-[var(--cp-card-shadow-lg)] transition-shadow duration-300">
|
||||
<div className="flex items-center justify-center w-14 h-14 bg-blue-100 rounded-full mb-6">
|
||||
<ArrowPathIcon className="w-7 h-7 text-blue-600" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-3">Seamless Migration</h3>
|
||||
<p className="text-gray-600">
|
||||
Guided onboarding for WHMCS customers with automatic data migration and validation.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-gray-100 rounded-[var(--cp-card-radius-lg)] p-[var(--cp-space-3xl)] shadow-[var(--cp-card-shadow)] hover:shadow-[var(--cp-card-shadow-lg)] transition-shadow duration-300">
|
||||
<div className="flex items-center justify-center w-14 h-14 bg-blue-100 rounded-full mb-6">
|
||||
<SparklesIcon className="w-7 h-7 text-blue-600" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-3">Future-Proof Platform</h3>
|
||||
<p className="text-gray-600">
|
||||
Built on modern infrastructure with continuous updates, security patches, and new
|
||||
features.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="bg-gradient-to-br from-blue-900 via-blue-800 to-blue-700 py-20">
|
||||
<div className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-page-padding)] sm:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-16 items-center">
|
||||
<div>
|
||||
<h2 className="text-4xl font-bold text-white mb-6">
|
||||
Ready to experience the new portal?
|
||||
</h2>
|
||||
<p className="text-blue-100 text-lg mb-8">
|
||||
Join thousands of customers who trust Assist Solutions to keep their business
|
||||
connected and secure.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<Link
|
||||
href="/auth/signup"
|
||||
className="inline-flex items-center justify-center px-8 py-3 border border-transparent text-base font-medium rounded-lg text-blue-600 bg-white hover:bg-blue-50 transition-colors duration-200"
|
||||
>
|
||||
Create an Account
|
||||
</Link>
|
||||
<Link
|
||||
href="/auth/login"
|
||||
className="inline-flex items-center justify-center px-8 py-3 border border-blue-300 text-base font-medium rounded-lg text-white bg-transparent hover:bg-blue-800 transition-colors duration-200"
|
||||
>
|
||||
Portal Login
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/10 backdrop-blur rounded-2xl p-8 border border-white/20">
|
||||
<h3 className="text-xl font-semibold text-white mb-4">What’s included?</h3>
|
||||
<ul className="space-y-4 text-blue-100">
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="mt-1 h-2 w-2 rounded-full bg-blue-300"></span>
|
||||
<span>Centralized service and subscription management dashboard</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="mt-1 h-2 w-2 rounded-full bg-blue-300"></span>
|
||||
<span>Automated billing with support for multiple payment methods</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="mt-1 h-2 w-2 rounded-full bg-blue-300"></span>
|
||||
<span>Priority access to our customer support specialists</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="mt-1 h-2 w-2 rounded-full bg-blue-300"></span>
|
||||
<span>Insights and analytics to track usage and growth</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-blue-950 text-blue-100 py-10">
|
||||
<div className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-page-padding)] sm:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
<div>
|
||||
<Logo size={32} />
|
||||
<p className="mt-4 text-sm text-blue-200">
|
||||
Delivering reliable IT solutions and support for businesses of all sizes.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white uppercase tracking-wider">Portal</h3>
|
||||
<ul className="mt-4 space-y-2 text-sm">
|
||||
<li>
|
||||
<Link
|
||||
href="/auth/login"
|
||||
className="hover:text-white transition-colors duration-200"
|
||||
>
|
||||
Login
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/auth/signup"
|
||||
className="hover:text-white transition-colors duration-200"
|
||||
>
|
||||
Create Account
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/support" className="hover:text-white transition-colors duration-200">
|
||||
Support Center
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white uppercase tracking-wider">
|
||||
Solutions
|
||||
</h3>
|
||||
<ul className="mt-4 space-y-2 text-sm">
|
||||
<li>Network Services</li>
|
||||
<li>Managed Security</li>
|
||||
<li>Cloud Infrastructure</li>
|
||||
<li>Professional Services</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white uppercase tracking-wider">Contact</h3>
|
||||
<ul className="mt-4 space-y-2 text-sm">
|
||||
<li>support@assistsolutions.com</li>
|
||||
<li>+1 (800) 555-0123</li>
|
||||
<li>123 Innovation Drive, Suite 400</li>
|
||||
<li>San Francisco, CA 94105</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 border-t border-blue-800 pt-6 text-sm text-blue-300 flex flex-col sm:flex-row justify-between gap-4">
|
||||
<p>© {new Date().getFullYear()} Assist Solutions. All rights reserved.</p>
|
||||
<div className="flex gap-6">
|
||||
<Link href="#" className="hover:text-white transition-colors duration-200">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
<Link href="#" className="hover:text-white transition-colors duration-200">
|
||||
Terms of Service
|
||||
</Link>
|
||||
<Link href="#" className="hover:text-white transition-colors duration-200">
|
||||
Status
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -42,8 +42,11 @@ export function ChangePlanModal({
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await apiClient.post(`/subscriptions/${subscriptionId}/sim/change-plan`, {
|
||||
newPlanCode: newPlanCode,
|
||||
await apiClient.POST("/subscriptions/{id}/sim/change-plan", {
|
||||
params: { path: { id: subscriptionId } },
|
||||
body: {
|
||||
newPlanCode,
|
||||
},
|
||||
});
|
||||
onSuccess();
|
||||
} catch (e: unknown) {
|
||||
|
||||
@ -58,7 +58,9 @@ export function SimActions({
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await apiClient.post(`/subscriptions/${subscriptionId}/sim/reissue-esim`);
|
||||
await apiClient.POST("/subscriptions/{id}/sim/reissue-esim", {
|
||||
params: { path: { id: subscriptionId } },
|
||||
});
|
||||
|
||||
setSuccess("eSIM profile reissued successfully");
|
||||
setShowReissueConfirm(false);
|
||||
@ -75,7 +77,9 @@ export function SimActions({
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await apiClient.post(`/subscriptions/${subscriptionId}/sim/cancel`, {});
|
||||
await apiClient.POST("/subscriptions/{id}/sim/cancel", {
|
||||
params: { path: { id: subscriptionId } },
|
||||
});
|
||||
|
||||
setSuccess("SIM service cancelled successfully");
|
||||
setShowCancelConfirm(false);
|
||||
|
||||
@ -75,10 +75,10 @@ export function SimFeatureToggles({
|
||||
if (nt !== initial.nt) featurePayload.networkType = nt;
|
||||
|
||||
if (Object.keys(featurePayload).length > 0) {
|
||||
await apiClient.post(
|
||||
`/subscriptions/${subscriptionId}/sim/features`,
|
||||
featurePayload
|
||||
);
|
||||
await apiClient.POST("/subscriptions/{id}/sim/features", {
|
||||
params: { path: { id: subscriptionId } },
|
||||
body: featurePayload,
|
||||
});
|
||||
}
|
||||
|
||||
setSuccess("Changes submitted successfully");
|
||||
|
||||
@ -30,24 +30,31 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
const data = await apiClient.get<{
|
||||
details: SimDetails;
|
||||
usage: SimUsage;
|
||||
}>(`/subscriptions/${subscriptionId}/sim`);
|
||||
const response = await apiClient.GET("/subscriptions/{id}/sim", {
|
||||
params: { path: { id: subscriptionId } },
|
||||
});
|
||||
|
||||
setSimInfo(data);
|
||||
const payload = response.data as SimInfo | undefined;
|
||||
|
||||
if (!payload) {
|
||||
throw new Error("Failed to load SIM information");
|
||||
}
|
||||
|
||||
setSimInfo(payload);
|
||||
} catch (err: unknown) {
|
||||
const hasStatus = (v: unknown): v is { status: number } =>
|
||||
typeof v === "object" &&
|
||||
v !== null &&
|
||||
"status" in v &&
|
||||
typeof (v as { status: unknown }).status === "number";
|
||||
const hasStatus = (value: unknown): value is { status: number } =>
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"status" in value &&
|
||||
typeof (value as { status: unknown }).status === "number";
|
||||
|
||||
if (hasStatus(err) && err.status === 400) {
|
||||
// Not a SIM subscription - this component shouldn't be shown
|
||||
setError("This subscription is not a SIM service");
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : "Failed to load SIM information");
|
||||
return;
|
||||
}
|
||||
|
||||
setError(err instanceof Error ? err.message : "Failed to load SIM information");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@ -45,7 +45,10 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
|
||||
quotaMb: getCurrentAmountMb(),
|
||||
};
|
||||
|
||||
await apiClient.post(`/subscriptions/${subscriptionId}/sim/top-up`, requestBody);
|
||||
await apiClient.POST("/subscriptions/{id}/sim/top-up", {
|
||||
params: { path: { id: subscriptionId } },
|
||||
body: requestBody,
|
||||
});
|
||||
|
||||
onSuccess();
|
||||
} catch (error: unknown) {
|
||||
|
||||
@ -22,14 +22,22 @@ export function useSubscriptions(options: UseSubscriptionsOptions = {}) {
|
||||
return useQuery<SubscriptionList | Subscription[]>({
|
||||
queryKey: ["subscriptions", status],
|
||||
queryFn: async () => {
|
||||
|
||||
const params = new URLSearchParams({
|
||||
...(status && { status }),
|
||||
});
|
||||
if (!hasValidToken) {
|
||||
throw new Error("Authentication required");
|
||||
|
||||
|
||||
const response = await apiClient.GET(
|
||||
"/subscriptions",
|
||||
status ? { params: { query: { status } } } : undefined
|
||||
);
|
||||
|
||||
if (!response.data) {
|
||||
return [] as Subscription[];
|
||||
}
|
||||
|
||||
return apiClient.get<SubscriptionList | Subscription[]>(`/subscriptions?${params}`);
|
||||
return response.data as SubscriptionList | Subscription[];
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
gcTime: 10 * 60 * 1000, // 10 minutes
|
||||
@ -50,7 +58,9 @@ export function useActiveSubscriptions() {
|
||||
throw new Error("Authentication required");
|
||||
}
|
||||
|
||||
return apiClient.get<Subscription[]>(`/subscriptions/active`);
|
||||
|
||||
const response = await apiClient.GET("/subscriptions/active");
|
||||
return (response.data ?? []) as Subscription[];
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
gcTime: 10 * 60 * 1000, // 10 minutes
|
||||
@ -77,14 +87,25 @@ export function useSubscriptionStats() {
|
||||
throw new Error("Authentication required");
|
||||
}
|
||||
|
||||
const stats = await apiClient.get<{
|
||||
|
||||
const response = await apiClient.GET("/subscriptions/stats");
|
||||
return (response.data ?? {
|
||||
total: 0,
|
||||
active: 0,
|
||||
suspended: 0,
|
||||
cancelled: 0,
|
||||
pending: 0,
|
||||
}) as {
|
||||
|
||||
|
||||
total: number;
|
||||
active: number;
|
||||
suspended: number;
|
||||
cancelled: number;
|
||||
pending: number;
|
||||
}>(`/subscriptions/stats`);
|
||||
return stats;
|
||||
|
||||
};
|
||||
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
gcTime: 10 * 60 * 1000, // 10 minutes
|
||||
@ -105,7 +126,17 @@ export function useSubscription(subscriptionId: number) {
|
||||
throw new Error("Authentication required");
|
||||
}
|
||||
|
||||
return apiClient.get<Subscription>(`/subscriptions/${subscriptionId}`);
|
||||
|
||||
const response = await apiClient.GET("/subscriptions/{id}", {
|
||||
params: { path: { id: subscriptionId } },
|
||||
});
|
||||
|
||||
if (!response.data) {
|
||||
throw new Error("Subscription not found");
|
||||
}
|
||||
|
||||
return response.data as Subscription;
|
||||
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
gcTime: 10 * 60 * 1000, // 10 minutes
|
||||
@ -130,13 +161,24 @@ export function useSubscriptionInvoices(
|
||||
throw new Error("Authentication required");
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
limit: limit.toString(),
|
||||
const response = await apiClient.GET("/subscriptions/{id}/invoices", {
|
||||
params: {
|
||||
path: { id: subscriptionId },
|
||||
query: { page, limit },
|
||||
},
|
||||
});
|
||||
return apiClient.get<InvoiceList>(
|
||||
`/subscriptions/${subscriptionId}/invoices?${params}`
|
||||
);
|
||||
|
||||
return (
|
||||
response.data ?? {
|
||||
invoices: [],
|
||||
pagination: {
|
||||
page,
|
||||
totalPages: 0,
|
||||
totalItems: 0,
|
||||
},
|
||||
}
|
||||
) as InvoiceList;
|
||||
|
||||
},
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
gcTime: 5 * 60 * 1000, // 5 minutes
|
||||
@ -152,7 +194,11 @@ export function useSubscriptionAction() {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, action }: { id: number; action: string }) => {
|
||||
return apiClient.post(`/subscriptions/${id}/actions`, { action });
|
||||
await apiClient.POST("/subscriptions/{id}/actions", {
|
||||
params: { path: { id } },
|
||||
body: { action },
|
||||
});
|
||||
|
||||
},
|
||||
onSuccess: (_, { id }) => {
|
||||
// Invalidate relevant queries after successful action
|
||||
|
||||
@ -1,9 +1 @@
|
||||
/**
|
||||
* Support Feature Module
|
||||
* Customer support functionality including components, hooks, and services
|
||||
*
|
||||
* Note: This feature module is currently empty and ready for future implementation
|
||||
*/
|
||||
|
||||
// This feature module is not yet implemented
|
||||
// Components, hooks, services, types, and utilities will be added as needed
|
||||
export * from "./views";
|
||||
|
||||
247
apps/portal/src/features/support/views/NewSupportCaseView.tsx
Normal file
247
apps/portal/src/features/support/views/NewSupportCaseView.tsx
Normal file
@ -0,0 +1,247 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
PaperAirplaneIcon,
|
||||
ExclamationCircleIcon,
|
||||
InformationCircleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
|
||||
import { logger } from "@customer-portal/logging";
|
||||
|
||||
export function NewSupportCaseView() {
|
||||
const router = useRouter();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
subject: "",
|
||||
category: "Technical",
|
||||
priority: "Medium",
|
||||
description: "",
|
||||
});
|
||||
|
||||
const handleSubmit = (event: FormEvent) => {
|
||||
event.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 (
|
||||
<div className="py-6">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 md:px-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center space-x-4 mb-4">
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="inline-flex items-center text-sm text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<ArrowLeftIcon className="h-4 w-4 mr-1" />
|
||||
Back to Support
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Create Support Case</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">Get help from our support team</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Help Tips */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<InformationCircleIcon className="h-5 w-5 text-blue-400" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-blue-800">Before creating a case</h3>
|
||||
<div className="mt-2 text-sm text-blue-700">
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>Check our knowledge base for common solutions</li>
|
||||
<li>Include relevant error messages or screenshots</li>
|
||||
<li>Provide detailed steps to reproduce the issue</li>
|
||||
<li>Mention your service or subscription if applicable</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||
{/* Subject */}
|
||||
<div>
|
||||
<label htmlFor="subject" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Subject *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="subject"
|
||||
value={formData.subject}
|
||||
onChange={event => handleInputChange("subject", event.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
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category and Priority */}
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="category" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Category
|
||||
</label>
|
||||
<select
|
||||
id="category"
|
||||
value={formData.category}
|
||||
onChange={event => handleInputChange("category", event.target.value)}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="Technical">Technical Support</option>
|
||||
<option value="Billing">Billing Question</option>
|
||||
<option value="General">General Inquiry</option>
|
||||
<option value="Feature Request">Feature Request</option>
|
||||
<option value="Bug Report">Bug Report</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="priority" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Priority
|
||||
</label>
|
||||
<select
|
||||
id="priority"
|
||||
value={formData.priority}
|
||||
onChange={event => handleInputChange("priority", event.target.value)}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="Low">Low - General question</option>
|
||||
<option value="Medium">Medium - Issue affecting work</option>
|
||||
<option value="High">High - Service disruption</option>
|
||||
<option value="Critical">Critical - Complete outage</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Description *
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
rows={6}
|
||||
value={formData.description}
|
||||
onChange={event => handleInputChange("description", event.target.value)}
|
||||
placeholder="Please provide a detailed description of your issue, including any error messages and steps to reproduce the problem..."
|
||||
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
|
||||
/>
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
The more details you provide, the faster we can help you.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Priority Warning */}
|
||||
{formData.priority === "Critical" && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<ExclamationCircleIcon className="h-5 w-5 text-red-400" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800">Critical Priority Selected</h3>
|
||||
<div className="mt-2 text-sm text-red-700">
|
||||
<p>
|
||||
Critical priority should only be used for complete service outages. For
|
||||
urgent issues that aren't complete outages, please use High priority.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between pt-6 border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isFormValid || isSubmitting}
|
||||
className="inline-flex items-center px-6 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Creating Case...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PaperAirplaneIcon className="h-4 w-4 mr-2" />
|
||||
Create Case
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Additional Help */}
|
||||
<div className="mt-8 bg-gray-50 rounded-lg p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Need immediate help?</h3>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900">Phone Support</h4>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
9:30-18:00 JST
|
||||
<br />
|
||||
<span className="font-medium text-blue-600">0120-660-470</span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900">Knowledge Base</h4>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Search our help articles for quick solutions
|
||||
<br />
|
||||
<Link href="/support/kb" className="font-medium text-blue-600 hover:text-blue-500">
|
||||
Browse Knowledge Base
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ChatBubbleLeftRightIcon,
|
||||
@ -28,7 +28,7 @@ interface SupportCase {
|
||||
assignedTo?: string;
|
||||
}
|
||||
|
||||
export default function SupportCasesPage() {
|
||||
export function SupportCasesView() {
|
||||
const [cases, setCases] = useState<SupportCase[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
@ -99,10 +99,12 @@ export default function SupportCasesPage() {
|
||||
},
|
||||
];
|
||||
|
||||
setTimeout(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
setCases(mockCases);
|
||||
setLoading(false);
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, []);
|
||||
|
||||
// Filter cases based on search, status, and priority
|
||||
@ -228,8 +230,8 @@ export default function SupportCasesPage() {
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">Open Cases</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">
|
||||
{
|
||||
cases.filter(c =>
|
||||
["Open", "In Progress", "Waiting on Customer"].includes(c.status)
|
||||
cases.filter(caseItem =>
|
||||
["Open", "In Progress", "Waiting on Customer"].includes(caseItem.status)
|
||||
).length
|
||||
}
|
||||
</dd>
|
||||
@ -249,7 +251,10 @@ export default function SupportCasesPage() {
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">High Priority</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">
|
||||
{cases.filter(c => ["High", "Critical"].includes(c.priority)).length}
|
||||
{
|
||||
cases.filter(caseItem => ["High", "Critical"].includes(caseItem.priority))
|
||||
.length
|
||||
}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
@ -267,7 +272,10 @@ export default function SupportCasesPage() {
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">Resolved</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">
|
||||
{cases.filter(c => ["Resolved", "Closed"].includes(c.status)).length}
|
||||
{
|
||||
cases.filter(caseItem => ["Resolved", "Closed"].includes(caseItem.status))
|
||||
.length
|
||||
}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
@ -289,7 +297,7 @@ export default function SupportCasesPage() {
|
||||
type="text"
|
||||
placeholder="Search cases..."
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
onChange={event => setSearchTerm(event.target.value)}
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
@ -298,7 +306,7 @@ export default function SupportCasesPage() {
|
||||
<div className="relative">
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={e => setStatusFilter(e.target.value)}
|
||||
onChange={event => setStatusFilter(event.target.value)}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md leading-5 bg-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
@ -314,7 +322,7 @@ export default function SupportCasesPage() {
|
||||
<div className="relative">
|
||||
<select
|
||||
value={priorityFilter}
|
||||
onChange={e => setPriorityFilter(e.target.value)}
|
||||
onChange={event => setPriorityFilter(event.target.value)}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md leading-5 bg-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="all">All Priorities</option>
|
||||
2
apps/portal/src/features/support/views/index.ts
Normal file
2
apps/portal/src/features/support/views/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./NewSupportCaseView";
|
||||
export * from "./SupportCasesView";
|
||||
@ -15,6 +15,12 @@
|
||||
// Path mappings
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@/components/*": ["./src/components/*"],
|
||||
"@/core/*": ["./src/core/*"],
|
||||
"@/features/*": ["./src/features/*"],
|
||||
"@/shared/*": ["./src/shared/*"],
|
||||
"@/styles/*": ["./src/styles/*"],
|
||||
"@/types/*": ["./src/types/*"],
|
||||
},
|
||||
// Enforce TS-only in portal and keep strict mode explicit (inherits from root)
|
||||
"allowJs": false,
|
||||
|
||||
@ -30,7 +30,7 @@ apps/portal/src/features/service-management/
|
||||
|
||||
## Integration
|
||||
|
||||
- Entry point: `apps/portal/src/app/(portal)/subscriptions/[id]/page.tsx` renders `ServiceManagementSection`
|
||||
- Entry point: `apps/portal/src/app/(authenticated)/subscriptions/[id]/page.tsx` renders `ServiceManagementSection`
|
||||
- Detection: SIM availability is inferred from `subscription.productName` including `sim` (case-insensitive)
|
||||
|
||||
## Future Expansion
|
||||
|
||||
@ -99,10 +99,10 @@ export default [
|
||||
},
|
||||
},
|
||||
|
||||
// Prevent importing the DashboardLayout directly in (portal) pages.
|
||||
// Pages should rely on the shared route-group layout at (portal)/layout.tsx.
|
||||
// Prevent importing the DashboardLayout directly in (authenticated) pages.
|
||||
// Pages should rely on the shared route-group layout at (authenticated)/layout.tsx.
|
||||
{
|
||||
files: ["apps/portal/src/app/(portal)/**/*.{ts,tsx}"],
|
||||
files: ["apps/portal/src/app/(authenticated)/**/*.{ts,tsx}"],
|
||||
rules: {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
@ -111,7 +111,7 @@ export default [
|
||||
{
|
||||
group: ["@/components/layout/dashboard-layout"],
|
||||
message:
|
||||
"Use the shared (portal)/layout.tsx instead of importing DashboardLayout in pages.",
|
||||
"Use the shared (authenticated)/layout.tsx instead of importing DashboardLayout in pages.",
|
||||
},
|
||||
],
|
||||
},
|
||||
@ -133,14 +133,14 @@ export default [
|
||||
},
|
||||
// Allow the shared layout file itself to import the layout component
|
||||
{
|
||||
files: ["apps/portal/src/app/(portal)/layout.tsx"],
|
||||
files: ["apps/portal/src/app/(authenticated)/layout.tsx"],
|
||||
rules: {
|
||||
"no-restricted-imports": "off",
|
||||
},
|
||||
},
|
||||
// Allow controlled window.location usage for invoice SSO download
|
||||
{
|
||||
files: ["apps/portal/src/app/(portal)/billing/invoices/[id]/page.tsx"],
|
||||
files: ["apps/portal/src/app/(authenticated)/billing/invoices/[id]/page.tsx"],
|
||||
rules: {
|
||||
"no-restricted-syntax": "off",
|
||||
},
|
||||
|
||||
@ -13,7 +13,11 @@ export function createClient(
|
||||
baseUrl: string,
|
||||
options: CreateClientOptions = {}
|
||||
): ApiClient {
|
||||
const client = createOpenApiClient<paths>({ baseUrl });
|
||||
const client = createOpenApiClient<paths>({
|
||||
baseUrl,
|
||||
throwOnError: true,
|
||||
});
|
||||
|
||||
|
||||
if (typeof client.use === "function" && options.getAuthHeader) {
|
||||
const resolveAuthHeader = options.getAuthHeader;
|
||||
|
||||
@ -46,6 +46,9 @@ export const isOrderId = (id: string): id is OrderId => typeof id === 'string';
|
||||
export const isInvoiceId = (id: string): id is InvoiceId => typeof id === 'string';
|
||||
export const isWhmcsClientId = (id: number): id is WhmcsClientId => typeof id === 'number';
|
||||
|
||||
// Shared ISO8601 timestamp string type used for serialized dates
|
||||
export type IsoDateTimeString = string;
|
||||
|
||||
// =====================================================
|
||||
// BASE ENTITY INTERFACES
|
||||
// =====================================================
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
// User and authentication types
|
||||
import type { BaseEntity, Address } from "../common";
|
||||
import type { BaseEntity, Address, IsoDateTimeString } from "../common";
|
||||
|
||||
export interface User extends BaseEntity {
|
||||
email: string;
|
||||
@ -46,7 +46,7 @@ export interface Activity {
|
||||
export interface AuthTokens {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
expiresAt: string; // ISO
|
||||
expiresAt: IsoDateTimeString;
|
||||
tokenType?: string;
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user