diff --git a/apps/bff/src/integrations/japanpost/facades/japanpost.facade.ts b/apps/bff/src/integrations/japanpost/facades/japanpost.facade.ts new file mode 100644 index 00000000..b9dfd272 --- /dev/null +++ b/apps/bff/src/integrations/japanpost/facades/japanpost.facade.ts @@ -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 { + return this.addressService.lookupByZipCode(zipCode, clientIp); + } + + /** + * Check if the Japan Post service is available + */ + isAvailable(): boolean { + return this.addressService.isAvailable(); + } +} diff --git a/apps/bff/src/integrations/japanpost/index.ts b/apps/bff/src/integrations/japanpost/index.ts index d9c9d674..8200ff0a 100644 --- a/apps/bff/src/integrations/japanpost/index.ts +++ b/apps/bff/src/integrations/japanpost/index.ts @@ -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"; diff --git a/apps/bff/src/integrations/japanpost/japanpost.module.ts b/apps/bff/src/integrations/japanpost/japanpost.module.ts index 5ff30599..bdaf5906 100644 --- a/apps/bff/src/integrations/japanpost/japanpost.module.ts +++ b/apps/bff/src/integrations/japanpost/japanpost.module.ts @@ -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 {} diff --git a/apps/bff/src/modules/address/address.controller.ts b/apps/bff/src/modules/address/address.controller.ts index 83861b24..8943c5d5 100644 --- a/apps/bff/src/modules/address/address.controller.ts +++ b/apps/bff/src/modules/address/address.controller.ts @@ -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 { 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() }; } } diff --git a/apps/bff/src/modules/billing/billing.controller.ts b/apps/bff/src/modules/billing/billing.controller.ts index dc673365..6628fd76 100644 --- a/apps/bff/src/modules/billing/billing.controller.ts +++ b/apps/bff/src/modules/billing/billing.controller.ts @@ -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 { 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 { // 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 { 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 diff --git a/apps/bff/src/modules/billing/billing.module.ts b/apps/bff/src/modules/billing/billing.module.ts index 1c648668..4bab9016 100644 --- a/apps/bff/src/modules/billing/billing.module.ts +++ b/apps/bff/src/modules/billing/billing.module.ts @@ -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 {} diff --git a/apps/bff/src/modules/billing/services/billing-orchestrator.service.ts b/apps/bff/src/modules/billing/services/billing-orchestrator.service.ts new file mode 100644 index 00000000..e2ac686a --- /dev/null +++ b/apps/bff/src/modules/billing/services/billing-orchestrator.service.ts @@ -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 { + return this.paymentService.getPaymentMethods(whmcsClientId, userId); + } + + /** + * Invalidate payment methods cache for a user + */ + async invalidatePaymentMethodsCache(userId: string): Promise { + return this.paymentService.invalidatePaymentMethodsCache(userId); + } + + /** + * Create SSO link for invoice access + */ + async createInvoiceSsoLink( + whmcsClientId: number, + invoiceId: number, + target: SsoTarget + ): Promise { + return this.ssoService.whmcsSsoForInvoice(whmcsClientId, invoiceId, target); + } +} diff --git a/apps/portal/src/components/organisms/AppShell/AppShell.tsx b/apps/portal/src/components/organisms/AppShell/AppShell.tsx index 55d41c4d..cb991c0a 100644 --- a/apps/portal/src/components/organisms/AppShell/AppShell.tsx +++ b/apps/portal/src/components/organisms/AppShell/AppShell.tsx @@ -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]); diff --git a/apps/portal/src/core/api/index.ts b/apps/portal/src/core/api/index.ts index bc41194f..75af747c 100644 --- a/apps/portal/src/core/api/index.ts +++ b/apps/portal/src/core/api/index.ts @@ -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 ]; /** diff --git a/apps/portal/src/features/auth/components/MarketingCheckbox.tsx b/apps/portal/src/features/auth/components/MarketingCheckbox.tsx new file mode 100644 index 00000000..958caa97 --- /dev/null +++ b/apps/portal/src/features/auth/components/MarketingCheckbox.tsx @@ -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 ( +
+ onChange(e.target.checked)} + disabled={disabled} + /> + +
+ ); +} diff --git a/apps/portal/src/features/auth/components/PasswordMatchIndicator.tsx b/apps/portal/src/features/auth/components/PasswordMatchIndicator.tsx new file mode 100644 index 00000000..f733ad1b --- /dev/null +++ b/apps/portal/src/features/auth/components/PasswordMatchIndicator.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { Check, X } from "lucide-react"; + +interface PasswordMatchIndicatorProps { + passwordsMatch: boolean; +} + +export function PasswordMatchIndicator({ passwordsMatch }: PasswordMatchIndicatorProps) { + if (passwordsMatch) { + return ( +
+ + Passwords match +
+ ); + } + + return ( +
+ + Passwords do not match +
+ ); +} diff --git a/apps/portal/src/features/auth/components/PasswordRequirements.tsx b/apps/portal/src/features/auth/components/PasswordRequirements.tsx new file mode 100644 index 00000000..7f3c2a3f --- /dev/null +++ b/apps/portal/src/features/auth/components/PasswordRequirements.tsx @@ -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 ( +
+ {met ? ( + + ) : ( + + )} + {label} +
+ ); +} + +interface PasswordRequirementsProps { + checks: PasswordChecks; + showHint?: boolean | undefined; +} + +export function PasswordRequirements({ checks, showHint = false }: PasswordRequirementsProps) { + if (showHint) { + return ( +

+ At least 8 characters with uppercase, lowercase, and numbers +

+ ); + } + + return ( +
+ + + + +
+ ); +} diff --git a/apps/portal/src/features/auth/components/TermsCheckbox.tsx b/apps/portal/src/features/auth/components/TermsCheckbox.tsx new file mode 100644 index 00000000..c8e157b7 --- /dev/null +++ b/apps/portal/src/features/auth/components/TermsCheckbox.tsx @@ -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 ( +
+
+ onChange(e.target.checked)} + disabled={disabled} + /> + +
+ {error &&

{error}

} +
+ ); +} diff --git a/apps/portal/src/features/auth/components/index.ts b/apps/portal/src/features/auth/components/index.ts index fe67df03..345469ca 100644 --- a/apps/portal/src/features/auth/components/index.ts +++ b/apps/portal/src/features/auth/components/index.ts @@ -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"; diff --git a/apps/portal/src/features/auth/hooks/use-auth.ts b/apps/portal/src/features/auth/hooks/use-auth.ts index 1b8ae1ae..5bba5ff3 100644 --- a/apps/portal/src/features/auth/hooks/use-auth.ts +++ b/apps/portal/src/features/auth/hooks/use-auth.ts @@ -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]); diff --git a/apps/portal/src/features/auth/hooks/usePasswordValidation.ts b/apps/portal/src/features/auth/hooks/usePasswordValidation.ts new file mode 100644 index 00000000..94e440fb --- /dev/null +++ b/apps/portal/src/features/auth/hooks/usePasswordValidation.ts @@ -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]); +} diff --git a/apps/portal/src/features/auth/stores/auth.store.ts b/apps/portal/src/features/auth/stores/auth.store.ts index 66947b6c..209adeed 100644 --- a/apps/portal/src/features/auth/stores/auth.store.ts +++ b/apps/portal/src/features/auth/stores/auth.store.ts @@ -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()((set, get) => { const applyAuthResponse = (data: AuthResponseData, keepLoading = false) => { set({ @@ -91,17 +120,43 @@ export const useAuthStore = create()((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 => { + /** + * 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 => { + // 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()((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()((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: {} }); } } }, diff --git a/apps/portal/src/features/get-started/components/GetStartedForm/steps/CompleteAccountStep.tsx b/apps/portal/src/features/get-started/components/GetStartedForm/steps/CompleteAccountStep.tsx index 6d95f136..1dde844f 100644 --- a/apps/portal/src/features/get-started/components/GetStartedForm/steps/CompleteAccountStep.tsx +++ b/apps/portal/src/features/get-started/components/GetStartedForm/steps/CompleteAccountStep.tsx @@ -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({}); - - // 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 (
- {/* Header */}

