From 78689da8fb1085f783e63a218ffeb6c01ce816aa Mon Sep 17 00:00:00 2001 From: barsa Date: Tue, 13 Jan 2026 18:41:17 +0900 Subject: [PATCH] feat(address): implement Japan address form with ZIP code lookup - Add JapanAddressForm component for complete Japanese address input. - Integrate ZipCodeInput for automatic address population via Japan Post API. - Create hooks for ZIP code lookup and address service status. - Define address-related types and constants in the domain package. - Document the feature, including environment variables and API endpoints. - Implement mapping functions for WHMCS and Salesforce address formats. --- apps/bff/src/integrations/japanpost/index.ts | 7 + .../japanpost/japanpost.module.ts | 16 + .../services/japanpost-address.service.ts | 84 +++ .../services/japanpost-connection.service.ts | 267 ++++++++++ .../src/modules/address/address.controller.ts | 93 ++++ .../bff/src/modules/address/address.module.ts | 15 + .../src/features/address/api/address.api.ts | 66 +++ apps/portal/src/features/address/api/index.ts | 1 + .../address/components/AddressStepJapan.tsx | 228 +++++++++ .../address/components/JapanAddressForm.tsx | 441 ++++++++++++++++ .../address/components/ZipCodeInput.tsx | 188 +++++++ .../src/features/address/components/index.ts | 7 + .../src/features/address/hooks/index.ts | 6 + .../address/hooks/useAddressLookup.ts | 99 ++++ apps/portal/src/features/address/index.ts | 22 + .../landing-page/views/PublicLandingView.tsx | 477 ++++++++++-------- docs/features/japan-post-address-lookup.md | 206 ++++++++ packages/domain/address/contract.ts | 25 + packages/domain/address/index.ts | 44 ++ packages/domain/address/providers/index.ts | 12 + .../address/providers/japanpost/index.ts | 23 + .../address/providers/japanpost/mapper.ts | 57 +++ .../address/providers/japanpost/raw.types.ts | 96 ++++ packages/domain/address/schema.ts | 193 +++++++ 24 files changed, 2466 insertions(+), 207 deletions(-) create mode 100644 apps/bff/src/integrations/japanpost/index.ts create mode 100644 apps/bff/src/integrations/japanpost/japanpost.module.ts create mode 100644 apps/bff/src/integrations/japanpost/services/japanpost-address.service.ts create mode 100644 apps/bff/src/integrations/japanpost/services/japanpost-connection.service.ts create mode 100644 apps/bff/src/modules/address/address.controller.ts create mode 100644 apps/bff/src/modules/address/address.module.ts create mode 100644 apps/portal/src/features/address/api/address.api.ts create mode 100644 apps/portal/src/features/address/api/index.ts create mode 100644 apps/portal/src/features/address/components/AddressStepJapan.tsx create mode 100644 apps/portal/src/features/address/components/JapanAddressForm.tsx create mode 100644 apps/portal/src/features/address/components/ZipCodeInput.tsx create mode 100644 apps/portal/src/features/address/components/index.ts create mode 100644 apps/portal/src/features/address/hooks/index.ts create mode 100644 apps/portal/src/features/address/hooks/useAddressLookup.ts create mode 100644 apps/portal/src/features/address/index.ts create mode 100644 docs/features/japan-post-address-lookup.md create mode 100644 packages/domain/address/contract.ts create mode 100644 packages/domain/address/index.ts create mode 100644 packages/domain/address/providers/index.ts create mode 100644 packages/domain/address/providers/japanpost/index.ts create mode 100644 packages/domain/address/providers/japanpost/mapper.ts create mode 100644 packages/domain/address/providers/japanpost/raw.types.ts create mode 100644 packages/domain/address/schema.ts diff --git a/apps/bff/src/integrations/japanpost/index.ts b/apps/bff/src/integrations/japanpost/index.ts new file mode 100644 index 00000000..d9c9d674 --- /dev/null +++ b/apps/bff/src/integrations/japanpost/index.ts @@ -0,0 +1,7 @@ +/** + * Japan Post Integration - Public API + */ + +export { JapanPostModule } from "./japanpost.module.js"; +export { JapanPostAddressService } from "./services/japanpost-address.service.js"; +export { JapanPostConnectionService } from "./services/japanpost-connection.service.js"; diff --git a/apps/bff/src/integrations/japanpost/japanpost.module.ts b/apps/bff/src/integrations/japanpost/japanpost.module.ts new file mode 100644 index 00000000..5ff30599 --- /dev/null +++ b/apps/bff/src/integrations/japanpost/japanpost.module.ts @@ -0,0 +1,16 @@ +/** + * Japan Post Module + * + * NestJS module for Japan Post API integration. + * Provides address lookup services. + */ + +import { Module } from "@nestjs/common"; +import { JapanPostConnectionService } from "./services/japanpost-connection.service.js"; +import { JapanPostAddressService } from "./services/japanpost-address.service.js"; + +@Module({ + providers: [JapanPostConnectionService, JapanPostAddressService], + exports: [JapanPostAddressService], +}) +export class JapanPostModule {} diff --git a/apps/bff/src/integrations/japanpost/services/japanpost-address.service.ts b/apps/bff/src/integrations/japanpost/services/japanpost-address.service.ts new file mode 100644 index 00000000..352d4e09 --- /dev/null +++ b/apps/bff/src/integrations/japanpost/services/japanpost-address.service.ts @@ -0,0 +1,84 @@ +/** + * Japan Post Address Service + * + * Address lookup service using Japan Post API. + * Transforms raw API responses to domain types using domain mappers. + */ + +import { + Injectable, + Inject, + BadRequestException, + ServiceUnavailableException, +} from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { extractErrorMessage } from "@bff/core/utils/error.util.js"; +import { JapanPostConnectionService } from "./japanpost-connection.service.js"; +import { JapanPost } from "@customer-portal/domain/address/providers"; +import type { AddressLookupResult } from "@customer-portal/domain/address"; + +@Injectable() +export class JapanPostAddressService { + constructor( + private readonly connection: JapanPostConnectionService, + @Inject(Logger) private readonly logger: Logger + ) {} + + /** + * Lookup address by ZIP code + * + * @param zipCode - ZIP code (with or without hyphen, e.g., "100-0001" or "1000001") + * @returns Domain AddressLookupResult with Japanese and romanized address data + * @throws BadRequestException if ZIP code format is invalid + * @throws ServiceUnavailableException if Japan Post API is unavailable + */ + async lookupByZipCode(zipCode: string): Promise { + // Normalize ZIP code (remove hyphen) + const normalizedZip = zipCode.replace(/-/g, ""); + + // Validate format + if (!/^\d{7}$/.test(normalizedZip)) { + throw new BadRequestException("ZIP code must be 7 digits (e.g., 100-0001)"); + } + + // Check if service is configured + if (!this.connection.isConfigured()) { + this.logger.error("Japan Post API not configured"); + throw new ServiceUnavailableException("Address lookup service is not available"); + } + + try { + const rawResponse = await this.connection.searchByZipCode(normalizedZip); + + // Use domain mapper for transformation (single transformation point) + const result = JapanPost.transformJapanPostSearchResponse(rawResponse); + + this.logger.log("Japan Post address lookup completed", { + zipCode: normalizedZip, + found: result.count > 0, + count: result.count, + }); + + return result; + } catch (error) { + // Re-throw known exceptions + if (error instanceof BadRequestException || error instanceof ServiceUnavailableException) { + throw error; + } + + this.logger.error("Japan Post address lookup failed", { + zipCode: normalizedZip, + error: extractErrorMessage(error), + }); + + throw new ServiceUnavailableException("Failed to lookup address. Please try again."); + } + } + + /** + * Check if the Japan Post service is available + */ + isAvailable(): boolean { + return this.connection.isConfigured(); + } +} diff --git a/apps/bff/src/integrations/japanpost/services/japanpost-connection.service.ts b/apps/bff/src/integrations/japanpost/services/japanpost-connection.service.ts new file mode 100644 index 00000000..2c9469fe --- /dev/null +++ b/apps/bff/src/integrations/japanpost/services/japanpost-connection.service.ts @@ -0,0 +1,267 @@ +/** + * Japan Post Connection Service + * + * HTTP client for Japan Post Digital Address API with OAuth token management. + * + * Required Environment Variables: + * JAPAN_POST_API_URL - Base URL for Japan Post Digital Address API + * JAPAN_POST_CLIENT_ID - OAuth client ID + * JAPAN_POST_CLIENT_SECRET - OAuth client secret + * + * Optional Environment Variables: + * JAPAN_POST_TIMEOUT - Request timeout in ms (default: 10000) + */ + +import { Injectable, Inject, type OnModuleInit } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Logger } from "nestjs-pino"; +import { extractErrorMessage } from "@bff/core/utils/error.util.js"; +import { + japanPostTokenResponseSchema, + type JapanPostTokenResponse, +} from "@customer-portal/domain/address/providers"; + +interface JapanPostConfig { + baseUrl: string; + clientId: string; + clientSecret: string; + timeout: number; +} + +interface ConfigValidationError { + envVar: string; + message: string; +} + +@Injectable() +export class JapanPostConnectionService implements OnModuleInit { + private accessToken: string | null = null; + private tokenExpiresAt: number = 0; + private readonly config: JapanPostConfig; + private readonly configErrors: ConfigValidationError[]; + + constructor( + private readonly configService: ConfigService, + @Inject(Logger) private readonly logger: Logger + ) { + this.config = { + baseUrl: this.configService.get("JAPAN_POST_API_URL") || "", + clientId: this.configService.get("JAPAN_POST_CLIENT_ID") || "", + clientSecret: this.configService.get("JAPAN_POST_CLIENT_SECRET") || "", + timeout: this.configService.get("JAPAN_POST_TIMEOUT") || 10000, + }; + + // Validate configuration + this.configErrors = this.validateConfig(); + } + + /** + * Validate required environment variables + */ + private validateConfig(): ConfigValidationError[] { + const errors: ConfigValidationError[] = []; + + if (!this.config.baseUrl) { + errors.push({ + envVar: "JAPAN_POST_API_URL", + message: "Missing required environment variable JAPAN_POST_API_URL", + }); + } else if (!this.config.baseUrl.startsWith("https://")) { + errors.push({ + envVar: "JAPAN_POST_API_URL", + message: "JAPAN_POST_API_URL must use HTTPS", + }); + } + + if (!this.config.clientId) { + errors.push({ + envVar: "JAPAN_POST_CLIENT_ID", + message: "Missing required environment variable JAPAN_POST_CLIENT_ID", + }); + } + + if (!this.config.clientSecret) { + errors.push({ + envVar: "JAPAN_POST_CLIENT_SECRET", + message: "Missing required environment variable JAPAN_POST_CLIENT_SECRET", + }); + } + + if (this.config.timeout < 1000 || this.config.timeout > 60000) { + errors.push({ + envVar: "JAPAN_POST_TIMEOUT", + message: "JAPAN_POST_TIMEOUT must be between 1000ms and 60000ms", + }); + } + + return errors; + } + + /** + * Log configuration status on module initialization + */ + onModuleInit() { + if (this.configErrors.length > 0) { + this.logger.error( + "Japan Post API configuration is invalid. Address lookup will be unavailable.", + { + errors: this.configErrors, + hint: "Add the required environment variables to your .env file", + } + ); + } else { + this.logger.log("Japan Post API configured successfully", { + baseUrl: this.config.baseUrl.replace(/\/+$/, ""), + timeout: this.config.timeout, + }); + } + } + + /** + * Get configuration validation errors (for health checks) + */ + getConfigErrors(): ConfigValidationError[] { + return this.configErrors; + } + + /** + * Get a valid access token, refreshing if necessary + */ + private async getAccessToken(): Promise { + // Fail fast if not configured + this.assertConfigured(); + + const now = Date.now(); + + // Return cached token if still valid (with 60s buffer) + if (this.accessToken && this.tokenExpiresAt > now + 60000) { + return this.accessToken; + } + + this.logger.debug("Acquiring Japan Post access token"); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.config.timeout); + + try { + const response = await fetch(`${this.config.baseUrl}/api/v1/j/token`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-forwarded-for": "127.0.0.1", // Required by API + }, + body: JSON.stringify({ + grant_type: "client_credentials", + client_id: this.config.clientId, + secret_key: this.config.clientSecret, + }), + signal: controller.signal, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => ""); + throw new Error( + `Token request failed: HTTP ${response.status} ${response.statusText}${errorText ? ` - ${errorText}` : ""}` + ); + } + + const data = (await response.json()) as JapanPostTokenResponse; + const validated = japanPostTokenResponseSchema.parse(data); + + const token = validated.token; + this.accessToken = token; + this.tokenExpiresAt = now + validated.expires_in * 1000; + + this.logger.debug("Japan Post token acquired", { + expiresIn: validated.expires_in, + tokenType: validated.token_type, + }); + + return token; + } catch (error) { + this.logger.error("Failed to acquire Japan Post access token", { + error: extractErrorMessage(error), + }); + throw error; + } finally { + clearTimeout(timeoutId); + } + } + + /** + * Search addresses by ZIP code + * + * @param zipCode - 7-digit ZIP code (no hyphen) + * @returns Raw Japan Post API response + */ + async searchByZipCode(zipCode: string): Promise { + const token = await this.getAccessToken(); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.config.timeout); + + try { + const url = `${this.config.baseUrl}/api/v1/searchcode/${zipCode}`; + + this.logger.debug("Japan Post ZIP code search", { zipCode, url }); + + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + signal: controller.signal, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => ""); + throw new Error( + `ZIP code search failed: HTTP ${response.status}${errorText ? ` - ${errorText}` : ""}` + ); + } + + const data = await response.json(); + + this.logger.debug("Japan Post search response received", { + zipCode, + resultCount: (data as { count?: number }).count, + }); + + return data; + } catch (error) { + this.logger.error("Japan Post ZIP code search failed", { + zipCode, + error: extractErrorMessage(error), + }); + throw error; + } finally { + clearTimeout(timeoutId); + } + } + + /** + * Check if the service is properly configured + */ + isConfigured(): boolean { + return this.configErrors.length === 0; + } + + /** + * Throw an error if configuration is invalid + */ + private assertConfigured(): void { + if (this.configErrors.length > 0) { + const missingVars = this.configErrors.map(e => e.envVar).join(", "); + throw new Error(`Japan Post API is not configured. Missing or invalid: ${missingVars}`); + } + } + + /** + * Clear cached token (for testing or forced refresh) + */ + clearTokenCache(): void { + this.accessToken = null; + this.tokenExpiresAt = 0; + } +} diff --git a/apps/bff/src/modules/address/address.controller.ts b/apps/bff/src/modules/address/address.controller.ts new file mode 100644 index 00000000..bb7a9f4e --- /dev/null +++ b/apps/bff/src/modules/address/address.controller.ts @@ -0,0 +1,93 @@ +/** + * Address Controller + * + * HTTP endpoints for address lookup and management. + */ + +import { + Controller, + Get, + Param, + UseGuards, + UseInterceptors, + ClassSerializerInterceptor, +} from "@nestjs/common"; +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 { JapanPostAddressService } from "@bff/integrations/japanpost/services/japanpost-address.service.js"; +import { + addressLookupResultSchema, + zipCodeLookupRequestSchema, +} from "@customer-portal/domain/address"; + +// ============================================================================ +// DTOs +// ============================================================================ + +/** + * ZIP code parameter DTO + * Validates ZIP code format (with or without hyphen) + */ +class ZipCodeParamDto extends createZodDto(zipCodeLookupRequestSchema) {} + +/** + * Address lookup result DTO + */ +class AddressLookupResultDto extends createZodDto(addressLookupResultSchema) {} + +// ============================================================================ +// Controller +// ============================================================================ + +@Controller("address") +@UseInterceptors(ClassSerializerInterceptor) +export class AddressController { + constructor(private readonly japanPostService: JapanPostAddressService) {} + + /** + * Lookup address by ZIP code + * + * @route GET /api/address/lookup/zip/:zipCode + * @param zipCode - Japanese ZIP code (e.g., "100-0001" or "1000001") + * @returns Address lookup result with Japanese and romanized address data + * + * @example + * GET /api/address/lookup/zip/100-0001 + * Response: + * { + * "zipCode": "1000001", + * "addresses": [ + * { + * "zipCode": "1000001", + * "prefecture": "東京都", + * "city": "千代田区", + * "town": "千代田", + * "prefectureRoma": "Tokyo", + * "cityRoma": "Chiyoda-ku", + * "townRoma": "Chiyoda" + * } + * ], + * "count": 1 + * } + */ + @Public() + @Get("lookup/zip/:zipCode") + @UseGuards(RateLimitGuard) + @RateLimit({ limit: 30, ttl: 60 }) // 30 requests per minute + async lookupByZipCode(@Param() params: ZipCodeParamDto): Promise { + return this.japanPostService.lookupByZipCode(params.zipCode); + } + + /** + * Check if address lookup service is available + * + * @route GET /api/address/status + * @returns Service availability status + */ + @Public() + @Get("status") + getStatus(): { available: boolean } { + return { available: this.japanPostService.isAvailable() }; + } +} diff --git a/apps/bff/src/modules/address/address.module.ts b/apps/bff/src/modules/address/address.module.ts new file mode 100644 index 00000000..6d6d6844 --- /dev/null +++ b/apps/bff/src/modules/address/address.module.ts @@ -0,0 +1,15 @@ +/** + * Address Module + * + * NestJS module for address lookup functionality. + */ + +import { Module } from "@nestjs/common"; +import { AddressController } from "./address.controller.js"; +import { JapanPostModule } from "@bff/integrations/japanpost/japanpost.module.js"; + +@Module({ + imports: [JapanPostModule], + controllers: [AddressController], +}) +export class AddressModule {} diff --git a/apps/portal/src/features/address/api/address.api.ts b/apps/portal/src/features/address/api/address.api.ts new file mode 100644 index 00000000..895a31b6 --- /dev/null +++ b/apps/portal/src/features/address/api/address.api.ts @@ -0,0 +1,66 @@ +/** + * Address Service + * + * Handles address lookup API calls using Japan Post ZIP code lookup. + * Hooks should use this service instead of calling the API directly. + */ + +import { apiClient, getDataOrThrow } from "@/core/api"; +import { + type AddressLookupResult, + addressLookupResultSchema, +} from "@customer-portal/domain/address"; + +// ============================================================================ +// Constants +// ============================================================================ + +const EMPTY_LOOKUP_RESULT: AddressLookupResult = { + zipCode: "", + addresses: [], + count: 0, +}; + +// ============================================================================ +// API Functions +// ============================================================================ + +/** + * Lookup address by ZIP code using Japan Post API + * + * @param zipCode - Japanese ZIP code (e.g., "100-0001" or "1000001") + * @returns Address lookup result with Japanese and romanized address data + */ +async function lookupByZipCode(zipCode: string): Promise { + // Normalize ZIP code (remove hyphen if present) + const normalizedZip = zipCode.replace(/-/g, ""); + + const response = await apiClient.GET("/api/address/lookup/zip/{zipCode}", { + params: { path: { zipCode: normalizedZip } }, + }); + + const data = getDataOrThrow(response, "ZIP code lookup failed"); + return addressLookupResultSchema.parse(data); +} + +/** + * Check if address lookup service is available + * + * @returns Service availability status + */ +async function getStatus(): Promise<{ available: boolean }> { + const response = await apiClient.GET<{ available: boolean }>("/api/address/status"); + return getDataOrThrow(response, "Failed to check address service status"); +} + +// ============================================================================ +// Service Export +// ============================================================================ + +export const addressService = { + lookupByZipCode, + getStatus, +} as const; + +// Re-export constants for use in hooks +export { EMPTY_LOOKUP_RESULT }; diff --git a/apps/portal/src/features/address/api/index.ts b/apps/portal/src/features/address/api/index.ts new file mode 100644 index 00000000..35a392ce --- /dev/null +++ b/apps/portal/src/features/address/api/index.ts @@ -0,0 +1 @@ +export { addressService, EMPTY_LOOKUP_RESULT } from "./address.api"; diff --git a/apps/portal/src/features/address/components/AddressStepJapan.tsx b/apps/portal/src/features/address/components/AddressStepJapan.tsx new file mode 100644 index 00000000..5e21f324 --- /dev/null +++ b/apps/portal/src/features/address/components/AddressStepJapan.tsx @@ -0,0 +1,228 @@ +"use client"; + +/** + * AddressStepJapan - Integration adapter for JapanAddressForm + * + * Maps JapanAddressForm data to the existing address format used by + * SignupForm, AddressCard, and other address consumers. + * + * WHMCS Field Mapping (English): + * - address1 = buildingName + roomNumber (for apartments) + * - address2 = town (romanized street/block) + * - city = city (romanized) + * - state = prefecture (romanized) + * - postcode = ZIP code + * - country = "JP" + * + * Japanese fields are stored separately for Salesforce sync. + */ + +import { useCallback, useEffect, useState } from "react"; +import { JapanAddressForm, type JapanAddressFormData } from "./JapanAddressForm"; +import { + type BilingualAddress, + RESIDENCE_TYPE, + prepareWhmcsAddressFields, +} from "@customer-portal/domain/address"; + +// ============================================================================ +// Types +// ============================================================================ + +/** + * Legacy address data format used by existing forms + * Maps to WHMCS address fields + */ +interface LegacyAddressData { + address1: string; + address2: string; + city: string; + state: string; + postcode: string; + country: string; + countryCode?: string; +} + +/** + * Form interface expected by SignupForm and similar consumers + */ +interface FormInterface { + values: { address: LegacyAddressData }; + errors: Record; + touched: Record; + setValue: (field: string, value: unknown) => void; + setTouchedField: (field: string) => void; +} + +interface AddressStepJapanProps { + form: FormInterface; + /** + * Called when Japanese address data changes. + * Use this to capture Japanese fields for Salesforce sync. + */ + onJapaneseAddressChange?: (data: BilingualAddress) => void; +} + +// ============================================================================ +// Helpers +// ============================================================================ + +/** + * Convert JapanAddressFormData to legacy address format for WHMCS + */ +function toWhmcsFormat(data: JapanAddressFormData): LegacyAddressData { + const whmcsFields = prepareWhmcsAddressFields(data); + + return { + address1: whmcsFields.address1 || "", + address2: whmcsFields.address2 || "", + city: whmcsFields.city || "", + state: whmcsFields.state || "", + postcode: whmcsFields.postcode || "", + country: "JP", + countryCode: "JP", + }; +} + +/** + * Convert legacy address format to JapanAddressFormData + * Used for initializing form from existing data + */ +function fromLegacyFormat(address: LegacyAddressData): Partial { + // Try to parse address1 into building name and room number + // Format: "BuildingName RoomNumber" or just "BuildingName" + const address1Parts = (address.address1 || "").trim(); + const lastSpaceIndex = address1Parts.lastIndexOf(" "); + + let buildingName = address1Parts; + let roomNumber = ""; + + // If there's a space and the last part looks like a room number (e.g., "201", "1F", etc.) + if (lastSpaceIndex > 0) { + const potentialRoom = address1Parts.slice(lastSpaceIndex + 1); + if (/^[0-9A-Z]+$/i.test(potentialRoom) && potentialRoom.length <= 10) { + buildingName = address1Parts.slice(0, lastSpaceIndex); + roomNumber = potentialRoom; + } + } + + // Determine residence type based on whether we have a room number + const residenceType = roomNumber ? RESIDENCE_TYPE.APARTMENT : RESIDENCE_TYPE.HOUSE; + + return { + postcode: address.postcode || "", + prefecture: address.state || "", + city: address.city || "", + town: address.address2 || "", + buildingName: buildingName || "", + roomNumber: roomNumber || "", + residenceType, + // Japanese fields are not available from legacy format + prefectureJa: "", + cityJa: "", + townJa: "", + }; +} + +// ============================================================================ +// Component +// ============================================================================ + +export function AddressStepJapan({ form, onJapaneseAddressChange }: AddressStepJapanProps) { + const { values, errors, touched, setValue, setTouchedField } = form; + const address = values.address; + + // Track Japan address form data separately + const [japanData, setJapanData] = useState(() => ({ + postcode: address.postcode || "", + prefecture: address.state || "", + city: address.city || "", + town: address.address2 || "", + buildingName: "", + roomNumber: "", + residenceType: RESIDENCE_TYPE.APARTMENT, + prefectureJa: "", + cityJa: "", + townJa: "", + ...fromLegacyFormat(address), + })); + + // Extract address field errors + const getError = (field: string): string | undefined => { + const key = `address.${field}`; + return touched[key] || touched.address ? (errors[key] ?? errors[field]) : undefined; + }; + + // Map Japan address field names to legacy field names for error display + const japanFieldErrors: Partial> = { + postcode: getError("postcode"), + prefecture: getError("state"), + city: getError("city"), + town: getError("address2"), + buildingName: getError("address1"), + roomNumber: getError("address1"), + }; + + // Map touched fields + const japanFieldTouched: Partial> = { + postcode: touched["address.postcode"] || touched.address, + prefecture: touched["address.state"] || touched.address, + city: touched["address.city"] || touched.address, + town: touched["address.address2"] || touched.address, + buildingName: touched["address.address1"] || touched.address, + roomNumber: touched["address.address1"] || touched.address, + }; + + // Handle Japan address form changes + const handleJapanAddressChange = useCallback( + (data: JapanAddressFormData, _isComplete: boolean) => { + setJapanData(data); + + // Convert to legacy format and update parent form + const legacyAddress = toWhmcsFormat(data); + setValue("address", legacyAddress); + + // Notify parent of Japanese address data for Salesforce + onJapaneseAddressChange?.(data); + }, + [setValue, onJapaneseAddressChange] + ); + + // Handle field blur - mark as touched in parent form + const handleBlur = useCallback( + (field: keyof JapanAddressFormData) => { + // Map Japan field to legacy field for touched tracking + const fieldMap: Record = { + postcode: "address.postcode", + prefecture: "address.state", + city: "address.city", + town: "address.address2", + buildingName: "address.address1", + roomNumber: "address.address1", + }; + + const legacyField = fieldMap[field]; + if (legacyField) { + setTouchedField(legacyField); + } + }, + [setTouchedField] + ); + + // Set Japan as default country on mount if empty + useEffect(() => { + if (!address.country) { + setValue("address", { ...address, country: "JP", countryCode: "JP" }); + } + }, [address, setValue]); + + return ( + + ); +} diff --git a/apps/portal/src/features/address/components/JapanAddressForm.tsx b/apps/portal/src/features/address/components/JapanAddressForm.tsx new file mode 100644 index 00000000..942aa698 --- /dev/null +++ b/apps/portal/src/features/address/components/JapanAddressForm.tsx @@ -0,0 +1,441 @@ +"use client"; + +/** + * JapanAddressForm - Complete Japanese address form with ZIP code lookup + * + * Features: + * - ZIP code lookup via Japan Post API (required) + * - Auto-fill prefecture, city, town from ZIP (read-only) + * - Progressive disclosure: residence type after ZIP, building fields after type selection + * - House/Apartment toggle with conditional room number + * - Captures both Japanese and English (romanized) addresses + * - Compatible with WHMCS and Salesforce field mapping + */ + +import { useCallback, useState, useEffect } from "react"; +import { Home, Building2, CheckCircle } from "lucide-react"; +import { Input } from "@/components/atoms"; +import { FormField } from "@/components/molecules/FormField/FormField"; +import { cn } from "@/shared/utils"; +import { ZipCodeInput } from "./ZipCodeInput"; +import { + type BilingualAddress, + type JapanPostAddress, + RESIDENCE_TYPE, + type ResidenceType, +} from "@customer-portal/domain/address"; + +// ============================================================================ +// Types +// ============================================================================ + +export type JapanAddressFormData = BilingualAddress; + +export interface JapanAddressFormProps { + /** Initial address values */ + initialValues?: Partial; + /** Called when any address field changes */ + onChange?: (address: JapanAddressFormData, isComplete: boolean) => void; + /** Field-level errors (keyed by field name) */ + errors?: Partial>; + /** Fields that have been touched */ + touched?: Partial>; + /** Mark a field as touched */ + onBlur?: (field: keyof JapanAddressFormData) => void; + /** Whether the form is disabled */ + disabled?: boolean; + /** Custom class name for container */ + className?: string; +} + +// ============================================================================ +// Default Values +// ============================================================================ + +const DEFAULT_ADDRESS: Omit & { + residenceType: ResidenceType | ""; +} = { + postcode: "", + prefecture: "", + prefectureJa: "", + city: "", + cityJa: "", + town: "", + townJa: "", + buildingName: "", + roomNumber: "", + residenceType: "", // User must explicitly choose +}; + +// ============================================================================ +// Component +// ============================================================================ + +// Internal form state allows empty residenceType for "not selected yet" +type InternalFormState = Omit & { + residenceType: ResidenceType | ""; +}; + +export function JapanAddressForm({ + initialValues, + onChange, + errors = {}, + touched = {}, + onBlur, + disabled = false, + className, +}: JapanAddressFormProps) { + // Form state - residenceType can be empty until user selects + const [address, setAddress] = useState(() => ({ + ...DEFAULT_ADDRESS, + ...initialValues, + })); + + // Track if ZIP lookup has verified the address (required for form completion) + const [isAddressVerified, setIsAddressVerified] = useState(false); + + // Track the ZIP code that was last looked up (to detect changes) + const [verifiedZipCode, setVerifiedZipCode] = useState(""); + + // Update address when initialValues change + useEffect(() => { + if (initialValues) { + setAddress(prev => ({ ...prev, ...initialValues })); + // If initialValues have address data, consider it verified + if (initialValues.prefecture && initialValues.city && initialValues.town) { + setIsAddressVerified(true); + setVerifiedZipCode(initialValues.postcode || ""); + } + } + }, [initialValues]); + + // Get error for a field (only show if touched) + const getError = (field: keyof JapanAddressFormData): string | undefined => { + return touched[field] ? errors[field] : undefined; + }; + + // Notify parent of address changes with completeness check + const notifyChange = useCallback( + (next: InternalFormState, verified: boolean) => { + const hasResidenceType = + next.residenceType === RESIDENCE_TYPE.HOUSE || + next.residenceType === RESIDENCE_TYPE.APARTMENT; + + const baseFieldsFilled = + next.postcode.trim() !== "" && + next.prefecture.trim() !== "" && + next.city.trim() !== "" && + next.town.trim() !== ""; + + // Room number is required for apartments + const roomNumberOk = + next.residenceType !== RESIDENCE_TYPE.APARTMENT || (next.roomNumber?.trim() ?? "") !== ""; + + // Must have verified address from ZIP lookup + const isComplete = verified && hasResidenceType && baseFieldsFilled && roomNumberOk; + + onChange?.(next as JapanAddressFormData, isComplete); + }, + [onChange] + ); + + // Handle ZIP code change - reset verification when ZIP changes + const handleZipChange = useCallback( + (value: string) => { + const normalizedNew = value.replace(/-/g, ""); + const normalizedVerified = verifiedZipCode.replace(/-/g, ""); + + setAddress(prev => { + // If ZIP code changed from verified one, reset address fields + if (normalizedNew !== normalizedVerified) { + const next: InternalFormState = { + ...prev, + postcode: value, + prefecture: "", + prefectureJa: "", + city: "", + cityJa: "", + town: "", + townJa: "", + // Keep user-entered fields + buildingName: prev.buildingName, + roomNumber: prev.roomNumber, + residenceType: prev.residenceType, + }; + setIsAddressVerified(false); + notifyChange(next, false); + return next; + } + + // Just update postcode formatting + const next = { ...prev, postcode: value }; + notifyChange(next, isAddressVerified); + return next; + }); + }, + [verifiedZipCode, isAddressVerified, notifyChange] + ); + + // Handle address found from ZIP lookup + const handleAddressFound = useCallback( + (found: JapanPostAddress) => { + setAddress(prev => { + const next: InternalFormState = { + ...prev, + // English (romanized) fields - for WHMCS + prefecture: found.prefectureRoma, + city: found.cityRoma, + town: found.townRoma, + // Japanese fields - for Salesforce + prefectureJa: found.prefecture, + cityJa: found.city, + townJa: found.town, + }; + + setIsAddressVerified(true); + setVerifiedZipCode(prev.postcode); + notifyChange(next, true); + return next; + }); + }, + [notifyChange] + ); + + // Handle lookup completion (success or failure) + const handleLookupComplete = useCallback( + (found: boolean) => { + if (!found) { + // Clear address fields on failed lookup + setAddress(prev => { + const next: InternalFormState = { + ...prev, + prefecture: "", + prefectureJa: "", + city: "", + cityJa: "", + town: "", + townJa: "", + }; + setIsAddressVerified(false); + notifyChange(next, false); + return next; + }); + } + }, + [notifyChange] + ); + + // Handle residence type change + const handleResidenceTypeChange = useCallback( + (type: ResidenceType) => { + setAddress(prev => { + const next: InternalFormState = { + ...prev, + residenceType: type, + // Clear room number when switching to house + roomNumber: type === RESIDENCE_TYPE.HOUSE ? "" : prev.roomNumber, + }; + notifyChange(next, isAddressVerified); + return next; + }); + }, + [isAddressVerified, notifyChange] + ); + + // Handle building name change + const handleBuildingNameChange = useCallback( + (value: string) => { + setAddress(prev => { + const next = { ...prev, buildingName: value }; + notifyChange(next, isAddressVerified); + return next; + }); + }, + [isAddressVerified, notifyChange] + ); + + // Handle room number change + const handleRoomNumberChange = useCallback( + (value: string) => { + setAddress(prev => { + const next = { ...prev, roomNumber: value }; + notifyChange(next, isAddressVerified); + return next; + }); + }, + [isAddressVerified, notifyChange] + ); + + const isApartment = address.residenceType === RESIDENCE_TYPE.APARTMENT; + const hasResidenceTypeSelected = + address.residenceType === RESIDENCE_TYPE.HOUSE || + address.residenceType === RESIDENCE_TYPE.APARTMENT; + + return ( +
+ {/* ZIP Code with auto-lookup */} + + + {/* Address fields - Read-only, populated by ZIP lookup */} +
+ {isAddressVerified && ( +
+ + Address verified +
+ )} + + {/* Prefecture - Read-only */} + + + + + {/* City/Ward - Read-only */} + + + + + {/* Town - Read-only */} + + + +
+ + {/* Residence Type Toggle - Only show after address is verified */} + {isAddressVerified && ( +
+ +
+ + +
+ {!hasResidenceTypeSelected && getError("residenceType") && ( +

{getError("residenceType")}

+ )} +
+ )} + + {/* Building fields - Only show after residence type is selected */} + {isAddressVerified && hasResidenceTypeSelected && ( + <> + {/* Building Name */} + + handleBuildingNameChange(e.target.value)} + onBlur={() => onBlur?.("buildingName")} + placeholder="Gramercy Heights" + disabled={disabled} + data-field="address.buildingName" + /> + + + {/* Room Number - Only for apartments */} + {isApartment && ( + + handleRoomNumberChange(e.target.value)} + onBlur={() => onBlur?.("roomNumber")} + placeholder="201" + disabled={disabled} + data-field="address.roomNumber" + /> + + )} + + )} +
+ ); +} diff --git a/apps/portal/src/features/address/components/ZipCodeInput.tsx b/apps/portal/src/features/address/components/ZipCodeInput.tsx new file mode 100644 index 00000000..25a1e999 --- /dev/null +++ b/apps/portal/src/features/address/components/ZipCodeInput.tsx @@ -0,0 +1,188 @@ +"use client"; + +/** + * ZipCodeInput - Japanese ZIP code input with auto-lookup + * + * Handles ZIP code formatting (XXX-XXXX) and triggers address lookup + * when a valid 7-digit ZIP code is entered. + */ + +import { useCallback, useEffect, useState } from "react"; +import { Search, Loader2, CheckCircle, XCircle } from "lucide-react"; +import { Input } from "@/components/atoms"; +import { FormField } from "@/components/molecules/FormField/FormField"; +import { cn } from "@/shared/utils"; +import { formatJapanesePostalCode } from "@/shared/constants"; +import { useZipCodeLookup } from "../hooks"; +import type { JapanPostAddress } from "@customer-portal/domain/address"; + +export interface ZipCodeInputProps { + /** Current ZIP code value */ + value: string; + /** Called when ZIP code changes */ + onChange: (value: string) => void; + /** Called when address is found from ZIP lookup */ + onAddressFound?: (address: JapanPostAddress) => void; + /** Called when lookup completes (found or not found) */ + onLookupComplete?: (found: boolean, addresses: JapanPostAddress[]) => void; + /** Field error message */ + error?: string; + /** Whether the field is required */ + required?: boolean; + /** Whether the input is disabled */ + disabled?: boolean; + /** Auto-focus the input */ + autoFocus?: boolean; + /** Custom label */ + label?: string; + /** Helper text below input */ + helperText?: string; + /** Whether to auto-trigger lookup on valid ZIP */ + autoLookup?: boolean; + /** Debounce delay for auto-lookup (ms) */ + debounceMs?: number; +} + +export function ZipCodeInput({ + value, + onChange, + onAddressFound, + onLookupComplete, + error, + required = true, + disabled = false, + autoFocus = false, + label = "Postal Code", + helperText = "Format: XXX-XXXX", + autoLookup = true, + debounceMs = 500, +}: ZipCodeInputProps) { + // Track debounced ZIP code for lookup + const [debouncedZip, setDebouncedZip] = useState(""); + + // ZIP code lookup hook + const { + data: lookupResult, + isLoading, + isError, + error: lookupError, + } = useZipCodeLookup(autoLookup ? debouncedZip : undefined); + + // Normalize ZIP code (remove hyphen) for validation + const normalizedZip = value.replace(/-/g, ""); + const isValidFormat = /^\d{7}$/.test(normalizedZip); + + // Handle input change with formatting + const handleChange = useCallback( + (e: React.ChangeEvent) => { + const formatted = formatJapanesePostalCode(e.target.value); + onChange(formatted); + }, + [onChange] + ); + + // Debounce ZIP code for auto-lookup + useEffect(() => { + if (!autoLookup || !isValidFormat) { + setDebouncedZip(""); + return; + } + + const timer = setTimeout(() => { + setDebouncedZip(normalizedZip); + }, debounceMs); + + return () => clearTimeout(timer); + }, [normalizedZip, isValidFormat, autoLookup, debounceMs]); + + // Handle lookup result + useEffect(() => { + if (!lookupResult) return; + + const addresses = lookupResult.addresses; + const hasAddresses = addresses.length > 0; + + // Notify parent of lookup completion + onLookupComplete?.(hasAddresses, addresses); + + // If addresses found, notify with first result + if (hasAddresses && addresses[0]) { + onAddressFound?.(addresses[0]); + } + }, [lookupResult, onAddressFound, onLookupComplete]); + + // Determine status icon + const renderStatusIcon = () => { + if (isLoading) { + return ; + } + + if (isValidFormat && lookupResult) { + if (lookupResult.count > 0) { + return ; + } + return ; + } + + return ; + }; + + // Generate helper text based on state + const getHelperText = () => { + if (isLoading) { + return "Looking up address..."; + } + + if (isError && lookupError) { + return "Failed to lookup address. Please try again."; + } + + if (isValidFormat && lookupResult) { + if (lookupResult.count === 0) { + return "We couldn't find an address for this ZIP code. Please check and try again."; + } + if (lookupResult.count === 1) { + return "Address found!"; + } + return `Found ${lookupResult.count} addresses`; + } + + return helperText; + }; + + const computedHelperText = getHelperText(); + const hasLookupSuccess = isValidFormat && lookupResult && lookupResult.count > 0; + const hasLookupWarning = isValidFormat && lookupResult && lookupResult.count === 0; + + return ( + +
+ +
+ {renderStatusIcon()} +
+
+
+ ); +} diff --git a/apps/portal/src/features/address/components/index.ts b/apps/portal/src/features/address/components/index.ts new file mode 100644 index 00000000..81f81376 --- /dev/null +++ b/apps/portal/src/features/address/components/index.ts @@ -0,0 +1,7 @@ +export { ZipCodeInput, type ZipCodeInputProps } from "./ZipCodeInput"; +export { + JapanAddressForm, + type JapanAddressFormProps, + type JapanAddressFormData, +} from "./JapanAddressForm"; +export { AddressStepJapan } from "./AddressStepJapan"; diff --git a/apps/portal/src/features/address/hooks/index.ts b/apps/portal/src/features/address/hooks/index.ts new file mode 100644 index 00000000..4223fd6e --- /dev/null +++ b/apps/portal/src/features/address/hooks/index.ts @@ -0,0 +1,6 @@ +export { + useZipCodeLookup, + useAddressServiceStatus, + getFirstAddress, + EMPTY_LOOKUP_RESULT, +} from "./useAddressLookup"; diff --git a/apps/portal/src/features/address/hooks/useAddressLookup.ts b/apps/portal/src/features/address/hooks/useAddressLookup.ts new file mode 100644 index 00000000..bd720699 --- /dev/null +++ b/apps/portal/src/features/address/hooks/useAddressLookup.ts @@ -0,0 +1,99 @@ +"use client"; + +import { useQuery, type UseQueryOptions, type UseQueryResult } from "@tanstack/react-query"; + +import { queryKeys } from "@/core/api"; +import { type AddressLookupResult } from "@customer-portal/domain/address"; +import { addressService, EMPTY_LOOKUP_RESULT } from "../api/address.api"; + +// ============================================================================ +// Type Helpers +// ============================================================================ + +type ZipLookupQueryKey = ReturnType; +type StatusQueryKey = ReturnType; + +type ZipLookupQueryOptions = Omit< + UseQueryOptions, + "queryKey" | "queryFn" +>; + +type StatusQueryOptions = Omit< + UseQueryOptions<{ available: boolean }, Error, { available: boolean }, StatusQueryKey>, + "queryKey" | "queryFn" +>; + +// ============================================================================ +// Hooks +// ============================================================================ + +/** + * Hook for looking up address by ZIP code + * + * @param zipCode - Japanese ZIP code (7 digits, with or without hyphen) + * @param options - React Query options + * @returns Query result with address data + * + * @example + * ```tsx + * const { data, isLoading, error } = useZipCodeLookup("100-0001"); + * + * if (data?.addresses.length > 0) { + * const address = data.addresses[0]; + * console.log(address.prefecture, address.city, address.town); + * } + * ``` + */ +export function useZipCodeLookup( + zipCode: string | undefined, + options?: ZipLookupQueryOptions +): UseQueryResult { + // Normalize ZIP code for validation + const normalizedZip = zipCode?.replace(/-/g, "") ?? ""; + const isValidZip = /^\d{7}$/.test(normalizedZip); + + return useQuery({ + queryKey: queryKeys.address.zipLookup(normalizedZip), + queryFn: () => addressService.lookupByZipCode(normalizedZip), + // Only enable when we have a valid 7-digit ZIP code + enabled: Boolean(zipCode) && isValidZip, + // Cache results for 5 minutes (ZIP code data rarely changes) + staleTime: 5 * 60 * 1000, + // Don't retry on 404s (invalid ZIP code) + retry: (failureCount, error) => { + if (error.message.includes("404") || error.message.includes("not found")) { + return false; + } + return failureCount < 2; + }, + ...options, + }); +} + +/** + * Hook for checking address service availability + * + * @param options - React Query options + * @returns Query result with service status + */ +export function useAddressServiceStatus( + options?: StatusQueryOptions +): UseQueryResult<{ available: boolean }, Error> { + return useQuery({ + queryKey: queryKeys.address.status(), + queryFn: addressService.getStatus, + staleTime: 60 * 1000, // Check every minute at most + ...options, + }); +} + +/** + * Get the first address from lookup result + * Convenience helper for single-address selections + */ +export function getFirstAddress(result: AddressLookupResult | undefined) { + return result?.addresses[0] ?? null; +} + +// Re-export empty result for components that need a default value +export { EMPTY_LOOKUP_RESULT }; diff --git a/apps/portal/src/features/address/index.ts b/apps/portal/src/features/address/index.ts new file mode 100644 index 00000000..12721f3e --- /dev/null +++ b/apps/portal/src/features/address/index.ts @@ -0,0 +1,22 @@ +/** + * Address Feature + * + * Provides ZIP code lookup and address management functionality + * using Japan Post Digital Address API. + */ + +// API +export { addressService, EMPTY_LOOKUP_RESULT } from "./api"; + +// Hooks +export { useZipCodeLookup, useAddressServiceStatus, getFirstAddress } from "./hooks"; + +// Components +export { + ZipCodeInput, + JapanAddressForm, + AddressStepJapan, + type ZipCodeInputProps, + type JapanAddressFormProps, + type JapanAddressFormData, +} from "./components"; diff --git a/apps/portal/src/features/landing-page/views/PublicLandingView.tsx b/apps/portal/src/features/landing-page/views/PublicLandingView.tsx index 3d362b37..36b78b90 100644 --- a/apps/portal/src/features/landing-page/views/PublicLandingView.tsx +++ b/apps/portal/src/features/landing-page/views/PublicLandingView.tsx @@ -6,265 +6,328 @@ import { Building2, Wrench, Tv, - CheckCircle, + CheckCircle2, Headphones, - Home, - Sparkles, Globe, + Calendar, + Users, + Shield, + Phone, } from "lucide-react"; import Link from "next/link"; -import { CtaButton, ServiceCard, TrustIndicators } from "../components"; +import { cn } from "@/shared/utils"; /** - * PublicLandingView - Premium Landing Page + * PublicLandingView - Clean Landing Page * - * Design Direction: "Clean, Modern & Trustworthy" - * - Centered hero with gradient accents - * - Trust-forward design for expat audience - * - Clear value propositions - * - Balanced service showcase + * Design Direction: Matches services page style + * - Clean, centered layout + * - Consistent card styling with colored accents + * - Simple value propositions */ -export function PublicLandingView() { - const concepts = [ - { - icon: CheckCircle, - title: "One Stop Solution", - description: "All you need is just to contact us and we will take care of everything.", - }, - { - icon: Headphones, - title: "English Support", - description: "We always assist you in English. No language barrier to worry about.", - }, - { - icon: Home, - title: "Onsite Support", - description: "Our tech staff can visit your residence for setup and troubleshooting.", - }, - ]; - const services = [ - { - href: "/services/internet", - icon: Wifi, - title: "Internet", - description: "Fiber optic connections up to 10Gbps with full English support.", - highlight: "From ¥4,950/mo", - }, - { - href: "/services/sim", - icon: Smartphone, - title: "SIM & eSIM", - description: "Flexible mobile plans with data-only and voice options.", - highlight: "From ¥990/mo", - }, - { - href: "/services/vpn", - icon: ShieldCheck, - title: "VPN Services", - description: "Secure access to streaming content from your home country.", - highlight: "Netflix US & more", - }, - { - href: "/services/business", - icon: Building2, - title: "Business Solutions", - description: "Enterprise connectivity and IT infrastructure for companies.", - }, - { - href: "/services/onsite", - icon: Wrench, - title: "Onsite Support", - description: "Professional technicians visit your location for setup.", - }, - { - href: "/services/tv", - icon: Tv, - title: "TV Services", - description: "International TV packages with channels worldwide.", - }, - ]; +interface ServiceCardProps { + href: string; + icon: React.ReactNode; + title: string; + description: string; + price?: string; + badge?: string; + accentColor?: "blue" | "green" | "purple" | "orange" | "cyan" | "pink"; +} + +function ServiceCard({ + href, + icon, + title, + description, + price, + badge, + accentColor = "blue", +}: ServiceCardProps) { + const accentStyles = { + blue: "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20", + green: "bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20", + purple: "bg-purple-500/10 text-purple-600 dark:text-purple-400 border-purple-500/20", + orange: "bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/20", + cyan: "bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 border-cyan-500/20", + pink: "bg-pink-500/10 text-pink-600 dark:text-pink-400 border-pink-500/20", + }; return ( -
- {/* ===== HERO SECTION - CENTERED ===== */} -
- {/* Decorative gradient blurs */} -
-
-
+ +
+ {badge && ( + + {badge} + + )} -
- {/* Badge */} +
- - Trusted by 10,000+ customers in Japan + {icon}
- - {/* Headline */} -

