Merge branch 'ver2' into codex/update-auth-token-structure-and-consumers

This commit is contained in:
NTumurbars 2025-09-18 16:45:11 +09:00 committed by GitHub
commit 69aee3b08e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
80 changed files with 1151 additions and 892 deletions

View File

@ -23,7 +23,9 @@ import { getErrorMessage } from "@bff/core/utils/error.util";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { sanitizeWhmcsRedirectPath } from "@bff/core/utils/sso.util"; import { sanitizeWhmcsRedirectPath } from "@bff/core/utils/sso.util";
import { EmailService } from "@bff/infra/email/email.service"; import { EmailService } from "@bff/infra/email/email.service";
import { User as SharedUser, type AuthTokens } from "@customer-portal/domain"; import { User as SharedUser, type AuthTokens } from "@customer-portal/domain";
import type { User as PrismaUser } from "@prisma/client"; import type { User as PrismaUser } from "@prisma/client";
import type { Request } from "express"; import type { Request } from "express";
import { WhmcsClientResponse } from "@bff/integrations/whmcs/types/whmcs-api.types"; import { WhmcsClientResponse } from "@bff/integrations/whmcs/types/whmcs-api.types";
@ -34,6 +36,8 @@ import { calculateExpiryDate } from "./utils/jwt-expiry.util";
export class AuthService { export class AuthService {
private readonly MAX_LOGIN_ATTEMPTS = 5; private readonly MAX_LOGIN_ATTEMPTS = 5;
private readonly LOCKOUT_DURATION_MINUTES = 15; private readonly LOCKOUT_DURATION_MINUTES = 15;
private readonly DEFAULT_TOKEN_TYPE = "Bearer";
private readonly DEFAULT_TOKEN_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
constructor( constructor(
private usersService: UsersService, private usersService: UsersService,
@ -803,6 +807,61 @@ export class AuthService {
return sanitizeWhmcsRedirectPath(path); 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> { async requestPasswordReset(email: string): Promise<void> {
const user = await this.usersService.findByEmailInternal(email); const user = await this.usersService.findByEmailInternal(email);
// Always act as if successful to avoid account enumeration // Always act as if successful to avoid account enumeration

View File

@ -6,26 +6,31 @@ This document outlines the new feature-driven architecture implemented for the c
``` ```
apps/portal/src/ apps/portal/src/
├── app/ # Next.js App Router pages ├── app/ # Next.js App Router entry points (route groups only)
├── components/ # Shared UI components (Design System) │ ├── (public)/ # Marketing + auth routes, pages import feature views
│ ├── ui/ # Base UI components (atoms) │ ├── (authenticated)/ # Signed-in portal routes, thin wrappers around features
│ ├── layout/ # Layout components (organisms) │ ├── api/ # App Router API routes
│ └── common/ # Shared business components (molecules) │ ├── favicon.ico / globals.css # Global assets
├── features/ # Feature-specific modules │ └── layout.tsx # Root layout/providers
│ ├── auth/ # Authentication feature ├── components/ # Shared UI components (design system atoms/molecules)
│ ├── dashboard/ # Dashboard feature │ ├── ui/
│ ├── billing/ # Billing feature │ ├── layout/
│ ├── subscriptions/ # Subscriptions feature │ └── common/
│ ├── catalog/ # Product catalog feature ├── core/ # App-wide configuration (env, logger, providers)
│ └── support/ # Support feature ├── features/ # Feature-specific modules composed by routes
├── lib/ # Core utilities and services (replaces core/shared) │ ├── account/
│ ├── api/ # API client and base services │ ├── auth/
│ ├── query.ts # Query client and keys │ ├── billing/
│ ├── env.ts # Runtime env parsing │ ├── catalog/
│ ├── types/ # Shared TypeScript types │ ├── dashboard/
│ └── utils/ # Utility functions (cn, currency, error-display, ...) │ ├── marketing/
├── providers/ # React context providers (e.g., QueryProvider) │ ├── orders/
└── styles/ # Global styles and design tokens │ ├── 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 ## Design Principles
@ -48,7 +53,7 @@ Each feature module contains:
- `utils/`: Utility functions - `utils/`: Utility functions
### 4. Centralized Shared Resources ### 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 ## Feature Module Structure
@ -118,20 +123,21 @@ import { DataTable } from '@/components/common';
import type { User, ApiResponse } from '@/types'; import type { User, ApiResponse } from '@/types';
// Utility imports // Utility imports
import { designSystem } from '@/lib/design-system'; import { QueryProvider } from '@/core/providers';
// Prefer feature services/hooks over direct apiClient usage in pages // Prefer feature services/hooks over direct api usage in pages
import { apiClient } from '@/lib/api/client'; import { logger } from '@/core/config';
``` ```
### Path Mappings ### Path Mappings
- `@/*` - Root src directory - `@/*` - Root src directory
- `@/components/*` - Component library - `@/components/*` - Component library
- `@/core/*` - App-wide configuration and providers
- `@/features/*` - Feature modules - `@/features/*` - Feature modules
- `@/lib/*` - Core utilities - `@/shared/*` - Shared helpers/constants
- `@/types` - Type definitions
- `@/styles/*` - Style files - `@/styles/*` - Style files
- `@shared/*` - Shared package - `@/types/*` - Portal-specific types
- `@shared/*` - Shared package exports
## Migration Strategy ## 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. 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 ### Current Feature Hooks/Services
- Catalog - Catalog
@ -191,3 +205,7 @@ This ensures pages remain declarative and the feature layer encapsulates logic.
- Service: `ordersService` (list/detail/create) - Service: `ordersService` (list/detail/create)
- Account - Account
- Service: `accountService` (`/me/address`) - Service: `accountService` (`/me/address`)
- Support
- Views: `SupportCasesView`, `NewSupportCaseView` (mock data, ready for API wiring)
- Marketing
- Views: `PublicLandingView`, `PublicLandingLoadingView`

View 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;

View 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;
}
})();

View File

@ -0,0 +1,5 @@
import { DashboardView } from "@/features/dashboard";
export default function DashboardPage() {
return <DashboardView />;
}

View File

@ -0,0 +1,5 @@
import { SupportCasesView } from "@/features/support";
export default function SupportCasesPage() {
return <SupportCasesView />;
}

View File

@ -0,0 +1,5 @@
import { NewSupportCaseView } from "@/features/support";
export default function NewSupportCasePage() {
return <NewSupportCaseView />;
}

View File

@ -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&apos;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>
);
}

View File

@ -0,0 +1,5 @@
import { PublicLandingLoadingView } from "@/features/marketing";
export default function PublicHomeLoading() {
return <PublicLandingLoadingView />;
}

View File

@ -0,0 +1,5 @@
import { PublicLandingView } from "@/features/marketing";
export default function PublicHomePage() {
return <PublicLandingView />;
}

View File

@ -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>
);
}