{hasPrefill @@ -190,291 +78,71 @@ export function CompleteAccountStep() {

- {/* Pre-filled info display in gray disabled inputs (SF-only users) */} - {hasPrefill && ( -
-
-
- - -
-
- - -
-
-
- - -
- {prefill?.address && ( -
- - -
- )} -
- )} + {hasPrefill && } - {/* Name fields (new customers only) */} {isNewCustomer && ( - <> -
-
- - { - setFirstName(e.target.value); - setLocalErrors(prev => ({ ...prev, firstName: undefined })); - }} - placeholder="Taro" - disabled={loading} - error={localErrors.firstName} - /> - {localErrors.firstName && ( -

{localErrors.firstName}

- )} -
- -
- - { - setLastName(e.target.value); - setLocalErrors(prev => ({ ...prev, lastName: undefined })); - }} - placeholder="Yamada" - disabled={loading} - error={localErrors.lastName} - /> - {localErrors.lastName && ( -

{localErrors.lastName}

- )} -
-
- - {/* Address Form (new customers only) */} -
- - - {localErrors.address &&

{localErrors.address}

} -
- + )} - {/* Phone */} -
- - { - setPhone(e.target.value); - setLocalErrors(prev => ({ ...prev, phone: undefined })); - }} - placeholder="090-1234-5678" - disabled={loading} - error={localErrors.phone} - /> - {localErrors.phone &&

{localErrors.phone}

} -
+ - {/* Date of Birth */} -
- - { - setDateOfBirth(e.target.value); - setLocalErrors(prev => ({ ...prev, dateOfBirth: undefined })); - }} - disabled={loading} - error={localErrors.dateOfBirth} - max={new Date().toISOString().split("T")[0]} - /> - {localErrors.dateOfBirth && ( -

{localErrors.dateOfBirth}

- )} -
+ - {/* Gender */} -
- -
- {(["male", "female", "other"] as const).map(option => ( - - ))} -
- {localErrors.gender &&

{localErrors.gender}

} -
- - {/* Password (at bottom before terms) */} -
- - { - setPassword(e.target.value); - setLocalErrors(prev => ({ ...prev, password: undefined })); - }} - placeholder="Create a strong password" - disabled={loading} - error={localErrors.password} - autoComplete="new-password" - /> - {localErrors.password &&

{localErrors.password}

} -

