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:
barsa 2026-01-13 18:41:17 +09:00
parent dc32e7aa07
commit 78689da8fb
24 changed files with 2466 additions and 207 deletions

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

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

View File

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

View File

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

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

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

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

View File

@ -0,0 +1 @@
export { addressService, EMPTY_LOOKUP_RESULT } from "./address.api";

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

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

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

View File

@ -0,0 +1,7 @@
export { ZipCodeInput, type ZipCodeInputProps } from "./ZipCodeInput";
export {
JapanAddressForm,
type JapanAddressFormProps,
type JapanAddressFormData,
} from "./JapanAddressForm";
export { AddressStepJapan } from "./AddressStepJapan";

View File

@ -0,0 +1,6 @@
export {
useZipCodeLookup,
useAddressServiceStatus,
getFirstAddress,
EMPTY_LOOKUP_RESULT,
} from "./useAddressLookup";

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

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

View File

@ -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&apos;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>

View 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

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

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

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

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

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

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

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