- Your One Stop Solution -
- - for IT in Japan - -

- - {/* Subtitle */} -

- Serving Japan's international community with reliable, English-supported internet, - mobile, and VPN solutions — for over 20 years. -

- - {/* CTAs */} -
- - Browse Services - - - - Contact Us - +
+

{title}

+ {price && ( + + From {price} + + )}
+
- {/* Trust indicators */} -
{description}

+ +
+ Learn more + +
+
+ + ); +} + +export function PublicLandingView() { + return ( +
+ {/* ===== HERO SECTION ===== */} +
+ + + 20+ Years Serving Japan + + +

+ Your One Stop Solution +
+ for Connectivity in Japan +

+ +

+ Full English support for all your connectivity needs — from setup to billing to technical + assistance. +

+ + {/* CTAs */} +
+ - + Browse Services + + + + Contact Us + +
+ + {/* Trust Stats */} +
+
+
+ +
+
+
20+
+
Years in Japan
+
+
+
+
+ +
+
+
10,000+
+
Customers Served
+
+
+
+
+ +
+
+
NTT
+
Authorized Partner
+
{/* ===== WHY CHOOSE US ===== */}
-
-

+

+

Why Choose Us -

-

- Built for the international community

-

+

We understand the unique challenges of living in Japan as an expat.

-
- {concepts.map((concept, idx) => ( -
- {/* Gradient hover effect */} -
- -
-
- -
-

- {concept.title} -

-

{concept.description}

-
+
+
+
+
- ))} +
+
One Stop Solution
+
We handle everything for you
+
+
+
+
+ +
+
+
English Support
+
No language barrier
+
+
+
+
+ +
+
+
Onsite Support
+
We come to you
+
+
{/* ===== OUR SERVICES ===== */}
-
-

+

+

Our Services -

-

- Everything you need to stay connected

-

- From high-speed internet to mobile plans, we've got you covered. +

+ Connectivity and support solutions for Japan's international community.

- {/* Services Grid - 3x2 */} -
- {services.map((service, idx) => ( - - ))} + {/* Value Props */} +
+
+ + One provider, all services +
+
+ + English support +
+
+ + No hidden fees +
-
+ {/* Services Grid */} +
+ } + title="Internet" + description="NTT Optical Fiber for homes and apartments. Speeds up to 10Gbps with professional installation." + price="¥3,200/mo" + accentColor="blue" + /> + + } + title="SIM & eSIM" + description="Data, voice & SMS on NTT Docomo network. Physical SIM or instant eSIM activation." + price="¥1,100/mo" + badge="1st month free" + accentColor="green" + /> + + } + title="VPN Router" + description="Access US & UK streaming content with a pre-configured router. Simple plug-and-play." + price="¥2,500/mo" + accentColor="purple" + /> + + } + title="Business" + description="Enterprise solutions for offices and commercial spaces. Dedicated support and SLAs." + accentColor="orange" + /> + + } + title="Onsite Support" + description="Professional technicians visit your location for setup, troubleshooting, and maintenance." + accentColor="cyan" + /> + + } + title="TV" + description="Streaming TV packages with international channels. Watch content from home countries." + accentColor="pink" + /> +
+ +
- - Explore all services + View all services
- {/* ===== FINAL CTA ===== */} -
- {/* Gradient background */} -
+ {/* ===== CTA ===== */} +
+