- At least 8 characters with uppercase, lowercase, and numbers -

-
- - {/* Confirm Password */} -
- - { - setConfirmPassword(e.target.value); - setLocalErrors(prev => ({ ...prev, confirmPassword: undefined })); - }} - placeholder="Confirm your password" - disabled={loading} - error={localErrors.confirmPassword} - autoComplete="new-password" - /> - {localErrors.confirmPassword && ( -

{localErrors.confirmPassword}

- )} -
- - {/* Terms & Marketing */}
-
- { - setAcceptTerms(e.target.checked); - setLocalErrors(prev => ({ ...prev, acceptTerms: undefined })); - }} - disabled={loading} - /> - -
- {localErrors.acceptTerms && ( -

{localErrors.acceptTerms}

- )} - -
- setMarketingConsent(e.target.checked)} - disabled={loading} - /> - -
+ { + form.setAcceptTerms(checked); + form.clearError("acceptTerms"); + }} + disabled={loading} + error={form.errors.acceptTerms} + /> +
- {/* Error display */} {error && (

{error}

)} - {/* Actions */}
- )} - - {onSkip && ( - - )} -
- - {onNext && ( - - )} -
- )} - - {/* Footer Content */} {footerContent &&
{footerContent}
} ); diff --git a/apps/portal/src/features/services/components/base/configuration-step/HelpPanel.tsx b/apps/portal/src/features/services/components/base/configuration-step/HelpPanel.tsx new file mode 100644 index 00000000..8a35fcc6 --- /dev/null +++ b/apps/portal/src/features/services/components/base/configuration-step/HelpPanel.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { InformationCircleIcon } from "@heroicons/react/24/outline"; + +interface HelpPanelProps { + text: string; +} + +export function HelpPanel({ text }: HelpPanelProps) { + return ( +
+
+ +

{text}

+
+
+ ); +} diff --git a/apps/portal/src/features/services/components/base/configuration-step/InfoPanel.tsx b/apps/portal/src/features/services/components/base/configuration-step/InfoPanel.tsx new file mode 100644 index 00000000..229bf464 --- /dev/null +++ b/apps/portal/src/features/services/components/base/configuration-step/InfoPanel.tsx @@ -0,0 +1,13 @@ +"use client"; + +interface InfoPanelProps { + text: string; +} + +export function InfoPanel({ text }: InfoPanelProps) { + return ( +
+

{text}

+
+ ); +} diff --git a/apps/portal/src/features/services/components/base/configuration-step/StepActions.tsx b/apps/portal/src/features/services/components/base/configuration-step/StepActions.tsx new file mode 100644 index 00000000..5637d6a0 --- /dev/null +++ b/apps/portal/src/features/services/components/base/configuration-step/StepActions.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { Button } from "@/components/atoms/button"; +import type { StepActionsProps } from "./types"; + +function LoadingSpinner() { + return ( + + + + + + Processing... + + ); +} + +export function StepActions({ + onNext, + onPrevious, + onSkip, + nextLabel = "Continue", + previousLabel = "Back", + skipLabel = "Skip", + loading = false, + disabled = false, + hasErrors = false, +}: StepActionsProps) { + const isButtonDisabled = disabled || loading; + + return ( +
+
+ {onPrevious && ( + + )} + + {onSkip && ( + + )} +
+ + {onNext && ( + + )} +
+ ); +} diff --git a/apps/portal/src/features/services/components/base/configuration-step/StepContent.tsx b/apps/portal/src/features/services/components/base/configuration-step/StepContent.tsx new file mode 100644 index 00000000..f12567c6 --- /dev/null +++ b/apps/portal/src/features/services/components/base/configuration-step/StepContent.tsx @@ -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 ( + <> +
{children}
+ {helpText && } + {infoText && } + + ); +} diff --git a/apps/portal/src/features/services/components/base/configuration-step/StepHeader.tsx b/apps/portal/src/features/services/components/base/configuration-step/StepHeader.tsx new file mode 100644 index 00000000..e394be5c --- /dev/null +++ b/apps/portal/src/features/services/components/base/configuration-step/StepHeader.tsx @@ -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 ( +
+
+ {showStepIndicator && } + +
+

+ {title} +

+ {description && ( +

+ {description} +

+ )} + + {validation && ( +
+ +
+ )} +
+
+ + {headerContent &&
{headerContent}
} +
+ ); +} + +export { deriveStatus }; diff --git a/apps/portal/src/features/services/components/base/configuration-step/StepIndicator.tsx b/apps/portal/src/features/services/components/base/configuration-step/StepIndicator.tsx new file mode 100644 index 00000000..e68be347 --- /dev/null +++ b/apps/portal/src/features/services/components/base/configuration-step/StepIndicator.tsx @@ -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 ( +
+ {status === "completed" ? : {stepNumber}} +
+ ); +} diff --git a/apps/portal/src/features/services/components/base/configuration-step/ValidationStatus.tsx b/apps/portal/src/features/services/components/base/configuration-step/ValidationStatus.tsx new file mode 100644 index 00000000..be9f0d1c --- /dev/null +++ b/apps/portal/src/features/services/components/base/configuration-step/ValidationStatus.tsx @@ -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 ( +
+ +
+ {errors.map((error, index) => ( +
{error}
+ ))} +
+
+ ); + } + + if (hasWarnings) { + return ( +
+ +
+ {warnings.map((warning, index) => ( +
{warning}
+ ))} +
+
+ ); + } + + if (showSuccess) { + return ( +
+ + Configuration complete +
+ ); + } + + return null; +} diff --git a/apps/portal/src/features/services/components/base/configuration-step/index.ts b/apps/portal/src/features/services/components/base/configuration-step/index.ts new file mode 100644 index 00000000..f770da50 --- /dev/null +++ b/apps/portal/src/features/services/components/base/configuration-step/index.ts @@ -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"; diff --git a/apps/portal/src/features/services/components/base/configuration-step/types.ts b/apps/portal/src/features/services/components/base/configuration-step/types.ts new file mode 100644 index 00000000..43dfb7ea --- /dev/null +++ b/apps/portal/src/features/services/components/base/configuration-step/types.ts @@ -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"; diff --git a/apps/portal/src/features/services/components/eligibility-check/steps/CompleteAccountStep.tsx b/apps/portal/src/features/services/components/eligibility-check/steps/CompleteAccountStep.tsx index 7be4f61c..1f248ebc 100644 --- a/apps/portal/src/features/services/components/eligibility-check/steps/CompleteAccountStep.tsx +++ b/apps/portal/src/features/services/components/eligibility-check/steps/CompleteAccountStep.tsx @@ -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 ( -
- {met ? ( - - ) : ( - - )} - {label} -
- ); + 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({}); - // 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 (
- {/* Pre-filled info display */} -
-