View File

@ -1,174 +1,27 @@
/**
* Core API Client * Core API client configuration
* Instantiates the shared OpenAPI client with portal-specific configuration. * Wraps the shared generated client to inject portal-specific behavior like auth headers.
*/ */
import { createClient as createOpenApiClient } from "@customer-portal/api-client"; import {
import type { ApiClient as GeneratedApiClient } from "@customer-portal/api-client"; createClient as createOpenApiClient,
type ApiClient as GeneratedApiClient,
type AuthHeaderResolver,
} from "@customer-portal/api-client";
import { env } from "../config/env"; 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; 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) => { export const apiClient: GeneratedApiClient = createOpenApiClient(baseUrl, {
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, {
getAuthHeader: resolveAuthHeader, getAuthHeader: resolveAuthHeader,
}); });
export const apiClient = Object.assign(openApiClient, restMethods) as ApiClient; export const configureApiClientAuth = (resolver?: AuthHeaderResolver) => {
authHeaderResolver = resolver;
export const configureApiClientAuth = (getter?: AuthHeaderGetter) => {
authHeaderGetter = getter;
}; };
export type ApiClient = GeneratedApiClient;

View File

@ -1,3 +1,5 @@
export { apiClient, configureApiClientAuth } from "./client"; export { apiClient, configureApiClientAuth } from "./client";
export { queryKeys } from "./query-keys"; 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";

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { logger } from "@customer-portal/logging"; 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 { useAuthStore } from "@/features/auth/services/auth.store";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -15,70 +15,70 @@ export function SessionTimeoutWarning({
const { isAuthenticated, tokens, logout, checkAuth } = useAuthStore(); const { isAuthenticated, tokens, logout, checkAuth } = useAuthStore();
const [showWarning, setShowWarning] = useState(false); const [showWarning, setShowWarning] = useState(false);
const [timeLeft, setTimeLeft] = useState<number>(0); const [timeLeft, setTimeLeft] = useState<number>(0);
const expiryRef = useRef<number | null>(null);
useEffect(() => { useEffect(() => {
if (!isAuthenticated || !tokens?.accessToken) { if (!isAuthenticated || !tokens?.expiresAt) {
expiryRef.current = null;
setShowWarning(false);
setTimeLeft(0);
return undefined; return undefined;
} }
// Parse JWT to get expiry time const expiryTime = Date.parse(tokens.expiresAt);
try { if (Number.isNaN(expiryTime)) {
const parts = tokens.accessToken.split("."); logger.warn("Invalid expiresAt on auth tokens", { expiresAt: tokens.expiresAt });
if (parts.length !== 3) { expiryRef.current = null;
throw new Error("Invalid token format"); setShowWarning(false);
} setTimeLeft(0);
return undefined;
}
const payload = JSON.parse(atob(parts[1])) as { exp?: number }; expiryRef.current = expiryTime;
if (!payload.exp) {
logger.warn("Token does not have expiration time");
return undefined;
}
const expiryTime = payload.exp * 1000; // Convert to milliseconds if (Date.now() >= expiryTime) {
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");
void logout(); void logout();
return undefined; 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(() => { useEffect(() => {
if (!showWarning) return undefined; if (!showWarning || !expiryRef.current) return undefined;
const interval = setInterval(() => { const interval = setInterval(() => {
setTimeLeft(prev => { const expiryTime = expiryRef.current;
if (prev <= 1) { if (!expiryTime) {
void logout(); return;
return 0; }
}
return prev - 1; const remaining = expiryTime - Date.now();
}); if (remaining <= 0) {
setTimeLeft(0);
void logout();
return;
}
setTimeLeft(Math.max(1, Math.ceil(remaining / (60 * 1000))));
}, 60000); }, 60000);
return () => clearInterval(interval); return () => clearInterval(interval);

View File

@ -53,8 +53,8 @@ export function useAuth() {
// Password reset request // Password reset request
const passwordResetMutation = useMutation({ const passwordResetMutation = useMutation({
mutationFn: (data: ForgotPasswordRequest) => mutationFn: (data: ForgotPasswordRequest) =>
apiClient.POST('/auth/forgot-password', { body: data }), apiClient.POST('/auth/request-password-reset', { body: data }),
}); });
// Password reset // Password reset

View File

@ -15,6 +15,7 @@ import type {
ResetPasswordRequest, ResetPasswordRequest,
ChangePasswordRequest, ChangePasswordRequest,
AuthError, AuthError,
IsoDateTimeString,
} from "@customer-portal/domain"; } from "@customer-portal/domain";
const DEFAULT_TOKEN_TYPE = "Bearer"; const DEFAULT_TOKEN_TYPE = "Bearer";
@ -31,10 +32,12 @@ type LegacyAuthTokens = {
type RawAuthTokens = type RawAuthTokens =
| AuthTokens | AuthTokens
| (Omit<AuthTokens, "expiresAt"> & { expiresAt: string | number | Date }) | (Omit<AuthTokens, "expiresAt"> & { expiresAt: string | number | Date })
| LegacyAuthTokens; | LegacyAuthTokens;
const toIsoString = (value: string | number | Date | null | undefined) => { const toIsoString = (value: string | number | Date | null | undefined) => {
if (value instanceof Date) { if (value instanceof Date) {
return value.toISOString(); return value.toISOString();
} }
@ -43,12 +46,14 @@ const toIsoString = (value: string | number | Date | null | undefined) => {
return new Date(value).toISOString(); return new Date(value).toISOString();
} }
if (typeof value === "string" && value) { if (typeof value === "string" && value) {
return value; return value;
} }
// Treat missing expiry as already expired to force re-authentication // Treat missing expiry as already expired to force re-authentication
return new Date(0).toISOString(); return new Date(0).toISOString();
}; };
const isLegacyTokens = (tokens: RawAuthTokens): tokens is LegacyAuthTokens => const isLegacyTokens = (tokens: RawAuthTokens): tokens is LegacyAuthTokens =>
@ -242,7 +247,7 @@ export const useAuthStore = create<AuthStoreState>()(
requestPasswordReset: async (data: ForgotPasswordRequest) => { requestPasswordReset: async (data: ForgotPasswordRequest) => {
set({ loading: true, error: null }); set({ loading: true, error: null });
try { try {
await apiClient.POST('/auth/forgot-password', { body: data }); await apiClient.POST('/auth/request-password-reset', { body: data });
set({ loading: false }); set({ loading: false });
} catch (error) { } catch (error) {
const authError = error as AuthError; const authError = error as AuthError;
@ -377,7 +382,16 @@ export const useAuthStore = create<AuthStoreState>()(
if (user) { if (user) {
set({ user, isAuthenticated: true, loading: false, error: null, hasCheckedAuth: true }); set({ user, isAuthenticated: true, loading: false, error: null, hasCheckedAuth: true });
} else { } 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) { } catch (error) {
// Token is invalid, clear auth state // Token is invalid, clear auth state

View File

@ -1,2 +1,3 @@
export * from "./components"; export * from "./components";
export * from "./hooks"; export * from "./hooks";
export * from "./views";

View File

@ -1,23 +1,16 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useAuthStore } from "@/features/auth/services/auth.store"; import type { Activity, DashboardSummary } from "@customer-portal/domain";
import { useDashboardSummary } from "@/features/dashboard/hooks/useDashboard";
import type { Activity } from "@customer-portal/domain";
import { import {
CreditCardIcon,
ServerIcon, ServerIcon,
ChatBubbleLeftRightIcon, ChatBubbleLeftRightIcon,
ExclamationTriangleIcon,
ChevronRightIcon, ChevronRightIcon,
PlusIcon,
DocumentTextIcon, DocumentTextIcon,
ArrowTrendingUpIcon, ArrowTrendingUpIcon,
CalendarDaysIcon, CalendarDaysIcon,
BellIcon,
ClipboardDocumentListIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import { import {
CreditCardIcon as CreditCardIconSolid, CreditCardIcon as CreditCardIconSolid,
@ -26,15 +19,19 @@ import {
ClipboardDocumentListIcon as ClipboardDocumentListIconSolid, ClipboardDocumentListIcon as ClipboardDocumentListIconSolid,
} from "@heroicons/react/24/solid"; } from "@heroicons/react/24/solid";
import { format, formatDistanceToNow } from "date-fns"; 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 { StatCard, QuickAction, DashboardActivityItem } from "@/features/dashboard/components";
import { LoadingSpinner } from "@/components/ui/loading-skeleton"; import { LoadingSpinner } from "@/components/ui/loading-skeleton";
import { ErrorState } from "@/components/ui/error-state"; import { ErrorState } from "@/components/ui/error-state";
import { formatCurrency, getCurrencyLocale } from "@customer-portal/domain"; import { formatCurrency, getCurrencyLocale } from "@customer-portal/domain";
export default function DashboardPage() { export function DashboardView() {
const router = useRouter(); const router = useRouter();
const { user, isAuthenticated, loading: authLoading } = useAuthStore(); const { user, isAuthenticated, loading: authLoading } = useAuthStore();
const { data: summary, isLoading: summaryLoading, error } = useDashboardSummary(); const { data: summary, isLoading: summaryLoading, error } = useDashboardSummary();
const upcomingInvoice = summary?.nextInvoice ?? null;
const [paymentLoading, setPaymentLoading] = useState(false); const [paymentLoading, setPaymentLoading] = useState(false);
const [paymentError, setPaymentError] = useState<string | null>(null); 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)]"> <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 <StatCard
title="Recent Orders" title="Recent Orders"
value={((summary?.stats as Record<string, unknown>)?.recentOrders as number) || 0} value={summary?.stats?.recentOrders ?? 0}
icon={ClipboardDocumentListIconSolid} icon={ClipboardDocumentListIconSolid}
gradient="from-gray-500 to-gray-600" gradient="from-gray-500 to-gray-600"
href="/orders" href="/orders"
@ -157,7 +154,7 @@ export default function DashboardPage() {
{/* Main Content Area */} {/* Main Content Area */}
<div className="lg:col-span-2 space-y-[var(--cp-space-3xl)]"> <div className="lg:col-span-2 space-y-[var(--cp-space-3xl)]">
{/* Upcoming Payment - compressed attention banner */} {/* Upcoming Payment - compressed attention banner */}
{summary?.nextInvoice && ( {upcomingInvoice && (
<div <div
id="attention" id="attention"
className="bg-white rounded-xl border border-orange-200 shadow-sm p-4" 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"> <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="font-semibold text-gray-900">Upcoming Payment</span>
<span className="text-gray-400"></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 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{" "} Due{" "}
{formatDistanceToNow(new Date(summary.nextInvoice.dueDate), { {formatDistanceToNow(new Date(upcomingInvoice.dueDate), {
addSuffix: true, addSuffix: true,
})} })}
</span> </span>
</div> </div>
<div className="mt-1 text-2xl font-bold text-gray-900"> <div className="mt-1 text-2xl font-bold text-gray-900">
{formatCurrency(summary.nextInvoice.amount, { {formatCurrency(upcomingInvoice.amount, {
currency: summary.nextInvoice.currency || "JPY", currency: upcomingInvoice.currency || "JPY",
locale: getCurrencyLocale(summary.nextInvoice.currency || "JPY"), locale: getCurrencyLocale(upcomingInvoice.currency || "JPY"),
})} })}
</div> </div>
<div className="mt-1 text-xs text-gray-500"> <div className="mt-1 text-xs text-gray-500">
Exact due date:{" "} Exact due date: {format(new Date(upcomingInvoice.dueDate), "MMMM d, yyyy")}
{format(new Date(summary.nextInvoice.dueDate), "MMMM d, yyyy")}
</div> </div>
</div> </div>
<div className="flex flex-col items-end gap-2"> <div className="flex flex-col items-end gap-2">
<button <button
onClick={() => handlePayNow(summary.nextInvoice!.id)} onClick={() => handlePayNow(upcomingInvoice.id)}
disabled={paymentLoading} 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" 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" />} {!paymentLoading && <ChevronRightIcon className="ml-2 h-4 w-4" />}
</button> </button>
<Link <Link
href={`/billing/invoices/${summary.nextInvoice.id}`} href={`/billing/invoices/${upcomingInvoice.id}`}
className="text-blue-600 hover:text-blue-700 font-medium text-sm" className="text-blue-600 hover:text-blue-700 font-medium text-sm"
> >
View invoice View invoice
@ -276,12 +272,13 @@ export default function DashboardPage() {
} }
// Helpers and small components (local to dashboard) // Helpers and small components (local to dashboard)
function truncateName(name: string, len = 28) { function TasksChip({
if (name.length <= len) return name; summaryLoading,
return name.slice(0, Math.max(0, len - 1)) + "…"; summary,
} }: {
summaryLoading: boolean;
function TasksChip({ summaryLoading, summary }: { summaryLoading: boolean; summary: any }) { summary: DashboardSummary | undefined;
}) {
const router = useRouter(); const router = useRouter();
if (summaryLoading) return null; if (summaryLoading) return null;
const tasks: Array<{ label: string; href: string }> = []; const tasks: Array<{ label: string; href: string }> = [];
@ -339,7 +336,11 @@ function RecentActivityCard({
<button <button
key={opt.k} key={opt.k}
onClick={() => setFilter(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} {opt.label}
</button> </button>

View File

@ -0,0 +1 @@
export * from "./DashboardView";

View File

@ -1,3 +1,5 @@
export * as billing from "./billing"; export * as billing from "./billing";
export * as subscriptions from "./subscriptions"; export * as subscriptions from "./subscriptions";
export * as dashboard from "./dashboard"; export * as dashboard from "./dashboard";
export * as marketing from "./marketing";
export * as support from "./support";

View File

@ -0,0 +1,2 @@
export * from "./views/PublicLandingView";
export * from "./views/PublicLandingLoadingView";

View File

@ -1,6 +1,6 @@
import { Skeleton } from "@/components/ui/loading-skeleton"; import { Skeleton } from "@/components/ui/loading-skeleton";
export default function RootLoading() { export function PublicLandingLoadingView() {
return ( return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-blue-100 to-blue-900"> <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"> <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"> <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" /> <Skeleton className="h-12 w-2/3" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{Array.from({ length: 3 }).map((_, i) => ( {Array.from({ length: 3 }).map((_, index) => (
<div key={i} className="bg-white rounded-2xl p-8 shadow-lg border border-gray-100 space-y-4"> <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-16 w-16 rounded-full mx-auto" />
<Skeleton className="h-6 w-1/2 mx-auto" /> <Skeleton className="h-6 w-1/2 mx-auto" />
<Skeleton className="h-4 w-3/4 mx-auto" /> <Skeleton className="h-4 w-3/4 mx-auto" />
@ -36,5 +39,3 @@ export default function RootLoading() {
</div> </div>
); );
} }

View 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">Whats 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>&copy; {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>
);
}

View File

@ -42,8 +42,11 @@ export function ChangePlanModal({
} }
setLoading(true); setLoading(true);
try { try {
await apiClient.post(`/subscriptions/${subscriptionId}/sim/change-plan`, { await apiClient.POST("/subscriptions/{id}/sim/change-plan", {
newPlanCode: newPlanCode, params: { path: { id: subscriptionId } },
body: {
newPlanCode,
},
}); });
onSuccess(); onSuccess();
} catch (e: unknown) { } catch (e: unknown) {

View File

@ -58,7 +58,9 @@ export function SimActions({
setError(null); setError(null);
try { 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"); setSuccess("eSIM profile reissued successfully");
setShowReissueConfirm(false); setShowReissueConfirm(false);
@ -75,7 +77,9 @@ export function SimActions({
setError(null); setError(null);
try { try {
await apiClient.post(`/subscriptions/${subscriptionId}/sim/cancel`, {}); await apiClient.POST("/subscriptions/{id}/sim/cancel", {
params: { path: { id: subscriptionId } },
});
setSuccess("SIM service cancelled successfully"); setSuccess("SIM service cancelled successfully");
setShowCancelConfirm(false); setShowCancelConfirm(false);

View File

@ -75,10 +75,10 @@ export function SimFeatureToggles({
if (nt !== initial.nt) featurePayload.networkType = nt; if (nt !== initial.nt) featurePayload.networkType = nt;
if (Object.keys(featurePayload).length > 0) { if (Object.keys(featurePayload).length > 0) {
await apiClient.post( await apiClient.POST("/subscriptions/{id}/sim/features", {
`/subscriptions/${subscriptionId}/sim/features`, params: { path: { id: subscriptionId } },
featurePayload body: featurePayload,
); });
} }
setSuccess("Changes submitted successfully"); setSuccess("Changes submitted successfully");

View File

@ -30,24 +30,31 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
try { try {
setError(null); setError(null);
const data = await apiClient.get<{ const response = await apiClient.GET("/subscriptions/{id}/sim", {
details: SimDetails; params: { path: { id: subscriptionId } },
usage: SimUsage; });
}>(`/subscriptions/${subscriptionId}/sim`);
setSimInfo(data); const payload = response.data as SimInfo | undefined;
if (!payload) {
throw new Error("Failed to load SIM information");
}
setSimInfo(payload);
} catch (err: unknown) { } catch (err: unknown) {
const hasStatus = (v: unknown): v is { status: number } => const hasStatus = (value: unknown): value is { status: number } =>
typeof v === "object" && typeof value === "object" &&
v !== null && value !== null &&
"status" in v && "status" in value &&
typeof (v as { status: unknown }).status === "number"; typeof (value as { status: unknown }).status === "number";
if (hasStatus(err) && err.status === 400) { if (hasStatus(err) && err.status === 400) {
// Not a SIM subscription - this component shouldn't be shown // Not a SIM subscription - this component shouldn't be shown
setError("This subscription is not a SIM service"); setError("This subscription is not a SIM service");
} else { return;
setError(err instanceof Error ? err.message : "Failed to load SIM information");
} }
setError(err instanceof Error ? err.message : "Failed to load SIM information");
} finally { } finally {
setLoading(false); setLoading(false);
} }

View File

@ -45,7 +45,10 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
quotaMb: getCurrentAmountMb(), 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(); onSuccess();
} catch (error: unknown) { } catch (error: unknown) {

View File

@ -22,14 +22,22 @@ export function useSubscriptions(options: UseSubscriptionsOptions = {}) {
return useQuery<SubscriptionList | Subscription[]>({ return useQuery<SubscriptionList | Subscription[]>({
queryKey: ["subscriptions", status], queryKey: ["subscriptions", status],
queryFn: async () => { queryFn: async () => {
const params = new URLSearchParams({ const params = new URLSearchParams({
...(status && { status }), ...(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 staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes gcTime: 10 * 60 * 1000, // 10 minutes
@ -50,7 +58,9 @@ export function useActiveSubscriptions() {
throw new Error("Authentication required"); 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 staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes gcTime: 10 * 60 * 1000, // 10 minutes
@ -77,14 +87,25 @@ export function useSubscriptionStats() {
throw new Error("Authentication required"); 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; total: number;
active: number; active: number;
suspended: number; suspended: number;
cancelled: number; cancelled: number;
pending: number; pending: number;
}>(`/subscriptions/stats`);
return stats; };
}, },
staleTime: 5 * 60 * 1000, // 5 minutes staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes gcTime: 10 * 60 * 1000, // 10 minutes
@ -105,7 +126,17 @@ export function useSubscription(subscriptionId: number) {
throw new Error("Authentication required"); 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 staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes gcTime: 10 * 60 * 1000, // 10 minutes
@ -130,13 +161,24 @@ export function useSubscriptionInvoices(
throw new Error("Authentication required"); throw new Error("Authentication required");
} }
const params = new URLSearchParams({ const response = await apiClient.GET("/subscriptions/{id}/invoices", {
page: page.toString(), params: {
limit: limit.toString(), 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 staleTime: 60 * 1000, // 1 minute
gcTime: 5 * 60 * 1000, // 5 minutes gcTime: 5 * 60 * 1000, // 5 minutes
@ -152,7 +194,11 @@ export function useSubscriptionAction() {
return useMutation({ return useMutation({
mutationFn: async ({ id, action }: { id: number; action: string }) => { 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 }) => { onSuccess: (_, { id }) => {
// Invalidate relevant queries after successful action // Invalidate relevant queries after successful action

View File

@ -1,9 +1 @@
/** export * from "./views";
* 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

View 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&apos;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>
);
}

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { import {
ChatBubbleLeftRightIcon, ChatBubbleLeftRightIcon,
@ -28,7 +28,7 @@ interface SupportCase {
assignedTo?: string; assignedTo?: string;
} }
export default function SupportCasesPage() { export function SupportCasesView() {
const [cases, setCases] = useState<SupportCase[]>([]); const [cases, setCases] = useState<SupportCase[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
@ -99,10 +99,12 @@ export default function SupportCasesPage() {
}, },
]; ];
setTimeout(() => { const timeout = setTimeout(() => {
setCases(mockCases); setCases(mockCases);
setLoading(false); setLoading(false);
}, 500); }, 500);
return () => clearTimeout(timeout);
}, []); }, []);
// Filter cases based on search, status, and priority // 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> <dt className="text-sm font-medium text-gray-500 truncate">Open Cases</dt>
<dd className="text-lg font-medium text-gray-900"> <dd className="text-lg font-medium text-gray-900">
{ {
cases.filter(c => cases.filter(caseItem =>
["Open", "In Progress", "Waiting on Customer"].includes(c.status) ["Open", "In Progress", "Waiting on Customer"].includes(caseItem.status)
).length ).length
} }
</dd> </dd>
@ -249,7 +251,10 @@ export default function SupportCasesPage() {
<dl> <dl>
<dt className="text-sm font-medium text-gray-500 truncate">High Priority</dt> <dt className="text-sm font-medium text-gray-500 truncate">High Priority</dt>
<dd className="text-lg font-medium text-gray-900"> <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> </dd>
</dl> </dl>
</div> </div>
@ -267,7 +272,10 @@ export default function SupportCasesPage() {
<dl> <dl>
<dt className="text-sm font-medium text-gray-500 truncate">Resolved</dt> <dt className="text-sm font-medium text-gray-500 truncate">Resolved</dt>
<dd className="text-lg font-medium text-gray-900"> <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> </dd>
</dl> </dl>
</div> </div>
@ -289,7 +297,7 @@ export default function SupportCasesPage() {
type="text" type="text"
placeholder="Search cases..." placeholder="Search cases..."
value={searchTerm} 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" 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> </div>
@ -298,7 +306,7 @@ export default function SupportCasesPage() {
<div className="relative"> <div className="relative">
<select <select
value={statusFilter} 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" 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> <option value="all">All Statuses</option>
@ -314,7 +322,7 @@ export default function SupportCasesPage() {
<div className="relative"> <div className="relative">
<select <select
value={priorityFilter} 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" 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> <option value="all">All Priorities</option>

View File

@ -0,0 +1,2 @@
export * from "./NewSupportCaseView";
export * from "./SupportCasesView";

View File

@ -15,6 +15,12 @@
// Path mappings // Path mappings
"paths": { "paths": {
"@/*": ["./src/*"], "@/*": ["./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) // Enforce TS-only in portal and keep strict mode explicit (inherits from root)
"allowJs": false, "allowJs": false,

View File

@ -30,7 +30,7 @@ apps/portal/src/features/service-management/
## Integration ## 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) - Detection: SIM availability is inferred from `subscription.productName` including `sim` (case-insensitive)
## Future Expansion ## Future Expansion

View File

@ -99,10 +99,10 @@ export default [
}, },
}, },
// Prevent importing the DashboardLayout directly in (portal) pages. // Prevent importing the DashboardLayout directly in (authenticated) pages.
// Pages should rely on the shared route-group layout at (portal)/layout.tsx. // 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: { rules: {
"no-restricted-imports": [ "no-restricted-imports": [
"error", "error",
@ -111,7 +111,7 @@ export default [
{ {
group: ["@/components/layout/dashboard-layout"], group: ["@/components/layout/dashboard-layout"],
message: 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 // 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: { rules: {
"no-restricted-imports": "off", "no-restricted-imports": "off",
}, },
}, },
// Allow controlled window.location usage for invoice SSO download // 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: { rules: {
"no-restricted-syntax": "off", "no-restricted-syntax": "off",
}, },

View File

@ -13,7 +13,11 @@ export function createClient(
baseUrl: string, baseUrl: string,
options: CreateClientOptions = {} options: CreateClientOptions = {}
): ApiClient { ): ApiClient {
const client = createOpenApiClient<paths>({ baseUrl }); const client = createOpenApiClient<paths>({
baseUrl,
throwOnError: true,
});
if (typeof client.use === "function" && options.getAuthHeader) { if (typeof client.use === "function" && options.getAuthHeader) {
const resolveAuthHeader = options.getAuthHeader; const resolveAuthHeader = options.getAuthHeader;

View File

@ -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 isInvoiceId = (id: string): id is InvoiceId => typeof id === 'string';
export const isWhmcsClientId = (id: number): id is WhmcsClientId => typeof id === 'number'; 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 // BASE ENTITY INTERFACES
// ===================================================== // =====================================================

View File

@ -1,5 +1,5 @@
// User and authentication types // User and authentication types
import type { BaseEntity, Address } from "../common"; import type { BaseEntity, Address, IsoDateTimeString } from "../common";
export interface User extends BaseEntity { export interface User extends BaseEntity {
email: string; email: string;
@ -46,7 +46,7 @@ export interface Activity {
export interface AuthTokens { export interface AuthTokens {
accessToken: string; accessToken: string;
refreshToken?: string; refreshToken?: string;
expiresAt: string; // ISO expiresAt: IsoDateTimeString;
tokenType?: string; tokenType?: string;
} }