refactor: standardize conditional expressions for improved readability
This commit is contained in:
parent
f257ffe35a
commit
61d2236b68
@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Japan Post Facade
|
||||
*
|
||||
* Public API for Japan Post integration.
|
||||
* Controllers should use this facade instead of internal services directly.
|
||||
*/
|
||||
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { JapanPostAddressService } from "../services/japanpost-address.service.js";
|
||||
import type { AddressLookupResult } from "@customer-portal/domain/address";
|
||||
|
||||
@Injectable()
|
||||
export class JapanPostFacade {
|
||||
constructor(private readonly addressService: JapanPostAddressService) {}
|
||||
|
||||
/**
|
||||
* Lookup address by ZIP code
|
||||
*
|
||||
* @param zipCode - ZIP code (with or without hyphen, e.g., "100-0001" or "1000001")
|
||||
* @param clientIp - Client IP address for API request (defaults to 127.0.0.1)
|
||||
* @returns Domain AddressLookupResult with Japanese and romanized address data
|
||||
*/
|
||||
async lookupByZipCode(
|
||||
zipCode: string,
|
||||
clientIp: string = "127.0.0.1"
|
||||
): Promise<AddressLookupResult> {
|
||||
return this.addressService.lookupByZipCode(zipCode, clientIp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the Japan Post service is available
|
||||
*/
|
||||
isAvailable(): boolean {
|
||||
return this.addressService.isAvailable();
|
||||
}
|
||||
}
|
||||
@ -3,5 +3,4 @@
|
||||
*/
|
||||
|
||||
export { JapanPostModule } from "./japanpost.module.js";
|
||||
export { JapanPostAddressService } from "./services/japanpost-address.service.js";
|
||||
export { JapanPostConnectionService } from "./services/japanpost-connection.service.js";
|
||||
export { JapanPostFacade } from "./facades/japanpost.facade.js";
|
||||
|
||||
@ -8,9 +8,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { JapanPostConnectionService } from "./services/japanpost-connection.service.js";
|
||||
import { JapanPostAddressService } from "./services/japanpost-address.service.js";
|
||||
import { JapanPostFacade } from "./facades/japanpost.facade.js";
|
||||
|
||||
@Module({
|
||||
providers: [JapanPostConnectionService, JapanPostAddressService],
|
||||
exports: [JapanPostAddressService],
|
||||
providers: [JapanPostConnectionService, JapanPostAddressService, JapanPostFacade],
|
||||
exports: [JapanPostFacade],
|
||||
})
|
||||
export class JapanPostModule {}
|
||||
|
||||
@ -18,7 +18,7 @@ import { createZodDto } from "nestjs-zod";
|
||||
import { Public } from "@bff/modules/auth/decorators/public.decorator.js";
|
||||
import { RateLimitGuard, RateLimit } from "@bff/core/rate-limiting/index.js";
|
||||
import { extractClientIp } from "@bff/core/http/request-context.util.js";
|
||||
import { JapanPostAddressService } from "@bff/integrations/japanpost/services/japanpost-address.service.js";
|
||||
import { JapanPostFacade } from "@bff/integrations/japanpost/index.js";
|
||||
import {
|
||||
addressLookupResultSchema,
|
||||
zipCodeLookupRequestSchema,
|
||||
@ -46,7 +46,7 @@ class AddressLookupResultDto extends createZodDto(addressLookupResultSchema) {}
|
||||
@Controller("address")
|
||||
@UseInterceptors(ClassSerializerInterceptor)
|
||||
export class AddressController {
|
||||
constructor(private readonly japanPostService: JapanPostAddressService) {}
|
||||
constructor(private readonly japanPost: JapanPostFacade) {}
|
||||
|
||||
/**
|
||||
* Lookup address by ZIP code
|
||||
@ -83,7 +83,7 @@ export class AddressController {
|
||||
@Req() req: Request
|
||||
): Promise<AddressLookupResultDto> {
|
||||
const clientIp = extractClientIp(req);
|
||||
return this.japanPostService.lookupByZipCode(params.zipCode, clientIp);
|
||||
return this.japanPost.lookupByZipCode(params.zipCode, clientIp);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -95,6 +95,6 @@ export class AddressController {
|
||||
@Public()
|
||||
@Get("status")
|
||||
getStatus(): { available: boolean } {
|
||||
return { available: this.japanPostService.isAvailable() };
|
||||
return { available: this.japanPost.isAvailable() };
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { Controller, Get, Post, Param, Query, Request, HttpCode, HttpStatus } from "@nestjs/common";
|
||||
import { InvoiceRetrievalService } from "./services/invoice-retrieval.service.js";
|
||||
import { WhmcsPaymentService } from "@bff/integrations/whmcs/services/whmcs-payment.service.js";
|
||||
import { WhmcsSsoService } from "@bff/integrations/whmcs/services/whmcs-sso.service.js";
|
||||
import { BillingOrchestrator } from "./services/billing-orchestrator.service.js";
|
||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||
import { createZodDto, ZodResponse } from "nestjs-zod";
|
||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
|
||||
@ -36,8 +35,7 @@ class PaymentMethodListDto extends createZodDto(paymentMethodListSchema) {}
|
||||
export class BillingController {
|
||||
constructor(
|
||||
private readonly invoicesService: InvoiceRetrievalService,
|
||||
private readonly whmcsPaymentService: WhmcsPaymentService,
|
||||
private readonly whmcsSsoService: WhmcsSsoService,
|
||||
private readonly billingOrchestrator: BillingOrchestrator,
|
||||
private readonly mappingsService: MappingsService
|
||||
) {}
|
||||
|
||||
@ -54,7 +52,7 @@ export class BillingController {
|
||||
@ZodResponse({ description: "List payment methods", type: PaymentMethodListDto })
|
||||
async getPaymentMethods(@Request() req: RequestWithUser): Promise<PaymentMethodList> {
|
||||
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(req.user.id);
|
||||
return this.whmcsPaymentService.getPaymentMethods(whmcsClientId, req.user.id);
|
||||
return this.billingOrchestrator.getPaymentMethods(whmcsClientId, req.user.id);
|
||||
}
|
||||
|
||||
@Post("payment-methods/refresh")
|
||||
@ -62,11 +60,11 @@ export class BillingController {
|
||||
@ZodResponse({ description: "Refresh payment methods", type: PaymentMethodListDto })
|
||||
async refreshPaymentMethods(@Request() req: RequestWithUser): Promise<PaymentMethodList> {
|
||||
// Invalidate cache first
|
||||
await this.whmcsPaymentService.invalidatePaymentMethodsCache(req.user.id);
|
||||
await this.billingOrchestrator.invalidatePaymentMethodsCache(req.user.id);
|
||||
|
||||
// Return fresh payment methods
|
||||
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(req.user.id);
|
||||
return this.whmcsPaymentService.getPaymentMethods(whmcsClientId, req.user.id);
|
||||
return this.billingOrchestrator.getPaymentMethods(whmcsClientId, req.user.id);
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@ -88,7 +86,7 @@ export class BillingController {
|
||||
): Promise<InvoiceSsoLink> {
|
||||
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(req.user.id);
|
||||
|
||||
const ssoUrl = await this.whmcsSsoService.whmcsSsoForInvoice(
|
||||
const ssoUrl = await this.billingOrchestrator.createInvoiceSsoLink(
|
||||
whmcsClientId,
|
||||
params.id,
|
||||
query.target
|
||||
|
||||
@ -3,6 +3,7 @@ import { BillingController } from "./billing.controller.js";
|
||||
import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module.js";
|
||||
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
|
||||
import { InvoiceRetrievalService } from "./services/invoice-retrieval.service.js";
|
||||
import { BillingOrchestrator } from "./services/billing-orchestrator.service.js";
|
||||
|
||||
/**
|
||||
* Billing Module
|
||||
@ -12,7 +13,7 @@ import { InvoiceRetrievalService } from "./services/invoice-retrieval.service.js
|
||||
@Module({
|
||||
imports: [WhmcsModule, MappingsModule],
|
||||
controllers: [BillingController],
|
||||
providers: [InvoiceRetrievalService],
|
||||
exports: [InvoiceRetrievalService],
|
||||
providers: [InvoiceRetrievalService, BillingOrchestrator],
|
||||
exports: [InvoiceRetrievalService, BillingOrchestrator],
|
||||
})
|
||||
export class BillingModule {}
|
||||
|
||||
@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Billing Orchestrator Service
|
||||
*
|
||||
* Orchestrates billing operations through integration services.
|
||||
* Controllers should use this orchestrator instead of integration services directly.
|
||||
*/
|
||||
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { WhmcsPaymentService } from "@bff/integrations/whmcs/services/whmcs-payment.service.js";
|
||||
import { WhmcsSsoService } from "@bff/integrations/whmcs/services/whmcs-sso.service.js";
|
||||
import type { PaymentMethodList } from "@customer-portal/domain/payments";
|
||||
|
||||
type SsoTarget = "view" | "download" | "pay";
|
||||
|
||||
@Injectable()
|
||||
export class BillingOrchestrator {
|
||||
constructor(
|
||||
private readonly paymentService: WhmcsPaymentService,
|
||||
private readonly ssoService: WhmcsSsoService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get payment methods for a client
|
||||
*/
|
||||
async getPaymentMethods(whmcsClientId: number, userId: string): Promise<PaymentMethodList> {
|
||||
return this.paymentService.getPaymentMethods(whmcsClientId, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate payment methods cache for a user
|
||||
*/
|
||||
async invalidatePaymentMethodsCache(userId: string): Promise<void> {
|
||||
return this.paymentService.invalidatePaymentMethodsCache(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create SSO link for invoice access
|
||||
*/
|
||||
async createInvoiceSsoLink(
|
||||
whmcsClientId: number,
|
||||
invoiceId: number,
|
||||
target: SsoTarget
|
||||
): Promise<string> {
|
||||
return this.ssoService.whmcsSsoForInvoice(whmcsClientId, invoiceId, target);
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useState, useEffect, useMemo, useRef } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useAuthStore, useAuthSession } from "@/features/auth/stores/auth.store";
|
||||
import { useActiveSubscriptions } from "@/features/subscriptions/hooks";
|
||||
@ -72,8 +72,12 @@ export function AppShell({ children }: AppShellProps) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Track if we've initiated auth check to prevent duplicate calls
|
||||
const authCheckInitiated = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasCheckedAuth) {
|
||||
if (!hasCheckedAuth && !authCheckInitiated.current) {
|
||||
authCheckInitiated.current = true;
|
||||
void checkAuth();
|
||||
}
|
||||
}, [hasCheckedAuth, checkAuth]);
|
||||
|
||||
@ -20,7 +20,7 @@ export * from "./response-helpers";
|
||||
|
||||
/**
|
||||
* Auth endpoints that should NOT trigger automatic logout on 401
|
||||
* These are endpoints where 401 means "invalid credentials", not "session expired"
|
||||
* These are endpoints where 401 means "invalid credentials" or is handled by the auth flow itself
|
||||
*/
|
||||
const AUTH_ENDPOINTS = [
|
||||
"/api/auth/login",
|
||||
@ -28,6 +28,9 @@ const AUTH_ENDPOINTS = [
|
||||
"/api/auth/set-password",
|
||||
"/api/auth/reset-password",
|
||||
"/api/auth/check-password-needed",
|
||||
"/api/auth/me", // Auth check endpoint - handled by refreshUser flow
|
||||
"/api/auth/refresh", // Refresh endpoint - handled by refreshSession flow
|
||||
"/api/me", // Profile endpoint - handled by refreshUser flow
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { Checkbox } from "@/components/atoms/checkbox";
|
||||
import { Label } from "@/components/atoms";
|
||||
|
||||
interface MarketingCheckboxProps {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
disabled?: boolean | undefined;
|
||||
}
|
||||
|
||||
export function MarketingCheckbox({ checked, onChange, disabled }: MarketingCheckboxProps) {
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<Checkbox
|
||||
id="marketingConsent"
|
||||
checked={checked}
|
||||
onChange={e => onChange(e.target.checked)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="marketingConsent"
|
||||
className="text-sm font-normal leading-tight cursor-pointer"
|
||||
>
|
||||
I would like to receive marketing emails and updates
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { Check, X } from "lucide-react";
|
||||
|
||||
interface PasswordMatchIndicatorProps {
|
||||
passwordsMatch: boolean;
|
||||
}
|
||||
|
||||
export function PasswordMatchIndicator({ passwordsMatch }: PasswordMatchIndicatorProps) {
|
||||
if (passwordsMatch) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<Check className="h-3 w-3 text-success" />
|
||||
<span className="text-success">Passwords match</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<X className="h-3 w-3 text-danger" />
|
||||
<span className="text-danger">Passwords do not match</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { Check, X } from "lucide-react";
|
||||
import type { PasswordChecks } from "../hooks/usePasswordValidation";
|
||||
|
||||
interface RequirementCheckProps {
|
||||
met: boolean;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function RequirementCheck({ met, label }: RequirementCheckProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{met ? (
|
||||
<Check className="h-3 w-3 text-success" />
|
||||
) : (
|
||||
<X className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
<span className={met ? "text-success" : "text-muted-foreground"}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PasswordRequirementsProps {
|
||||
checks: PasswordChecks;
|
||||
showHint?: boolean | undefined;
|
||||
}
|
||||
|
||||
export function PasswordRequirements({ checks, showHint = false }: PasswordRequirementsProps) {
|
||||
if (showHint) {
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
At least 8 characters with uppercase, lowercase, and numbers
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-1 text-xs">
|
||||
<RequirementCheck met={checks.minLength} label="8+ characters" />
|
||||
<RequirementCheck met={checks.hasUppercase} label="Uppercase letter" />
|
||||
<RequirementCheck met={checks.hasLowercase} label="Lowercase letter" />
|
||||
<RequirementCheck met={checks.hasNumber} label="Number" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
apps/portal/src/features/auth/components/TermsCheckbox.tsx
Normal file
48
apps/portal/src/features/auth/components/TermsCheckbox.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { Checkbox } from "@/components/atoms/checkbox";
|
||||
import { Label } from "@/components/atoms";
|
||||
|
||||
interface TermsCheckboxProps {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
disabled?: boolean | undefined;
|
||||
error?: string | undefined;
|
||||
}
|
||||
|
||||
export function TermsCheckbox({ checked, onChange, disabled, error }: TermsCheckboxProps) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-start gap-2">
|
||||
<Checkbox
|
||||
id="acceptTerms"
|
||||
checked={checked}
|
||||
onChange={e => onChange(e.target.checked)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Label htmlFor="acceptTerms" className="text-sm font-normal leading-tight cursor-pointer">
|
||||
I accept the{" "}
|
||||
<a
|
||||
href="/terms"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Terms of Service
|
||||
</a>{" "}
|
||||
and{" "}
|
||||
<a
|
||||
href="/privacy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Privacy Policy
|
||||
</a>{" "}
|
||||
<span className="text-danger">*</span>
|
||||
</Label>
|
||||
</div>
|
||||
{error && <p className="text-sm text-danger ml-6">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -8,3 +8,9 @@ export { LoginOtpStep } from "./LoginOtpStep";
|
||||
export { PasswordResetForm } from "./PasswordResetForm/PasswordResetForm";
|
||||
export { SetPasswordForm } from "./SetPasswordForm/SetPasswordForm";
|
||||
export { AuthLayout } from "@/components/templates/AuthLayout";
|
||||
|
||||
// Account creation components
|
||||
export { PasswordRequirements } from "./PasswordRequirements";
|
||||
export { PasswordMatchIndicator } from "./PasswordMatchIndicator";
|
||||
export { TermsCheckbox } from "./TermsCheckbox";
|
||||
export { MarketingCheckbox } from "./MarketingCheckbox";
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useAuthStore } from "../stores/auth.store";
|
||||
import { getPostLoginRedirect } from "@/features/auth/utils/route-protection";
|
||||
@ -203,16 +203,34 @@ export function usePasswordChange() {
|
||||
|
||||
/**
|
||||
* Hook for session management
|
||||
*
|
||||
* Refresh strategy (to avoid rate limits):
|
||||
* - On mount: check auth once
|
||||
* - Periodically: check every 4 minutes (the store will only actually refresh if token is expiring)
|
||||
* - On tab focus: check once (the store will skip if token is still fresh)
|
||||
*
|
||||
* The actual decision to call the refresh API is made in the auth store based on token expiry.
|
||||
*/
|
||||
export function useSession() {
|
||||
const { isAuthenticated, user, checkAuth, refreshSession, logout } = useAuth();
|
||||
const isAuthenticated = useAuthStore(state => state.isAuthenticated);
|
||||
const user = useAuthStore(state => state.user);
|
||||
const hasCheckedAuth = useAuthStore(state => state.hasCheckedAuth);
|
||||
const checkAuth = useAuthStore(state => state.checkAuth);
|
||||
const refreshSession = useAuthStore(state => state.refreshSession);
|
||||
const logout = useAuthStore(state => state.logout);
|
||||
|
||||
// Auto-check auth on mount
|
||||
// Track if we've initiated auth check to prevent duplicate calls
|
||||
const authCheckInitiated = useRef(false);
|
||||
|
||||
// Auto-check auth on mount - only once
|
||||
useEffect(() => {
|
||||
void checkAuth();
|
||||
}, [checkAuth]);
|
||||
if (!hasCheckedAuth && !authCheckInitiated.current) {
|
||||
authCheckInitiated.current = true;
|
||||
void checkAuth();
|
||||
}
|
||||
}, [hasCheckedAuth, checkAuth]);
|
||||
|
||||
// Auto-refresh session periodically
|
||||
// Periodic refresh check - the store will only actually refresh if token is expiring soon
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
return;
|
||||
@ -222,46 +240,28 @@ export function useSession() {
|
||||
() => {
|
||||
void refreshSession();
|
||||
},
|
||||
5 * 60 * 1000
|
||||
); // Check every 5 minutes
|
||||
4 * 60 * 1000
|
||||
); // Check every 4 minutes (store decides if refresh is needed)
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isAuthenticated, refreshSession]);
|
||||
|
||||
// Refresh on tab visibility - the store will skip if token is still fresh
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
let lastRefresh = 0;
|
||||
const minInterval = 60 * 1000;
|
||||
|
||||
const triggerRefresh = () => {
|
||||
const now = Date.now();
|
||||
if (now - lastRefresh < minInterval) {
|
||||
return;
|
||||
}
|
||||
lastRefresh = now;
|
||||
void refreshSession();
|
||||
};
|
||||
|
||||
const handleVisibility = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
triggerRefresh();
|
||||
// The store will only refresh if the token is expiring soon
|
||||
void refreshSession();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
triggerRefresh();
|
||||
};
|
||||
|
||||
window.addEventListener("focus", handleFocus);
|
||||
document.addEventListener("visibilitychange", handleVisibility);
|
||||
|
||||
triggerRefresh();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("focus", handleFocus);
|
||||
document.removeEventListener("visibilitychange", handleVisibility);
|
||||
};
|
||||
}, [isAuthenticated, refreshSession]);
|
||||
|
||||
40
apps/portal/src/features/auth/hooks/usePasswordValidation.ts
Normal file
40
apps/portal/src/features/auth/hooks/usePasswordValidation.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
export interface PasswordChecks {
|
||||
minLength: boolean;
|
||||
hasUppercase: boolean;
|
||||
hasLowercase: boolean;
|
||||
hasNumber: boolean;
|
||||
}
|
||||
|
||||
export interface PasswordValidation {
|
||||
checks: PasswordChecks;
|
||||
isValid: boolean;
|
||||
error: string | undefined;
|
||||
}
|
||||
|
||||
export function validatePasswordRules(password: string): string | undefined {
|
||||
if (!password) return "Password is required";
|
||||
if (password.length < 8) return "Password must be at least 8 characters";
|
||||
if (!/[A-Z]/.test(password)) return "Password must contain an uppercase letter";
|
||||
if (!/[a-z]/.test(password)) return "Password must contain a lowercase letter";
|
||||
if (!/[0-9]/.test(password)) return "Password must contain a number";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function usePasswordValidation(password: string): PasswordValidation {
|
||||
return useMemo(() => {
|
||||
const checks: PasswordChecks = {
|
||||
minLength: password.length >= 8,
|
||||
hasUppercase: /[A-Z]/.test(password),
|
||||
hasLowercase: /[a-z]/.test(password),
|
||||
hasNumber: /[0-9]/.test(password),
|
||||
};
|
||||
|
||||
const isValid =
|
||||
checks.minLength && checks.hasUppercase && checks.hasLowercase && checks.hasNumber;
|
||||
const error = validatePasswordRules(password);
|
||||
|
||||
return { checks, isValid, error };
|
||||
}, [password]);
|
||||
}
|
||||
@ -67,6 +67,35 @@ type AuthResponseData = {
|
||||
|
||||
let unauthorizedSubscriptionInitialized = false;
|
||||
|
||||
// Rate limit backoff state (module-level to persist across store recreations)
|
||||
let rateLimitBackoffUntil = 0;
|
||||
|
||||
/**
|
||||
* Check if we should actually refresh the token.
|
||||
* Only refresh if:
|
||||
* 1. We're not in a rate limit backoff period
|
||||
* 2. The token is expiring within the threshold (default: 5 minutes)
|
||||
*/
|
||||
function shouldRefreshToken(session: SessionState, thresholdMs = 5 * 60 * 1000): boolean {
|
||||
// Don't refresh if we're in a rate limit backoff
|
||||
if (Date.now() < rateLimitBackoffUntil) {
|
||||
logger.debug("Skipping refresh: rate limit backoff active");
|
||||
return false;
|
||||
}
|
||||
|
||||
// If we don't have expiry info, refresh to get it
|
||||
if (!session.accessExpiresAt) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const expiresAt = new Date(session.accessExpiresAt).getTime();
|
||||
const now = Date.now();
|
||||
const timeUntilExpiry = expiresAt - now;
|
||||
|
||||
// Only refresh if token expires within threshold
|
||||
return timeUntilExpiry < thresholdMs;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()((set, get) => {
|
||||
const applyAuthResponse = (data: AuthResponseData, keepLoading = false) => {
|
||||
set({
|
||||
@ -91,17 +120,43 @@ export const useAuthStore = create<AuthState>()((set, get) => {
|
||||
if (!parsed.success) {
|
||||
throw new Error(parsed.error.issues?.[0]?.message ?? "Session refresh failed");
|
||||
}
|
||||
// Clear any rate limit backoff on successful refresh
|
||||
rateLimitBackoffUntil = 0;
|
||||
applyAuthResponse(parsed.data);
|
||||
} catch (error) {
|
||||
logger.error("Failed to refresh session", error);
|
||||
const parsed = parseError(error);
|
||||
const reason = logoutReasonFromErrorCode(parsed.code) ?? LOGOUT_REASON.SESSION_EXPIRED;
|
||||
await get().logout({ reason });
|
||||
|
||||
// Handle rate limiting: set backoff period instead of logging out
|
||||
// The token is still valid, user just needs to wait
|
||||
if (parsed.code === "NET_003") {
|
||||
// Back off for 60 seconds on rate limit (will be extended if hit again)
|
||||
rateLimitBackoffUntil = Date.now() + 60 * 1000;
|
||||
logger.warn("Token refresh rate limited, backing off for 60s");
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Only logout for errors that require it (e.g., token revoked, session expired)
|
||||
if (parsed.shouldLogout) {
|
||||
const reason = logoutReasonFromErrorCode(parsed.code) ?? LOGOUT_REASON.SESSION_EXPIRED;
|
||||
await get().logout({ reason });
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const ensureSingleRefresh = async (): Promise<void> => {
|
||||
/**
|
||||
* Ensures only one refresh runs at a time, and only if the token actually needs refreshing.
|
||||
* @param force - If true, skip the expiry check (used when we know the token is invalid)
|
||||
*/
|
||||
const ensureSingleRefresh = async (force = false): Promise<void> => {
|
||||
// Check if we should refresh (unless forced)
|
||||
if (!force && !shouldRefreshToken(get().session)) {
|
||||
logger.debug("Skipping refresh: token not expiring soon");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = (async () => {
|
||||
try {
|
||||
@ -381,18 +436,22 @@ export const useAuthStore = create<AuthState>()((set, get) => {
|
||||
// Fall back to lightweight auth check.
|
||||
}
|
||||
|
||||
const authResponse = await apiClient.GET<{
|
||||
isAuthenticated?: boolean;
|
||||
user?: AuthenticatedUser;
|
||||
}>("/api/auth/me");
|
||||
const authData = getNullableData(authResponse);
|
||||
if (authData?.isAuthenticated && authData.user) {
|
||||
set({
|
||||
user: authData.user,
|
||||
isAuthenticated: true,
|
||||
error: null,
|
||||
});
|
||||
return;
|
||||
try {
|
||||
const authResponse = await apiClient.GET<{
|
||||
isAuthenticated?: boolean;
|
||||
user?: AuthenticatedUser;
|
||||
}>("/api/auth/me");
|
||||
const authData = getNullableData(authResponse);
|
||||
if (authData?.isAuthenticated && authData.user) {
|
||||
set({
|
||||
user: authData.user,
|
||||
isAuthenticated: true,
|
||||
error: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Both auth checks failed - user is not authenticated
|
||||
}
|
||||
|
||||
set({ user: null, isAuthenticated: false, session: {} });
|
||||
@ -409,11 +468,13 @@ export const useAuthStore = create<AuthState>()((set, get) => {
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureSingleRefresh();
|
||||
// Force refresh since we got an auth error - the token is invalid
|
||||
await ensureSingleRefresh(true);
|
||||
await fetchProfile();
|
||||
} catch (refreshError) {
|
||||
logger.error("Failed to refresh session after auth error", refreshError);
|
||||
return;
|
||||
// Ensure we mark user as not authenticated when refresh fails
|
||||
set({ user: null, isAuthenticated: false, session: {} });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@ -8,29 +8,18 @@
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { Button, Input, Label } from "@/components/atoms";
|
||||
import { Checkbox } from "@/components/atoms/checkbox";
|
||||
import {
|
||||
JapanAddressForm,
|
||||
type JapanAddressFormData,
|
||||
} from "@/features/address/components/JapanAddressForm";
|
||||
import { prepareWhmcsAddressFields } from "@customer-portal/domain/address";
|
||||
import { Button } from "@/components/atoms";
|
||||
import { getSafeRedirect } from "@/features/auth/utils/route-protection";
|
||||
import { TermsCheckbox, MarketingCheckbox } from "@/features/auth/components";
|
||||
import { useGetStartedStore } from "../../../stores/get-started.store";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface FormErrors {
|
||||
firstName?: string | undefined;
|
||||
lastName?: string | undefined;
|
||||
address?: string | undefined;
|
||||
password?: string | undefined;
|
||||
confirmPassword?: string | undefined;
|
||||
phone?: string | undefined;
|
||||
dateOfBirth?: string | undefined;
|
||||
gender?: string | undefined;
|
||||
acceptTerms?: string | undefined;
|
||||
}
|
||||
import {
|
||||
PrefilledUserInfo,
|
||||
NewCustomerFields,
|
||||
PersonalInfoFields,
|
||||
PasswordSection,
|
||||
useCompleteAccountForm,
|
||||
} from "./complete-account";
|
||||
|
||||
export function CompleteAccountStep() {
|
||||
const router = useRouter();
|
||||
@ -48,140 +37,39 @@ export function CompleteAccountStep() {
|
||||
serviceContext,
|
||||
} = useGetStartedStore();
|
||||
|
||||
// Compute effective redirect URL from store state (with validation)
|
||||
const effectiveRedirectTo = getSafeRedirect(
|
||||
redirectTo || serviceContext?.redirectTo,
|
||||
"/account/dashboard"
|
||||
);
|
||||
|
||||
// Check if this is a new customer (needs full form) or SF-only (has prefill)
|
||||
const isNewCustomer = accountStatus === "new_customer";
|
||||
const hasPrefill = !!(prefill?.firstName || prefill?.lastName);
|
||||
|
||||
const [firstName, setFirstName] = useState(formData.firstName || prefill?.firstName || "");
|
||||
const [lastName, setLastName] = useState(formData.lastName || prefill?.lastName || "");
|
||||
const [isAddressComplete, setIsAddressComplete] = useState(!isNewCustomer);
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [phone, setPhone] = useState(formData.phone || prefill?.phone || "");
|
||||
const [dateOfBirth, setDateOfBirth] = useState(formData.dateOfBirth);
|
||||
const [gender, setGender] = useState<"male" | "female" | "other" | "">(formData.gender);
|
||||
const [acceptTerms, setAcceptTerms] = useState(formData.acceptTerms);
|
||||
const [marketingConsent, setMarketingConsent] = useState(formData.marketingConsent);
|
||||
const [localErrors, setLocalErrors] = useState<FormErrors>({});
|
||||
|
||||
// Handle address form changes (only for new customers)
|
||||
const handleAddressChange = useCallback(
|
||||
(data: JapanAddressFormData, isComplete: boolean) => {
|
||||
setIsAddressComplete(isComplete);
|
||||
const whmcsFields = prepareWhmcsAddressFields(data);
|
||||
updateFormData({
|
||||
address: {
|
||||
address1: whmcsFields.address1 || "",
|
||||
address2: whmcsFields.address2 || "",
|
||||
city: whmcsFields.city || "",
|
||||
state: whmcsFields.state || "",
|
||||
postcode: whmcsFields.postcode || "",
|
||||
country: "JP",
|
||||
},
|
||||
});
|
||||
const form = useCompleteAccountForm({
|
||||
initialValues: {
|
||||
firstName: formData.firstName || prefill?.firstName,
|
||||
lastName: formData.lastName || prefill?.lastName,
|
||||
phone: formData.phone || prefill?.phone,
|
||||
dateOfBirth: formData.dateOfBirth,
|
||||
gender: formData.gender,
|
||||
acceptTerms: formData.acceptTerms,
|
||||
marketingConsent: formData.marketingConsent,
|
||||
},
|
||||
[updateFormData]
|
||||
);
|
||||
|
||||
const validatePassword = (pass: string): string | undefined => {
|
||||
if (!pass) return "Password is required";
|
||||
if (pass.length < 8) return "Password must be at least 8 characters";
|
||||
if (!/[A-Z]/.test(pass)) return "Password must contain an uppercase letter";
|
||||
if (!/[a-z]/.test(pass)) return "Password must contain a lowercase letter";
|
||||
if (!/[0-9]/.test(pass)) return "Password must contain a number";
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const validate = (): boolean => {
|
||||
const errors: FormErrors = {};
|
||||
|
||||
// Validate name and address only for new customers
|
||||
if (isNewCustomer) {
|
||||
if (!firstName.trim()) {
|
||||
errors.firstName = "First name is required";
|
||||
}
|
||||
if (!lastName.trim()) {
|
||||
errors.lastName = "Last name is required";
|
||||
}
|
||||
if (!isAddressComplete) {
|
||||
errors.address = "Please complete the address";
|
||||
}
|
||||
}
|
||||
|
||||
const passwordError = validatePassword(password);
|
||||
if (passwordError) {
|
||||
errors.password = passwordError;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
errors.confirmPassword = "Passwords do not match";
|
||||
}
|
||||
|
||||
if (!phone.trim()) {
|
||||
errors.phone = "Phone number is required";
|
||||
}
|
||||
|
||||
if (!dateOfBirth) {
|
||||
errors.dateOfBirth = "Date of birth is required";
|
||||
}
|
||||
|
||||
if (!gender) {
|
||||
errors.gender = "Please select a gender";
|
||||
}
|
||||
|
||||
if (!acceptTerms) {
|
||||
errors.acceptTerms = "You must accept the terms of service";
|
||||
}
|
||||
|
||||
setLocalErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
isNewCustomer,
|
||||
updateFormData,
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
clearError();
|
||||
if (!form.validate()) return;
|
||||
|
||||
if (!validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update form data
|
||||
updateFormData({
|
||||
firstName: firstName.trim(),
|
||||
lastName: lastName.trim(),
|
||||
password,
|
||||
confirmPassword,
|
||||
phone: phone.trim(),
|
||||
dateOfBirth,
|
||||
gender: gender as "male" | "female" | "other",
|
||||
acceptTerms,
|
||||
marketingConsent,
|
||||
});
|
||||
|
||||
updateFormData(form.getFormData());
|
||||
const result = await completeAccount();
|
||||
if (result) {
|
||||
// Redirect to the effective redirect URL on success
|
||||
router.push(effectiveRedirectTo);
|
||||
}
|
||||
if (result) router.push(effectiveRedirectTo);
|
||||
};
|
||||
|
||||
const canSubmit =
|
||||
password &&
|
||||
confirmPassword &&
|
||||
phone &&
|
||||
dateOfBirth &&
|
||||
gender &&
|
||||
acceptTerms &&
|
||||
(isNewCustomer ? firstName && lastName && isAddressComplete : true);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="text-center space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{hasPrefill
|
||||
@ -190,291 +78,71 @@ export function CompleteAccountStep() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Pre-filled info display in gray disabled inputs (SF-only users) */}
|
||||
{hasPrefill && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">First Name</Label>
|
||||
<Input
|
||||
value={prefill?.firstName || ""}
|
||||
disabled
|
||||
className="bg-muted text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">Last Name</Label>
|
||||
<Input
|
||||
value={prefill?.lastName || ""}
|
||||
disabled
|
||||
className="bg-muted text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">Email</Label>
|
||||
<Input value={formData.email} disabled className="bg-muted text-muted-foreground" />
|
||||
</div>
|
||||
{prefill?.address && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">Address</Label>
|
||||
<Input
|
||||
value={[
|
||||
prefill.address.postcode,
|
||||
prefill.address.state,
|
||||
prefill.address.city,
|
||||
prefill.address.address1,
|
||||
prefill.address.address2,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ")}
|
||||
disabled
|
||||
className="bg-muted text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{hasPrefill && <PrefilledUserInfo prefill={prefill!} email={formData.email} />}
|
||||
|
||||
{/* Name fields (new customers only) */}
|
||||
{isNewCustomer && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="firstName">
|
||||
First Name <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="firstName"
|
||||
value={firstName}
|
||||
onChange={e => {
|
||||
setFirstName(e.target.value);
|
||||
setLocalErrors(prev => ({ ...prev, firstName: undefined }));
|
||||
}}
|
||||
placeholder="Taro"
|
||||
disabled={loading}
|
||||
error={localErrors.firstName}
|
||||
/>
|
||||
{localErrors.firstName && (
|
||||
<p className="text-sm text-danger">{localErrors.firstName}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lastName">
|
||||
Last Name <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="lastName"
|
||||
value={lastName}
|
||||
onChange={e => {
|
||||
setLastName(e.target.value);
|
||||
setLocalErrors(prev => ({ ...prev, lastName: undefined }));
|
||||
}}
|
||||
placeholder="Yamada"
|
||||
disabled={loading}
|
||||
error={localErrors.lastName}
|
||||
/>
|
||||
{localErrors.lastName && (
|
||||
<p className="text-sm text-danger">{localErrors.lastName}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Address Form (new customers only) */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Address <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<JapanAddressForm onChange={handleAddressChange} disabled={loading} />
|
||||
{localErrors.address && <p className="text-sm text-danger">{localErrors.address}</p>}
|
||||
</div>
|
||||
</>
|
||||
<NewCustomerFields
|
||||
firstName={form.firstName}
|
||||
lastName={form.lastName}
|
||||
onFirstNameChange={form.setFirstName}
|
||||
onLastNameChange={form.setLastName}
|
||||
onAddressChange={form.handleAddressChange}
|
||||
errors={form.errors}
|
||||
clearError={form.clearError}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Phone */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">
|
||||
Phone Number <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
value={phone}
|
||||
onChange={e => {
|
||||
setPhone(e.target.value);
|
||||
setLocalErrors(prev => ({ ...prev, phone: undefined }));
|
||||
}}
|
||||
placeholder="090-1234-5678"
|
||||
disabled={loading}
|
||||
error={localErrors.phone}
|
||||
/>
|
||||
{localErrors.phone && <p className="text-sm text-danger">{localErrors.phone}</p>}
|
||||
</div>
|
||||
<PersonalInfoFields
|
||||
phone={form.phone}
|
||||
dateOfBirth={form.dateOfBirth}
|
||||
gender={form.gender}
|
||||
onPhoneChange={form.setPhone}
|
||||
onDateOfBirthChange={form.setDateOfBirth}
|
||||
onGenderChange={form.setGender}
|
||||
errors={form.errors}
|
||||
clearError={form.clearError}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
{/* Date of Birth */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dateOfBirth">
|
||||
Date of Birth <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="dateOfBirth"
|
||||
type="date"
|
||||
value={dateOfBirth}
|
||||
onChange={e => {
|
||||
setDateOfBirth(e.target.value);
|
||||
setLocalErrors(prev => ({ ...prev, dateOfBirth: undefined }));
|
||||
}}
|
||||
disabled={loading}
|
||||
error={localErrors.dateOfBirth}
|
||||
max={new Date().toISOString().split("T")[0]}
|
||||
/>
|
||||
{localErrors.dateOfBirth && (
|
||||
<p className="text-sm text-danger">{localErrors.dateOfBirth}</p>
|
||||
)}
|
||||
</div>
|
||||
<PasswordSection
|
||||
password={form.password}
|
||||
confirmPassword={form.confirmPassword}
|
||||
onPasswordChange={form.setPassword}
|
||||
onConfirmPasswordChange={form.setConfirmPassword}
|
||||
errors={form.errors}
|
||||
clearError={form.clearError}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
{/* Gender */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Gender <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<div className="flex gap-4">
|
||||
{(["male", "female", "other"] as const).map(option => (
|
||||
<label key={option} className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="gender"
|
||||
value={option}
|
||||
checked={gender === option}
|
||||
onChange={() => {
|
||||
setGender(option);
|
||||
setLocalErrors(prev => ({ ...prev, gender: undefined }));
|
||||
}}
|
||||
disabled={loading}
|
||||
className="h-4 w-4 text-primary focus:ring-primary"
|
||||
/>
|
||||
<span className="text-sm capitalize">{option}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{localErrors.gender && <p className="text-sm text-danger">{localErrors.gender}</p>}
|
||||
</div>
|
||||
|
||||
{/* Password (at bottom before terms) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">
|
||||
Password <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => {
|
||||
setPassword(e.target.value);
|
||||
setLocalErrors(prev => ({ ...prev, password: undefined }));
|
||||
}}
|
||||
placeholder="Create a strong password"
|
||||
disabled={loading}
|
||||
error={localErrors.password}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
{localErrors.password && <p className="text-sm text-danger">{localErrors.password}</p>}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
At least 8 characters with uppercase, lowercase, and numbers
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Confirm Password */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">
|
||||
Confirm Password <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={e => {
|
||||
setConfirmPassword(e.target.value);
|
||||
setLocalErrors(prev => ({ ...prev, confirmPassword: undefined }));
|
||||
}}
|
||||
placeholder="Confirm your password"
|
||||
disabled={loading}
|
||||
error={localErrors.confirmPassword}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
{localErrors.confirmPassword && (
|
||||
<p className="text-sm text-danger">{localErrors.confirmPassword}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Terms & Marketing */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Checkbox
|
||||
id="acceptTerms"
|
||||
checked={acceptTerms}
|
||||
onChange={e => {
|
||||
setAcceptTerms(e.target.checked);
|
||||
setLocalErrors(prev => ({ ...prev, acceptTerms: undefined }));
|
||||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Label htmlFor="acceptTerms" className="text-sm font-normal leading-tight cursor-pointer">
|
||||
I accept the{" "}
|
||||
<a
|
||||
href="/terms"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Terms of Service
|
||||
</a>{" "}
|
||||
and{" "}
|
||||
<a
|
||||
href="/privacy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Privacy Policy
|
||||
</a>{" "}
|
||||
<span className="text-danger">*</span>
|
||||
</Label>
|
||||
</div>
|
||||
{localErrors.acceptTerms && (
|
||||
<p className="text-sm text-danger ml-6">{localErrors.acceptTerms}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<Checkbox
|
||||
id="marketingConsent"
|
||||
checked={marketingConsent}
|
||||
onChange={e => setMarketingConsent(e.target.checked)}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="marketingConsent"
|
||||
className="text-sm font-normal leading-tight cursor-pointer"
|
||||
>
|
||||
I would like to receive marketing emails and updates
|
||||
</Label>
|
||||
</div>
|
||||
<TermsCheckbox
|
||||
checked={form.acceptTerms}
|
||||
onChange={checked => {
|
||||
form.setAcceptTerms(checked);
|
||||
form.clearError("acceptTerms");
|
||||
}}
|
||||
disabled={loading}
|
||||
error={form.errors.acceptTerms}
|
||||
/>
|
||||
<MarketingCheckbox
|
||||
checked={form.marketingConsent}
|
||||
onChange={form.setMarketingConsent}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-danger/10 border border-danger/20">
|
||||
<p className="text-sm text-danger">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={loading || !canSubmit}
|
||||
disabled={loading || !form.canSubmit}
|
||||
loading={loading}
|
||||
className="w-full h-11"
|
||||
>
|
||||
|
||||
@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { Input, Label } from "@/components/atoms";
|
||||
import {
|
||||
JapanAddressForm,
|
||||
type JapanAddressFormData,
|
||||
} from "@/features/address/components/JapanAddressForm";
|
||||
import type { AccountFormErrors } from "./types";
|
||||
|
||||
interface NewCustomerFieldsProps {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
onFirstNameChange: (value: string) => void;
|
||||
onLastNameChange: (value: string) => void;
|
||||
onAddressChange: (data: JapanAddressFormData, isComplete: boolean) => void;
|
||||
errors: AccountFormErrors;
|
||||
clearError: (field: keyof AccountFormErrors) => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function NewCustomerFields({
|
||||
firstName,
|
||||
lastName,
|
||||
onFirstNameChange,
|
||||
onLastNameChange,
|
||||
onAddressChange,
|
||||
errors,
|
||||
clearError,
|
||||
loading,
|
||||
}: NewCustomerFieldsProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="firstName">
|
||||
First Name <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="firstName"
|
||||
value={firstName}
|
||||
onChange={e => {
|
||||
onFirstNameChange(e.target.value);
|
||||
clearError("firstName");
|
||||
}}
|
||||
placeholder="Taro"
|
||||
disabled={loading}
|
||||
error={errors.firstName}
|
||||
/>
|
||||
{errors.firstName && <p className="text-sm text-danger">{errors.firstName}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lastName">
|
||||
Last Name <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="lastName"
|
||||
value={lastName}
|
||||
onChange={e => {
|
||||
onLastNameChange(e.target.value);
|
||||
clearError("lastName");
|
||||
}}
|
||||
placeholder="Yamada"
|
||||
disabled={loading}
|
||||
error={errors.lastName}
|
||||
/>
|
||||
{errors.lastName && <p className="text-sm text-danger">{errors.lastName}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Address <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<JapanAddressForm onChange={onAddressChange} disabled={loading} />
|
||||
{errors.address && <p className="text-sm text-danger">{errors.address}</p>}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { Input, Label } from "@/components/atoms";
|
||||
import { PasswordRequirements } from "@/features/auth/components";
|
||||
import { usePasswordValidation } from "@/features/auth/hooks/usePasswordValidation";
|
||||
import type { AccountFormErrors } from "./types";
|
||||
|
||||
interface PasswordSectionProps {
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
onPasswordChange: (value: string) => void;
|
||||
onConfirmPasswordChange: (value: string) => void;
|
||||
errors: AccountFormErrors;
|
||||
clearError: (field: keyof AccountFormErrors) => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function PasswordSection({
|
||||
password,
|
||||
confirmPassword,
|
||||
onPasswordChange,
|
||||
onConfirmPasswordChange,
|
||||
errors,
|
||||
clearError,
|
||||
loading,
|
||||
}: PasswordSectionProps) {
|
||||
const { checks } = usePasswordValidation(password);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">
|
||||
Password <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => {
|
||||
onPasswordChange(e.target.value);
|
||||
clearError("password");
|
||||
}}
|
||||
placeholder="Create a strong password"
|
||||
disabled={loading}
|
||||
error={errors.password}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
{errors.password && <p className="text-sm text-danger">{errors.password}</p>}
|
||||
<PasswordRequirements checks={checks} showHint={password.length === 0} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">
|
||||
Confirm Password <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={e => {
|
||||
onConfirmPasswordChange(e.target.value);
|
||||
clearError("confirmPassword");
|
||||
}}
|
||||
placeholder="Confirm your password"
|
||||
disabled={loading}
|
||||
error={errors.confirmPassword}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
{errors.confirmPassword && <p className="text-sm text-danger">{errors.confirmPassword}</p>}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { Input, Label } from "@/components/atoms";
|
||||
import type { AccountFormErrors } from "./types";
|
||||
|
||||
type Gender = "male" | "female" | "other" | "";
|
||||
|
||||
interface PersonalInfoFieldsProps {
|
||||
phone: string;
|
||||
dateOfBirth: string;
|
||||
gender: Gender;
|
||||
onPhoneChange: (value: string) => void;
|
||||
onDateOfBirthChange: (value: string) => void;
|
||||
onGenderChange: (value: "male" | "female" | "other") => void;
|
||||
errors: AccountFormErrors;
|
||||
clearError: (field: keyof AccountFormErrors) => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const GENDER_OPTIONS = ["male", "female", "other"] as const;
|
||||
|
||||
export function PersonalInfoFields({
|
||||
phone,
|
||||
dateOfBirth,
|
||||
gender,
|
||||
onPhoneChange,
|
||||
onDateOfBirthChange,
|
||||
onGenderChange,
|
||||
errors,
|
||||
clearError,
|
||||
loading,
|
||||
}: PersonalInfoFieldsProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">
|
||||
Phone Number <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
value={phone}
|
||||
onChange={e => {
|
||||
onPhoneChange(e.target.value);
|
||||
clearError("phone");
|
||||
}}
|
||||
placeholder="090-1234-5678"
|
||||
disabled={loading}
|
||||
error={errors.phone}
|
||||
/>
|
||||
{errors.phone && <p className="text-sm text-danger">{errors.phone}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dateOfBirth">
|
||||
Date of Birth <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="dateOfBirth"
|
||||
type="date"
|
||||
value={dateOfBirth}
|
||||
onChange={e => {
|
||||
onDateOfBirthChange(e.target.value);
|
||||
clearError("dateOfBirth");
|
||||
}}
|
||||
disabled={loading}
|
||||
error={errors.dateOfBirth}
|
||||
max={new Date().toISOString().split("T")[0]}
|
||||
/>
|
||||
{errors.dateOfBirth && <p className="text-sm text-danger">{errors.dateOfBirth}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Gender <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<div className="flex gap-4">
|
||||
{GENDER_OPTIONS.map(option => (
|
||||
<label key={option} className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="gender"
|
||||
value={option}
|
||||
checked={gender === option}
|
||||
onChange={() => {
|
||||
onGenderChange(option);
|
||||
clearError("gender");
|
||||
}}
|
||||
disabled={loading}
|
||||
className="h-4 w-4 text-primary focus:ring-primary"
|
||||
/>
|
||||
<span className="text-sm capitalize">{option}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{errors.gender && <p className="text-sm text-danger">{errors.gender}</p>}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { Input, Label } from "@/components/atoms";
|
||||
import type { PrefillData } from "./types";
|
||||
|
||||
interface PrefilledUserInfoProps {
|
||||
prefill: PrefillData;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export function PrefilledUserInfo({ prefill, email }: PrefilledUserInfoProps) {
|
||||
const addressDisplay = prefill.address
|
||||
? [
|
||||
prefill.address.postcode,
|
||||
prefill.address.state,
|
||||
prefill.address.city,
|
||||
prefill.address.address1,
|
||||
prefill.address.address2,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ")
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">First Name</Label>
|
||||
<Input
|
||||
value={prefill.firstName || ""}
|
||||
disabled
|
||||
className="bg-muted text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">Last Name</Label>
|
||||
<Input
|
||||
value={prefill.lastName || ""}
|
||||
disabled
|
||||
className="bg-muted text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">Email</Label>
|
||||
<Input value={email} disabled className="bg-muted text-muted-foreground" />
|
||||
</div>
|
||||
{addressDisplay && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">Address</Label>
|
||||
<Input value={addressDisplay} disabled className="bg-muted text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
export { PrefilledUserInfo } from "./PrefilledUserInfo";
|
||||
export { NewCustomerFields } from "./NewCustomerFields";
|
||||
export { PersonalInfoFields } from "./PersonalInfoFields";
|
||||
export { PasswordSection } from "./PasswordSection";
|
||||
export { useCompleteAccountForm } from "./useCompleteAccountForm";
|
||||
export type { AccountFormData, AccountFormErrors, PrefillData } from "./types";
|
||||
@ -0,0 +1,38 @@
|
||||
export interface AccountFormData {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
phone: string;
|
||||
dateOfBirth: string;
|
||||
gender: "male" | "female" | "other" | "";
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
acceptTerms: boolean;
|
||||
marketingConsent: boolean;
|
||||
}
|
||||
|
||||
export interface AccountFormErrors {
|
||||
firstName?: string | undefined;
|
||||
lastName?: string | undefined;
|
||||
address?: string | undefined;
|
||||
password?: string | undefined;
|
||||
confirmPassword?: string | undefined;
|
||||
phone?: string | undefined;
|
||||
dateOfBirth?: string | undefined;
|
||||
gender?: string | undefined;
|
||||
acceptTerms?: string | undefined;
|
||||
}
|
||||
|
||||
export interface PrefillData {
|
||||
firstName?: string | undefined;
|
||||
lastName?: string | undefined;
|
||||
phone?: string | undefined;
|
||||
address?:
|
||||
| {
|
||||
postcode?: string | undefined;
|
||||
state?: string | undefined;
|
||||
city?: string | undefined;
|
||||
address1?: string | undefined;
|
||||
address2?: string | undefined;
|
||||
}
|
||||
| undefined;
|
||||
}
|
||||
@ -0,0 +1,171 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { type JapanAddressFormData } from "@/features/address/components/JapanAddressForm";
|
||||
import { prepareWhmcsAddressFields } from "@customer-portal/domain/address";
|
||||
import { validatePasswordRules } from "@/features/auth/hooks/usePasswordValidation";
|
||||
import type { AccountFormErrors } from "./types";
|
||||
|
||||
interface FormState {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
phone: string;
|
||||
dateOfBirth: string;
|
||||
gender: "male" | "female" | "other" | "";
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
acceptTerms: boolean;
|
||||
marketingConsent: boolean;
|
||||
}
|
||||
|
||||
interface InitialValues {
|
||||
firstName?: string | undefined;
|
||||
lastName?: string | undefined;
|
||||
phone?: string | undefined;
|
||||
dateOfBirth?: string | undefined;
|
||||
gender?: "male" | "female" | "other" | "" | undefined;
|
||||
acceptTerms?: boolean | undefined;
|
||||
marketingConsent?: boolean | undefined;
|
||||
}
|
||||
|
||||
interface UseCompleteAccountFormOptions {
|
||||
initialValues: InitialValues;
|
||||
isNewCustomer: boolean;
|
||||
updateFormData: (data: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function useCompleteAccountForm({
|
||||
initialValues,
|
||||
isNewCustomer,
|
||||
updateFormData,
|
||||
}: UseCompleteAccountFormOptions) {
|
||||
const [firstName, setFirstName] = useState(initialValues.firstName || "");
|
||||
const [lastName, setLastName] = useState(initialValues.lastName || "");
|
||||
const [isAddressComplete, setIsAddressComplete] = useState(!isNewCustomer);
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [phone, setPhone] = useState(initialValues.phone || "");
|
||||
const [dateOfBirth, setDateOfBirth] = useState(initialValues.dateOfBirth || "");
|
||||
const [gender, setGender] = useState<"male" | "female" | "other" | "">(
|
||||
initialValues.gender || ""
|
||||
);
|
||||
const [acceptTerms, setAcceptTerms] = useState(initialValues.acceptTerms || false);
|
||||
const [marketingConsent, setMarketingConsent] = useState(initialValues.marketingConsent || false);
|
||||
const [errors, setErrors] = useState<AccountFormErrors>({});
|
||||
|
||||
const clearError = useCallback((field: keyof AccountFormErrors) => {
|
||||
setErrors(prev => ({ ...prev, [field]: undefined }));
|
||||
}, []);
|
||||
|
||||
const handleAddressChange = useCallback(
|
||||
(data: JapanAddressFormData, isComplete: boolean) => {
|
||||
setIsAddressComplete(isComplete);
|
||||
const whmcsFields = prepareWhmcsAddressFields(data);
|
||||
updateFormData({
|
||||
address: {
|
||||
address1: whmcsFields.address1 || "",
|
||||
address2: whmcsFields.address2 || "",
|
||||
city: whmcsFields.city || "",
|
||||
state: whmcsFields.state || "",
|
||||
postcode: whmcsFields.postcode || "",
|
||||
country: "JP",
|
||||
},
|
||||
});
|
||||
},
|
||||
[updateFormData]
|
||||
);
|
||||
|
||||
const validate = useCallback((): boolean => {
|
||||
const newErrors: AccountFormErrors = {};
|
||||
|
||||
if (isNewCustomer) {
|
||||
if (!firstName.trim()) newErrors.firstName = "First name is required";
|
||||
if (!lastName.trim()) newErrors.lastName = "Last name is required";
|
||||
if (!isAddressComplete) newErrors.address = "Please complete the address";
|
||||
}
|
||||
|
||||
const passwordError = validatePasswordRules(password);
|
||||
if (passwordError) newErrors.password = passwordError;
|
||||
if (password !== confirmPassword) newErrors.confirmPassword = "Passwords do not match";
|
||||
if (!phone.trim()) newErrors.phone = "Phone number is required";
|
||||
if (!dateOfBirth) newErrors.dateOfBirth = "Date of birth is required";
|
||||
if (!gender) newErrors.gender = "Please select a gender";
|
||||
if (!acceptTerms) newErrors.acceptTerms = "You must accept the terms of service";
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
}, [
|
||||
isNewCustomer,
|
||||
firstName,
|
||||
lastName,
|
||||
isAddressComplete,
|
||||
password,
|
||||
confirmPassword,
|
||||
phone,
|
||||
dateOfBirth,
|
||||
gender,
|
||||
acceptTerms,
|
||||
]);
|
||||
|
||||
const getFormData = useCallback(
|
||||
(): FormState => ({
|
||||
firstName: firstName.trim(),
|
||||
lastName: lastName.trim(),
|
||||
phone: phone.trim(),
|
||||
dateOfBirth,
|
||||
gender: gender as "male" | "female" | "other" | "",
|
||||
password,
|
||||
confirmPassword,
|
||||
acceptTerms,
|
||||
marketingConsent,
|
||||
}),
|
||||
[
|
||||
firstName,
|
||||
lastName,
|
||||
phone,
|
||||
dateOfBirth,
|
||||
gender,
|
||||
password,
|
||||
confirmPassword,
|
||||
acceptTerms,
|
||||
marketingConsent,
|
||||
]
|
||||
);
|
||||
|
||||
const canSubmit =
|
||||
password &&
|
||||
confirmPassword &&
|
||||
phone &&
|
||||
dateOfBirth &&
|
||||
gender &&
|
||||
acceptTerms &&
|
||||
(isNewCustomer ? firstName && lastName && isAddressComplete : true);
|
||||
|
||||
return {
|
||||
// Form values
|
||||
firstName,
|
||||
lastName,
|
||||
phone,
|
||||
dateOfBirth,
|
||||
gender,
|
||||
password,
|
||||
confirmPassword,
|
||||
acceptTerms,
|
||||
marketingConsent,
|
||||
errors,
|
||||
// Setters
|
||||
setFirstName,
|
||||
setLastName,
|
||||
setPhone,
|
||||
setDateOfBirth,
|
||||
setGender,
|
||||
setPassword,
|
||||
setConfirmPassword,
|
||||
setAcceptTerms,
|
||||
setMarketingConsent,
|
||||
// Handlers
|
||||
clearError,
|
||||
handleAddressChange,
|
||||
validate,
|
||||
getFormData,
|
||||
canSubmit,
|
||||
};
|
||||
}
|
||||
@ -1,38 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
InformationCircleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import type { ReactNode } from "react";
|
||||
import { AnimatedCard } from "@/components/molecules/AnimatedCard/AnimatedCard";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import {
|
||||
StepHeader,
|
||||
StepActions,
|
||||
StepContent,
|
||||
deriveStatus,
|
||||
type StepValidation,
|
||||
type CardVariant,
|
||||
} from "./configuration-step";
|
||||
|
||||
export interface StepValidation {
|
||||
isValid: boolean;
|
||||
errors?: string[];
|
||||
warnings?: string[];
|
||||
}
|
||||
export type { StepValidation };
|
||||
|
||||
export interface ConfigurationStepProps {
|
||||
// Step identification
|
||||
stepNumber: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
|
||||
// Step state
|
||||
isActive?: boolean;
|
||||
isCompleted?: boolean;
|
||||
isDisabled?: boolean;
|
||||
validation?: StepValidation;
|
||||
|
||||
// Content
|
||||
children: ReactNode;
|
||||
helpText?: string;
|
||||
infoText?: string;
|
||||
|
||||
// Actions
|
||||
onNext?: () => void;
|
||||
onPrevious?: () => void;
|
||||
onSkip?: () => void;
|
||||
@ -40,20 +31,23 @@ export interface ConfigurationStepProps {
|
||||
previousLabel?: string;
|
||||
skipLabel?: string;
|
||||
showActions?: boolean;
|
||||
|
||||
// Styling
|
||||
variant?: "default" | "highlighted" | "compact";
|
||||
variant?: CardVariant;
|
||||
showStepIndicator?: boolean;
|
||||
|
||||
// State
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
|
||||
// Custom content
|
||||
headerContent?: ReactNode;
|
||||
footerContent?: ReactNode;
|
||||
}
|
||||
|
||||
function getCardVariant(
|
||||
variant: CardVariant,
|
||||
isDisabled: boolean
|
||||
): "highlighted" | "static" | "default" {
|
||||
if (variant === "highlighted") return "highlighted";
|
||||
if (isDisabled) return "static";
|
||||
return "default";
|
||||
}
|
||||
|
||||
export function ConfigurationStep({
|
||||
stepNumber,
|
||||
title,
|
||||
@ -68,9 +62,9 @@ export function ConfigurationStep({
|
||||
onNext,
|
||||
onPrevious,
|
||||
onSkip,
|
||||
nextLabel = "Continue",
|
||||
previousLabel = "Back",
|
||||
skipLabel = "Skip",
|
||||
nextLabel,
|
||||
previousLabel,
|
||||
skipLabel,
|
||||
showActions = true,
|
||||
variant = "default",
|
||||
showStepIndicator = true,
|
||||
@ -79,183 +73,43 @@ export function ConfigurationStep({
|
||||
headerContent,
|
||||
footerContent,
|
||||
}: ConfigurationStepProps) {
|
||||
const getStepIndicatorClasses = () => {
|
||||
if (isCompleted) {
|
||||
return "bg-green-500 border-green-500 text-white";
|
||||
}
|
||||
if (isActive && !isDisabled) {
|
||||
return "border-blue-500 text-blue-500 bg-blue-50";
|
||||
}
|
||||
if (isDisabled) {
|
||||
return "border-gray-300 text-gray-400 bg-gray-50";
|
||||
}
|
||||
return "border-gray-300 text-gray-500 bg-white";
|
||||
};
|
||||
|
||||
const getCardVariant = () => {
|
||||
if (variant === "highlighted") return "highlighted";
|
||||
if (isDisabled) return "static";
|
||||
return "default";
|
||||
};
|
||||
|
||||
const status = deriveStatus({ isActive, isCompleted, isDisabled });
|
||||
const hasErrors = validation?.errors && validation.errors.length > 0;
|
||||
const hasWarnings = validation?.warnings && validation.warnings.length > 0;
|
||||
const isValid = validation?.isValid !== false;
|
||||
const showStepActions = showActions && !isDisabled;
|
||||
|
||||
return (
|
||||
<AnimatedCard variant={getCardVariant()} className={`p-6 ${isDisabled ? "opacity-60" : ""}`}>
|
||||
{/* Step Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Step Indicator */}
|
||||
{showStepIndicator && (
|
||||
<div
|
||||
className={`flex-shrink-0 w-10 h-10 rounded-full border-2 flex items-center justify-center font-bold transition-all duration-300 ${getStepIndicatorClasses()}`}
|
||||
>
|
||||
{isCompleted ? <CheckCircleIcon className="w-6 h-6" /> : <span>{stepNumber}</span>}
|
||||
</div>
|
||||
)}
|
||||
<AnimatedCard
|
||||
variant={getCardVariant(variant, isDisabled)}
|
||||
className={`p-6 ${isDisabled ? "opacity-60" : ""}`}
|
||||
>
|
||||
<StepHeader
|
||||
stepNumber={stepNumber}
|
||||
title={title}
|
||||
description={description}
|
||||
status={status}
|
||||
validation={validation}
|
||||
showStepIndicator={showStepIndicator}
|
||||
headerContent={headerContent}
|
||||
/>
|
||||
|
||||
{/* Step Title and Description */}
|
||||
<div className="flex-1">
|
||||
<h3
|
||||
className={`text-xl font-bold mb-2 ${isDisabled ? "text-gray-500" : "text-gray-900"}`}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
{description && (
|
||||
<p
|
||||
className={`text-sm leading-relaxed ${isDisabled ? "text-gray-400" : "text-gray-600"}`}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
<StepContent helpText={helpText} infoText={infoText} isDisabled={isDisabled}>
|
||||
{children}
|
||||
</StepContent>
|
||||
|
||||
{/* Validation Status */}
|
||||
{validation && (
|
||||
<div className="mt-3">
|
||||
{hasErrors && (
|
||||
<div className="flex items-start gap-2 text-red-600">
|
||||
<ExclamationTriangleIcon className="h-4 w-4 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm">
|
||||
{validation.errors!.map((error, index) => (
|
||||
<div key={index}>{error}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasWarnings && !hasErrors && (
|
||||
<div className="flex items-start gap-2 text-amber-600">
|
||||
<ExclamationTriangleIcon className="h-4 w-4 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm">
|
||||
{validation.warnings!.map((warning, index) => (
|
||||
<div key={index}>{warning}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isValid && !hasWarnings && isCompleted && (
|
||||
<div className="flex items-center gap-2 text-green-600">
|
||||
<CheckCircleIcon className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Configuration complete</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{headerContent && <div className="mt-4">{headerContent}</div>}
|
||||
</div>
|
||||
|
||||
{/* Step Content */}
|
||||
{!isDisabled && <div className="mb-6">{children}</div>}
|
||||
|
||||
{/* Help Text */}
|
||||
{helpText && !isDisabled && (
|
||||
<div className="mb-6 p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<div className="flex items-start gap-2">
|
||||
<InformationCircleIcon className="h-5 w-5 text-blue-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-blue-700">{helpText}</p>
|
||||
</div>
|
||||
</div>
|
||||
{showStepActions && (
|
||||
<StepActions
|
||||
onNext={onNext}
|
||||
onPrevious={onPrevious}
|
||||
onSkip={onSkip}
|
||||
nextLabel={nextLabel}
|
||||
previousLabel={previousLabel}
|
||||
skipLabel={skipLabel}
|
||||
loading={loading}
|
||||
disabled={disabled}
|
||||
hasErrors={hasErrors}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Info Text */}
|
||||
{infoText && !isDisabled && (
|
||||
<div className="mb-6 p-3 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<p className="text-sm text-gray-600">{infoText}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{showActions && !isDisabled && (
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="flex gap-3 flex-1">
|
||||
{onPrevious && (
|
||||
<Button
|
||||
onClick={onPrevious}
|
||||
variant="outline"
|
||||
className="flex-1 sm:flex-none"
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
{previousLabel}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{onSkip && (
|
||||
<Button
|
||||
onClick={onSkip}
|
||||
variant="outline"
|
||||
className="flex-1 sm:flex-none"
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
{skipLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{onNext && (
|
||||
<Button
|
||||
onClick={onNext}
|
||||
className="flex-1 sm:flex-none sm:min-w-[120px]"
|
||||
disabled={disabled || loading || hasErrors}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center justify-center">
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-3 h-4 w-4 text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
Processing...
|
||||
</span>
|
||||
) : (
|
||||
nextLabel
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer Content */}
|
||||
{footerContent && <div className="mt-6 pt-4 border-t border-gray-200">{footerContent}</div>}
|
||||
</AnimatedCard>
|
||||
);
|
||||
|
||||
@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { InformationCircleIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
interface HelpPanelProps {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export function HelpPanel({ text }: HelpPanelProps) {
|
||||
return (
|
||||
<div className="mb-6 p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<div className="flex items-start gap-2">
|
||||
<InformationCircleIcon className="h-5 w-5 text-blue-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-blue-700">{text}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
interface InfoPanelProps {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export function InfoPanel({ text }: InfoPanelProps) {
|
||||
return (
|
||||
<div className="mb-6 p-3 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<p className="text-sm text-gray-600">{text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import type { StepActionsProps } from "./types";
|
||||
|
||||
function LoadingSpinner() {
|
||||
return (
|
||||
<span className="flex items-center justify-center">
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-3 h-4 w-4 text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
Processing...
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function StepActions({
|
||||
onNext,
|
||||
onPrevious,
|
||||
onSkip,
|
||||
nextLabel = "Continue",
|
||||
previousLabel = "Back",
|
||||
skipLabel = "Skip",
|
||||
loading = false,
|
||||
disabled = false,
|
||||
hasErrors = false,
|
||||
}: StepActionsProps) {
|
||||
const isButtonDisabled = disabled || loading;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="flex gap-3 flex-1">
|
||||
{onPrevious && (
|
||||
<Button
|
||||
onClick={onPrevious}
|
||||
variant="outline"
|
||||
className="flex-1 sm:flex-none"
|
||||
disabled={isButtonDisabled}
|
||||
>
|
||||
{previousLabel}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{onSkip && (
|
||||
<Button
|
||||
onClick={onSkip}
|
||||
variant="outline"
|
||||
className="flex-1 sm:flex-none"
|
||||
disabled={isButtonDisabled}
|
||||
>
|
||||
{skipLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{onNext && (
|
||||
<Button
|
||||
onClick={onNext}
|
||||
className="flex-1 sm:flex-none sm:min-w-[120px]"
|
||||
disabled={isButtonDisabled || hasErrors}
|
||||
>
|
||||
{loading ? <LoadingSpinner /> : nextLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { HelpPanel } from "./HelpPanel";
|
||||
import { InfoPanel } from "./InfoPanel";
|
||||
|
||||
interface StepContentProps {
|
||||
children: ReactNode;
|
||||
helpText?: string | undefined;
|
||||
infoText?: string | undefined;
|
||||
isDisabled: boolean;
|
||||
}
|
||||
|
||||
export function StepContent({ children, helpText, infoText, isDisabled }: StepContentProps) {
|
||||
if (isDisabled) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6">{children}</div>
|
||||
{helpText && <HelpPanel text={helpText} />}
|
||||
{infoText && <InfoPanel text={infoText} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { StepIndicator } from "./StepIndicator";
|
||||
import { ValidationStatus } from "./ValidationStatus";
|
||||
import type { StepHeaderProps, StepStatus } from "./types";
|
||||
|
||||
function deriveStatus(props: {
|
||||
isActive?: boolean;
|
||||
isCompleted?: boolean;
|
||||
isDisabled?: boolean;
|
||||
}): StepStatus {
|
||||
if (props.isCompleted) return "completed";
|
||||
if (props.isDisabled) return "disabled";
|
||||
if (props.isActive) return "active";
|
||||
return "pending";
|
||||
}
|
||||
|
||||
export function StepHeader({
|
||||
stepNumber,
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
validation,
|
||||
showStepIndicator = true,
|
||||
headerContent,
|
||||
}: StepHeaderProps) {
|
||||
const isDisabled = status === "disabled";
|
||||
const isCompleted = status === "completed";
|
||||
const isValid = validation?.isValid !== false;
|
||||
const hasWarnings = validation?.warnings && validation.warnings.length > 0;
|
||||
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-start gap-4">
|
||||
{showStepIndicator && <StepIndicator stepNumber={stepNumber} status={status} />}
|
||||
|
||||
<div className="flex-1">
|
||||
<h3
|
||||
className={`text-xl font-bold mb-2 ${isDisabled ? "text-gray-500" : "text-gray-900"}`}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
{description && (
|
||||
<p
|
||||
className={`text-sm leading-relaxed ${isDisabled ? "text-gray-400" : "text-gray-600"}`}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{validation && (
|
||||
<div className="mt-3">
|
||||
<ValidationStatus
|
||||
errors={validation.errors}
|
||||
warnings={validation.warnings}
|
||||
showSuccess={isValid && !hasWarnings && isCompleted}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{headerContent && <div className="mt-4">{headerContent}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { deriveStatus };
|
||||
@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { CheckCircleIcon } from "@heroicons/react/24/outline";
|
||||
import type { StepIndicatorProps, StepStatus } from "./types";
|
||||
|
||||
function getStepIndicatorClasses(status: StepStatus): string {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return "bg-green-500 border-green-500 text-white";
|
||||
case "active":
|
||||
return "border-blue-500 text-blue-500 bg-blue-50";
|
||||
case "disabled":
|
||||
return "border-gray-300 text-gray-400 bg-gray-50";
|
||||
default:
|
||||
return "border-gray-300 text-gray-500 bg-white";
|
||||
}
|
||||
}
|
||||
|
||||
export function StepIndicator({ stepNumber, status }: StepIndicatorProps) {
|
||||
return (
|
||||
<div
|
||||
className={`flex-shrink-0 w-10 h-10 rounded-full border-2 flex items-center justify-center font-bold transition-all duration-300 ${getStepIndicatorClasses(status)}`}
|
||||
>
|
||||
{status === "completed" ? <CheckCircleIcon className="w-6 h-6" /> : <span>{stepNumber}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { CheckCircleIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||
import type { ValidationStatusProps } from "./types";
|
||||
|
||||
export function ValidationStatus({ errors, warnings, showSuccess }: ValidationStatusProps) {
|
||||
const hasErrors = errors && errors.length > 0;
|
||||
const hasWarnings = warnings && warnings.length > 0;
|
||||
|
||||
if (hasErrors) {
|
||||
return (
|
||||
<div className="flex items-start gap-2 text-red-600">
|
||||
<ExclamationTriangleIcon className="h-4 w-4 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm">
|
||||
{errors.map((error, index) => (
|
||||
<div key={index}>{error}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasWarnings) {
|
||||
return (
|
||||
<div className="flex items-start gap-2 text-amber-600">
|
||||
<ExclamationTriangleIcon className="h-4 w-4 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm">
|
||||
{warnings.map((warning, index) => (
|
||||
<div key={index}>{warning}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (showSuccess) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-green-600">
|
||||
<CheckCircleIcon className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Configuration complete</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
export { StepIndicator } from "./StepIndicator";
|
||||
export { StepHeader, deriveStatus } from "./StepHeader";
|
||||
export { StepActions } from "./StepActions";
|
||||
export { StepContent } from "./StepContent";
|
||||
export { ValidationStatus } from "./ValidationStatus";
|
||||
export { HelpPanel } from "./HelpPanel";
|
||||
export { InfoPanel } from "./InfoPanel";
|
||||
export type {
|
||||
StepStatus,
|
||||
StepValidation,
|
||||
StepIndicatorProps,
|
||||
ValidationStatusProps,
|
||||
StepHeaderProps,
|
||||
StepActionsProps,
|
||||
CardVariant,
|
||||
} from "./types";
|
||||
@ -0,0 +1,44 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type StepStatus = "pending" | "active" | "completed" | "disabled";
|
||||
|
||||
export interface StepValidation {
|
||||
isValid: boolean;
|
||||
errors?: string[] | undefined;
|
||||
warnings?: string[] | undefined;
|
||||
}
|
||||
|
||||
export interface StepIndicatorProps {
|
||||
stepNumber: number;
|
||||
status: StepStatus;
|
||||
}
|
||||
|
||||
export interface ValidationStatusProps {
|
||||
errors?: string[] | undefined;
|
||||
warnings?: string[] | undefined;
|
||||
showSuccess?: boolean | undefined;
|
||||
}
|
||||
|
||||
export interface StepHeaderProps {
|
||||
stepNumber: number;
|
||||
title: string;
|
||||
description?: string | undefined;
|
||||
status: StepStatus;
|
||||
validation?: StepValidation | undefined;
|
||||
showStepIndicator?: boolean | undefined;
|
||||
headerContent?: ReactNode | undefined;
|
||||
}
|
||||
|
||||
export interface StepActionsProps {
|
||||
onNext?: (() => void) | undefined;
|
||||
onPrevious?: (() => void) | undefined;
|
||||
onSkip?: (() => void) | undefined;
|
||||
nextLabel?: string | undefined;
|
||||
previousLabel?: string | undefined;
|
||||
skipLabel?: string | undefined;
|
||||
loading?: boolean | undefined;
|
||||
disabled?: boolean | undefined;
|
||||
hasErrors?: boolean | undefined;
|
||||
}
|
||||
|
||||
export type CardVariant = "default" | "highlighted" | "compact";
|
||||
@ -8,32 +8,23 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { ArrowLeft, Check, X } from "lucide-react";
|
||||
import { Button, Input, Label, ErrorMessage } from "@/components/atoms";
|
||||
import { Checkbox } from "@/components/atoms/checkbox";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Button, ErrorMessage } from "@/components/atoms";
|
||||
import { TermsCheckbox, MarketingCheckbox } from "@/features/auth/components";
|
||||
import {
|
||||
validatePasswordRules,
|
||||
usePasswordValidation,
|
||||
} from "@/features/auth/hooks/usePasswordValidation";
|
||||
import { useEligibilityCheckStore } from "../../../stores/eligibility-check.store";
|
||||
import { AccountInfoDisplay, PersonalInfoFields, PasswordSection } from "./complete-account";
|
||||
|
||||
interface AccountFormErrors {
|
||||
password?: string;
|
||||
confirmPassword?: string;
|
||||
phone?: string;
|
||||
dateOfBirth?: string;
|
||||
gender?: string;
|
||||
acceptTerms?: string;
|
||||
}
|
||||
|
||||
/** Helper component for password requirement indicators */
|
||||
function RequirementCheck({ met, label }: { met: boolean; label: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{met ? (
|
||||
<Check className="h-3 w-3 text-success" />
|
||||
) : (
|
||||
<X className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
<span className={met ? "text-success" : "text-muted-foreground"}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
password?: string | undefined;
|
||||
confirmPassword?: string | undefined;
|
||||
phone?: string | undefined;
|
||||
dateOfBirth?: string | undefined;
|
||||
gender?: string | undefined;
|
||||
acceptTerms?: string | undefined;
|
||||
}
|
||||
|
||||
export function CompleteAccountStep() {
|
||||
@ -50,8 +41,7 @@ export function CompleteAccountStep() {
|
||||
|
||||
const [accountErrors, setAccountErrors] = useState<AccountFormErrors>({});
|
||||
|
||||
// Clear specific error
|
||||
const handleClearError = useCallback((field: keyof AccountFormErrors) => {
|
||||
const clearLocalError = useCallback((field: keyof AccountFormErrors) => {
|
||||
setAccountErrors(prev => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[field];
|
||||
@ -59,61 +49,24 @@ export function CompleteAccountStep() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Password requirement checks for real-time feedback
|
||||
const passwordChecks = {
|
||||
length: accountData.password.length >= 8,
|
||||
uppercase: /[A-Z]/.test(accountData.password),
|
||||
lowercase: /[a-z]/.test(accountData.password),
|
||||
number: /[0-9]/.test(accountData.password),
|
||||
};
|
||||
|
||||
// Validate password
|
||||
const validatePassword = useCallback((pass: string): string | undefined => {
|
||||
if (!pass) return "Password is required";
|
||||
if (pass.length < 8) return "Password must be at least 8 characters";
|
||||
if (!/[A-Z]/.test(pass)) return "Password must contain an uppercase letter";
|
||||
if (!/[a-z]/.test(pass)) return "Password must contain a lowercase letter";
|
||||
if (!/[0-9]/.test(pass)) return "Password must contain a number";
|
||||
return undefined;
|
||||
}, []);
|
||||
|
||||
// Check if password is valid (for canSubmit)
|
||||
const isPasswordValid = validatePassword(accountData.password) === undefined;
|
||||
const { isValid: isPasswordValid } = usePasswordValidation(accountData.password);
|
||||
const doPasswordsMatch = accountData.password === accountData.confirmPassword;
|
||||
const showPasswordMatch = accountData.confirmPassword.length > 0;
|
||||
|
||||
// Validate account form
|
||||
const validateAccountForm = useCallback((): boolean => {
|
||||
const errors: AccountFormErrors = {};
|
||||
|
||||
const passwordError = validatePassword(accountData.password);
|
||||
if (passwordError) {
|
||||
errors.password = passwordError;
|
||||
}
|
||||
|
||||
if (accountData.password !== accountData.confirmPassword) {
|
||||
const passwordError = validatePasswordRules(accountData.password);
|
||||
if (passwordError) errors.password = passwordError;
|
||||
if (accountData.password !== accountData.confirmPassword)
|
||||
errors.confirmPassword = "Passwords do not match";
|
||||
}
|
||||
|
||||
if (!accountData.phone.trim()) {
|
||||
errors.phone = "Phone number is required";
|
||||
}
|
||||
|
||||
if (!accountData.dateOfBirth) {
|
||||
errors.dateOfBirth = "Date of birth is required";
|
||||
}
|
||||
|
||||
if (!accountData.gender) {
|
||||
errors.gender = "Please select a gender";
|
||||
}
|
||||
|
||||
if (!accountData.acceptTerms) {
|
||||
errors.acceptTerms = "You must accept the terms of service";
|
||||
}
|
||||
if (!accountData.phone.trim()) errors.phone = "Phone number is required";
|
||||
if (!accountData.dateOfBirth) errors.dateOfBirth = "Date of birth is required";
|
||||
if (!accountData.gender) errors.gender = "Please select a gender";
|
||||
if (!accountData.acceptTerms) errors.acceptTerms = "You must accept the terms of service";
|
||||
|
||||
setAccountErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
}, [accountData, validatePassword]);
|
||||
}, [accountData]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!validateAccountForm()) return;
|
||||
@ -134,224 +87,58 @@ export function CompleteAccountStep() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Pre-filled info display */}
|
||||
<div className="p-4 rounded-lg bg-muted/50 border border-border">
|
||||
<p className="text-sm text-muted-foreground mb-1">Account details:</p>
|
||||
<p className="font-medium text-foreground">
|
||||
{formData.firstName} {formData.lastName}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">{formData.email}</p>
|
||||
{formData.address && (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
〒{formData.address.postcode} {formData.address.prefectureJa}
|
||||
{formData.address.cityJa}
|
||||
{formData.address.townJa}
|
||||
{formData.address.streetAddress}
|
||||
{formData.address.buildingName && ` ${formData.address.buildingName}`}
|
||||
{formData.address.roomNumber && ` ${formData.address.roomNumber}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<AccountInfoDisplay
|
||||
firstName={formData.firstName}
|
||||
lastName={formData.lastName}
|
||||
email={formData.email}
|
||||
address={formData.address}
|
||||
/>
|
||||
|
||||
{/* Phone */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">
|
||||
Phone Number <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
value={accountData.phone}
|
||||
onChange={e => {
|
||||
updateAccountData({ phone: e.target.value });
|
||||
handleClearError("phone");
|
||||
}}
|
||||
placeholder="090-1234-5678"
|
||||
disabled={loading}
|
||||
error={accountErrors.phone}
|
||||
/>
|
||||
<ErrorMessage>{accountErrors.phone}</ErrorMessage>
|
||||
</div>
|
||||
<PersonalInfoFields
|
||||
phone={accountData.phone}
|
||||
dateOfBirth={accountData.dateOfBirth}
|
||||
gender={accountData.gender}
|
||||
onPhoneChange={phone => updateAccountData({ phone })}
|
||||
onDateOfBirthChange={dateOfBirth => updateAccountData({ dateOfBirth })}
|
||||
onGenderChange={gender => updateAccountData({ gender })}
|
||||
errors={accountErrors}
|
||||
clearError={clearLocalError}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
{/* Date of Birth */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dateOfBirth">
|
||||
Date of Birth <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="dateOfBirth"
|
||||
type="date"
|
||||
value={accountData.dateOfBirth}
|
||||
onChange={e => {
|
||||
updateAccountData({ dateOfBirth: e.target.value });
|
||||
handleClearError("dateOfBirth");
|
||||
}}
|
||||
disabled={loading}
|
||||
error={accountErrors.dateOfBirth}
|
||||
max={new Date().toISOString().split("T")[0]}
|
||||
/>
|
||||
<ErrorMessage>{accountErrors.dateOfBirth}</ErrorMessage>
|
||||
</div>
|
||||
<PasswordSection
|
||||
password={accountData.password}
|
||||
confirmPassword={accountData.confirmPassword}
|
||||
onPasswordChange={password => updateAccountData({ password })}
|
||||
onConfirmPasswordChange={confirmPassword => updateAccountData({ confirmPassword })}
|
||||
errors={accountErrors}
|
||||
clearError={clearLocalError}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
{/* Gender */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Gender <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<div className="flex gap-4">
|
||||
{(["male", "female", "other"] as const).map(option => (
|
||||
<label key={option} className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="gender"
|
||||
value={option}
|
||||
checked={accountData.gender === option}
|
||||
onChange={() => {
|
||||
updateAccountData({ gender: option });
|
||||
handleClearError("gender");
|
||||
}}
|
||||
disabled={loading}
|
||||
className="h-4 w-4 text-primary focus:ring-primary"
|
||||
/>
|
||||
<span className="text-sm capitalize">{option}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<ErrorMessage>{accountErrors.gender}</ErrorMessage>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">
|
||||
Password <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={accountData.password}
|
||||
onChange={e => {
|
||||
updateAccountData({ password: e.target.value });
|
||||
handleClearError("password");
|
||||
}}
|
||||
placeholder="Create a strong password"
|
||||
disabled={loading}
|
||||
error={accountErrors.password}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<ErrorMessage>{accountErrors.password}</ErrorMessage>
|
||||
{/* Real-time password requirements */}
|
||||
{accountData.password.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-1 text-xs">
|
||||
<RequirementCheck met={passwordChecks.length} label="8+ characters" />
|
||||
<RequirementCheck met={passwordChecks.uppercase} label="Uppercase letter" />
|
||||
<RequirementCheck met={passwordChecks.lowercase} label="Lowercase letter" />
|
||||
<RequirementCheck met={passwordChecks.number} label="Number" />
|
||||
</div>
|
||||
)}
|
||||
{accountData.password.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
At least 8 characters with uppercase, lowercase, and numbers
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirm Password */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">
|
||||
Confirm Password <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={accountData.confirmPassword}
|
||||
onChange={e => {
|
||||
updateAccountData({ confirmPassword: e.target.value });
|
||||
handleClearError("confirmPassword");
|
||||
}}
|
||||
placeholder="Confirm your password"
|
||||
disabled={loading}
|
||||
error={accountErrors.confirmPassword}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<ErrorMessage>{accountErrors.confirmPassword}</ErrorMessage>
|
||||
{/* Real-time password match indicator */}
|
||||
{showPasswordMatch && !accountErrors.confirmPassword && (
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
{doPasswordsMatch ? (
|
||||
<>
|
||||
<Check className="h-3 w-3 text-success" />
|
||||
<span className="text-success">Passwords match</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<X className="h-3 w-3 text-danger" />
|
||||
<span className="text-danger">Passwords do not match</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Terms & Marketing */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Checkbox
|
||||
id="acceptTerms"
|
||||
checked={accountData.acceptTerms}
|
||||
onChange={e => {
|
||||
updateAccountData({ acceptTerms: e.target.checked });
|
||||
handleClearError("acceptTerms");
|
||||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Label htmlFor="acceptTerms" className="text-sm font-normal leading-tight cursor-pointer">
|
||||
I accept the{" "}
|
||||
<a
|
||||
href="/terms"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Terms of Service
|
||||
</a>{" "}
|
||||
and{" "}
|
||||
<a
|
||||
href="/privacy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Privacy Policy
|
||||
</a>{" "}
|
||||
<span className="text-danger">*</span>
|
||||
</Label>
|
||||
</div>
|
||||
<ErrorMessage className="ml-6">{accountErrors.acceptTerms}</ErrorMessage>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<Checkbox
|
||||
id="marketingConsent"
|
||||
checked={accountData.marketingConsent}
|
||||
onChange={e => updateAccountData({ marketingConsent: e.target.checked })}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="marketingConsent"
|
||||
className="text-sm font-normal leading-tight cursor-pointer"
|
||||
>
|
||||
I would like to receive marketing emails and updates
|
||||
</Label>
|
||||
</div>
|
||||
<TermsCheckbox
|
||||
checked={accountData.acceptTerms}
|
||||
onChange={acceptTerms => {
|
||||
updateAccountData({ acceptTerms });
|
||||
clearLocalError("acceptTerms");
|
||||
}}
|
||||
disabled={loading}
|
||||
error={accountErrors.acceptTerms}
|
||||
/>
|
||||
<MarketingCheckbox
|
||||
checked={accountData.marketingConsent}
|
||||
onChange={marketingConsent => updateAccountData({ marketingConsent })}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* API Error */}
|
||||
{error && (
|
||||
<div className="p-4 rounded-lg bg-danger/10 border border-danger/20">
|
||||
<ErrorMessage showIcon>{error}</ErrorMessage>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
interface AddressData {
|
||||
postcode?: string | null | undefined;
|
||||
prefectureJa?: string | null | undefined;
|
||||
cityJa?: string | null | undefined;
|
||||
townJa?: string | null | undefined;
|
||||
streetAddress?: string | null | undefined;
|
||||
buildingName?: string | null | undefined;
|
||||
roomNumber?: string | null | undefined;
|
||||
}
|
||||
|
||||
interface AccountInfoDisplayProps {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
address?: AddressData | null | undefined;
|
||||
}
|
||||
|
||||
export function AccountInfoDisplay({
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
address,
|
||||
}: AccountInfoDisplayProps) {
|
||||
const formatAddress = (addr: AddressData): string => {
|
||||
const parts = [
|
||||
addr.postcode ? `〒${addr.postcode}` : "",
|
||||
addr.prefectureJa || "",
|
||||
addr.cityJa || "",
|
||||
addr.townJa || "",
|
||||
addr.streetAddress || "",
|
||||
addr.buildingName || "",
|
||||
addr.roomNumber || "",
|
||||
].filter(Boolean);
|
||||
return parts.join("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 rounded-lg bg-muted/50 border border-border">
|
||||
<p className="text-sm text-muted-foreground mb-1">Account details:</p>
|
||||
<p className="font-medium text-foreground">
|
||||
{firstName} {lastName}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">{email}</p>
|
||||
{address && <p className="text-sm text-muted-foreground mt-1">{formatAddress(address)}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { Input, Label, ErrorMessage } from "@/components/atoms";
|
||||
import { PasswordRequirements, PasswordMatchIndicator } from "@/features/auth/components";
|
||||
import { usePasswordValidation } from "@/features/auth/hooks/usePasswordValidation";
|
||||
|
||||
interface PasswordSectionProps {
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
onPasswordChange: (value: string) => void;
|
||||
onConfirmPasswordChange: (value: string) => void;
|
||||
errors: {
|
||||
password?: string | undefined;
|
||||
confirmPassword?: string | undefined;
|
||||
};
|
||||
clearError: (field: "password" | "confirmPassword") => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function PasswordSection({
|
||||
password,
|
||||
confirmPassword,
|
||||
onPasswordChange,
|
||||
onConfirmPasswordChange,
|
||||
errors,
|
||||
clearError,
|
||||
loading,
|
||||
}: PasswordSectionProps) {
|
||||
const { checks } = usePasswordValidation(password);
|
||||
const showPasswordMatch = confirmPassword.length > 0 && !errors.confirmPassword;
|
||||
const passwordsMatch = password === confirmPassword;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">
|
||||
Password <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => {
|
||||
onPasswordChange(e.target.value);
|
||||
clearError("password");
|
||||
}}
|
||||
placeholder="Create a strong password"
|
||||
disabled={loading}
|
||||
error={errors.password}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<ErrorMessage>{errors.password}</ErrorMessage>
|
||||
<PasswordRequirements checks={checks} showHint={password.length === 0} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">
|
||||
Confirm Password <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={e => {
|
||||
onConfirmPasswordChange(e.target.value);
|
||||
clearError("confirmPassword");
|
||||
}}
|
||||
placeholder="Confirm your password"
|
||||
disabled={loading}
|
||||
error={errors.confirmPassword}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<ErrorMessage>{errors.confirmPassword}</ErrorMessage>
|
||||
{showPasswordMatch && <PasswordMatchIndicator passwordsMatch={passwordsMatch} />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import { Input, Label, ErrorMessage } from "@/components/atoms";
|
||||
|
||||
type Gender = "male" | "female" | "other" | "";
|
||||
|
||||
interface PersonalInfoFieldsProps {
|
||||
phone: string;
|
||||
dateOfBirth: string;
|
||||
gender: Gender;
|
||||
onPhoneChange: (value: string) => void;
|
||||
onDateOfBirthChange: (value: string) => void;
|
||||
onGenderChange: (value: "male" | "female" | "other") => void;
|
||||
errors: {
|
||||
phone?: string | undefined;
|
||||
dateOfBirth?: string | undefined;
|
||||
gender?: string | undefined;
|
||||
};
|
||||
clearError: (field: "phone" | "dateOfBirth" | "gender") => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const GENDER_OPTIONS = ["male", "female", "other"] as const;
|
||||
|
||||
export function PersonalInfoFields({
|
||||
phone,
|
||||
dateOfBirth,
|
||||
gender,
|
||||
onPhoneChange,
|
||||
onDateOfBirthChange,
|
||||
onGenderChange,
|
||||
errors,
|
||||
clearError,
|
||||
loading,
|
||||
}: PersonalInfoFieldsProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">
|
||||
Phone Number <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
value={phone}
|
||||
onChange={e => {
|
||||
onPhoneChange(e.target.value);
|
||||
clearError("phone");
|
||||
}}
|
||||
placeholder="090-1234-5678"
|
||||
disabled={loading}
|
||||
error={errors.phone}
|
||||
/>
|
||||
<ErrorMessage>{errors.phone}</ErrorMessage>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dateOfBirth">
|
||||
Date of Birth <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="dateOfBirth"
|
||||
type="date"
|
||||
value={dateOfBirth}
|
||||
onChange={e => {
|
||||
onDateOfBirthChange(e.target.value);
|
||||
clearError("dateOfBirth");
|
||||
}}
|
||||
disabled={loading}
|
||||
error={errors.dateOfBirth}
|
||||
max={new Date().toISOString().split("T")[0]}
|
||||
/>
|
||||
<ErrorMessage>{errors.dateOfBirth}</ErrorMessage>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Gender <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<div className="flex gap-4">
|
||||
{GENDER_OPTIONS.map(option => (
|
||||
<label key={option} className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="gender"
|
||||
value={option}
|
||||
checked={gender === option}
|
||||
onChange={() => {
|
||||
onGenderChange(option);
|
||||
clearError("gender");
|
||||
}}
|
||||
disabled={loading}
|
||||
className="h-4 w-4 text-primary focus:ring-primary"
|
||||
/>
|
||||
<span className="text-sm capitalize">{option}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<ErrorMessage>{errors.gender}</ErrorMessage>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
export { AccountInfoDisplay } from "./AccountInfoDisplay";
|
||||
export { PersonalInfoFields } from "./PersonalInfoFields";
|
||||
export { PasswordSection } from "./PasswordSection";
|
||||
@ -1,25 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { PageLayout } from "@/components/templates/PageLayout";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import { AnimatedCard, ProgressSteps } from "@/components/molecules";
|
||||
import { AddonGroup } from "@/features/services/components/base/AddonGroup";
|
||||
import { StepHeader } from "@/components/atoms";
|
||||
import { SimTypeSelector } from "@/features/services/components/sim/SimTypeSelector";
|
||||
import { ActivationForm } from "@/features/services/components/sim/ActivationForm";
|
||||
import { MnpForm } from "@/features/services/components/sim/MnpForm";
|
||||
import { ProgressSteps } from "@/components/molecules";
|
||||
import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink";
|
||||
import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
ArrowRightIcon,
|
||||
DevicePhoneMobileIcon,
|
||||
ExclamationTriangleIcon,
|
||||
UsersIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { DevicePhoneMobileIcon } from "@heroicons/react/24/outline";
|
||||
import type { UseSimConfigureResult } from "@/features/services/hooks/useSimConfigure";
|
||||
import type { SimActivationFeeCatalogItem } from "@customer-portal/domain/services";
|
||||
import { formatIsoMonthDay } from "@/shared/utils";
|
||||
import {
|
||||
LoadingSkeleton,
|
||||
PlanNotFound,
|
||||
PlanCard,
|
||||
PlatinumNotice,
|
||||
SimTypeStep,
|
||||
ActivationStep,
|
||||
AddonsStep,
|
||||
NumberPortingStep,
|
||||
ReviewOrderStep,
|
||||
getRequiredActivationFee,
|
||||
resolveOneTimeCharge,
|
||||
formatActivationFeeDetails,
|
||||
calculateOrderTotals,
|
||||
} from "./configure";
|
||||
|
||||
type Props = UseSimConfigureResult & {
|
||||
onConfirm: () => void;
|
||||
@ -50,130 +51,23 @@ export function SimConfigureView({
|
||||
onConfirm,
|
||||
}: Props) {
|
||||
const servicesBasePath = useServicesBasePath();
|
||||
const getRequiredActivationFee = (
|
||||
fees: SimActivationFeeCatalogItem[]
|
||||
): SimActivationFeeCatalogItem | undefined => {
|
||||
if (!Array.isArray(fees) || fees.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return fees.find(fee => fee.catalogMetadata?.isDefault) || fees[0];
|
||||
};
|
||||
|
||||
const resolveOneTimeCharge = (
|
||||
value?:
|
||||
| {
|
||||
oneTimePrice?: number | undefined;
|
||||
unitPrice?: number | undefined;
|
||||
monthlyPrice?: number | undefined;
|
||||
}
|
||||
| undefined
|
||||
): number => {
|
||||
if (!value) return 0;
|
||||
return value.oneTimePrice ?? value.unitPrice ?? value.monthlyPrice ?? 0;
|
||||
};
|
||||
|
||||
const requiredActivationFee = getRequiredActivationFee(activationFees);
|
||||
const activationFeeAmount = resolveOneTimeCharge(requiredActivationFee);
|
||||
const activationFeeDetails =
|
||||
requiredActivationFee && activationFeeAmount > 0
|
||||
? {
|
||||
name: requiredActivationFee.name,
|
||||
amount: activationFeeAmount,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Calculate display totals from services prices (for display only)
|
||||
// Note: BFF will recalculate authoritative pricing
|
||||
const monthlyTotal =
|
||||
(plan?.monthlyPrice ?? 0) +
|
||||
selectedAddons.reduce((sum, addonSku) => {
|
||||
const addon = addons.find(a => a.sku === addonSku);
|
||||
return sum + (addon?.monthlyPrice ?? 0);
|
||||
}, 0);
|
||||
|
||||
const oneTimeTotal =
|
||||
(plan?.oneTimePrice ?? 0) +
|
||||
activationFeeAmount +
|
||||
selectedAddons.reduce((sum, addonSku) => {
|
||||
const addon = addons.find(a => a.sku === addonSku);
|
||||
return sum + (addon?.oneTimePrice ?? 0);
|
||||
}, 0);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PageLayout
|
||||
title="Configure SIM"
|
||||
description="Customize your mobile service"
|
||||
icon={<DevicePhoneMobileIcon className="h-6 w-6" />}
|
||||
>
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
{/* Header card skeleton */}
|
||||
<div className="bg-card rounded-xl border border-border p-6 shadow-[var(--cp-shadow-1)]">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-5 w-5 bg-accent rounded" />
|
||||
<div className="h-5 w-48 bg-muted rounded" />
|
||||
<div className="h-5 w-24 bg-success-soft rounded-full" />
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-4 w-24 bg-muted rounded" />
|
||||
<div className="h-4 w-28 bg-muted rounded" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right space-y-2">
|
||||
<div className="h-7 w-24 bg-accent rounded" />
|
||||
<div className="h-4 w-28 bg-success-soft rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Steps indicator */}
|
||||
<div className="flex items-center justify-between max-w-2xl mx-auto">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="flex-1 flex items-center">
|
||||
<div className="h-3 w-3 rounded-full bg-border" />
|
||||
{i < 3 && <div className="h-1 flex-1 bg-border mx-2 rounded" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step 1 card skeleton */}
|
||||
<div className="bg-card rounded-xl border border-border p-8 shadow-[var(--cp-shadow-1)]">
|
||||
<div className="mb-6">
|
||||
<div className="h-5 w-48 bg-muted rounded mb-2" />
|
||||
<div className="h-4 w-72 bg-muted rounded" />
|
||||
</div>
|
||||
<div className="h-10 w-full bg-muted rounded mb-4" />
|
||||
<div className="h-10 w-72 bg-muted rounded ml-auto" />
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
return <LoadingSkeleton />;
|
||||
}
|
||||
|
||||
if (!plan) {
|
||||
return (
|
||||
<PageLayout
|
||||
title="Plan Not Found"
|
||||
description="The selected plan could not be found"
|
||||
icon={<ExclamationTriangleIcon className="h-6 w-6" />}
|
||||
>
|
||||
<div className="text-center py-12">
|
||||
<ExclamationTriangleIcon className="h-12 w-12 mx-auto text-danger mb-4" />
|
||||
<h2 className="text-xl font-semibold text-foreground mb-2">Plan Not Found</h2>
|
||||
<p className="text-muted-foreground mb-4">The selected plan could not be found</p>
|
||||
<a
|
||||
href={`${servicesBasePath}/sim`}
|
||||
className="text-primary hover:text-primary-hover font-medium"
|
||||
>
|
||||
← Return to SIM Plans
|
||||
</a>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
return <PlanNotFound />;
|
||||
}
|
||||
|
||||
const requiredActivationFee = getRequiredActivationFee(activationFees);
|
||||
const activationFeeAmount = resolveOneTimeCharge(requiredActivationFee);
|
||||
const activationFeeDetails = formatActivationFeeDetails(
|
||||
requiredActivationFee,
|
||||
activationFeeAmount
|
||||
);
|
||||
const totals = calculateOrderTotals(plan, selectedAddons, addons, activationFeeAmount);
|
||||
|
||||
const steps = [
|
||||
{ number: 1, title: "SIM Type", completed: currentStep > 1 },
|
||||
{ number: 2, title: "Activation", completed: currentStep > 2 },
|
||||
@ -181,7 +75,79 @@ export function SimConfigureView({
|
||||
{ number: 4, title: "Number Porting", completed: currentStep > 4 },
|
||||
];
|
||||
|
||||
const handleAddonSelection = (newSelectedSkus: string[]) => setSelectedAddons(newSelectedSkus);
|
||||
const renderStep = () => {
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return (
|
||||
<SimTypeStep
|
||||
simType={simType}
|
||||
setSimType={setSimType}
|
||||
eid={eid}
|
||||
setEid={setEid}
|
||||
validate={validate}
|
||||
onNext={() => setCurrentStep(2)}
|
||||
/>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<ActivationStep
|
||||
activationType={activationType}
|
||||
setActivationType={setActivationType}
|
||||
scheduledActivationDate={scheduledActivationDate}
|
||||
setScheduledActivationDate={setScheduledActivationDate}
|
||||
activationFee={activationFeeDetails}
|
||||
validate={validate}
|
||||
onNext={() => setCurrentStep(3)}
|
||||
onBack={() => setCurrentStep(1)}
|
||||
/>
|
||||
);
|
||||
case 3:
|
||||
return (
|
||||
<AddonsStep
|
||||
addons={addons}
|
||||
selectedAddons={selectedAddons}
|
||||
setSelectedAddons={setSelectedAddons}
|
||||
planType={plan.simPlanType ?? ""}
|
||||
onNext={() => setCurrentStep(4)}
|
||||
onBack={() => setCurrentStep(2)}
|
||||
/>
|
||||
);
|
||||
case 4:
|
||||
return (
|
||||
<NumberPortingStep
|
||||
wantsMnp={wantsMnp}
|
||||
setWantsMnp={setWantsMnp}
|
||||
mnpData={mnpData}
|
||||
setMnpData={setMnpData}
|
||||
activationType={activationType}
|
||||
validate={validate}
|
||||
onNext={() => setCurrentStep(5)}
|
||||
onBack={() => setCurrentStep(3)}
|
||||
/>
|
||||
);
|
||||
case 5:
|
||||
return (
|
||||
<ReviewOrderStep
|
||||
plan={plan}
|
||||
simType={simType}
|
||||
eid={eid}
|
||||
activationType={activationType}
|
||||
scheduledActivationDate={scheduledActivationDate}
|
||||
wantsMnp={wantsMnp}
|
||||
selectedAddons={selectedAddons}
|
||||
addons={addons}
|
||||
activationFee={activationFeeDetails}
|
||||
monthlyTotal={totals.monthly}
|
||||
oneTimeTotal={totals.oneTime}
|
||||
onBack={() => setCurrentStep(4)}
|
||||
onConfirm={onConfirm}
|
||||
isDefault={requiredActivationFee?.catalogMetadata?.isDefault}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
@ -191,388 +157,10 @@ export function SimConfigureView({
|
||||
>
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
<ServicesBackLink href={`${servicesBasePath}/sim`} label="Back to SIM Plans" />
|
||||
|
||||
<AnimatedCard variant="static" className="p-6">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<DevicePhoneMobileIcon className="h-5 w-5 text-primary" />
|
||||
<h3 className="font-bold text-lg text-foreground">{plan.name}</h3>
|
||||
{plan.simHasFamilyDiscount && (
|
||||
<span className="bg-success-soft text-success text-xs px-2 py-1 rounded-full font-medium flex items-center gap-1">
|
||||
<UsersIcon className="h-3 w-3" />
|
||||
Family Discount
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground mb-2">
|
||||
<span>
|
||||
<strong>Data:</strong> {plan.simDataSize}
|
||||
</span>
|
||||
<span>
|
||||
<strong>Type:</strong>{" "}
|
||||
{plan.simPlanType === "DataSmsVoice"
|
||||
? "Data + SMS + Voice"
|
||||
: plan.simPlanType === "DataOnly"
|
||||
? "Data Only"
|
||||
: "Voice + SMS Only"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold text-primary">
|
||||
¥{(plan.monthlyPrice ?? plan.unitPrice ?? plan.oneTimePrice ?? 0).toLocaleString()}
|
||||
/mo
|
||||
</div>
|
||||
{plan.simHasFamilyDiscount && (
|
||||
<div className="text-sm text-success font-medium">Discounted Price</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedCard>
|
||||
|
||||
<PlanCard plan={plan} />
|
||||
<ProgressSteps steps={steps} currentStep={currentStep} />
|
||||
|
||||
{plan.name.toLowerCase().includes("platinum") && (
|
||||
<div className="bg-warning-soft border border-warning/25 rounded-lg p-4">
|
||||
<div className="flex items-start">
|
||||
<ExclamationTriangleIcon className="w-5 h-5 text-warning mt-0.5 flex-shrink-0" />
|
||||
<div className="ml-3">
|
||||
<h5 className="font-medium text-foreground">PLATINUM Plan Notice</h5>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Additional device subscription fees may apply. Contact support for details.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-8">
|
||||
{currentStep === 1 && (
|
||||
<AnimatedCard variant="static" className="p-8">
|
||||
<div className="mb-6">
|
||||
<StepHeader
|
||||
stepNumber={1}
|
||||
title="Select SIM Type"
|
||||
description="Choose between eSIM and physical SIM"
|
||||
/>
|
||||
</div>
|
||||
<SimTypeSelector
|
||||
simType={simType}
|
||||
onSimTypeChange={setSimType}
|
||||
eid={eid}
|
||||
onEidChange={setEid}
|
||||
errors={{}}
|
||||
/>
|
||||
<div className="flex justify-end mt-6">
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (simType === "eSIM" && !validate()) {
|
||||
return;
|
||||
}
|
||||
setCurrentStep(2);
|
||||
}}
|
||||
rightIcon={<ArrowRightIcon className="w-4 h-4" />}
|
||||
>
|
||||
Continue to Activation
|
||||
</Button>
|
||||
</div>
|
||||
</AnimatedCard>
|
||||
)}
|
||||
|
||||
{currentStep === 2 && (
|
||||
<AnimatedCard
|
||||
variant="static"
|
||||
className={`p-8 transition-all duration-500 ease-in-out transform opacity-100 translate-y-0`}
|
||||
>
|
||||
<div className="mb-6">
|
||||
<StepHeader
|
||||
stepNumber={2}
|
||||
title="Activation"
|
||||
description="Choose when to start your service"
|
||||
/>
|
||||
</div>
|
||||
<ActivationForm
|
||||
activationType={activationType}
|
||||
onActivationTypeChange={setActivationType}
|
||||
scheduledActivationDate={scheduledActivationDate}
|
||||
onScheduledActivationDateChange={setScheduledActivationDate}
|
||||
errors={{}}
|
||||
activationFee={activationFeeDetails}
|
||||
/>
|
||||
<div className="flex justify-between mt-6">
|
||||
<Button
|
||||
onClick={() => setCurrentStep(1)}
|
||||
variant="outline"
|
||||
leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
|
||||
>
|
||||
Back to SIM Type
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (activationType === "Scheduled" && !validate()) {
|
||||
return;
|
||||
}
|
||||
setCurrentStep(3);
|
||||
}}
|
||||
rightIcon={<ArrowRightIcon className="w-4 h-4" />}
|
||||
>
|
||||
Continue to Add-ons
|
||||
</Button>
|
||||
</div>
|
||||
</AnimatedCard>
|
||||
)}
|
||||
|
||||
{currentStep === 3 && (
|
||||
<AnimatedCard
|
||||
variant="static"
|
||||
className={`p-8 transition-all duration-500 ease-in-out transform opacity-100 translate-y-0`}
|
||||
>
|
||||
<div className="mb-6">
|
||||
<StepHeader
|
||||
stepNumber={3}
|
||||
title="Add-ons"
|
||||
description="Optional services to enhance your experience"
|
||||
/>
|
||||
</div>
|
||||
{addons.length > 0 ? (
|
||||
<AddonGroup
|
||||
addons={addons}
|
||||
selectedAddonSkus={selectedAddons}
|
||||
onAddonToggle={handleAddonSelection}
|
||||
showSkus={false}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground">
|
||||
{plan.simPlanType === "DataOnly"
|
||||
? "No add-ons are available for data-only plans."
|
||||
: "No add-ons are available for this plan."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between mt-6">
|
||||
<Button
|
||||
onClick={() => setCurrentStep(2)}
|
||||
variant="outline"
|
||||
leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
|
||||
>
|
||||
Back to Activation
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setCurrentStep(4)}
|
||||
rightIcon={<ArrowRightIcon className="w-4 h-4" />}
|
||||
>
|
||||
Continue to Number Porting
|
||||
</Button>
|
||||
</div>
|
||||
</AnimatedCard>
|
||||
)}
|
||||
|
||||
{currentStep === 4 && (
|
||||
<AnimatedCard
|
||||
variant="static"
|
||||
className={`p-8 transition-all duration-500 ease-in-out transform opacity-100 translate-y-0`}
|
||||
>
|
||||
<div className="mb-6">
|
||||
<StepHeader
|
||||
stepNumber={4}
|
||||
title="Number Porting (Optional)"
|
||||
description="Keep your existing phone number by transferring it to your new SIM"
|
||||
/>
|
||||
</div>
|
||||
<MnpForm
|
||||
wantsMnp={wantsMnp}
|
||||
onWantsMnpChange={setWantsMnp}
|
||||
mnpData={mnpData}
|
||||
onMnpDataChange={setMnpData}
|
||||
errors={{}}
|
||||
/>
|
||||
<div className="flex justify-between mt-6">
|
||||
<Button
|
||||
onClick={() => setCurrentStep(3)}
|
||||
variant="outline"
|
||||
leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
|
||||
>
|
||||
Back to Add-ons
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if ((wantsMnp || activationType === "Scheduled") && !validate()) return;
|
||||
setCurrentStep(5);
|
||||
}}
|
||||
rightIcon={<ArrowRightIcon className="w-4 h-4" />}
|
||||
>
|
||||
Review Order
|
||||
</Button>
|
||||
</div>
|
||||
</AnimatedCard>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{currentStep === 5 && (
|
||||
<AnimatedCard variant="static" className="p-8">
|
||||
<div className="mb-6">
|
||||
<StepHeader
|
||||
stepNumber={5}
|
||||
title="Review Your Order"
|
||||
description="Review your configuration and proceed to checkout"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-w-lg mx-auto mb-8 bg-card shadow-[var(--cp-shadow-2)] rounded-lg border border-border p-6">
|
||||
<div className="text-center border-b-2 border-dashed border-border/60 pb-4 mb-6">
|
||||
<h3 className="text-xl font-bold text-foreground mb-1">Order Summary</h3>
|
||||
<p className="text-sm text-muted-foreground">Review your configuration</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 mb-6">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h4 className="font-semibold text-foreground">{plan.name}</h4>
|
||||
<p className="text-sm text-muted-foreground">{plan.simDataSize}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-semibold text-foreground">
|
||||
¥{plan.monthlyPrice?.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">per month</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border pt-4 mb-6">
|
||||
<h4 className="font-medium text-foreground mb-3">Configuration</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">SIM Type:</span>
|
||||
<span className="text-foreground">{simType || "Not selected"}</span>
|
||||
</div>
|
||||
{simType === "eSIM" && eid && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">EID:</span>
|
||||
<span className="text-foreground font-mono text-xs">
|
||||
{eid.slice(0, 12)}...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Activation:</span>
|
||||
<span className="text-foreground">
|
||||
{activationType === "Scheduled" && scheduledActivationDate
|
||||
? `${formatIsoMonthDay(scheduledActivationDate)}`
|
||||
: activationType || "Not selected"}
|
||||
</span>
|
||||
</div>
|
||||
{wantsMnp && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Number Porting:</span>
|
||||
<span className="text-foreground">Requested</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedAddons.length > 0 && (
|
||||
<div className="border-t border-border pt-4 mb-6">
|
||||
<h4 className="font-medium text-foreground mb-3">Add-ons</h4>
|
||||
<div className="space-y-2">
|
||||
{selectedAddons.map(addonSku => {
|
||||
const addon = addons.find(a => a.sku === addonSku);
|
||||
const addonAmount = addon
|
||||
? addon.billingCycle === "Monthly"
|
||||
? (addon.monthlyPrice ?? addon.unitPrice ?? 0)
|
||||
: (addon.oneTimePrice ?? addon.unitPrice ?? 0)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div key={addonSku} className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">{addon?.name || addonSku}</span>
|
||||
<span className="text-foreground">
|
||||
¥{addonAmount.toLocaleString()}
|
||||
<span className="text-xs text-muted-foreground ml-1">
|
||||
/{addon?.billingCycle === "Monthly" ? "mo" : "once"}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activationFeeDetails && (
|
||||
<div className="border-t border-border pt-4 mb-6">
|
||||
<h4 className="font-medium text-foreground mb-3">One-time Fees</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">{activationFeeDetails.name}</span>
|
||||
<span className="text-foreground">
|
||||
¥{activationFeeDetails.amount.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
{requiredActivationFee?.catalogMetadata?.isDefault && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Required for all new SIM activations
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t-2 border-dashed border-border/60 pt-4 bg-muted -mx-6 px-6 py-4 rounded-b-lg">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-xl font-bold">
|
||||
<span className="text-foreground">Monthly Total</span>
|
||||
<span className="text-primary">¥{monthlyTotal.toLocaleString()}</span>
|
||||
</div>
|
||||
{oneTimeTotal > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">One-time Total</span>
|
||||
<span className="text-warning font-semibold">
|
||||
¥{oneTimeTotal.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground pt-2">
|
||||
Prices exclude 10% consumption tax
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Verification notice */}
|
||||
<div className="max-w-lg mx-auto mb-6 bg-info/10 border border-info/25 rounded-lg p-4">
|
||||
<p className="text-sm text-foreground">
|
||||
<span className="font-medium">Next steps after checkout:</span>{" "}
|
||||
<span className="text-muted-foreground">
|
||||
We'll review your order and ID verification within 1-2 business days. You'll
|
||||
receive an email once approved.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center pt-6 border-t border-border">
|
||||
<Button
|
||||
onClick={() => setCurrentStep(4)}
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="px-8 py-4 text-lg"
|
||||
leftIcon={<ArrowLeftIcon className="w-5 h-5" />}
|
||||
>
|
||||
Back to Number Porting
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
size="lg"
|
||||
className="px-12 py-4 text-lg font-semibold"
|
||||
rightIcon={<ArrowRightIcon className="w-5 h-5" />}
|
||||
>
|
||||
Proceed to Checkout
|
||||
</Button>
|
||||
</div>
|
||||
</AnimatedCard>
|
||||
)}
|
||||
<PlatinumNotice planName={plan.name} />
|
||||
<div className="space-y-8">{renderStep()}</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
|
||||
@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { PageLayout } from "@/components/templates/PageLayout";
|
||||
import { DevicePhoneMobileIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
export function LoadingSkeleton() {
|
||||
return (
|
||||
<PageLayout
|
||||
title="Configure SIM"
|
||||
description="Customize your mobile service"
|
||||
icon={<DevicePhoneMobileIcon className="h-6 w-6" />}
|
||||
>
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
{/* Header card skeleton */}
|
||||
<div className="bg-card rounded-xl border border-border p-6 shadow-[var(--cp-shadow-1)]">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-5 w-5 bg-accent rounded" />
|
||||
<div className="h-5 w-48 bg-muted rounded" />
|
||||
<div className="h-5 w-24 bg-success-soft rounded-full" />
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-4 w-24 bg-muted rounded" />
|
||||
<div className="h-4 w-28 bg-muted rounded" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right space-y-2">
|
||||
<div className="h-7 w-24 bg-accent rounded" />
|
||||
<div className="h-4 w-28 bg-success-soft rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Steps indicator */}
|
||||
<div className="flex items-center justify-between max-w-2xl mx-auto">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="flex-1 flex items-center">
|
||||
<div className="h-3 w-3 rounded-full bg-border" />
|
||||
{i < 3 && <div className="h-1 flex-1 bg-border mx-2 rounded" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step 1 card skeleton */}
|
||||
<div className="bg-card rounded-xl border border-border p-8 shadow-[var(--cp-shadow-1)]">
|
||||
<div className="mb-6">
|
||||
<div className="h-5 w-48 bg-muted rounded mb-2" />
|
||||
<div className="h-4 w-72 bg-muted rounded" />
|
||||
</div>
|
||||
<div className="h-10 w-full bg-muted rounded mb-4" />
|
||||
<div className="h-10 w-72 bg-muted rounded ml-auto" />
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatedCard } from "@/components/molecules";
|
||||
import { DevicePhoneMobileIcon, UsersIcon } from "@heroicons/react/24/outline";
|
||||
import type { PlanDisplayProps } from "./types";
|
||||
|
||||
function formatPlanType(planType: string): string {
|
||||
if (planType === "DataSmsVoice") return "Data + SMS + Voice";
|
||||
if (planType === "DataOnly") return "Data Only";
|
||||
return "Voice + SMS Only";
|
||||
}
|
||||
|
||||
export function PlanCard({ plan }: PlanDisplayProps) {
|
||||
const price = plan.monthlyPrice ?? plan.unitPrice ?? plan.oneTimePrice ?? 0;
|
||||
|
||||
return (
|
||||
<AnimatedCard variant="static" className="p-6">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<DevicePhoneMobileIcon className="h-5 w-5 text-primary" />
|
||||
<h3 className="font-bold text-lg text-foreground">{plan.name}</h3>
|
||||
{plan.simHasFamilyDiscount && (
|
||||
<span className="bg-success-soft text-success text-xs px-2 py-1 rounded-full font-medium flex items-center gap-1">
|
||||
<UsersIcon className="h-3 w-3" />
|
||||
Family Discount
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground mb-2">
|
||||
<span>
|
||||
<strong>Data:</strong> {plan.simDataSize}
|
||||
</span>
|
||||
<span>
|
||||
<strong>Type:</strong> {formatPlanType(plan.simPlanType ?? "")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold text-primary">¥{price.toLocaleString()}/mo</div>
|
||||
{plan.simHasFamilyDiscount && (
|
||||
<div className="text-sm text-success font-medium">Discounted Price</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedCard>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { PageLayout } from "@/components/templates/PageLayout";
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||
import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath";
|
||||
|
||||
export function PlanNotFound() {
|
||||
const servicesBasePath = useServicesBasePath();
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title="Plan Not Found"
|
||||
description="The selected plan could not be found"
|
||||
icon={<ExclamationTriangleIcon className="h-6 w-6" />}
|
||||
>
|
||||
<div className="text-center py-12">
|
||||
<ExclamationTriangleIcon className="h-12 w-12 mx-auto text-danger mb-4" />
|
||||
<h2 className="text-xl font-semibold text-foreground mb-2">Plan Not Found</h2>
|
||||
<p className="text-muted-foreground mb-4">The selected plan could not be found</p>
|
||||
<a
|
||||
href={`${servicesBasePath}/sim`}
|
||||
className="text-primary hover:text-primary-hover font-medium"
|
||||
>
|
||||
← Return to SIM Plans
|
||||
</a>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
interface PlatinumNoticeProps {
|
||||
planName: string;
|
||||
}
|
||||
|
||||
export function PlatinumNotice({ planName }: PlatinumNoticeProps) {
|
||||
if (!planName.toLowerCase().includes("platinum")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-warning-soft border border-warning/25 rounded-lg p-4">
|
||||
<div className="flex items-start">
|
||||
<ExclamationTriangleIcon className="w-5 h-5 text-warning mt-0.5 flex-shrink-0" />
|
||||
<div className="ml-3">
|
||||
<h5 className="font-medium text-foreground">PLATINUM Plan Notice</h5>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Additional device subscription fees may apply. Contact support for details.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
export { LoadingSkeleton } from "./LoadingSkeleton";
|
||||
export { PlanNotFound } from "./PlanNotFound";
|
||||
export { PlanCard } from "./PlanCard";
|
||||
export { PlatinumNotice } from "./PlatinumNotice";
|
||||
export {
|
||||
getRequiredActivationFee,
|
||||
resolveOneTimeCharge,
|
||||
formatActivationFeeDetails,
|
||||
calculateOrderTotals,
|
||||
} from "./pricing";
|
||||
export {
|
||||
SimTypeStep,
|
||||
ActivationStep,
|
||||
AddonsStep,
|
||||
NumberPortingStep,
|
||||
ReviewOrderStep,
|
||||
} from "./steps";
|
||||
export type * from "./types";
|
||||
@ -0,0 +1,60 @@
|
||||
import type {
|
||||
SimActivationFeeCatalogItem,
|
||||
SimCatalogProduct,
|
||||
} from "@customer-portal/domain/services";
|
||||
import type { ActivationFeeDetails, OrderTotals } from "./types";
|
||||
|
||||
interface PriceContainer {
|
||||
oneTimePrice?: number | undefined;
|
||||
unitPrice?: number | undefined;
|
||||
monthlyPrice?: number | undefined;
|
||||
}
|
||||
|
||||
export function getRequiredActivationFee(
|
||||
fees: SimActivationFeeCatalogItem[]
|
||||
): SimActivationFeeCatalogItem | undefined {
|
||||
if (!Array.isArray(fees) || fees.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return fees.find(fee => fee.catalogMetadata?.isDefault) || fees[0];
|
||||
}
|
||||
|
||||
export function resolveOneTimeCharge(value?: PriceContainer | undefined): number {
|
||||
if (!value) return 0;
|
||||
return value.oneTimePrice ?? value.unitPrice ?? value.monthlyPrice ?? 0;
|
||||
}
|
||||
|
||||
export function formatActivationFeeDetails(
|
||||
fee: SimActivationFeeCatalogItem | undefined,
|
||||
amount: number
|
||||
): ActivationFeeDetails | undefined {
|
||||
if (!fee || amount <= 0) return undefined;
|
||||
return {
|
||||
name: fee.name,
|
||||
amount,
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateOrderTotals(
|
||||
plan: SimCatalogProduct,
|
||||
selectedAddonSkus: string[],
|
||||
addons: SimCatalogProduct[],
|
||||
activationFeeAmount: number
|
||||
): OrderTotals {
|
||||
const monthlyTotal =
|
||||
(plan.monthlyPrice ?? 0) +
|
||||
selectedAddonSkus.reduce((sum, addonSku) => {
|
||||
const addon = addons.find(a => a.sku === addonSku);
|
||||
return sum + (addon?.monthlyPrice ?? 0);
|
||||
}, 0);
|
||||
|
||||
const oneTimeTotal =
|
||||
(plan.oneTimePrice ?? 0) +
|
||||
activationFeeAmount +
|
||||
selectedAddonSkus.reduce((sum, addonSku) => {
|
||||
const addon = addons.find(a => a.sku === addonSku);
|
||||
return sum + (addon?.oneTimePrice ?? 0);
|
||||
}, 0);
|
||||
|
||||
return { monthly: monthlyTotal, oneTime: oneTimeTotal };
|
||||
}
|
||||
@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatedCard } from "@/components/molecules";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import { StepHeader } from "@/components/atoms";
|
||||
import { ActivationForm } from "@/features/services/components/sim/ActivationForm";
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "@heroicons/react/24/outline";
|
||||
import type { ActivationStepProps } from "../types";
|
||||
|
||||
export function ActivationStep({
|
||||
activationType,
|
||||
setActivationType,
|
||||
scheduledActivationDate,
|
||||
setScheduledActivationDate,
|
||||
activationFee,
|
||||
validate,
|
||||
onNext,
|
||||
onBack,
|
||||
}: ActivationStepProps) {
|
||||
const handleContinue = () => {
|
||||
if (activationType === "Scheduled" && !validate()) {
|
||||
return;
|
||||
}
|
||||
onNext();
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatedCard
|
||||
variant="static"
|
||||
className="p-8 transition-all duration-500 ease-in-out transform opacity-100 translate-y-0"
|
||||
>
|
||||
<div className="mb-6">
|
||||
<StepHeader
|
||||
stepNumber={2}
|
||||
title="Activation"
|
||||
description="Choose when to start your service"
|
||||
/>
|
||||
</div>
|
||||
<ActivationForm
|
||||
activationType={activationType || "Immediate"}
|
||||
onActivationTypeChange={setActivationType}
|
||||
scheduledActivationDate={scheduledActivationDate}
|
||||
onScheduledActivationDateChange={setScheduledActivationDate}
|
||||
errors={{}}
|
||||
activationFee={activationFee}
|
||||
/>
|
||||
<div className="flex justify-between mt-6">
|
||||
<Button onClick={onBack} variant="outline" leftIcon={<ArrowLeftIcon className="w-4 h-4" />}>
|
||||
Back to SIM Type
|
||||
</Button>
|
||||
<Button onClick={handleContinue} rightIcon={<ArrowRightIcon className="w-4 h-4" />}>
|
||||
Continue to Add-ons
|
||||
</Button>
|
||||
</div>
|
||||
</AnimatedCard>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatedCard } from "@/components/molecules";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import { StepHeader } from "@/components/atoms";
|
||||
import { AddonGroup } from "@/features/services/components/base/AddonGroup";
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "@heroicons/react/24/outline";
|
||||
import type { AddonsStepProps } from "../types";
|
||||
|
||||
export function AddonsStep({
|
||||
addons,
|
||||
selectedAddons,
|
||||
setSelectedAddons,
|
||||
planType,
|
||||
onNext,
|
||||
onBack,
|
||||
}: AddonsStepProps) {
|
||||
return (
|
||||
<AnimatedCard
|
||||
variant="static"
|
||||
className="p-8 transition-all duration-500 ease-in-out transform opacity-100 translate-y-0"
|
||||
>
|
||||
<div className="mb-6">
|
||||
<StepHeader
|
||||
stepNumber={3}
|
||||
title="Add-ons"
|
||||
description="Optional services to enhance your experience"
|
||||
/>
|
||||
</div>
|
||||
{addons.length > 0 ? (
|
||||
<AddonGroup
|
||||
addons={addons}
|
||||
selectedAddonSkus={selectedAddons}
|
||||
onAddonToggle={setSelectedAddons}
|
||||
showSkus={false}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground">
|
||||
{planType === "DataOnly"
|
||||
? "No add-ons are available for data-only plans."
|
||||
: "No add-ons are available for this plan."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between mt-6">
|
||||
<Button onClick={onBack} variant="outline" leftIcon={<ArrowLeftIcon className="w-4 h-4" />}>
|
||||
Back to Activation
|
||||
</Button>
|
||||
<Button onClick={onNext} rightIcon={<ArrowRightIcon className="w-4 h-4" />}>
|
||||
Continue to Number Porting
|
||||
</Button>
|
||||
</div>
|
||||
</AnimatedCard>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatedCard } from "@/components/molecules";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import { StepHeader } from "@/components/atoms";
|
||||
import { MnpForm } from "@/features/services/components/sim/MnpForm";
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "@heroicons/react/24/outline";
|
||||
import type { NumberPortingStepProps } from "../types";
|
||||
|
||||
export function NumberPortingStep({
|
||||
wantsMnp,
|
||||
setWantsMnp,
|
||||
mnpData,
|
||||
setMnpData,
|
||||
activationType,
|
||||
validate,
|
||||
onNext,
|
||||
onBack,
|
||||
}: NumberPortingStepProps) {
|
||||
const handleContinue = () => {
|
||||
if ((wantsMnp || activationType === "Scheduled") && !validate()) {
|
||||
return;
|
||||
}
|
||||
onNext();
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatedCard
|
||||
variant="static"
|
||||
className="p-8 transition-all duration-500 ease-in-out transform opacity-100 translate-y-0"
|
||||
>
|
||||
<div className="mb-6">
|
||||
<StepHeader
|
||||
stepNumber={4}
|
||||
title="Number Porting (Optional)"
|
||||
description="Keep your existing phone number by transferring it to your new SIM"
|
||||
/>
|
||||
</div>
|
||||
<MnpForm
|
||||
wantsMnp={wantsMnp}
|
||||
onWantsMnpChange={setWantsMnp}
|
||||
mnpData={mnpData}
|
||||
onMnpDataChange={setMnpData}
|
||||
errors={{}}
|
||||
/>
|
||||
<div className="flex justify-between mt-6">
|
||||
<Button onClick={onBack} variant="outline" leftIcon={<ArrowLeftIcon className="w-4 h-4" />}>
|
||||
Back to Add-ons
|
||||
</Button>
|
||||
<Button onClick={handleContinue} rightIcon={<ArrowRightIcon className="w-4 h-4" />}>
|
||||
Review Order
|
||||
</Button>
|
||||
</div>
|
||||
</AnimatedCard>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,191 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatedCard } from "@/components/molecules";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import { StepHeader } from "@/components/atoms";
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "@heroicons/react/24/outline";
|
||||
import { formatIsoMonthDay } from "@/shared/utils";
|
||||
import type { ReviewOrderStepProps } from "../types";
|
||||
|
||||
function getAddonAmount(
|
||||
addon:
|
||||
| {
|
||||
billingCycle?: string | undefined;
|
||||
monthlyPrice?: number | undefined;
|
||||
unitPrice?: number | undefined;
|
||||
oneTimePrice?: number | undefined;
|
||||
}
|
||||
| undefined
|
||||
): number {
|
||||
if (!addon) return 0;
|
||||
return addon.billingCycle === "Monthly"
|
||||
? (addon.monthlyPrice ?? addon.unitPrice ?? 0)
|
||||
: (addon.oneTimePrice ?? addon.unitPrice ?? 0);
|
||||
}
|
||||
|
||||
export function ReviewOrderStep({
|
||||
plan,
|
||||
simType,
|
||||
eid,
|
||||
activationType,
|
||||
scheduledActivationDate,
|
||||
wantsMnp,
|
||||
selectedAddons,
|
||||
addons,
|
||||
activationFee,
|
||||
monthlyTotal,
|
||||
oneTimeTotal,
|
||||
onBack,
|
||||
onConfirm,
|
||||
isDefault,
|
||||
}: ReviewOrderStepProps) {
|
||||
return (
|
||||
<AnimatedCard variant="static" className="p-8">
|
||||
<div className="mb-6">
|
||||
<StepHeader
|
||||
stepNumber={5}
|
||||
title="Review Your Order"
|
||||
description="Review your configuration and proceed to checkout"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-w-lg mx-auto mb-8 bg-card shadow-[var(--cp-shadow-2)] rounded-lg border border-border p-6">
|
||||
<div className="text-center border-b-2 border-dashed border-border/60 pb-4 mb-6">
|
||||
<h3 className="text-xl font-bold text-foreground mb-1">Order Summary</h3>
|
||||
<p className="text-sm text-muted-foreground">Review your configuration</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 mb-6">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h4 className="font-semibold text-foreground">{plan.name}</h4>
|
||||
<p className="text-sm text-muted-foreground">{plan.simDataSize}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-semibold text-foreground">
|
||||
¥{plan.monthlyPrice?.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">per month</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border pt-4 mb-6">
|
||||
<h4 className="font-medium text-foreground mb-3">Configuration</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">SIM Type:</span>
|
||||
<span className="text-foreground">{simType || "Not selected"}</span>
|
||||
</div>
|
||||
{simType === "eSIM" && eid && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">EID:</span>
|
||||
<span className="text-foreground font-mono text-xs">{eid.slice(0, 12)}...</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Activation:</span>
|
||||
<span className="text-foreground">
|
||||
{activationType === "Scheduled" && scheduledActivationDate
|
||||
? formatIsoMonthDay(scheduledActivationDate)
|
||||
: activationType || "Not selected"}
|
||||
</span>
|
||||
</div>
|
||||
{wantsMnp && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Number Porting:</span>
|
||||
<span className="text-foreground">Requested</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedAddons.length > 0 && (
|
||||
<div className="border-t border-border pt-4 mb-6">
|
||||
<h4 className="font-medium text-foreground mb-3">Add-ons</h4>
|
||||
<div className="space-y-2">
|
||||
{selectedAddons.map(addonSku => {
|
||||
const addon = addons.find(a => a.sku === addonSku);
|
||||
const addonAmount = getAddonAmount(addon);
|
||||
return (
|
||||
<div key={addonSku} className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">{addon?.name || addonSku}</span>
|
||||
<span className="text-foreground">
|
||||
¥{addonAmount.toLocaleString()}
|
||||
<span className="text-xs text-muted-foreground ml-1">
|
||||
/{addon?.billingCycle === "Monthly" ? "mo" : "once"}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activationFee && (
|
||||
<div className="border-t border-border pt-4 mb-6">
|
||||
<h4 className="font-medium text-foreground mb-3">One-time Fees</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">{activationFee.name}</span>
|
||||
<span className="text-foreground">¥{activationFee.amount.toLocaleString()}</span>
|
||||
</div>
|
||||
{isDefault && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Required for all new SIM activations
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t-2 border-dashed border-border/60 pt-4 bg-muted -mx-6 px-6 py-4 rounded-b-lg">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-xl font-bold">
|
||||
<span className="text-foreground">Monthly Total</span>
|
||||
<span className="text-primary">¥{monthlyTotal.toLocaleString()}</span>
|
||||
</div>
|
||||
{oneTimeTotal > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">One-time Total</span>
|
||||
<span className="text-warning font-semibold">¥{oneTimeTotal.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground pt-2">Prices exclude 10% consumption tax</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-lg mx-auto mb-6 bg-info/10 border border-info/25 rounded-lg p-4">
|
||||
<p className="text-sm text-foreground">
|
||||
<span className="font-medium">Next steps after checkout:</span>{" "}
|
||||
<span className="text-muted-foreground">
|
||||
We'll review your order and ID verification within 1-2 business days. You'll receive an
|
||||
email once approved.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center pt-6 border-t border-border">
|
||||
<Button
|
||||
onClick={onBack}
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="px-8 py-4 text-lg"
|
||||
leftIcon={<ArrowLeftIcon className="w-5 h-5" />}
|
||||
>
|
||||
Back to Number Porting
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
size="lg"
|
||||
className="px-12 py-4 text-lg font-semibold"
|
||||
rightIcon={<ArrowRightIcon className="w-5 h-5" />}
|
||||
>
|
||||
Proceed to Checkout
|
||||
</Button>
|
||||
</div>
|
||||
</AnimatedCard>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatedCard } from "@/components/molecules";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import { StepHeader } from "@/components/atoms";
|
||||
import { SimTypeSelector } from "@/features/services/components/sim/SimTypeSelector";
|
||||
import { ArrowRightIcon } from "@heroicons/react/24/outline";
|
||||
import type { SimTypeStepProps } from "../types";
|
||||
|
||||
export function SimTypeStep({
|
||||
simType,
|
||||
setSimType,
|
||||
eid,
|
||||
setEid,
|
||||
validate,
|
||||
onNext,
|
||||
}: SimTypeStepProps) {
|
||||
const handleContinue = () => {
|
||||
if (simType === "eSIM" && !validate()) {
|
||||
return;
|
||||
}
|
||||
onNext();
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatedCard variant="static" className="p-8">
|
||||
<div className="mb-6">
|
||||
<StepHeader
|
||||
stepNumber={1}
|
||||
title="Select SIM Type"
|
||||
description="Choose between eSIM and physical SIM"
|
||||
/>
|
||||
</div>
|
||||
<SimTypeSelector
|
||||
simType={simType}
|
||||
onSimTypeChange={setSimType}
|
||||
eid={eid}
|
||||
onEidChange={setEid}
|
||||
errors={{}}
|
||||
/>
|
||||
<div className="flex justify-end mt-6">
|
||||
<Button onClick={handleContinue} rightIcon={<ArrowRightIcon className="w-4 h-4" />}>
|
||||
Continue to Activation
|
||||
</Button>
|
||||
</div>
|
||||
</AnimatedCard>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
export { SimTypeStep } from "./SimTypeStep";
|
||||
export { ActivationStep } from "./ActivationStep";
|
||||
export { AddonsStep } from "./AddonsStep";
|
||||
export { NumberPortingStep } from "./NumberPortingStep";
|
||||
export { ReviewOrderStep } from "./ReviewOrderStep";
|
||||
@ -0,0 +1,77 @@
|
||||
import type { SimCatalogProduct } from "@customer-portal/domain/services";
|
||||
import type { MnpData } from "@customer-portal/domain/sim";
|
||||
|
||||
export interface StepConfig {
|
||||
number: number;
|
||||
title: string;
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
export interface ActivationFeeDetails {
|
||||
name: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface PlanDisplayProps {
|
||||
plan: SimCatalogProduct;
|
||||
}
|
||||
|
||||
export interface BaseStepProps {
|
||||
onNext: () => void;
|
||||
onBack?: (() => void) | undefined;
|
||||
}
|
||||
|
||||
export interface SimTypeStepProps extends BaseStepProps {
|
||||
simType: "eSIM" | "Physical SIM" | "";
|
||||
setSimType: (type: "eSIM" | "Physical SIM") => void;
|
||||
eid: string;
|
||||
setEid: (eid: string) => void;
|
||||
validate: () => boolean;
|
||||
}
|
||||
|
||||
export interface ActivationStepProps extends BaseStepProps {
|
||||
activationType: "Immediate" | "Scheduled" | "";
|
||||
setActivationType: (type: "Immediate" | "Scheduled") => void;
|
||||
scheduledActivationDate: string;
|
||||
setScheduledActivationDate: (date: string) => void;
|
||||
activationFee?: ActivationFeeDetails | undefined;
|
||||
validate: () => boolean;
|
||||
}
|
||||
|
||||
export interface AddonsStepProps extends BaseStepProps {
|
||||
addons: SimCatalogProduct[];
|
||||
selectedAddons: string[];
|
||||
setSelectedAddons: (addons: string[]) => void;
|
||||
planType: string;
|
||||
}
|
||||
|
||||
export interface NumberPortingStepProps extends BaseStepProps {
|
||||
wantsMnp: boolean;
|
||||
setWantsMnp: (wants: boolean) => void;
|
||||
mnpData: MnpData;
|
||||
setMnpData: (data: MnpData) => void;
|
||||
activationType: "Immediate" | "Scheduled" | "";
|
||||
validate: () => boolean;
|
||||
}
|
||||
|
||||
export interface ReviewOrderStepProps {
|
||||
plan: SimCatalogProduct;
|
||||
simType: "eSIM" | "Physical SIM" | "";
|
||||
eid: string;
|
||||
activationType: "Immediate" | "Scheduled" | "";
|
||||
scheduledActivationDate: string;
|
||||
wantsMnp: boolean;
|
||||
selectedAddons: string[];
|
||||
addons: SimCatalogProduct[];
|
||||
activationFee?: ActivationFeeDetails | undefined;
|
||||
monthlyTotal: number;
|
||||
oneTimeTotal: number;
|
||||
onBack: () => void;
|
||||
onConfirm: () => void;
|
||||
isDefault?: boolean | undefined;
|
||||
}
|
||||
|
||||
export interface OrderTotals {
|
||||
monthly: number;
|
||||
oneTime: number;
|
||||
}
|
||||
1
portal-backend.latest.tar.sha256
Normal file
1
portal-backend.latest.tar.sha256
Normal file
@ -0,0 +1 @@
|
||||
e35685ef6ccb89f970ef919f003aa30b5738452dec6cb9ccdeb82d08dba02b3c /home/barsa/projects/customer_portal/customer-portal/portal-backend.latest.tar
|
||||
1
portal-frontend.latest.tar.sha256
Normal file
1
portal-frontend.latest.tar.sha256
Normal file
@ -0,0 +1 @@
|
||||
9f24a647acd92c8e9300eed9eb1a73aceb75713a769e380be02a413fb551e916 /home/barsa/projects/customer_portal/customer-portal/portal-frontend.latest.tar
|
||||
Loading…
x
Reference in New Issue
Block a user