Account details:

-

- {formData.firstName} {formData.lastName} -

-

{formData.email}

- {formData.address && ( -

- 〒{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}`} -

- )} -
+ - {/* Phone */} -
- - { - updateAccountData({ phone: e.target.value }); - handleClearError("phone"); - }} - placeholder="090-1234-5678" - disabled={loading} - error={accountErrors.phone} - /> - {accountErrors.phone} -
+ updateAccountData({ phone })} + onDateOfBirthChange={dateOfBirth => updateAccountData({ dateOfBirth })} + onGenderChange={gender => updateAccountData({ gender })} + errors={accountErrors} + clearError={clearLocalError} + loading={loading} + /> - {/* Date of Birth */} -
- - { - updateAccountData({ dateOfBirth: e.target.value }); - handleClearError("dateOfBirth"); - }} - disabled={loading} - error={accountErrors.dateOfBirth} - max={new Date().toISOString().split("T")[0]} - /> - {accountErrors.dateOfBirth} -
+ updateAccountData({ password })} + onConfirmPasswordChange={confirmPassword => updateAccountData({ confirmPassword })} + errors={accountErrors} + clearError={clearLocalError} + loading={loading} + /> - {/* Gender */} -
- -
- {(["male", "female", "other"] as const).map(option => ( - - ))} -
- {accountErrors.gender} -
- - {/* Password */} -
- - { - updateAccountData({ password: e.target.value }); - handleClearError("password"); - }} - placeholder="Create a strong password" - disabled={loading} - error={accountErrors.password} - autoComplete="new-password" - /> - {accountErrors.password} - {/* Real-time password requirements */} - {accountData.password.length > 0 && ( -
- - - - -
- )} - {accountData.password.length === 0 && ( -

- At least 8 characters with uppercase, lowercase, and numbers -

- )} -
- - {/* Confirm Password */} -
- - { - updateAccountData({ confirmPassword: e.target.value }); - handleClearError("confirmPassword"); - }} - placeholder="Confirm your password" - disabled={loading} - error={accountErrors.confirmPassword} - autoComplete="new-password" - /> - {accountErrors.confirmPassword} - {/* Real-time password match indicator */} - {showPasswordMatch && !accountErrors.confirmPassword && ( -
- {doPasswordsMatch ? ( - <> - - Passwords match - - ) : ( - <> - - Passwords do not match - - )} -
- )} -
- - {/* Terms & Marketing */}
-
- { - updateAccountData({ acceptTerms: e.target.checked }); - handleClearError("acceptTerms"); - }} - disabled={loading} - /> - -
- {accountErrors.acceptTerms} - -
- updateAccountData({ marketingConsent: e.target.checked })} - disabled={loading} - /> - -
+ { + updateAccountData({ acceptTerms }); + clearLocalError("acceptTerms"); + }} + disabled={loading} + error={accountErrors.acceptTerms} + /> + updateAccountData({ marketingConsent })} + disabled={loading} + />
- {/* API Error */} {error && (
{error}
)} - {/* Actions */}
-
- - )} - - {currentStep === 2 && ( - -
- -
- -
- - -
-
- )} - - {currentStep === 3 && ( - -
- -
- {addons.length > 0 ? ( - - ) : ( -
-

- {plan.simPlanType === "DataOnly" - ? "No add-ons are available for data-only plans." - : "No add-ons are available for this plan."} -

-
- )} -
- - -
-
- )} - - {currentStep === 4 && ( - -
- -
- -
- - -
-
- )} -
- - {currentStep === 5 && ( - -
- -
- -
-
-

Order Summary

-

Review your configuration

-
- -
-
-
-

{plan.name}

-

{plan.simDataSize}

-
-
-

- ¥{plan.monthlyPrice?.toLocaleString()} -

-

per month

-
-
-
- -
-

Configuration

-
-
- SIM Type: - {simType || "Not selected"} -
- {simType === "eSIM" && eid && ( -
- EID: - - {eid.slice(0, 12)}... - -
- )} -
- Activation: - - {activationType === "Scheduled" && scheduledActivationDate - ? `${formatIsoMonthDay(scheduledActivationDate)}` - : activationType || "Not selected"} - -
- {wantsMnp && ( -
- Number Porting: - Requested -
- )} -
-
- - {selectedAddons.length > 0 && ( -
-

Add-ons

-
- {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 ( -
- {addon?.name || addonSku} - - ¥{addonAmount.toLocaleString()} - - /{addon?.billingCycle === "Monthly" ? "mo" : "once"} - - -
- ); - })} -
-
- )} - - {activationFeeDetails && ( -
-

One-time Fees

-
-
- {activationFeeDetails.name} - - ¥{activationFeeDetails.amount.toLocaleString()} - -
- {requiredActivationFee?.catalogMetadata?.isDefault && ( -

- Required for all new SIM activations -

- )} -
-
- )} - -
-
-
- Monthly Total - ¥{monthlyTotal.toLocaleString()} -
- {oneTimeTotal > 0 && ( -
- One-time Total - - ¥{oneTimeTotal.toLocaleString()} - -
- )} -

- Prices exclude 10% consumption tax -

-
-
-
- - {/* Verification notice */} -
-

- Next steps after checkout:{" "} - - We'll review your order and ID verification within 1-2 business days. You'll - receive an email once approved. - -

-
- -
- - -
-
- )} + +
{renderStep()}
); diff --git a/apps/portal/src/features/services/components/sim/configure/LoadingSkeleton.tsx b/apps/portal/src/features/services/components/sim/configure/LoadingSkeleton.tsx new file mode 100644 index 00000000..d49355c7 --- /dev/null +++ b/apps/portal/src/features/services/components/sim/configure/LoadingSkeleton.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { PageLayout } from "@/components/templates/PageLayout"; +import { DevicePhoneMobileIcon } from "@heroicons/react/24/outline"; + +export function LoadingSkeleton() { + return ( + } + > +
+ {/* Header card skeleton */} +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + {/* Steps indicator */} +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+
+ {i < 3 &&
} +
+ ))} +
+ + {/* Step 1 card skeleton */} +
+
+
+
+
+
+
+
+
+ + ); +} diff --git a/apps/portal/src/features/services/components/sim/configure/PlanCard.tsx b/apps/portal/src/features/services/components/sim/configure/PlanCard.tsx new file mode 100644 index 00000000..41d10531 --- /dev/null +++ b/apps/portal/src/features/services/components/sim/configure/PlanCard.tsx @@ -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 ( + +
+
+
+ +

{plan.name}

+ {plan.simHasFamilyDiscount && ( + + + Family Discount + + )} +
+
+ + Data: {plan.simDataSize} + + + Type: {formatPlanType(plan.simPlanType ?? "")} + +
+
+
+
¥{price.toLocaleString()}/mo
+ {plan.simHasFamilyDiscount && ( +
Discounted Price
+ )} +
+
+
+ ); +} diff --git a/apps/portal/src/features/services/components/sim/configure/PlanNotFound.tsx b/apps/portal/src/features/services/components/sim/configure/PlanNotFound.tsx new file mode 100644 index 00000000..e5473d0c --- /dev/null +++ b/apps/portal/src/features/services/components/sim/configure/PlanNotFound.tsx @@ -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 ( + } + > +
+ +

Plan Not Found

+

The selected plan could not be found

+ + ← Return to SIM Plans + +
+
+ ); +} diff --git a/apps/portal/src/features/services/components/sim/configure/PlatinumNotice.tsx b/apps/portal/src/features/services/components/sim/configure/PlatinumNotice.tsx new file mode 100644 index 00000000..15ddceec --- /dev/null +++ b/apps/portal/src/features/services/components/sim/configure/PlatinumNotice.tsx @@ -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 ( +
+
+ +
+
PLATINUM Plan Notice
+

+ Additional device subscription fees may apply. Contact support for details. +

+
+
+
+ ); +} diff --git a/apps/portal/src/features/services/components/sim/configure/index.ts b/apps/portal/src/features/services/components/sim/configure/index.ts new file mode 100644 index 00000000..cf514a4d --- /dev/null +++ b/apps/portal/src/features/services/components/sim/configure/index.ts @@ -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"; diff --git a/apps/portal/src/features/services/components/sim/configure/pricing.ts b/apps/portal/src/features/services/components/sim/configure/pricing.ts new file mode 100644 index 00000000..c8dd5c02 --- /dev/null +++ b/apps/portal/src/features/services/components/sim/configure/pricing.ts @@ -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 }; +} diff --git a/apps/portal/src/features/services/components/sim/configure/steps/ActivationStep.tsx b/apps/portal/src/features/services/components/sim/configure/steps/ActivationStep.tsx new file mode 100644 index 00000000..0c71995e --- /dev/null +++ b/apps/portal/src/features/services/components/sim/configure/steps/ActivationStep.tsx @@ -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 ( + +
+ +
+ +
+ + +
+
+ ); +} diff --git a/apps/portal/src/features/services/components/sim/configure/steps/AddonsStep.tsx b/apps/portal/src/features/services/components/sim/configure/steps/AddonsStep.tsx new file mode 100644 index 00000000..1704bf23 --- /dev/null +++ b/apps/portal/src/features/services/components/sim/configure/steps/AddonsStep.tsx @@ -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 ( + +
+ +
+ {addons.length > 0 ? ( + + ) : ( +
+

+ {planType === "DataOnly" + ? "No add-ons are available for data-only plans." + : "No add-ons are available for this plan."} +

+
+ )} +
+ + +
+
+ ); +} diff --git a/apps/portal/src/features/services/components/sim/configure/steps/NumberPortingStep.tsx b/apps/portal/src/features/services/components/sim/configure/steps/NumberPortingStep.tsx new file mode 100644 index 00000000..ea911ac4 --- /dev/null +++ b/apps/portal/src/features/services/components/sim/configure/steps/NumberPortingStep.tsx @@ -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 ( + +
+ +
+ +
+ + +
+
+ ); +} diff --git a/apps/portal/src/features/services/components/sim/configure/steps/ReviewOrderStep.tsx b/apps/portal/src/features/services/components/sim/configure/steps/ReviewOrderStep.tsx new file mode 100644 index 00000000..9fb4ca68 --- /dev/null +++ b/apps/portal/src/features/services/components/sim/configure/steps/ReviewOrderStep.tsx @@ -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 ( + +
+ +
+ +
+
+

Order Summary

+

Review your configuration

+
+ +
+
+
+

{plan.name}

+

{plan.simDataSize}

+
+
+

+ ¥{plan.monthlyPrice?.toLocaleString()} +

+

per month

+
+
+
+ +
+

Configuration

+
+
+ SIM Type: + {simType || "Not selected"} +
+ {simType === "eSIM" && eid && ( +
+ EID: + {eid.slice(0, 12)}... +
+ )} +
+ Activation: + + {activationType === "Scheduled" && scheduledActivationDate + ? formatIsoMonthDay(scheduledActivationDate) + : activationType || "Not selected"} + +
+ {wantsMnp && ( +
+ Number Porting: + Requested +
+ )} +
+
+ + {selectedAddons.length > 0 && ( +
+

Add-ons

+
+ {selectedAddons.map(addonSku => { + const addon = addons.find(a => a.sku === addonSku); + const addonAmount = getAddonAmount(addon); + return ( +
+ {addon?.name || addonSku} + + ¥{addonAmount.toLocaleString()} + + /{addon?.billingCycle === "Monthly" ? "mo" : "once"} + + +
+ ); + })} +
+
+ )} + + {activationFee && ( +
+

One-time Fees

+
+
+ {activationFee.name} + ¥{activationFee.amount.toLocaleString()} +
+ {isDefault && ( +

+ Required for all new SIM activations +

+ )} +
+
+ )} + +
+
+
+ Monthly Total + ¥{monthlyTotal.toLocaleString()} +
+ {oneTimeTotal > 0 && ( +
+ One-time Total + ¥{oneTimeTotal.toLocaleString()} +
+ )} +

Prices exclude 10% consumption tax

+
+
+
+ +
+

+ Next steps after checkout:{" "} + + We'll review your order and ID verification within 1-2 business days. You'll receive an + email once approved. + +

+
+ +
+ + +
+
+ ); +} diff --git a/apps/portal/src/features/services/components/sim/configure/steps/SimTypeStep.tsx b/apps/portal/src/features/services/components/sim/configure/steps/SimTypeStep.tsx new file mode 100644 index 00000000..a8d308d6 --- /dev/null +++ b/apps/portal/src/features/services/components/sim/configure/steps/SimTypeStep.tsx @@ -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 ( + +
+ +
+ +
+ +
+
+ ); +} diff --git a/apps/portal/src/features/services/components/sim/configure/steps/index.ts b/apps/portal/src/features/services/components/sim/configure/steps/index.ts new file mode 100644 index 00000000..be210b97 --- /dev/null +++ b/apps/portal/src/features/services/components/sim/configure/steps/index.ts @@ -0,0 +1,5 @@ +export { SimTypeStep } from "./SimTypeStep"; +export { ActivationStep } from "./ActivationStep"; +export { AddonsStep } from "./AddonsStep"; +export { NumberPortingStep } from "./NumberPortingStep"; +export { ReviewOrderStep } from "./ReviewOrderStep"; diff --git a/apps/portal/src/features/services/components/sim/configure/types.ts b/apps/portal/src/features/services/components/sim/configure/types.ts new file mode 100644 index 00000000..0fd89389 --- /dev/null +++ b/apps/portal/src/features/services/components/sim/configure/types.ts @@ -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; +} diff --git a/portal-backend.latest.tar.sha256 b/portal-backend.latest.tar.sha256 new file mode 100644 index 00000000..3def9012 --- /dev/null +++ b/portal-backend.latest.tar.sha256 @@ -0,0 +1 @@ +e35685ef6ccb89f970ef919f003aa30b5738452dec6cb9ccdeb82d08dba02b3c /home/barsa/projects/customer_portal/customer-portal/portal-backend.latest.tar diff --git a/portal-frontend.latest.tar.sha256 b/portal-frontend.latest.tar.sha256 new file mode 100644 index 00000000..ff3027e8 --- /dev/null +++ b/portal-frontend.latest.tar.sha256 @@ -0,0 +1 @@ +9f24a647acd92c8e9300eed9eb1a73aceb75713a769e380be02a413fb551e916 /home/barsa/projects/customer_portal/customer-portal/portal-frontend.latest.tar