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.
This commit is contained in:
parent
dc32e7aa07
commit
78689da8fb
7
apps/bff/src/integrations/japanpost/index.ts
Normal file
7
apps/bff/src/integrations/japanpost/index.ts
Normal file
@ -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";
|
||||
16
apps/bff/src/integrations/japanpost/japanpost.module.ts
Normal file
16
apps/bff/src/integrations/japanpost/japanpost.module.ts
Normal file
@ -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 {}
|
||||
@ -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<AddressLookupResult> {
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
@ -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<string>("JAPAN_POST_API_URL") || "",
|
||||
clientId: this.configService.get<string>("JAPAN_POST_CLIENT_ID") || "",
|
||||
clientSecret: this.configService.get<string>("JAPAN_POST_CLIENT_SECRET") || "",
|
||||
timeout: this.configService.get<number>("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<string> {
|
||||
// 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<unknown> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
93
apps/bff/src/modules/address/address.controller.ts
Normal file
93
apps/bff/src/modules/address/address.controller.ts
Normal file
@ -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<AddressLookupResultDto> {
|
||||
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() };
|
||||
}
|
||||
}
|
||||
15
apps/bff/src/modules/address/address.module.ts
Normal file
15
apps/bff/src/modules/address/address.module.ts
Normal file
@ -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 {}
|
||||
66
apps/portal/src/features/address/api/address.api.ts
Normal file
66
apps/portal/src/features/address/api/address.api.ts
Normal file
@ -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<AddressLookupResult> {
|
||||
// Normalize ZIP code (remove hyphen if present)
|
||||
const normalizedZip = zipCode.replace(/-/g, "");
|
||||
|
||||
const response = await apiClient.GET<AddressLookupResult>("/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 };
|
||||
1
apps/portal/src/features/address/api/index.ts
Normal file
1
apps/portal/src/features/address/api/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { addressService, EMPTY_LOOKUP_RESULT } from "./address.api";
|
||||
228
apps/portal/src/features/address/components/AddressStepJapan.tsx
Normal file
228
apps/portal/src/features/address/components/AddressStepJapan.tsx
Normal file
@ -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<string, string | undefined>;
|
||||
touched: Record<string, boolean | undefined>;
|
||||
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<JapanAddressFormData> {
|
||||
// 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<JapanAddressFormData>(() => ({
|
||||
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<Record<keyof JapanAddressFormData, string | undefined>> = {
|
||||
postcode: getError("postcode"),
|
||||
prefecture: getError("state"),
|
||||
city: getError("city"),
|
||||
town: getError("address2"),
|
||||
buildingName: getError("address1"),
|
||||
roomNumber: getError("address1"),
|
||||
};
|
||||
|
||||
// Map touched fields
|
||||
const japanFieldTouched: Partial<Record<keyof JapanAddressFormData, boolean>> = {
|
||||
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<string, string> = {
|
||||
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 (
|
||||
<JapanAddressForm
|
||||
initialValues={japanData}
|
||||
onChange={handleJapanAddressChange}
|
||||
errors={japanFieldErrors}
|
||||
touched={japanFieldTouched}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
);
|
||||
}
|
||||
441
apps/portal/src/features/address/components/JapanAddressForm.tsx
Normal file
441
apps/portal/src/features/address/components/JapanAddressForm.tsx
Normal file
@ -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<JapanAddressFormData>;
|
||||
/** Called when any address field changes */
|
||||
onChange?: (address: JapanAddressFormData, isComplete: boolean) => void;
|
||||
/** Field-level errors (keyed by field name) */
|
||||
errors?: Partial<Record<keyof JapanAddressFormData, string>>;
|
||||
/** Fields that have been touched */
|
||||
touched?: Partial<Record<keyof JapanAddressFormData, boolean>>;
|
||||
/** 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<JapanAddressFormData, "residenceType"> & {
|
||||
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<JapanAddressFormData, "residenceType"> & {
|
||||
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<InternalFormState>(() => ({
|
||||
...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<string>("");
|
||||
|
||||
// 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 (
|
||||
<div className={cn("space-y-5", className)}>
|
||||
{/* ZIP Code with auto-lookup */}
|
||||
<ZipCodeInput
|
||||
value={address.postcode}
|
||||
onChange={handleZipChange}
|
||||
onAddressFound={handleAddressFound}
|
||||
onLookupComplete={handleLookupComplete}
|
||||
error={getError("postcode")}
|
||||
required
|
||||
disabled={disabled}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
{/* Address fields - Read-only, populated by ZIP lookup */}
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-4 p-4 rounded-lg border transition-all",
|
||||
isAddressVerified ? "border-success/50 bg-success/5" : "border-border bg-muted/30"
|
||||
)}
|
||||
>
|
||||
{isAddressVerified && (
|
||||
<div className="flex items-center gap-2 text-sm text-success font-medium">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
Address verified
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Prefecture - Read-only */}
|
||||
<FormField
|
||||
label="Prefecture"
|
||||
required
|
||||
helperText={isAddressVerified && address.prefectureJa ? address.prefectureJa : undefined}
|
||||
>
|
||||
<Input
|
||||
value={isAddressVerified ? address.prefecture : ""}
|
||||
placeholder={isAddressVerified ? "" : "Enter ZIP code above"}
|
||||
disabled
|
||||
readOnly
|
||||
className={cn("bg-transparent", isAddressVerified && "text-foreground")}
|
||||
data-field="address.state"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* City/Ward - Read-only */}
|
||||
<FormField
|
||||
label="City / Ward"
|
||||
required
|
||||
helperText={isAddressVerified && address.cityJa ? address.cityJa : undefined}
|
||||
>
|
||||
<Input
|
||||
value={isAddressVerified ? address.city : ""}
|
||||
placeholder={isAddressVerified ? "" : "Enter ZIP code above"}
|
||||
disabled
|
||||
readOnly
|
||||
className={cn("bg-transparent", isAddressVerified && "text-foreground")}
|
||||
data-field="address.city"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* Town - Read-only */}
|
||||
<FormField
|
||||
label="Town / Street / Block"
|
||||
required
|
||||
helperText={isAddressVerified && address.townJa ? address.townJa : undefined}
|
||||
>
|
||||
<Input
|
||||
value={isAddressVerified ? address.town : ""}
|
||||
placeholder={isAddressVerified ? "" : "Enter ZIP code above"}
|
||||
disabled
|
||||
readOnly
|
||||
className={cn("bg-transparent", isAddressVerified && "text-foreground")}
|
||||
data-field="address.address2"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
{/* Residence Type Toggle - Only show after address is verified */}
|
||||
{isAddressVerified && (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-muted-foreground">
|
||||
Residence Type <span className="text-danger">*</span>
|
||||
</label>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleResidenceTypeChange(RESIDENCE_TYPE.HOUSE)}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg border-2 transition-all",
|
||||
"text-sm font-medium",
|
||||
address.residenceType === RESIDENCE_TYPE.HOUSE
|
||||
? "border-primary bg-primary/5 text-primary"
|
||||
: "border-border bg-card text-muted-foreground hover:border-muted-foreground/50",
|
||||
!hasResidenceTypeSelected && getError("residenceType") && "border-danger",
|
||||
disabled && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<Home className="h-4 w-4" />
|
||||
House
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleResidenceTypeChange(RESIDENCE_TYPE.APARTMENT)}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg border-2 transition-all",
|
||||
"text-sm font-medium",
|
||||
address.residenceType === RESIDENCE_TYPE.APARTMENT
|
||||
? "border-primary bg-primary/5 text-primary"
|
||||
: "border-border bg-card text-muted-foreground hover:border-muted-foreground/50",
|
||||
!hasResidenceTypeSelected && getError("residenceType") && "border-danger",
|
||||
disabled && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<Building2 className="h-4 w-4" />
|
||||
Apartment / Mansion
|
||||
</button>
|
||||
</div>
|
||||
{!hasResidenceTypeSelected && getError("residenceType") && (
|
||||
<p className="text-sm text-danger">{getError("residenceType")}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Building fields - Only show after residence type is selected */}
|
||||
{isAddressVerified && hasResidenceTypeSelected && (
|
||||
<>
|
||||
{/* Building Name */}
|
||||
<FormField
|
||||
label="Building Name"
|
||||
error={getError("buildingName")}
|
||||
required={false}
|
||||
helperText="e.g., Gramercy Heights"
|
||||
>
|
||||
<Input
|
||||
value={address.buildingName ?? ""}
|
||||
onChange={e => handleBuildingNameChange(e.target.value)}
|
||||
onBlur={() => onBlur?.("buildingName")}
|
||||
placeholder="Gramercy Heights"
|
||||
disabled={disabled}
|
||||
data-field="address.buildingName"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* Room Number - Only for apartments */}
|
||||
{isApartment && (
|
||||
<FormField
|
||||
label="Room Number"
|
||||
error={getError("roomNumber")}
|
||||
required
|
||||
helperText="Required for apartments"
|
||||
>
|
||||
<Input
|
||||
value={address.roomNumber ?? ""}
|
||||
onChange={e => handleRoomNumberChange(e.target.value)}
|
||||
onBlur={() => onBlur?.("roomNumber")}
|
||||
placeholder="201"
|
||||
disabled={disabled}
|
||||
data-field="address.roomNumber"
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
188
apps/portal/src/features/address/components/ZipCodeInput.tsx
Normal file
188
apps/portal/src/features/address/components/ZipCodeInput.tsx
Normal file
@ -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<string>("");
|
||||
|
||||
// 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<HTMLInputElement>) => {
|
||||
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 <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />;
|
||||
}
|
||||
|
||||
if (isValidFormat && lookupResult) {
|
||||
if (lookupResult.count > 0) {
|
||||
return <CheckCircle className="h-4 w-4 text-success" />;
|
||||
}
|
||||
return <XCircle className="h-4 w-4 text-warning" />;
|
||||
}
|
||||
|
||||
return <Search className="h-4 w-4 text-muted-foreground" />;
|
||||
};
|
||||
|
||||
// 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 (
|
||||
<FormField
|
||||
label={label}
|
||||
error={error}
|
||||
required={required}
|
||||
helperText={!error ? computedHelperText : undefined}
|
||||
>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
placeholder="100-0001"
|
||||
autoComplete="postal-code"
|
||||
maxLength={8}
|
||||
disabled={disabled}
|
||||
autoFocus={autoFocus}
|
||||
className={cn(
|
||||
"pr-10",
|
||||
hasLookupSuccess && "border-success focus-visible:border-success",
|
||||
hasLookupWarning && "border-warning focus-visible:border-warning"
|
||||
)}
|
||||
data-field="address.postcode"
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
||||
{renderStatusIcon()}
|
||||
</div>
|
||||
</div>
|
||||
</FormField>
|
||||
);
|
||||
}
|
||||
7
apps/portal/src/features/address/components/index.ts
Normal file
7
apps/portal/src/features/address/components/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export { ZipCodeInput, type ZipCodeInputProps } from "./ZipCodeInput";
|
||||
export {
|
||||
JapanAddressForm,
|
||||
type JapanAddressFormProps,
|
||||
type JapanAddressFormData,
|
||||
} from "./JapanAddressForm";
|
||||
export { AddressStepJapan } from "./AddressStepJapan";
|
||||
6
apps/portal/src/features/address/hooks/index.ts
Normal file
6
apps/portal/src/features/address/hooks/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export {
|
||||
useZipCodeLookup,
|
||||
useAddressServiceStatus,
|
||||
getFirstAddress,
|
||||
EMPTY_LOOKUP_RESULT,
|
||||
} from "./useAddressLookup";
|
||||
99
apps/portal/src/features/address/hooks/useAddressLookup.ts
Normal file
99
apps/portal/src/features/address/hooks/useAddressLookup.ts
Normal file
@ -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<typeof queryKeys.address.zipLookup>;
|
||||
type StatusQueryKey = ReturnType<typeof queryKeys.address.status>;
|
||||
|
||||
type ZipLookupQueryOptions = Omit<
|
||||
UseQueryOptions<AddressLookupResult, Error, AddressLookupResult, ZipLookupQueryKey>,
|
||||
"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<AddressLookupResult, Error> {
|
||||
// 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 };
|
||||
22
apps/portal/src/features/address/index.ts
Normal file
22
apps/portal/src/features/address/index.ts
Normal file
@ -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";
|
||||
@ -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 (
|
||||
<div className="space-y-24 sm:space-y-32 pb-20">
|
||||
{/* ===== HERO SECTION - CENTERED ===== */}
|
||||
<section className="relative pt-12 sm:pt-20 pb-8 overflow-hidden">
|
||||
{/* Decorative gradient blurs */}
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-[600px] h-[400px] bg-gradient-to-b from-primary/8 to-transparent rounded-full blur-3xl -z-10" />
|
||||
<div className="absolute top-40 right-0 w-72 h-72 bg-primary/5 rounded-full blur-3xl -z-10" />
|
||||
<div className="absolute top-60 left-0 w-64 h-64 bg-accent/5 rounded-full blur-3xl -z-10" />
|
||||
<Link href={href} className="group block">
|
||||
<div
|
||||
className={cn(
|
||||
"relative h-full flex flex-col rounded-2xl border bg-card p-6",
|
||||
"transition-all duration-200",
|
||||
"hover:-translate-y-1 hover:shadow-lg hover:border-primary/30"
|
||||
)}
|
||||
>
|
||||
{badge && (
|
||||
<span className="absolute -top-2.5 right-4 rounded-full bg-success px-2.5 py-0.5 text-xs font-medium text-success-foreground">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="relative max-w-3xl mx-auto text-center">
|
||||
{/* Badge */}
|
||||
<div className="flex items-start gap-4 mb-4">
|
||||
<div
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-primary/10 border border-primary/20 text-primary text-sm font-medium mb-8 animate-in fade-in slide-in-from-bottom-2 duration-500"
|
||||
style={{ animationDelay: "0ms" }}
|
||||
className={cn(
|
||||
"flex h-12 w-12 items-center justify-center rounded-xl border",
|
||||
accentStyles[accentColor]
|
||||
)}
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Trusted by 10,000+ customers in Japan
|
||||
{icon}
|
||||
</div>
|
||||
|
||||
{/* Headline */}
|
||||
<h1
|
||||
className="text-display-lg sm:text-display-xl lg:text-[3.75rem] font-display font-bold text-foreground leading-[1.08] tracking-tight animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||
style={{ animationDelay: "100ms" }}
|
||||
>
|
||||
Your One Stop Solution
|
||||
<br />
|
||||
<span className="bg-gradient-to-r from-primary to-accent-gradient bg-clip-text text-transparent">
|
||||
for IT in Japan
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
{/* Subtitle */}
|
||||
<p
|
||||
className="mt-6 text-lg sm:text-xl text-muted-foreground max-w-2xl mx-auto leading-relaxed animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||
style={{ animationDelay: "200ms" }}
|
||||
>
|
||||
Serving Japan's international community with reliable, English-supported internet,
|
||||
mobile, and VPN solutions — for over 20 years.
|
||||
</p>
|
||||
|
||||
{/* CTAs */}
|
||||
<div
|
||||
className="flex flex-col sm:flex-row items-center justify-center gap-4 mt-10 animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||
style={{ animationDelay: "300ms" }}
|
||||
>
|
||||
<CtaButton href="#services" variant="primary" size="lg">
|
||||
Browse Services
|
||||
<ArrowRight className="h-5 w-5" />
|
||||
</CtaButton>
|
||||
<CtaButton href="/contact" variant="secondary" size="lg">
|
||||
Contact Us
|
||||
</CtaButton>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-foreground font-display">{title}</h3>
|
||||
{price && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
From <span className="font-medium text-foreground">{price}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trust indicators */}
|
||||
<div
|
||||
className="mt-14 pt-10 border-t border-border/40 animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||
style={{ animationDelay: "400ms" }}
|
||||
<p className="text-sm text-muted-foreground leading-relaxed flex-grow">{description}</p>
|
||||
|
||||
<div className="mt-4 flex items-center gap-1 text-sm font-medium text-primary">
|
||||
Learn more
|
||||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function PublicLandingView() {
|
||||
return (
|
||||
<div className="space-y-16 pb-16">
|
||||
{/* ===== HERO SECTION ===== */}
|
||||
<section className="text-center pt-12 sm:pt-16">
|
||||
<span className="inline-flex items-center gap-2 rounded-full bg-primary/8 border border-primary/15 px-4 py-2 text-sm text-primary font-medium mb-6">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
20+ Years Serving Japan
|
||||
</span>
|
||||
|
||||
<h1 className="text-display-lg sm:text-display-xl font-display font-bold text-foreground mb-5">
|
||||
Your One Stop Solution
|
||||
<br />
|
||||
<span className="text-primary">for Connectivity in Japan</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-lg text-muted-foreground max-w-xl mx-auto mb-8">
|
||||
Full English support for all your connectivity needs — from setup to billing to technical
|
||||
assistance.
|
||||
</p>
|
||||
|
||||
{/* CTAs */}
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 mb-12">
|
||||
<Link
|
||||
href="#services"
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-primary px-6 py-3 font-semibold text-primary-foreground hover:bg-primary-hover transition-colors"
|
||||
>
|
||||
<TrustIndicators variant="horizontal" className="justify-center" />
|
||||
Browse Services
|
||||
<ArrowRight className="h-5 w-5" />
|
||||
</Link>
|
||||
<Link
|
||||
href="/contact"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-border px-6 py-3 font-semibold text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
Contact Us
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Trust Stats */}
|
||||
<div className="flex flex-wrap justify-center gap-8 pt-8 border-t border-border/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/8">
|
||||
<Calendar className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="text-lg font-bold text-foreground">20+</div>
|
||||
<div className="text-sm text-muted-foreground">Years in Japan</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-success/10">
|
||||
<Users className="h-5 w-5 text-success" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="text-lg font-bold text-foreground">10,000+</div>
|
||||
<div className="text-sm text-muted-foreground">Customers Served</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-info/10">
|
||||
<Shield className="h-5 w-5 text-info" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="text-lg font-bold text-foreground">NTT</div>
|
||||
<div className="text-sm text-muted-foreground">Authorized Partner</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ===== WHY CHOOSE US ===== */}
|
||||
<section>
|
||||
<div className="text-center max-w-2xl mx-auto mb-14">
|
||||
<p className="text-sm font-medium text-primary uppercase tracking-wider mb-3">
|
||||
<div className="text-center mb-10">
|
||||
<h2 className="text-display-sm font-display font-bold text-foreground mb-3">
|
||||
Why Choose Us
|
||||
</p>
|
||||
<h2 className="text-display-sm sm:text-display-md font-display font-bold text-foreground">
|
||||
Built for the international community
|
||||
</h2>
|
||||
<p className="mt-4 text-muted-foreground text-lg">
|
||||
<p className="text-muted-foreground max-w-md mx-auto">
|
||||
We understand the unique challenges of living in Japan as an expat.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 lg:gap-8">
|
||||
{concepts.map((concept, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="group relative p-6 sm:p-8 rounded-2xl bg-card border border-border hover:border-primary/20 hover:shadow-lg transition-all duration-300 animate-in fade-in slide-in-from-bottom-4"
|
||||
style={{ animationDelay: `${idx * 100}ms` }}
|
||||
>
|
||||
{/* Gradient hover effect */}
|
||||
<div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
|
||||
<div className="relative">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-gradient-to-br from-primary/15 to-primary/5 mb-5 group-hover:scale-105 transition-transform">
|
||||
<concept.icon className="h-7 w-7 text-primary" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-foreground mb-3 font-display">
|
||||
{concept.title}
|
||||
</h3>
|
||||
<p className="text-muted-foreground leading-relaxed">{concept.description}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-center gap-6 text-sm">
|
||||
<div className="flex items-center gap-3 rounded-xl border bg-card px-5 py-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-500/10">
|
||||
<CheckCircle2 className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<div className="font-semibold text-foreground">One Stop Solution</div>
|
||||
<div className="text-muted-foreground">We handle everything for you</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 rounded-xl border bg-card px-5 py-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-green-500/10">
|
||||
<Headphones className="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-foreground">English Support</div>
|
||||
<div className="text-muted-foreground">No language barrier</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 rounded-xl border bg-card px-5 py-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-500/10">
|
||||
<Globe className="h-5 w-5 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-foreground">Onsite Support</div>
|
||||
<div className="text-muted-foreground">We come to you</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ===== OUR SERVICES ===== */}
|
||||
<section id="services">
|
||||
<div className="text-center max-w-2xl mx-auto mb-14">
|
||||
<p className="text-sm font-medium text-primary uppercase tracking-wider mb-3">
|
||||
<div className="text-center mb-10">
|
||||
<h2 className="text-display-sm font-display font-bold text-foreground mb-3">
|
||||
Our Services
|
||||
</p>
|
||||
<h2 className="text-display-sm sm:text-display-md font-display font-bold text-foreground">
|
||||
Everything you need to stay connected
|
||||
</h2>
|
||||
<p className="mt-4 text-muted-foreground text-lg">
|
||||
From high-speed internet to mobile plans, we've got you covered.
|
||||
<p className="text-muted-foreground max-w-md mx-auto">
|
||||
Connectivity and support solutions for Japan's international community.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Services Grid - 3x2 */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5 lg:gap-6">
|
||||
{services.map((service, idx) => (
|
||||
<ServiceCard
|
||||
key={idx}
|
||||
href={service.href}
|
||||
icon={service.icon}
|
||||
title={service.title}
|
||||
description={service.description}
|
||||
highlight={service.highlight}
|
||||
/>
|
||||
))}
|
||||
{/* Value Props */}
|
||||
<div className="flex flex-wrap justify-center gap-6 text-sm mb-10">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Globe className="h-4 w-4 text-primary" />
|
||||
<span>One provider, all services</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Headphones className="h-4 w-4 text-success" />
|
||||
<span>English support</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<CheckCircle2 className="h-4 w-4 text-info" />
|
||||
<span>No hidden fees</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center mt-12">
|
||||
{/* Services Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
<ServiceCard
|
||||
href="/services/internet"
|
||||
icon={<Wifi className="h-6 w-6" />}
|
||||
title="Internet"
|
||||
description="NTT Optical Fiber for homes and apartments. Speeds up to 10Gbps with professional installation."
|
||||
price="¥3,200/mo"
|
||||
accentColor="blue"
|
||||
/>
|
||||
|
||||
<ServiceCard
|
||||
href="/services/sim"
|
||||
icon={<Smartphone className="h-6 w-6" />}
|
||||
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"
|
||||
/>
|
||||
|
||||
<ServiceCard
|
||||
href="/services/vpn"
|
||||
icon={<ShieldCheck className="h-6 w-6" />}
|
||||
title="VPN Router"
|
||||
description="Access US & UK streaming content with a pre-configured router. Simple plug-and-play."
|
||||
price="¥2,500/mo"
|
||||
accentColor="purple"
|
||||
/>
|
||||
|
||||
<ServiceCard
|
||||
href="/services/business"
|
||||
icon={<Building2 className="h-6 w-6" />}
|
||||
title="Business"
|
||||
description="Enterprise solutions for offices and commercial spaces. Dedicated support and SLAs."
|
||||
accentColor="orange"
|
||||
/>
|
||||
|
||||
<ServiceCard
|
||||
href="/services/onsite"
|
||||
icon={<Wrench className="h-6 w-6" />}
|
||||
title="Onsite Support"
|
||||
description="Professional technicians visit your location for setup, troubleshooting, and maintenance."
|
||||
accentColor="cyan"
|
||||
/>
|
||||
|
||||
<ServiceCard
|
||||
href="/services/tv"
|
||||
icon={<Tv className="h-6 w-6" />}
|
||||
title="TV"
|
||||
description="Streaming TV packages with international channels. Watch content from home countries."
|
||||
accentColor="pink"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-center mt-8">
|
||||
<Link
|
||||
href="/services"
|
||||
className="inline-flex items-center gap-2 text-primary font-medium hover:underline underline-offset-4 transition-colors hover:text-primary-hover"
|
||||
className="inline-flex items-center gap-2 text-primary font-medium hover:underline underline-offset-4"
|
||||
>
|
||||
<Globe className="h-4 w-4" />
|
||||
Explore all services
|
||||
View all services
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ===== FINAL CTA ===== */}
|
||||
<section className="relative overflow-hidden rounded-3xl">
|
||||
{/* Gradient background */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-navy via-primary to-accent-gradient" />
|
||||
{/* ===== CTA ===== */}
|
||||
<section className="rounded-2xl bg-gradient-to-br from-muted/50 to-muted/80 p-8 sm:p-10 text-center">
|
||||
<h2 className="text-xl font-bold text-foreground font-display mb-3">
|
||||
Ready to get connected?
|
||||
</h2>
|
||||
<p className="text-muted-foreground mb-6 max-w-md mx-auto">
|
||||
Our bilingual team is here to help you find the right solution for your needs.
|
||||
</p>
|
||||
|
||||
{/* Pattern overlay */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-10"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(circle at 20% 80%, rgba(255,255,255,0.4) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 20%, rgba(255,255,255,0.3) 0%, transparent 40%)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative px-8 py-16 sm:px-12 sm:py-20 text-center">
|
||||
<h2 className="text-display-sm sm:text-display-md font-display font-bold text-white mb-4">
|
||||
Ready to get connected?
|
||||
</h2>
|
||||
<p className="text-lg text-white/85 mb-10 max-w-xl mx-auto leading-relaxed">
|
||||
Contact us anytime — our bilingual team is here to help you find the right solution for
|
||||
your needs.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<Link
|
||||
href="/contact"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg bg-white px-8 py-4 text-lg font-semibold text-primary hover:bg-white/95 hover:shadow-lg transition-all"
|
||||
>
|
||||
Contact Us
|
||||
<ArrowRight className="h-5 w-5" />
|
||||
</Link>
|
||||
<Link
|
||||
href="/services"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg bg-white/15 border border-white/30 px-8 py-4 text-lg font-semibold text-white hover:bg-white/25 transition-all"
|
||||
>
|
||||
Browse Services
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
|
||||
<Link
|
||||
href="/contact"
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-primary px-5 py-2.5 font-medium text-primary-foreground hover:bg-primary-hover transition-colors"
|
||||
>
|
||||
Contact Us
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
<a
|
||||
href="tel:0120660470"
|
||||
className="inline-flex items-center gap-2 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Phone className="h-4 w-4" />
|
||||
0120-660-470 (Toll Free)
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
206
docs/features/japan-post-address-lookup.md
Normal file
206
docs/features/japan-post-address-lookup.md
Normal file
@ -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";
|
||||
|
||||
<ZipCodeInput
|
||||
value={zipCode}
|
||||
onChange={setZipCode}
|
||||
onAddressFound={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";
|
||||
|
||||
<JapanAddressForm
|
||||
onChange={(data, isComplete) => {
|
||||
// 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";
|
||||
|
||||
<AddressStepJapan
|
||||
form={form}
|
||||
onJapaneseAddressChange={bilingualData => {
|
||||
// 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
|
||||
25
packages/domain/address/contract.ts
Normal file
25
packages/domain/address/contract.ts
Normal file
@ -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];
|
||||
44
packages/domain/address/index.ts
Normal file
44
packages/domain/address/index.ts
Normal file
@ -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";
|
||||
12
packages/domain/address/providers/index.ts
Normal file
12
packages/domain/address/providers/index.ts
Normal file
@ -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";
|
||||
23
packages/domain/address/providers/japanpost/index.ts
Normal file
23
packages/domain/address/providers/japanpost/index.ts
Normal file
@ -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";
|
||||
57
packages/domain/address/providers/japanpost/mapper.ts
Normal file
57
packages/domain/address/providers/japanpost/mapper.ts
Normal file
@ -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);
|
||||
}
|
||||
96
packages/domain/address/providers/japanpost/raw.types.ts
Normal file
96
packages/domain/address/providers/japanpost/raw.types.ts
Normal file
@ -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<typeof japanPostTokenResponseSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// 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<typeof japanPostAddressRecordSchema>;
|
||||
|
||||
/**
|
||||
* 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<typeof japanPostSearchResponseSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// 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<typeof japanPostErrorResponseSchema>;
|
||||
193
packages/domain/address/schema.ts
Normal file
193
packages/domain/address/schema.ts
Normal file
@ -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<typeof zipCodeSchema>;
|
||||
export type ZipCodeLookupRequest = z.infer<typeof zipCodeLookupRequestSchema>;
|
||||
export type JapanPostAddress = z.infer<typeof japanPostAddressSchema>;
|
||||
export type AddressLookupResult = z.infer<typeof addressLookupResultSchema>;
|
||||
export type BuildingInfo = z.infer<typeof buildingInfoSchema>;
|
||||
export type BilingualAddress = z.infer<typeof bilingualAddressSchema>;
|
||||
export type AddressUpdateRequest = z.infer<typeof addressUpdateRequestSchema>;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user