Refactor and clean up BFF and portal components for improved maintainability

- Removed deprecated files and components from the BFF application, including various auth and catalog services, enhancing code clarity.
- Updated package.json scripts for better organization and streamlined development processes.
- Refactored portal components to improve structure and maintainability, including the removal of unused files and components.
- Enhanced type definitions and imports across the application for consistency and clarity.
This commit is contained in:
T. Narantuya 2025-09-18 14:52:26 +09:00
parent ed6fae677d
commit a22b84f128
230 changed files with 745 additions and 6630 deletions

View File

@ -1900,11 +1900,11 @@
"AddressDto": {
"type": "object",
"properties": {
"line1": {
"street": {
"type": "string",
"example": "123 Main Street"
},
"line2": {
"streetLine2": {
"type": "string",
"example": "Apt 4B"
},
@ -1927,7 +1927,7 @@
}
},
"required": [
"line1",
"street",
"city",
"state",
"postalCode",

View File

@ -12,15 +12,14 @@
"dev": "NODE_OPTIONS=\"--no-deprecation --max-old-space-size=4096\" nest start --watch --preserveWatchOutput -c tsconfig.build.json",
"start:debug": "NODE_OPTIONS=\"--no-deprecation --max-old-space-size=4096\" nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"lint": "NODE_OPTIONS=\"--max-old-space-size=4096\" eslint .",
"lint:fix": "NODE_OPTIONS=\"--max-old-space-size=4096\" eslint . --fix",
"test": "NODE_OPTIONS=\"--max-old-space-size=4096\" jest",
"test:watch": "NODE_OPTIONS=\"--max-old-space-size=4096\" jest --watch",
"test:cov": "NODE_OPTIONS=\"--max-old-space-size=4096\" jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "NODE_OPTIONS=\"--max-old-space-size=4096\" jest --config ./test/jest-e2e.json",
"type-check": "NODE_OPTIONS=\"--max-old-space-size=8192\" tsc --noEmit",
"type-check:incremental": "NODE_OPTIONS=\"--max-old-space-size=8192\" tsc --noEmit --incremental",
"clean": "rm -rf dist",
"db:migrate": "prisma migrate dev",
"db:generate": "prisma generate",

View File

@ -1,193 +0,0 @@
import { Controller, Post, Body, UseGuards, Get, Req, HttpCode } from "@nestjs/common";
import type { Request } from "express";
import { Throttle } from "@nestjs/throttler";
import { AuthService } from "./auth.service";
import { LocalAuthGuard } from "./guards/local-auth.guard";
import { AuthThrottleGuard } from "./guards/auth-throttle.guard";
import { ApiTags, ApiOperation, ApiResponse, ApiOkResponse } from "@nestjs/swagger";
import { RequestPasswordResetDto } from "./dto/request-password-reset.dto";
import { ResetPasswordDto } from "./dto/reset-password.dto";
import { ChangePasswordDto } from "./dto/change-password.dto";
import { LinkWhmcsDto } from "./dto/link-whmcs.dto";
import { SetPasswordDto } from "./dto/set-password.dto";
import { ValidateSignupDto } from "./dto/validate-signup.dto";
import { AccountStatusRequestDto, AccountStatusResponseDto } from "./dto/account-status.dto";
import { Public } from "./decorators/public.decorator";
import { SsoLinkDto } from "./dto/sso-link.dto";
import { SignupDto } from "./dto/signup.dto";
@ApiTags("auth")
@Controller("auth")
export class AuthController {
constructor(private authService: AuthService) {}
@Public()
@Post("validate-signup")
@UseGuards(AuthThrottleGuard)
@Throttle({ default: { limit: 10, ttl: 900000 } }) // 10 validations per 15 minutes per IP
@ApiOperation({ summary: "Validate customer number for signup" })
@ApiResponse({ status: 200, description: "Validation successful" })
@ApiResponse({ status: 409, description: "Customer already has account" })
@ApiResponse({ status: 400, description: "Customer number not found" })
@ApiResponse({ status: 429, description: "Too many validation attempts" })
async validateSignup(@Body() validateDto: ValidateSignupDto, @Req() req: Request) {
return this.authService.validateSignup(validateDto, req);
}
@Public()
@Get("health-check")
@ApiOperation({ summary: "Check auth service health and integrations" })
@ApiResponse({ status: 200, description: "Health check results" })
async healthCheck() {
return this.authService.healthCheck();
}
@Public()
@Post("signup-preflight")
@UseGuards(AuthThrottleGuard)
@Throttle({ default: { limit: 10, ttl: 900000 } })
@HttpCode(200)
@ApiOperation({ summary: "Validate full signup data without creating anything" })
@ApiResponse({ status: 200, description: "Preflight results with next action guidance" })
async signupPreflight(@Body() body: SignupDto) {
return this.authService.signupPreflight(body);
}
@Public()
@Post("account-status")
@ApiOperation({ summary: "Get account status by email" })
@ApiOkResponse({ description: "Account status" })
async accountStatus(@Body() body: AccountStatusRequestDto): Promise<AccountStatusResponseDto> {
return this.authService.getAccountStatus(body.email);
}
@Public()
@Post("signup")
@UseGuards(AuthThrottleGuard)
@Throttle({ default: { limit: 3, ttl: 900000 } }) // 3 signups per 15 minutes per IP
@ApiOperation({ summary: "Create new user account" })
@ApiResponse({ status: 201, description: "User created successfully" })
@ApiResponse({ status: 409, description: "User already exists" })
@ApiResponse({ status: 429, description: "Too many signup attempts" })
async signup(@Body() signupDto: SignupDto, @Req() req: Request) {
return this.authService.signup(signupDto, req);
}
@Public()
@UseGuards(LocalAuthGuard)
@Post("login")
@ApiOperation({ summary: "Authenticate user" })
@ApiResponse({ status: 200, description: "Login successful" })
@ApiResponse({ status: 401, description: "Invalid credentials" })
async login(@Req() req: Request & { user: { id: string; email: string; role?: string } }) {
return this.authService.login(req.user, req);
}
@Post("logout")
@ApiOperation({ summary: "Logout user" })
@ApiResponse({ status: 200, description: "Logout successful" })
async logout(@Req() req: Request & { user: { id: string } }) {
const authHeader = req.headers.authorization as string | string[] | undefined;
let bearer: string | undefined;
if (typeof authHeader === "string") {
bearer = authHeader;
} else if (Array.isArray(authHeader) && authHeader.length > 0) {
bearer = authHeader[0];
}
const token = bearer?.startsWith("Bearer ") ? bearer.slice(7) : undefined;
await this.authService.logout(req.user.id, token ?? "", req);
return { message: "Logout successful" };
}
@Public()
@Post("link-whmcs")
@UseGuards(AuthThrottleGuard)
@Throttle({ default: { limit: 3, ttl: 900000 } }) // 3 attempts per 15 minutes per IP
@ApiOperation({ summary: "Link existing WHMCS user" })
@ApiResponse({
status: 200,
description: "WHMCS account linked successfully",
})
@ApiResponse({ status: 401, description: "Invalid WHMCS credentials" })
@ApiResponse({ status: 429, description: "Too many link attempts" })
async linkWhmcs(@Body() linkDto: LinkWhmcsDto, @Req() req: Request) {
return this.authService.linkWhmcsUser(linkDto, req);
}
@Public()
@Post("set-password")
@UseGuards(AuthThrottleGuard)
@Throttle({ default: { limit: 3, ttl: 300000 } }) // 3 attempts per 5 minutes per IP
@ApiOperation({ summary: "Set password for linked user" })
@ApiResponse({ status: 200, description: "Password set successfully" })
@ApiResponse({ status: 401, description: "User not found" })
@ApiResponse({ status: 429, description: "Too many password attempts" })
async setPassword(@Body() setPasswordDto: SetPasswordDto, @Req() req: Request) {
return this.authService.setPassword(setPasswordDto, req);
}
@Public()
@Post("check-password-needed")
@HttpCode(200)
@ApiOperation({ summary: "Check if user needs to set password" })
@ApiResponse({ status: 200, description: "Password status checked" })
async checkPasswordNeeded(@Body() { email }: { email: string }) {
return this.authService.checkPasswordNeeded(email);
}
@Public()
@Post("request-password-reset")
@Throttle({ default: { limit: 5, ttl: 900000 } })
@ApiOperation({ summary: "Request password reset email" })
@ApiResponse({ status: 200, description: "Reset email sent if account exists" })
async requestPasswordReset(@Body() body: RequestPasswordResetDto) {
await this.authService.requestPasswordReset(body.email);
return { message: "If an account exists, a reset email has been sent" };
}
@Public()
@Post("reset-password")
@Throttle({ default: { limit: 5, ttl: 900000 } })
@ApiOperation({ summary: "Reset password with token" })
@ApiResponse({ status: 200, description: "Password reset successful" })
async resetPassword(@Body() body: ResetPasswordDto) {
return this.authService.resetPassword(body.token, body.password);
}
@Post("change-password")
@Throttle({ default: { limit: 5, ttl: 300000 } })
@ApiOperation({ summary: "Change password (authenticated)" })
@ApiResponse({ status: 200, description: "Password changed successfully" })
async changePassword(
@Req() req: Request & { user: { id: string } },
@Body() body: ChangePasswordDto
) {
return this.authService.changePassword(req.user.id, body.currentPassword, body.newPassword);
}
@Get("me")
@ApiOperation({ summary: "Get current authentication status" })
getAuthStatus(@Req() req: Request & { user: { id: string; email: string; role?: string } }) {
// Return basic auth info only - full profile should use /api/me
return {
isAuthenticated: true,
user: {
id: req.user.id,
email: req.user.email,
role: req.user.role,
},
};
}
@Post("sso-link")
@ApiOperation({ summary: "Create SSO link to WHMCS" })
@ApiResponse({ status: 200, description: "SSO link created successfully" })
@ApiResponse({
status: 404,
description: "User not found or not linked to WHMCS",
})
async createSsoLink(@Req() req: Request & { user: { id: string } }, @Body() body: SsoLinkDto) {
const destination = body?.destination;
return this.authService.createSsoLink(req.user.id, destination);
}
}

View File

@ -1,12 +1,12 @@
import type { Routes } from "@nestjs/core";
import { AuthModule } from "../../auth/auth.module";
import { UsersModule } from "../../users/users.module";
import { MappingsModule } from "../../id-mappings/mappings.module";
import { CatalogModule } from "../../catalog/catalog.module";
import { OrdersModule } from "../../orders/orders.module";
import { InvoicesModule } from "../../invoices/invoices.module";
import { SubscriptionsModule } from "../../subscriptions/subscriptions.module";
import { CasesModule } from "../../cases/cases.module";
import { AuthModule } from "@bff/modules/auth/auth.module";
import { UsersModule } from "@bff/modules/users/users.module";
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module";
import { CatalogModule } from "@bff/modules/catalog/catalog.module";
import { OrdersModule } from "@bff/modules/orders/orders.module";
import { InvoicesModule } from "@bff/modules/invoices/invoices.module";
import { SubscriptionsModule } from "@bff/modules/subscriptions/subscriptions.module";
import { CasesModule } from "@bff/modules/cases/cases.module";
export const apiRoutes: Routes = [
{

View File

@ -1,7 +1,7 @@
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { IntegrationsModule } from "@bff/integrations/integrations.module";
import { OrdersModule } from "../../../orders/orders.module";
import { OrdersModule } from "@bff/modules/orders/orders.module";
import { SalesforcePubSubSubscriber } from "./pubsub.subscriber";
@Module({

View File

@ -3,7 +3,7 @@ import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino";
import PubSubApiClientPkg from "salesforce-pubsub-api-client";
import { SalesforceConnection } from "../services/salesforce-connection.service";
import { ProvisioningQueueService } from "../../../orders/queue/provisioning.queue";
import { ProvisioningQueueService } from "@bff/modules/orders/queue/provisioning.queue";
import { CacheService } from "@bff/infra/cache/cache.service";
import {
replayKey as sfReplayKey,

View File

@ -6,7 +6,7 @@ import { LocalAuthGuard } from "./guards/local-auth.guard";
import { AuthThrottleGuard } from "./guards/auth-throttle.guard";
import { ApiTags, ApiOperation, ApiResponse, ApiOkResponse } from "@nestjs/swagger";
import { Public } from "./decorators/public.decorator";
import { ZodPipe } from "../core/validation";
import { ZodPipe } from "@bff/core/validation";
// Import Zod schemas from domain
import {

View File

@ -4,10 +4,9 @@ import { PassportModule } from "@nestjs/passport";
import { ConfigService } from "@nestjs/config";
import { APP_GUARD } from "@nestjs/core";
import { AuthService } from "./auth.service";
import { AuthController } from "./auth.controller";
import { AuthZodController } from "./auth-zod.controller";
import { AuthAdminController } from "./auth-admin.controller";
import { UsersModule } from "@/users/users.module";
import { UsersModule } from "@bff/modules/users/users.module";
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module";
import { IntegrationsModule } from "@bff/integrations/integrations.module";
import { JwtStrategy } from "./strategies/jwt.strategy";

View File

@ -281,7 +281,7 @@ export class AuthService {
// Validate required WHMCS fields
if (
!address?.line1 ||
!address?.street ||
!address?.city ||
!address?.state ||
!address?.postalCode ||
@ -311,8 +311,8 @@ export class AuthService {
email,
companyname: company || "",
phonenumber: phone,
address1: address.line1,
address2: address.line2 || "",
address1: address.street,
address2: address.streetLine2 || "",
city: address.city,
state: address.state,
postcode: address.postalCode,

View File

@ -15,12 +15,12 @@ export class AddressDto {
@ApiProperty({ example: "123 Main Street" })
@IsString()
@IsNotEmpty()
line1: string;
street: string;
@ApiProperty({ example: "Apt 4B", required: false })
@IsOptional()
@IsString()
line2?: string;
streetLine2?: string;
@ApiProperty({ example: "Tokyo" })
@IsString()

View File

@ -1,7 +1,7 @@
import { Injectable, Inject } from "@nestjs/common";
import { BaseCatalogService } from "./base-catalog.service";
import { InternetProduct, fromSalesforceProduct2, SalesforceProduct2Record } from "@customer-portal/domain";
import { MappingsService } from "../../id-mappings/mappings.service";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service";
import { Logger } from "nestjs-pino";
import { getErrorMessage } from "@bff/core/utils/error.util";

View File

@ -1,7 +1,7 @@
import { Injectable, Inject } from "@nestjs/common";
import { BaseCatalogService } from "./base-catalog.service";
import { SimProduct, fromSalesforceProduct2, SalesforceProduct2Record } from "@customer-portal/domain";
import { MappingsService } from "../../id-mappings/mappings.service";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service";
import { Logger } from "nestjs-pino";
import { WhmcsConnectionService } from "@bff/integrations/whmcs/services/whmcs-connection.service";

View File

@ -1,6 +1,6 @@
import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { CacheService } from "../../infra/cache/cache.service";
import { CacheService } from "@bff/infra/cache/cache.service";
import { UserIdMapping } from "../types/mapping.types";
import { getErrorMessage } from "@bff/core/utils/error.util";

View File

@ -2,7 +2,7 @@ import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { CreateOrderBody, UserMapping } from "../dto/order.dto";
import { getSalesforceFieldMap } from "@bff/core/config/field-map";
import { UsersService } from "../../users/users.service";
import { UsersService } from "@bff/modules/users/users.service";
/**
* Handles building order header data from selections

View File

@ -2,7 +2,7 @@ import { Injectable, BadRequestException, ConflictException, Inject } from "@nes
import { Logger } from "nestjs-pino";
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service";
import { WhmcsPaymentService } from "@bff/integrations/whmcs/services/whmcs-payment.service";
import { MappingsService } from "../../id-mappings/mappings.service";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
import { getErrorMessage } from "@bff/core/utils/error.util";
import { SalesforceOrder } from "@customer-portal/domain";
import { getSalesforceFieldMap } from "@bff/core/config/field-map";

View File

@ -1,6 +1,6 @@
import { Injectable, BadRequestException, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { MappingsService } from "../../id-mappings/mappings.service";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
import { WhmcsConnectionService } from "@bff/integrations/whmcs/services/whmcs-connection.service";
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service";
import { getSalesforceFieldMap } from "@bff/core/config/field-map";

View File

@ -9,7 +9,7 @@
"@/*": ["src/*"],
"@bff/core/*": ["src/core/*"],
"@bff/infra/*": ["src/infra/*"],
"@bff/modules/*": ["src/*"],
"@bff/modules/*": ["src/modules/*"],
"@bff/integrations/*": ["src/integrations/*"]
},
"strict": true,

View File

@ -15,7 +15,7 @@
"@/*": ["src/*"],
"@bff/core/*": ["src/core/*"],
"@bff/infra/*": ["src/infra/*"],
"@bff/modules/*": ["src/*"],
"@bff/modules/*": ["src/modules/*"],
"@bff/integrations/*": ["src/integrations/*"]
}
},

View File

@ -1,31 +0,0 @@
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
// Memory optimization settings
"incremental": true,
"tsBuildInfoFile": "./.tsbuildinfo-memory",
"skipLibCheck": true,
"skipDefaultLibCheck": true,
// Reduce type checking strictness for memory optimization
"noImplicitAny": false,
"strictNullChecks": false,
"strictBindCallApply": false,
"noImplicitReturns": false,
"noFallthroughCasesInSwitch": false,
// Disable some expensive checks
"noUnusedLocals": false,
"noUnusedParameters": false,
"exactOptionalPropertyTypes": false,
"noImplicitOverride": false
},
"include": ["src/**/*"],
"exclude": [
"node_modules",
"dist",
"test",
"**/*.spec.ts",
"**/*.test.ts",
"**/*.e2e-spec.ts"
]
}

View File

@ -1,62 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020"],
"module": "CommonJS",
"moduleResolution": "node",
"baseUrl": "./",
"paths": {
"@/*": ["src/*"],
"@bff/core/*": ["src/core/*"],
"@bff/infra/*": ["src/infra/*"],
"@bff/modules/*": ["src/*"],
"@bff/integrations/*": ["src/integrations/*"]
},
"noEmit": true,
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"types": ["node"],
// Ultra-light settings - disable most type checking for memory
"strict": false,
"noImplicitAny": false,
"strictNullChecks": false,
"strictBindCallApply": false,
"noImplicitReturns": false,
"noFallthroughCasesInSwitch": false,
"noImplicitOverride": false,
"strictPropertyInitialization": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
"exactOptionalPropertyTypes": false,
"noImplicitThis": false,
"alwaysStrict": false,
"strictFunctionTypes": false,
"useUnknownInCatchVariables": false,
// Performance optimizations
"incremental": false,
"assumeChangesOnlyAffectDirectDependencies": true,
"disableReferencedProjectLoad": true,
"disableSolutionSearching": true,
"disableSourceOfProjectReferenceRedirect": true
},
"include": ["src/**/*"],
"exclude": [
"node_modules",
"dist",
"test",
"**/*.spec.ts",
"**/*.test.ts",
"**/*.e2e-spec.ts",
"src/**/*.spec.ts",
"src/**/*.test.ts"
]
}

View File

@ -7,7 +7,7 @@ const nextConfig = {
output: process.env.NODE_ENV === "production" ? "standalone" : undefined,
// Ensure workspace package resolves/transpiles correctly in monorepo
transpilePackages: ["@customer-portal/shared"],
transpilePackages: [],
experimental: {
externalDir: true,
},

View File

@ -14,7 +14,6 @@
"test": "echo 'No tests yet'"
},
"dependencies": {
"@customer-portal/shared": "workspace:*",
"@heroicons/react": "^2.2.0",
"@hookform/resolvers": "^5.2.1",
"@tanstack/react-query": "^5.85.5",

View File

@ -1,431 +0,0 @@
"use client";
import { useState, useEffect, Suspense } from "react";
import { useSearchParams } from "next/navigation";
import { authenticatedApi } from "@/lib/api";
import {
CreditCardIcon,
MapPinIcon,
PencilIcon,
CheckIcon,
XMarkIcon,
ExclamationTriangleIcon,
} from "@heroicons/react/24/outline";
interface Address {
street: string | null;
streetLine2: string | null;
city: string | null;
state: string | null;
postalCode: string | null;
country: string | null;
}
interface BillingInfo {
company: string | null;
email: string;
phone: string | null;
address: Address;
isComplete: boolean;
}
function BillingHeading() {
const searchParams = useSearchParams();
const isCompletionFlow = searchParams.get("complete") === "true";
return (
<h1 className="text-3xl font-bold text-gray-900">
{isCompletionFlow ? "Complete Your Profile" : "Billing & Address"}
</h1>
);
}
function BillingCompletionBanner() {
const searchParams = useSearchParams();
const isCompletionFlow = searchParams.get("complete") === "true";
if (!isCompletionFlow) return null;
return (
<div className="bg-blue-50 border border-blue-200 rounded-xl p-6 mb-6">
<div className="flex items-start space-x-4">
<div className="flex-shrink-0">
<ExclamationTriangleIcon className="h-6 w-6 text-blue-600" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-blue-900 mb-2">Profile Completion Required</h3>
<p className="text-blue-800">
Please review and complete your address information to access all features and enable
service ordering.
</p>
</div>
</div>
</div>
);
}
export default function BillingPage() {
const [billingInfo, setBillingInfo] = useState<BillingInfo | null>(null);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(false);
const [editedAddress, setEditedAddress] = useState<Address | null>(null);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
void fetchBillingInfo();
}, []);
const fetchBillingInfo = async () => {
try {
setLoading(true);
const data = await authenticatedApi.get<BillingInfo>("/me/billing");
setBillingInfo(data);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load billing information");
} finally {
setLoading(false);
}
};
const handleEdit = () => {
setEditing(true);
setEditedAddress(
billingInfo?.address || {
street: "",
streetLine2: "",
city: "",
state: "",
postalCode: "",
country: "",
}
);
};
const handleSave = async () => {
if (!editedAddress) return;
// Validate required fields
const isComplete = !!(
editedAddress.street?.trim() &&
editedAddress.city?.trim() &&
editedAddress.state?.trim() &&
editedAddress.postalCode?.trim() &&
editedAddress.country?.trim()
);
if (!isComplete) {
setError("Please fill in all required address fields");
return;
}
try {
setSaving(true);
setError(null);
// Update address via API
const updated = await authenticatedApi.patch<BillingInfo>("/me/address", {
street: editedAddress.street,
streetLine2: editedAddress.streetLine2,
city: editedAddress.city,
state: editedAddress.state,
postalCode: editedAddress.postalCode,
country: editedAddress.country,
});
// Update local state from authoritative response
setBillingInfo(updated);
setEditing(false);
setEditedAddress(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to update address");
} finally {
setSaving(false);
}
};
const handleCancel = () => {
setEditing(false);
setEditedAddress(null);
setError(null);
};
if (loading) {
return (
<div className="max-w-4xl mx-auto">
<div className="flex items-center space-x-3 mb-6">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span className="text-xl font-semibold text-gray-900">Loading billing information...</span>
</div>
</div>
);
}
return (
<>
<div className="max-w-4xl mx-auto">
<div className="flex items-center space-x-3 mb-6">
<CreditCardIcon className="h-8 w-8 text-blue-600" />
<Suspense
fallback={<h1 className="text-3xl font-bold text-gray-900">Billing & Address</h1>}
>
<BillingHeading />
</Suspense>
</div>
<Suspense fallback={null}>
<BillingCompletionBanner />
</Suspense>
{error && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 mb-6">
<div className="flex items-start space-x-3">
<ExclamationTriangleIcon className="h-5 w-5 text-red-500 mt-0.5 flex-shrink-0" />
<div>
<h3 className="text-sm font-medium text-red-800">Error</h3>
<p className="text-sm text-red-700 mt-1">{error}</p>
</div>
</div>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Address Information */}
<div className="bg-white border rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-3">
<MapPinIcon className="h-6 w-6 text-blue-600" />
<h2 className="text-xl font-semibold text-gray-900">Service Address</h2>
</div>
{billingInfo?.isComplete && !editing && (
<button
onClick={handleEdit}
className="flex items-center space-x-2 text-blue-600 hover:text-blue-700 text-sm font-medium"
>
<PencilIcon className="h-4 w-4" />
<span>Edit</span>
</button>
)}
</div>
{/* Address is required at signup, so this should rarely be needed */}
{editing ? (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Street Address *
</label>
<input
type="text"
value={editedAddress?.street || ""}
onChange={e =>
setEditedAddress(prev => (prev ? { ...prev, street: e.target.value } : null))
}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="123 Main Street"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Street Address Line 2
</label>
<input
type="text"
value={editedAddress?.streetLine2 || ""}
onChange={e =>
setEditedAddress(prev =>
prev ? { ...prev, streetLine2: e.target.value } : null
)
}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Apartment, suite, etc. (optional)"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">City *</label>
<input
type="text"
value={editedAddress?.city || ""}
onChange={e =>
setEditedAddress(prev => (prev ? { ...prev, city: e.target.value } : null))
}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Tokyo"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
State/Prefecture *
</label>
<input
type="text"
value={editedAddress?.state || ""}
onChange={e =>
setEditedAddress(prev => (prev ? { ...prev, state: e.target.value } : null))
}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Tokyo"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Postal Code *
</label>
<input
type="text"
value={editedAddress?.postalCode || ""}
onChange={e =>
setEditedAddress(prev =>
prev ? { ...prev, postalCode: e.target.value } : null
)
}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="100-0001"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Country *
</label>
<select
value={editedAddress?.country || ""}
onChange={e =>
setEditedAddress(prev =>
prev ? { ...prev, country: e.target.value } : null
)
}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Select Country</option>
<option value="JP">Japan</option>
<option value="US">United States</option>
<option value="GB">United Kingdom</option>
<option value="CA">Canada</option>
<option value="AU">Australia</option>
</select>
</div>
</div>
<div className="flex items-center space-x-3 pt-4">
<button
onClick={() => void handleSave()}
disabled={saving}
className="flex items-center space-x-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:bg-gray-400 transition-colors"
>
<CheckIcon className="h-4 w-4" />
<span>{saving ? "Saving..." : "Save Address"}</span>
</button>
<button
onClick={handleCancel}
disabled={saving}
className="flex items-center space-x-2 bg-gray-100 text-gray-700 px-4 py-2 rounded-lg hover:bg-gray-200 disabled:bg-gray-300 transition-colors"
>
<XMarkIcon className="h-4 w-4" />
<span>Cancel</span>
</button>
</div>
</div>
) : (
<div>
{billingInfo?.address.street ? (
<div className="bg-gray-50 rounded-lg p-4">
<div className="text-gray-900">
<p className="font-medium">{billingInfo.address.street}</p>
{billingInfo.address.streetLine2 && <p>{billingInfo.address.streetLine2}</p>}
<p>
{billingInfo.address.city}, {billingInfo.address.state}{" "}
{billingInfo.address.postalCode}
</p>
<p>{billingInfo.address.country}</p>
</div>
<div className="mt-3 pt-3 border-t border-gray-200">
<div className="flex items-center space-x-2">
{billingInfo.isComplete ? (
<>
<CheckIcon className="h-4 w-4 text-green-500" />
<span className="text-sm text-green-700 font-medium">
Address Complete
</span>
</>
) : (
<>
<ExclamationTriangleIcon className="h-4 w-4 text-yellow-500" />
<span className="text-sm text-yellow-700 font-medium">
Address Incomplete
</span>
</>
)}
</div>
</div>
</div>
) : (
<div className="text-center py-8">
<MapPinIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-600 mb-4">No address on file</p>
<button
onClick={handleEdit}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"
>
Add Address
</button>
</div>
)}
</div>
)}
</div>
{/* Contact Information */}
<div className="bg-white border rounded-xl p-6">
<div className="flex items-center space-x-3 mb-4">
<CreditCardIcon className="h-6 w-6 text-blue-600" />
<h2 className="text-xl font-semibold text-gray-900">Contact Information</h2>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-gray-900">{billingInfo?.email}</p>
</div>
</div>
{billingInfo?.company && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Company</label>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-gray-900">{billingInfo.company}</p>
</div>
</div>
)}
{billingInfo?.phone && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Phone</label>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-gray-900">{billingInfo.phone}</p>
</div>
</div>
)}
</div>
<div className="mt-6 p-4 bg-blue-50 rounded-lg">
<p className="text-sm text-blue-800">
<strong>Note:</strong> Contact information is managed through your account settings.
Address changes are synchronized with our billing system. This address is used for
both billing and service delivery.
</p>
</div>
</div>
</div>
</div>
</>
);
}

View File

@ -1,13 +1,11 @@
"use client";
import { logger } from "@/lib/logger";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useAuthStore } from "@/lib/auth/store";
import { useDashboardSummary } from "@/features/dashboard/hooks";
import { useAuthStore } from "@/features/auth/services/auth.store";
import { useDashboardSummary } from "@/features/dashboard/hooks/useDashboard";
import type { Activity } from "@customer-portal/shared";
import type { Activity } from "@customer-portal/domain";
import {
CreditCardIcon,
ServerIcon,
@ -31,11 +29,11 @@ import { format, formatDistanceToNow } from "date-fns";
import { StatCard, QuickAction, DashboardActivityItem } from "@/features/dashboard/components";
import { LoadingSpinner } from "@/components/ui/loading-skeleton";
import { ErrorState } from "@/components/ui/error-state";
import { formatCurrency, getCurrencyLocale } from "@/utils/currency";
import { formatCurrency, getCurrencyLocale } from "@customer-portal/domain";
export default function DashboardPage() {
const router = useRouter();
const { user, isAuthenticated, isLoading: authLoading } = useAuthStore();
const { user, isAuthenticated, loading: authLoading } = useAuthStore();
const { data: summary, isLoading: summaryLoading, error } = useDashboardSummary();
const [paymentLoading, setPaymentLoading] = useState(false);
@ -48,11 +46,11 @@ export default function DashboardPage() {
void (async () => {
try {
const { createInvoiceSsoLink } = await import("@/hooks/useInvoices");
const ssoLink = await createInvoiceSsoLink(invoiceId, "pay");
const { BillingService } = await import("@/features/billing/services");
const ssoLink = await BillingService.createInvoiceSsoLink({ invoiceId, target: "pay" });
window.open(ssoLink.url, "_blank", "noopener,noreferrer");
} catch (error) {
logger.error(error, "Failed to create payment link");
console.error("Failed to create payment link:", error);
setPaymentError(error instanceof Error ? error.message : "Failed to open payment page");
} finally {
setPaymentLoading(false);

View File

@ -1,5 +1,5 @@
"use client";
import { logger } from "@/lib/logger";
import { logger } from "@/core/config";
import { useState } from "react";
import { useRouter } from "next/navigation";

View File

@ -1,8 +1,8 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { QueryProvider } from "@/providers/query-provider";
import { SessionTimeoutWarning } from "@/components/auth/session-timeout-warning";
import { QueryProvider } from "@/core/providers";
import { SessionTimeoutWarning } from "@/features/auth/components/SessionTimeoutWarning";
const geistSans = Geist({
variable: "--font-geist-sans",

View File

@ -1,37 +0,0 @@
import Link from "next/link";
interface AuthLayoutProps {
children: React.ReactNode;
title: string;
subtitle?: string;
}
export function AuthLayout({ children, title, subtitle }: AuthLayoutProps) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
{/* Header */}
<div className="text-center">
<Link href="/" className="inline-block">
<div className="flex items-center justify-center space-x-2">
<div className="w-8 h-8 bg-gradient-to-r from-blue-600 to-indigo-600 rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-sm">AS</span>
</div>
<span className="text-xl font-semibold text-gray-900">Assist Solutions</span>
</div>
</Link>
<h2 className="mt-6 text-3xl font-bold text-gray-900">{title}</h2>
{subtitle && <p className="mt-2 text-sm text-gray-600">{subtitle}</p>}
</div>
{/* Content */}
<div className="bg-white rounded-lg shadow-lg p-8">{children}</div>
{/* Footer */}
<div className="text-center">
<p className="text-sm text-gray-500">© 2025 Assist Solutions. All rights reserved.</p>
</div>
</div>
</div>
);
}

View File

@ -1,134 +0,0 @@
"use client";
import { logger } from "@/lib/logger";
import { useEffect, useState } from "react";
import { useAuthStore } from "@/lib/auth/store";
import { Button } from "@/components/ui/button";
interface SessionTimeoutWarningProps {
warningTime?: number; // Minutes before token expires to show warning
}
export function SessionTimeoutWarning({
warningTime = 10, // Show warning 10 minutes before expiry
}: SessionTimeoutWarningProps) {
const { isAuthenticated, token, logout, checkAuth } = useAuthStore();
const [showWarning, setShowWarning] = useState(false);
const [timeLeft, setTimeLeft] = useState<number>(0);
useEffect(() => {
if (!isAuthenticated || !token) {
return undefined;
}
// Parse JWT to get expiry time
try {
const parts = token.split(".");
if (parts.length !== 3) {
throw new Error("Invalid token format");
}
const payload = JSON.parse(atob(parts[1])) as { exp?: number };
if (!payload.exp) {
logger.warn("Token does not have expiration time");
return undefined;
}
const expiryTime = payload.exp * 1000; // Convert to milliseconds
const currentTime = Date.now();
const warningThreshold = warningTime * 60 * 1000; // Convert to milliseconds
const timeUntilExpiry = expiryTime - currentTime;
const timeUntilWarning = timeUntilExpiry - warningThreshold;
if (timeUntilExpiry <= 0) {
// Token already expired
void logout();
return undefined;
}
if (timeUntilWarning <= 0) {
// Should show warning immediately
setShowWarning(true);
setTimeLeft(Math.ceil(timeUntilExpiry / 1000 / 60)); // Minutes left
return undefined;
} else {
// Set timeout to show warning
const warningTimeout = setTimeout(() => {
setShowWarning(true);
setTimeLeft(warningTime);
}, timeUntilWarning);
return () => clearTimeout(warningTimeout);
}
} catch (error) {
logger.error(error, "Error parsing JWT token");
void logout();
return undefined;
}
}, [isAuthenticated, token, warningTime, logout]);
useEffect(() => {
if (!showWarning) return undefined;
const interval = setInterval(() => {
setTimeLeft(prev => {
if (prev <= 1) {
void logout();
return 0;
}
return prev - 1;
});
}, 60000);
return () => clearInterval(interval);
}, [showWarning, logout]);
const handleExtendSession = () => {
void (async () => {
try {
await checkAuth();
setShowWarning(false);
setTimeLeft(0);
} catch (error) {
logger.error(error, "Failed to extend session");
await logout();
}
})();
};
const handleLogoutNow = () => {
void logout();
setShowWarning(false);
};
if (!showWarning) {
return null;
}
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4 shadow-xl">
<div className="flex items-center gap-2 mb-4">
<span className="text-yellow-500 text-xl"></span>
<h2 className="text-lg font-semibold">Session Expiring Soon</h2>
</div>
<p className="text-gray-600 mb-6">
Your session will expire in{" "}
<strong>
{timeLeft} minute{timeLeft !== 1 ? "s" : ""}
</strong>
. Would you like to extend your session?
</p>
<div className="flex gap-2 justify-end">
<Button variant="outline" onClick={handleLogoutNow}>
Logout Now
</Button>
<Button onClick={handleExtendSession}>Extend Session</Button>
</div>
</div>
</div>
);
}

View File

@ -1,92 +0,0 @@
interface ActivationFormProps {
activationType: "Immediate" | "Scheduled";
onActivationTypeChange: (type: "Immediate" | "Scheduled") => void;
scheduledActivationDate: string;
onScheduledActivationDateChange: (date: string) => void;
errors: Record<string, string>;
}
export function ActivationForm({
activationType,
onActivationTypeChange,
scheduledActivationDate,
onScheduledActivationDateChange,
errors,
}: ActivationFormProps) {
return (
<div className="space-y-4">
<label
className={`flex items-start gap-3 p-4 rounded-lg border-2 cursor-pointer transition-all duration-300 ease-in-out hover:scale-[1.01] ${
activationType === "Immediate"
? "border-blue-500 bg-blue-50 ring-2 ring-blue-100 shadow-sm"
: "border-gray-200 hover:border-blue-300 hover:bg-blue-50"
}`}
>
<input
type="radio"
name="activationType"
value="Immediate"
checked={activationType === "Immediate"}
onChange={e => onActivationTypeChange(e.target.value as "Immediate")}
className="mt-1 h-4 w-4 text-blue-600 border-gray-300 focus:ring-blue-500"
/>
<div>
<span className="font-medium text-gray-900">Immediate Activation</span>
<p className="text-sm text-gray-600 mt-1">
Activate your SIM card as soon as it&apos;s delivered and set up
</p>
</div>
</label>
<label
className={`flex items-start gap-3 p-4 rounded-lg border-2 cursor-pointer transition-all duration-300 ease-in-out hover:scale-[1.01] ${
activationType === "Scheduled"
? "border-blue-500 bg-blue-50 ring-2 ring-blue-100 shadow-sm"
: "border-gray-200 hover:border-blue-300 hover:bg-blue-50"
}`}
>
<input
type="radio"
name="activationType"
value="Scheduled"
checked={activationType === "Scheduled"}
onChange={e => onActivationTypeChange(e.target.value as "Scheduled")}
className="mt-1 h-4 w-4 text-blue-600 border-gray-300 focus:ring-blue-500"
/>
<div className="flex-1">
<span className="font-medium text-gray-900">Scheduled Activation</span>
<p className="text-sm text-gray-600 mt-1">
Choose a specific date for activation (up to 30 days from today)
</p>
{activationType === "Scheduled" && (
<div className="mt-3">
<label
htmlFor="scheduledActivationDate"
className="block text-sm font-medium text-gray-700 mb-1"
>
Preferred Activation Date *
</label>
<input
type="date"
id="scheduledActivationDate"
value={scheduledActivationDate}
onChange={e => onScheduledActivationDateChange(e.target.value)}
min={new Date().toISOString().split("T")[0]} // Today's date
max={new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0]} // 30 days from now
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
{errors.scheduledActivationDate && (
<p className="text-red-600 text-sm mt-1">{errors.scheduledActivationDate}</p>
)}
<p className="text-xs text-blue-700 mt-1">
Note: Scheduled activation is subject to business day processing. Weekend/holiday
requests may be processed on the next business day.
</p>
</div>
)}
</div>
</label>
</div>
);
}

View File

@ -1,196 +0,0 @@
"use client";
import { CheckCircleIcon } from "@heroicons/react/24/solid";
interface AddonItem {
id: string;
name: string;
sku: string;
description: string;
monthlyPrice?: number;
oneTimePrice?: number;
isBundledAddon?: boolean;
bundledAddonId?: string;
displayOrder?: number;
}
interface AddonGroup {
id: string;
name: string;
description: string;
monthlyPrice?: number;
oneTimePrice?: number;
skus: string[];
isBundled: boolean;
}
interface AddonGroupProps {
addons: AddonItem[];
selectedAddonSkus: string[];
onAddonToggle: (skus: string[]) => void;
showSkus?: boolean;
}
export function AddonGroup({
addons,
selectedAddonSkus,
onAddonToggle,
showSkus = false,
}: AddonGroupProps) {
// Group bundled addons together
const groupedAddons = (() => {
const groups: AddonGroup[] = [];
const processedAddonIds = new Set<string>();
// Sort addons by display order first
const sortedAddons = [...addons].sort((a, b) => (a.displayOrder || 0) - (b.displayOrder || 0));
sortedAddons.forEach(addon => {
if (processedAddonIds.has(addon.id)) return;
if (addon.isBundledAddon && addon.bundledAddonId) {
// Find the bundled partner
const bundledPartner = sortedAddons.find(a => a.id === addon.bundledAddonId);
if (bundledPartner && !processedAddonIds.has(bundledPartner.id)) {
// Create a combined group
const monthlyAddon = addon.monthlyPrice ? addon : bundledPartner;
const activationAddon = addon.oneTimePrice ? addon : bundledPartner;
// Generate clean name and description
const cleanName = monthlyAddon.name
.replace(/\s*(Monthly|Installation|Fee)\s*/gi, "")
.trim();
const bundleName = cleanName || monthlyAddon.name.split(" ").slice(0, 2).join(" "); // Use first two words if cleaning removes everything
groups.push({
id: `bundle-${addon.id}-${bundledPartner.id}`,
name: bundleName,
description: `${bundleName} (installation included)`,
monthlyPrice: monthlyAddon.monthlyPrice,
oneTimePrice: activationAddon.oneTimePrice,
skus: [addon.sku, bundledPartner.sku],
isBundled: true,
});
processedAddonIds.add(addon.id);
processedAddonIds.add(bundledPartner.id);
} else if (!bundledPartner) {
// Orphaned bundled addon - treat as individual
groups.push({
id: addon.id,
name: addon.name,
description: addon.description,
monthlyPrice: addon.monthlyPrice,
oneTimePrice: addon.oneTimePrice,
skus: [addon.sku],
isBundled: false,
});
processedAddonIds.add(addon.id);
}
} else {
// Individual addon
groups.push({
id: addon.id,
name: addon.name,
description: addon.description,
monthlyPrice: addon.monthlyPrice,
oneTimePrice: addon.oneTimePrice,
skus: [addon.sku],
isBundled: false,
});
processedAddonIds.add(addon.id);
}
});
return groups;
})();
const handleGroupToggle = (addonGroup: AddonGroup) => {
const allSkusSelected = addonGroup.skus.every(sku => selectedAddonSkus.includes(sku));
if (allSkusSelected) {
// Unselect all SKUs in the bundle
const remainingSkus = selectedAddonSkus.filter(sku => !addonGroup.skus.includes(sku));
onAddonToggle(remainingSkus);
} else {
// Select all SKUs in the bundle
const filtered = selectedAddonSkus.filter(sku => !addonGroup.skus.includes(sku));
onAddonToggle([...filtered, ...addonGroup.skus]);
}
};
if (groupedAddons.length === 0) {
return (
<div className="text-center py-4 text-gray-500">
<p>No add-ons available for this plan</p>
</div>
);
}
return (
<div className="space-y-4">
{groupedAddons.map(addonGroup => {
const allSkusSelected = addonGroup.skus.every(sku => selectedAddonSkus.includes(sku));
return (
<label
key={addonGroup.id}
className={`flex items-start gap-3 p-4 rounded-lg border-2 cursor-pointer transition-all ${
allSkusSelected
? "border-green-500 bg-green-50 ring-2 ring-green-100"
: "border-gray-200 hover:border-gray-300"
}`}
>
<input
type="checkbox"
checked={allSkusSelected}
onChange={() => handleGroupToggle(addonGroup)}
className="text-green-600 focus:ring-green-500 mt-1"
/>
<div className="flex-1">
<div className="flex items-center justify-between">
<span className="font-medium text-gray-900">{addonGroup.name}</span>
{allSkusSelected && <CheckCircleIcon className="h-5 w-5 text-green-600" />}
</div>
<p className="text-sm text-gray-600 mt-1">{addonGroup.description}</p>
<div className="flex flex-wrap gap-4 mt-2">
{addonGroup.monthlyPrice && (
<span className="text-sm font-semibold text-blue-600">
¥{addonGroup.monthlyPrice.toLocaleString()}/month
</span>
)}
{addonGroup.oneTimePrice && (
<span className="text-sm font-semibold text-orange-600">
Activation: ¥{addonGroup.oneTimePrice.toLocaleString()}
</span>
)}
</div>
{addonGroup.isBundled && (
<div className="text-xs text-green-600 mt-1 flex items-center gap-1">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z"
clipRule="evenodd"
/>
</svg>
Bundle Package
</div>
)}
{showSkus && (
<div className="text-xs text-gray-500 mt-1">SKUs: {addonGroup.skus.join(", ")}</div>
)}
</div>
</label>
);
})}
{selectedAddonSkus.length === 0 && (
<div className="text-center py-4 text-gray-500">
<p>Select add-ons to enhance your service</p>
</div>
)}
</div>
);
}

View File

@ -1,67 +0,0 @@
import { ReactNode } from "react";
import Link from "next/link";
interface AnimatedButtonProps {
children: ReactNode;
variant?: "primary" | "secondary" | "outline";
size?: "sm" | "md" | "lg";
className?: string;
onClick?: () => void;
href?: string;
disabled?: boolean;
type?: "button" | "submit";
}
export function AnimatedButton({
children,
variant = "primary",
size = "md",
className = "",
onClick,
href,
disabled = false,
type = "button",
}: AnimatedButtonProps) {
const baseClasses =
"inline-flex items-center justify-center font-medium rounded-lg transition-all duration-300 ease-in-out transform focus:outline-none focus:ring-2 focus:ring-offset-2";
const sizeClasses = {
sm: "px-3 py-2 text-sm",
md: "px-6 py-3 text-base",
lg: "px-8 py-4 text-lg",
};
const variantClasses = {
primary:
"bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500 hover:scale-105 hover:shadow-lg",
secondary:
"bg-gray-600 text-white hover:bg-gray-700 focus:ring-gray-500 hover:scale-105 hover:shadow-lg",
outline:
"border-2 border-blue-600 text-blue-600 hover:bg-blue-50 focus:ring-blue-500 hover:scale-105",
};
const disabledClasses = disabled
? "opacity-50 cursor-not-allowed transform-none hover:scale-100 hover:shadow-none"
: "";
const allClasses = `${baseClasses} ${sizeClasses[size]} ${variantClasses[variant]} ${disabledClasses} ${className}`;
if (href && !disabled) {
return (
<Link href={href} className={allClasses}>
{children}
</Link>
);
}
return (
<button
type={type}
className={allClasses}
onClick={disabled ? undefined : onClick}
disabled={disabled}
>
{children}
</button>
);
}

View File

@ -1,42 +0,0 @@
import { ReactNode } from "react";
interface AnimatedCardProps {
children: ReactNode;
className?: string;
variant?: "default" | "highlighted" | "success" | "static";
onClick?: () => void;
disabled?: boolean;
}
export function AnimatedCard({
children,
className = "",
variant = "default",
onClick,
disabled = false,
}: AnimatedCardProps) {
const baseClasses =
"bg-white rounded-xl border-2 shadow-sm transition-all duration-300 ease-in-out transform";
const variantClasses: Record<"default" | "highlighted" | "success" | "static", string> = {
default: "border-gray-200 hover:shadow-xl hover:-translate-y-1",
highlighted:
"border-blue-300 ring-2 ring-blue-100 shadow-md hover:shadow-xl hover:-translate-y-1",
success:
"border-green-300 ring-2 ring-green-100 shadow-md hover:shadow-xl hover:-translate-y-1",
static: "border-gray-200 shadow-sm", // No hover animations for static containers
};
const interactiveClasses = onClick && !disabled ? "cursor-pointer hover:scale-[1.02]" : "";
const disabledClasses = disabled ? "opacity-50 cursor-not-allowed" : "";
return (
<div
className={`${baseClasses} ${variantClasses[variant]} ${interactiveClasses} ${disabledClasses} ${className}`}
onClick={disabled ? undefined : onClick}
>
{children}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More