+ Ready to get connected? +

+

+ Our bilingual team is here to help you find the right solution for your needs. +

- {/* Pattern overlay */} -
- -
-

- Ready to get connected? -

-

- Contact us anytime — our bilingual team is here to help you find the right solution for - your needs. -

- -
- - Contact Us - - - - Browse Services - -
+
diff --git a/docs/features/japan-post-address-lookup.md b/docs/features/japan-post-address-lookup.md new file mode 100644 index 00000000..b44b2f2b --- /dev/null +++ b/docs/features/japan-post-address-lookup.md @@ -0,0 +1,206 @@ +# Japan Post ZIP Code Address Lookup + +This feature provides Japanese address auto-completion using the Japan Post Digital Address API. When users enter a ZIP code, the system automatically looks up and populates prefecture, city, and town fields in both Japanese and romanized (English) formats. + +## Environment Variables + +Add these to your `.env` file: + +```bash +# Japan Post Digital Address API +JAPAN_POST_API_URL=https://api.da.posta.japanpost.jp +JAPAN_POST_CLIENT_ID=your_client_id +JAPAN_POST_CLIENT_SECRET=your_client_secret +JAPAN_POST_TIMEOUT=10000 # Optional, defaults to 10000ms +``` + +Contact Japan Post to obtain API credentials for the Digital Address service. + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Portal (Next.js) │ +├─────────────────────────────────────────────────────────────────────┤ +│ ZipCodeInput ─► useZipCodeLookup ─► addressService.lookupByZipCode │ +│ │ │ │ +│ ▼ ▼ │ +│ JapanAddressForm GET /api/address/lookup/zip/:zip│ +└─────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ BFF (NestJS) │ +├─────────────────────────────────────────────────────────────────────┤ +│ AddressController ─► JapanPostAddressService │ +│ │ │ │ +│ │ ▼ │ +│ │ JapanPostConnectionService │ +│ │ (OAuth token caching) │ +│ │ │ │ +│ ▼ ▼ │ +│ PATCH /me/address/bilingual Japan Post API │ +│ │ │ +│ ▼ │ +│ UserProfileService.updateBilingualAddress() │ +│ │ │ +│ ├──► WHMCS (English address - source of truth) │ +│ └──► Salesforce (Japanese address - secondary) │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +## API Endpoints + +### Public Endpoints (No Auth Required) + +| Endpoint | Method | Description | Rate Limit | +| ---------------------------------- | ------ | --------------------------- | ---------- | +| `/api/address/lookup/zip/:zipCode` | GET | Look up address by ZIP code | 30/min | +| `/api/address/status` | GET | Check service availability | - | + +### Authenticated Endpoints + +| Endpoint | Method | Description | +| --------------------------- | ------ | ---------------------------------------------------- | +| `/api/me/address/bilingual` | PATCH | Update address with dual-write to WHMCS + Salesforce | + +## Field Mappings + +### WHMCS (English/Romanized) + +| WHMCS Field | Source | +| ----------- | ------------------------------------------------------------------ | +| `address1` | `{buildingName} {roomNumber}` (for apartments) or `{buildingName}` | +| `address2` | `town` (romanized street/block) | +| `city` | `city` (romanized) | +| `state` | `prefecture` (romanized) | +| `postcode` | ZIP code | +| `country` | "JP" | + +### Salesforce Contact (Japanese) + +| Salesforce Field | Source | +| ------------------- | ------------------------- | +| `MailingStreet` | `townJa` (Japanese) | +| `MailingCity` | `cityJa` (Japanese) | +| `MailingState` | `prefectureJa` (Japanese) | +| `MailingPostalCode` | ZIP code | +| `MailingCountry` | "Japan" | +| `BuildingName__c` | Building name (English) | +| `RoomNumber__c` | Room number | + +## Components + +### ZipCodeInput + +Auto-lookup ZIP code input with visual feedback. + +```tsx +import { ZipCodeInput } from "@/features/address"; + + { + // address contains both Japanese and romanized fields + console.log(address.prefecture, address.prefectureJa); + }} +/>; +``` + +### JapanAddressForm + +Complete address form with residence type selection. + +```tsx +import { JapanAddressForm } from "@/features/address"; + + { + // data is BilingualAddress type + // isComplete is true when all required fields are filled + }} +/>; +``` + +### AddressStepJapan + +Drop-in replacement for signup/registration flows. + +```tsx +import { AddressStepJapan } from "@/features/address"; + + { + // Capture Japanese fields for Salesforce sync + }} +/>; +``` + +## Dual-Write Behavior + +When updating addresses via `PATCH /me/address/bilingual`: + +1. **WHMCS Update (Blocking)**: English address is written to WHMCS as source of truth +2. **Salesforce Update (Non-Blocking)**: Japanese address is written to Salesforce + - If Salesforce update fails, it's logged but doesn't fail the request + - If no Salesforce mapping exists, update is skipped silently + +## Residence Type + +Users select either: + +- **House**: Building name optional, no room number +- **Apartment/Mansion**: Building name optional, room number required + +The `address1` field format changes based on residence type: + +- House: `{buildingName}` or empty +- Apartment: `{buildingName} {roomNumber}` + +## Manual Address Entry + +If users skip ZIP code lookup and enter addresses manually: + +| Scenario | WHMCS | Salesforce | +| --------------------------- | --------------------- | ------------------------------------- | +| ZIP lookup used | English fields filled | Japanese fields filled | +| Manual entry (no ZIP) | English fields filled | **Japanese fields empty** | +| Manual then ZIP lookup used | English updated | Japanese fields populated from lookup | + +**Important**: Manual address entry will work for WHMCS but Salesforce will receive empty Japanese address fields. The system does NOT perform reverse lookups on manually entered addresses. + +**Recommendation**: Encourage users to enter ZIP code first to get proper Japanese address data for Salesforce. + +## Residence Type Selection + +Users must explicitly choose their residence type: + +- **House**: Building name optional, no room number field shown +- **Apartment/Mansion**: Building name optional, room number **required** + +The form does not default to either option - users must select one to proceed. + +## Configuration Validation + +On BFF startup, the service validates environment variables and logs errors: + +``` +# Missing configuration example log: +ERROR Japan Post API configuration is invalid. Address lookup will be unavailable. + errors: [ + { envVar: "JAPAN_POST_API_URL", message: "Missing required environment variable" }, + { envVar: "JAPAN_POST_CLIENT_ID", message: "Missing required environment variable" } + ] + hint: "Add the required environment variables to your .env file" +``` + +The service will throw clear errors if called without proper configuration. + +## Error Handling + +- Invalid ZIP codes return empty results (no error) +- Japan Post API timeouts return 500 error +- Rate limiting returns 429 with retry-after header +- Missing/invalid configuration throws descriptive error on API call diff --git a/packages/domain/address/contract.ts b/packages/domain/address/contract.ts new file mode 100644 index 00000000..110e31d2 --- /dev/null +++ b/packages/domain/address/contract.ts @@ -0,0 +1,25 @@ +/** + * Address Domain - Contract + * + * Constants and provider-agnostic types for address lookup. + */ + +/** + * Supported residence types for Japanese addresses + */ +export const RESIDENCE_TYPE = { + HOUSE: "house", + APARTMENT: "apartment", +} as const; + +export type ResidenceType = (typeof RESIDENCE_TYPE)[keyof typeof RESIDENCE_TYPE]; + +/** + * Address lookup provider identifiers + */ +export const ADDRESS_LOOKUP_PROVIDER = { + JAPAN_POST: "japan_post", +} as const; + +export type AddressLookupProvider = + (typeof ADDRESS_LOOKUP_PROVIDER)[keyof typeof ADDRESS_LOOKUP_PROVIDER]; diff --git a/packages/domain/address/index.ts b/packages/domain/address/index.ts new file mode 100644 index 00000000..9db064d0 --- /dev/null +++ b/packages/domain/address/index.ts @@ -0,0 +1,44 @@ +/** + * Address Domain + * + * Exports address lookup contracts + schemas. + * + * Provider adapters (BFF-only) live under: `@customer-portal/domain/address/providers`. + * + * Types are derived from Zod schemas (Schema-First Approach) + */ + +// Constants +export { RESIDENCE_TYPE, ADDRESS_LOOKUP_PROVIDER } from "./contract.js"; +export type { ResidenceType, AddressLookupProvider } from "./contract.js"; + +// Schemas (includes derived types) +export { + // ZIP code + zipCodeSchema, + zipCodeLookupRequestSchema, + // Japan Post address + japanPostAddressSchema, + addressLookupResultSchema, + // Building info + buildingInfoSchema, + // Bilingual address + bilingualAddressSchema, + addressUpdateRequestSchema, + // Mapping functions + prepareWhmcsAddressFields, + prepareSalesforceContactAddressFields, +} from "./schema.js"; + +// Types +export type { + ZipCode, + ZipCodeLookupRequest, + JapanPostAddress, + AddressLookupResult, + BuildingInfo, + BilingualAddress, + AddressUpdateRequest, + WhmcsAddressFields, + SalesforceContactAddressFields, +} from "./schema.js"; diff --git a/packages/domain/address/providers/index.ts b/packages/domain/address/providers/index.ts new file mode 100644 index 00000000..c9ec5156 --- /dev/null +++ b/packages/domain/address/providers/index.ts @@ -0,0 +1,12 @@ +/** + * Address Domain - Providers + * + * Provider-specific adapters for BFF only. + * Portal should never import from this path. + */ + +// Re-export everything from Japan Post provider +export * from "./japanpost/index.js"; + +// Also export as namespace for convenience +export * as JapanPost from "./japanpost/index.js"; diff --git a/packages/domain/address/providers/japanpost/index.ts b/packages/domain/address/providers/japanpost/index.ts new file mode 100644 index 00000000..6bcb41d6 --- /dev/null +++ b/packages/domain/address/providers/japanpost/index.ts @@ -0,0 +1,23 @@ +/** + * Japan Post Provider - Public API + */ + +export { + transformJapanPostAddress, + transformJapanPostSearchResponse, + parseJapanPostSearchResponse, +} from "./mapper.js"; + +export type { + JapanPostTokenResponse, + JapanPostAddressRecord, + JapanPostSearchResponse, + JapanPostErrorResponse, +} from "./raw.types.js"; + +export { + japanPostTokenResponseSchema, + japanPostAddressRecordSchema, + japanPostSearchResponseSchema, + japanPostErrorResponseSchema, +} from "./raw.types.js"; diff --git a/packages/domain/address/providers/japanpost/mapper.ts b/packages/domain/address/providers/japanpost/mapper.ts new file mode 100644 index 00000000..969fc7d9 --- /dev/null +++ b/packages/domain/address/providers/japanpost/mapper.ts @@ -0,0 +1,57 @@ +/** + * Japan Post API - Mapper + * + * Transforms Japan Post API responses to domain types. + * Single transformation point: Raw API -> Domain type + */ + +import type { JapanPostAddressRecord, JapanPostSearchResponse } from "./raw.types.js"; +import { japanPostSearchResponseSchema } from "./raw.types.js"; +import type { JapanPostAddress, AddressLookupResult } from "../../schema.js"; + +/** + * Transform a single Japan Post address record to domain type + */ +export function transformJapanPostAddress(raw: JapanPostAddressRecord): JapanPostAddress { + // Get ZIP code from either field name + const zipCode = raw.zipcode || raw.zip_code || ""; + + return { + zipCode, + // Japanese + prefecture: raw.pref_name || "", + prefectureKana: raw.pref_kana, + city: raw.city_name || "", + cityKana: raw.city_kana, + town: raw.town_name || "", + townKana: raw.town_kana, + // Romanized + prefectureRoma: raw.pref_roma || "", + cityRoma: raw.city_roma || "", + townRoma: raw.town_roma || "", + }; +} + +/** + * Transform Japan Post search response to domain AddressLookupResult + */ +export function transformJapanPostSearchResponse(raw: unknown): AddressLookupResult { + const parsed = japanPostSearchResponseSchema.parse(raw); + + // Get ZIP code from first address or empty string + const firstAddress = parsed.addresses[0]; + const zipCode = firstAddress?.zipcode || firstAddress?.zip_code || ""; + + return { + zipCode, + addresses: parsed.addresses.map(transformJapanPostAddress), + count: parsed.count, + }; +} + +/** + * Parse and validate raw Japan Post API response + */ +export function parseJapanPostSearchResponse(raw: unknown): JapanPostSearchResponse { + return japanPostSearchResponseSchema.parse(raw); +} diff --git a/packages/domain/address/providers/japanpost/raw.types.ts b/packages/domain/address/providers/japanpost/raw.types.ts new file mode 100644 index 00000000..5c5c79d5 --- /dev/null +++ b/packages/domain/address/providers/japanpost/raw.types.ts @@ -0,0 +1,96 @@ +/** + * Japan Post API - Raw Types + * + * Types for Japan Post Digital Address API responses. + * These match the actual API response structure. + */ + +import { z } from "zod"; + +// ============================================================================ +// Token Response +// ============================================================================ + +/** + * Token response from POST /api/v1/j/token + */ +export const japanPostTokenResponseSchema = z.object({ + scope: z.string().optional(), + token_type: z.string(), + expires_in: z.number(), + token: z.string(), +}); + +export type JapanPostTokenResponse = z.infer; + +// ============================================================================ +// Address Search Response +// ============================================================================ + +/** + * Single address record from Japan Post API + * Fields from GET /api/v1/searchcode/{search_code} + */ +export const japanPostAddressRecordSchema = z.object({ + // ZIP code + zipcode: z.string().optional(), + zip_code: z.string().optional(), + + // Prefecture + pref_code: z.string().optional(), + pref_name: z.string().optional(), + pref_kana: z.string().optional(), + pref_roma: z.string().optional(), + + // City + city_code: z.string().optional(), + city_name: z.string().optional(), + city_kana: z.string().optional(), + city_roma: z.string().optional(), + + // Town + town_code: z.string().optional(), + town_name: z.string().optional(), + town_kana: z.string().optional(), + town_roma: z.string().optional(), + + // Additional fields that may be present + block_name: z.string().optional(), + block_kana: z.string().optional(), + block_roma: z.string().optional(), + + // Office/company info (for business ZIP codes) + office_name: z.string().optional(), + office_kana: z.string().optional(), + office_roma: z.string().optional(), +}); + +export type JapanPostAddressRecord = z.infer; + +/** + * Search response from GET /api/v1/searchcode/{search_code} + */ +export const japanPostSearchResponseSchema = z.object({ + addresses: z.array(japanPostAddressRecordSchema), + searchtype: z.string().optional(), + limit: z.number().optional(), + count: z.number(), + page: z.number().optional(), +}); + +export type JapanPostSearchResponse = z.infer; + +// ============================================================================ +// Error Response +// ============================================================================ + +/** + * Error response from Japan Post API + */ +export const japanPostErrorResponseSchema = z.object({ + error: z.string().optional(), + error_description: z.string().optional(), + message: z.string().optional(), +}); + +export type JapanPostErrorResponse = z.infer; diff --git a/packages/domain/address/schema.ts b/packages/domain/address/schema.ts new file mode 100644 index 00000000..649f872d --- /dev/null +++ b/packages/domain/address/schema.ts @@ -0,0 +1,193 @@ +/** + * Address Domain - Schemas + * + * Zod validation schemas for address lookup and bilingual address data. + * Types are derived from schemas (Schema-First Approach). + */ + +import { z } from "zod"; + +// ============================================================================ +// ZIP Code Schemas +// ============================================================================ + +/** + * Japanese ZIP code schema + * Accepts: "1000001", "100-0001" -> normalizes to "1000001" + */ +export const zipCodeSchema = z + .string() + .regex(/^\d{3}-?\d{4}$/, "ZIP code must be 7 digits (e.g., 100-0001)") + .transform(val => val.replace(/-/g, "")); + +/** + * ZIP code lookup request + */ +export const zipCodeLookupRequestSchema = z.object({ + zipCode: zipCodeSchema, +}); + +// ============================================================================ +// Japan Post Address Schemas +// ============================================================================ + +/** + * Address from Japan Post API lookup + * Contains both Japanese and romanized versions + */ +export const japanPostAddressSchema = z.object({ + zipCode: z.string(), + // Japanese (for Salesforce) + prefecture: z.string(), + prefectureKana: z.string().optional(), + city: z.string(), + cityKana: z.string().optional(), + town: z.string(), + townKana: z.string().optional(), + // Romanized (for WHMCS) + prefectureRoma: z.string(), + cityRoma: z.string(), + townRoma: z.string(), +}); + +/** + * Address lookup result containing multiple potential matches + */ +export const addressLookupResultSchema = z.object({ + zipCode: z.string(), + addresses: z.array(japanPostAddressSchema), + count: z.number(), +}); + +// ============================================================================ +// Bilingual Address Schemas (Extended from customer/addressSchema) +// ============================================================================ + +/** + * Building information for Japanese addresses + */ +export const buildingInfoSchema = z.object({ + buildingName: z.string().max(200).optional().nullable(), + roomNumber: z.string().max(50).optional().nullable(), + residenceType: z.enum(["house", "apartment"]), +}); + +/** + * Extended address data with bilingual fields + * Used when updating address in both WHMCS (English) and Salesforce (Japanese) + */ +export const bilingualAddressSchema = z.object({ + // ZIP code + postcode: z.string(), + + // English/Romanized (for WHMCS) + prefecture: z.string(), // romanized + city: z.string(), // romanized + town: z.string(), // romanized + + // Japanese (for Salesforce) + prefectureJa: z.string(), + cityJa: z.string(), + townJa: z.string(), + + // Building info (same for both systems) + buildingName: z.string().max(200).optional().nullable(), + roomNumber: z.string().max(50).optional().nullable(), + residenceType: z.enum(["house", "apartment"]), +}); + +/** + * Address update request for profile/signup + * Combines bilingual address data for dual-write to WHMCS + Salesforce + */ +export const addressUpdateRequestSchema = bilingualAddressSchema.extend({ + country: z.literal("JP").default("JP"), + countryCode: z.literal("JP").default("JP"), +}); + +// ============================================================================ +// WHMCS Address Mapping +// ============================================================================ + +/** + * Prepare address fields for WHMCS update + * Maps bilingual address to WHMCS field format + */ +export function prepareWhmcsAddressFields(address: BilingualAddress): WhmcsAddressFields { + const buildingPart = address.buildingName || ""; + const roomPart = address.roomNumber || ""; + + // address1: "{BuildingName} {RoomNumber}" for apartment, "{BuildingName}" for house + const address1 = + address.residenceType === "apartment" ? `${buildingPart} ${roomPart}`.trim() : buildingPart; + + return { + address1: address1 || undefined, + address2: address.town, // romanized town/street + city: address.city, // romanized city + state: address.prefecture, // romanized prefecture + postcode: address.postcode, + country: "JP", + countrycode: "JP", + }; +} + +// ============================================================================ +// Salesforce Contact Address Mapping +// ============================================================================ + +/** + * Prepare address fields for Salesforce Contact update + * Maps bilingual address to Salesforce field format + */ +export function prepareSalesforceContactAddressFields( + address: BilingualAddress +): SalesforceContactAddressFields { + return { + MailingStreet: address.townJa, // Japanese town/street + MailingCity: address.cityJa, // Japanese city + MailingState: address.prefectureJa, // Japanese prefecture + MailingPostalCode: address.postcode, + MailingCountry: "Japan", + BuildingName__c: address.buildingName || null, + RoomNumber__c: address.roomNumber || null, + }; +} + +// ============================================================================ +// Exported Types +// ============================================================================ + +export type ZipCode = z.input; +export type ZipCodeLookupRequest = z.infer; +export type JapanPostAddress = z.infer; +export type AddressLookupResult = z.infer; +export type BuildingInfo = z.infer; +export type BilingualAddress = z.infer; +export type AddressUpdateRequest = z.infer; + +/** + * WHMCS address field structure + */ +export interface WhmcsAddressFields { + address1?: string; + address2?: string; + city?: string; + state?: string; + postcode?: string; + country?: string; + countrycode?: string; +} + +/** + * Salesforce Contact address field structure + */ +export interface SalesforceContactAddressFields { + MailingStreet: string; + MailingCity: string; + MailingState: string; + MailingPostalCode: string; + MailingCountry: string; + BuildingName__c: string | null; + RoomNumber__c: string | null; +}