refactor: standardize conditional expressions for improved readability

This commit is contained in:
barsa 2026-02-03 15:21:45 +09:00
parent f257ffe35a
commit 61d2236b68
56 changed files with 2528 additions and 1460 deletions

View File

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

View File

@ -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";

View File

@ -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 {}

View File

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

View File

@ -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

View File

@ -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 {}

View File

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

View File

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

View File

@ -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
];
/**

View File

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

View File

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

View File

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

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

View File

@ -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";

View File

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

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

View File

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

View File

@ -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"
>

View File

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

View File

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

View File

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

View File

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

View File

@ -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";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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";

View File

@ -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";

View File

@ -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"

View File

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

View File

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

View File

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

View File

@ -0,0 +1,3 @@
export { AccountInfoDisplay } from "./AccountInfoDisplay";
export { PersonalInfoFields } from "./PersonalInfoFields";
export { PasswordSection } from "./PasswordSection";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,5 @@
export { SimTypeStep } from "./SimTypeStep";
export { ActivationStep } from "./ActivationStep";
export { AddonsStep } from "./AddonsStep";
export { NumberPortingStep } from "./NumberPortingStep";
export { ReviewOrderStep } from "./ReviewOrderStep";

View File

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

View File

@ -0,0 +1 @@
e35685ef6ccb89f970ef919f003aa30b5738452dec6cb9ccdeb82d08dba02b3c /home/barsa/projects/customer_portal/customer-portal/portal-backend.latest.tar

View File

@ -0,0 +1 @@
9f24a647acd92c8e9300eed9eb1a73aceb75713a769e380be02a413fb551e916 /home/barsa/projects/customer_portal/customer-portal/portal-frontend.latest.tar