feat: Enhance About Us page with animations and updated styles

- Updated typography for headings and paragraphs in AboutUsView.
- Added animation effects for header and sections to improve user experience.
- Refactored section headers to use new display styles.

feat: Implement bilingual address handling in AddressConfirmation

- Integrated JapanAddressForm for ZIP code lookup and bilingual address input.
- Updated state management to handle bilingual addresses and validation.
- Enhanced save functionality to support dual-write to WHMCS and Salesforce.

fix: Adjust Japan Post address mapping to handle nullish values

- Updated address mapping to use nullish coalescing for optional fields.
- Ensured compatibility with API responses that may return null for certain fields.

feat: Add ServiceCard component for displaying services

- Created a flexible ServiceCard component with multiple variants (default, featured, minimal, bento).
- Implemented accent color options and responsive design for better UI.
- Added detailed props documentation and usage examples.

chore: Clean up development scripts

- Removed unnecessary build steps for validation package in manage.sh.
This commit is contained in:
barsa 2026-01-14 16:25:06 +09:00
parent 7624684e6b
commit bb4be98444
32 changed files with 1953 additions and 1177 deletions

View File

@ -74,6 +74,14 @@ AUTH_RATE_LIMIT_LIMIT=10
# FREEBIT_BASE_URL=
# FREEBIT_OEM_KEY=
# --- SendGrid (Email) ---
# SENDGRID_API_KEY=
# EMAIL_FROM=no-reply@example.com
# --- Email (SendGrid) ---
# SENDGRID_API_KEY= # Required: Your SendGrid API key
# EMAIL_FROM=no-reply@example.com # Required: Sender email address
# EMAIL_FROM_NAME=Customer Portal # Optional: Sender display name
# EMAIL_ENABLED=true # Enable/disable email sending
# EMAIL_USE_QUEUE=true # Use BullMQ queue (recommended)
# SENDGRID_SANDBOX=false # Enable sandbox mode for testing
# --- Email Templates (Optional - SendGrid Dynamic Templates) ---
# EMAIL_TEMPLATE_OTP_VERIFICATION= # Template ID for OTP emails
# EMAIL_TEMPLATE_RESET= # Template ID for password reset

View File

@ -149,6 +149,21 @@ export class CacheService {
return (await this.redis.exists(key)) === 1;
}
/**
* Set a key only if it does not exist (atomic)
* Useful for idempotency checks and deduplication
* @param key Cache key
* @param value Value to cache (will be JSON serialized)
* @param ttlSeconds TTL in seconds
* @returns true if key was set (didn't exist), false if key already exists
*/
async setIfNotExists(key: string, value: unknown, ttlSeconds: number): Promise<boolean> {
const serialized = JSON.stringify(value);
const ttl = Math.max(1, Math.floor(ttlSeconds));
const result = await this.redis.set(key, serialized, "EX", ttl, "NX");
return result === "OK";
}
/**
* Build a structured cache key
* @param prefix Key prefix (e.g., "orders", "catalog")

View File

@ -1,13 +1,15 @@
import { Module } from "@nestjs/common";
import { Global, Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { EmailService } from "./email.service.js";
import { SendGridEmailProvider } from "./providers/sendgrid.provider.js";
import { LoggingModule } from "@bff/core/logging/logging.module.js";
import { QueueModule } from "@bff/infra/queue/queue.module.js";
import { EmailQueueService } from "./queue/email.queue.js";
import { EmailProcessor } from "./queue/email.processor.js";
@Global()
@Module({
imports: [ConfigModule, LoggingModule],
imports: [ConfigModule, LoggingModule, QueueModule],
providers: [EmailService, SendGridEmailProvider, EmailQueueService, EmailProcessor],
exports: [EmailService, EmailQueueService],
})

View File

@ -13,31 +13,173 @@ export interface SendEmailOptions {
html?: string;
templateId?: string;
dynamicTemplateData?: Record<string, unknown>;
/** Category for tracking (e.g., 'otp', 'password-reset', 'welcome') */
category?: EmailCategory;
}
/** Predefined email categories for consistent tracking */
export type EmailCategory =
| "otp-verification"
| "password-reset"
| "welcome"
| "eligibility-submitted"
| "account-notification"
| "transactional";
export interface SendEmailResult {
success: boolean;
queued: boolean;
error?: string;
}
@Injectable()
export class EmailService {
private readonly emailEnabled: boolean;
private readonly useQueue: boolean;
constructor(
private readonly config: ConfigService,
private readonly provider: SendGridEmailProvider,
private readonly queue: EmailQueueService,
@Inject(Logger) private readonly logger: Logger
) {}
) {
this.emailEnabled = this.config.get("EMAIL_ENABLED", "true") === "true";
this.useQueue = this.config.get("EMAIL_USE_QUEUE", "true") === "true";
async sendEmail(options: SendEmailOptions): Promise<void> {
const enabled = this.config.get("EMAIL_ENABLED", "true") === "true";
if (!enabled) {
this.logger.log("Email sending disabled; skipping", {
to: options.to,
subject: options.subject,
this.logger.log("EmailService initialized", {
service: "email",
enabled: this.emailEnabled,
useQueue: this.useQueue,
});
}
async sendEmail(options: SendEmailOptions): Promise<SendEmailResult> {
const emailContext = {
service: "email",
to: this.getRecipientCount(options.to),
subject: options.subject,
category: options.category || "transactional",
hasTemplate: !!options.templateId,
};
// Validate required fields
const validationError = this.validateOptions(options);
if (validationError) {
this.logger.error("Email validation failed", {
...emailContext,
error: validationError,
});
return;
return { success: false, queued: false, error: validationError };
}
const useQueue = this.config.get("EMAIL_USE_QUEUE", "true") === "true";
if (useQueue) {
await this.queue.enqueueEmail(options as EmailJobData);
} else {
await this.provider.send(options);
// Check if email is enabled
if (!this.emailEnabled) {
this.logger.log("Email sending disabled - skipping", emailContext);
return { success: true, queued: false };
}
// Add category to options for downstream tracking
const enrichedOptions: EmailJobData = {
...options,
category: options.category || "transactional",
};
try {
if (this.useQueue) {
await this.queue.enqueueEmail(enrichedOptions);
this.logger.log("Email queued for delivery", emailContext);
return { success: true, queued: true };
}
// Direct send (synchronous)
await this.provider.send(enrichedOptions);
this.logger.log("Email sent directly", emailContext);
return { success: true, queued: false };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error("Failed to send/queue email", {
...emailContext,
error: errorMessage,
mode: this.useQueue ? "queued" : "direct",
});
return { success: false, queued: false, error: errorMessage };
}
}
/**
* Send email directly without queue (useful for critical emails)
* Use sparingly - prefer queued delivery for reliability
*/
async sendEmailDirect(options: SendEmailOptions): Promise<SendEmailResult> {
const emailContext = {
service: "email",
mode: "direct-forced",
to: this.getRecipientCount(options.to),
subject: options.subject,
category: options.category || "transactional",
};
const validationError = this.validateOptions(options);
if (validationError) {
this.logger.error("Email validation failed", { ...emailContext, error: validationError });
return { success: false, queued: false, error: validationError };
}
if (!this.emailEnabled) {
this.logger.log("Email sending disabled - skipping direct send", emailContext);
return { success: true, queued: false };
}
try {
await this.provider.send({
...options,
category: options.category || "transactional",
});
this.logger.log("Email sent directly (forced)", emailContext);
return { success: true, queued: false };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error("Direct email send failed", { ...emailContext, error: errorMessage });
return { success: false, queued: false, error: errorMessage };
}
}
private validateOptions(options: SendEmailOptions): string | null {
if (!options.to || (Array.isArray(options.to) && options.to.length === 0)) {
return "Recipient (to) is required";
}
if (!options.subject || options.subject.trim().length === 0) {
return "Subject is required";
}
// Must have either template or content
if (!options.templateId && !options.html && !options.text) {
return "Email must have templateId, html, or text content";
}
// Basic email format validation
const emails = Array.isArray(options.to) ? options.to : [options.to];
for (const email of emails) {
if (!this.isValidEmailFormat(email)) {
return `Invalid email format: ${email}`;
}
}
return null;
}
private isValidEmailFormat(email: string): boolean {
// Stricter email validation to prevent queue pollution with invalid emails
// Catches: multiple @, dots at start/end, consecutive dots, invalid domain
const emailRegex =
/^[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/;
return emailRegex.test(email);
}
private getRecipientCount(to: string | string[]): number {
return Array.isArray(to) ? to.length : 1;
}
}

View File

@ -1,8 +1,9 @@
import { Injectable, Inject } from "@nestjs/common";
import type { OnModuleInit } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino";
import sgMail from "@sendgrid/mail";
import type { MailDataRequired } from "@sendgrid/mail";
import type { MailDataRequired, ResponseError } from "@sendgrid/mail";
export interface ProviderSendOptions {
to: string | string[];
@ -12,44 +13,213 @@ export interface ProviderSendOptions {
html?: string;
templateId?: string;
dynamicTemplateData?: Record<string, unknown>;
/** Optional category for tracking/analytics */
category?: string;
}
export interface SendGridErrorDetail {
message: string;
field?: string;
help?: string;
}
export interface ParsedSendGridError {
statusCode: number;
message: string;
errors: SendGridErrorDetail[];
isRetryable: boolean;
}
/** HTTP status codes that indicate transient failures worth retrying */
const RETRYABLE_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504]);
@Injectable()
export class SendGridEmailProvider {
export class SendGridEmailProvider implements OnModuleInit {
private isConfigured = false;
private readonly sandboxMode: boolean;
private readonly defaultFrom: string | undefined;
private readonly defaultFromName: string | undefined;
constructor(
private readonly config: ConfigService,
@Inject(Logger) private readonly logger: Logger
) {
const apiKey = this.config.get<string>("SENDGRID_API_KEY");
if (apiKey) {
sgMail.setApiKey(apiKey);
}
this.sandboxMode = this.config.get("SENDGRID_SANDBOX", "false") === "true";
this.defaultFrom = this.config.get<string>("EMAIL_FROM");
this.defaultFromName = this.config.get<string>("EMAIL_FROM_NAME");
}
async send(options: ProviderSendOptions): Promise<void> {
const from = options.from || this.config.get<string>("EMAIL_FROM");
if (!from) {
this.logger.warn("EMAIL_FROM is not configured; email not sent");
onModuleInit(): void {
const apiKey = this.config.get<string>("SENDGRID_API_KEY");
if (!apiKey) {
this.logger.warn("SendGrid API key not configured - email sending will fail", {
provider: "sendgrid",
});
return;
}
sgMail.setApiKey(apiKey);
this.isConfigured = true;
this.logger.log("SendGrid email provider initialized", {
provider: "sendgrid",
sandboxMode: this.sandboxMode,
defaultFrom: this.defaultFrom,
});
}
async send(options: ProviderSendOptions): Promise<void> {
const emailContext = {
provider: "sendgrid",
to: this.maskEmail(options.to),
subject: options.subject,
category: options.category,
hasTemplate: !!options.templateId,
templateId: options.templateId,
};
if (!this.isConfigured) {
this.logger.error("SendGrid not configured - cannot send email", emailContext);
throw new Error("SendGrid API key not configured");
}
const from = this.buildFromAddress(options.from);
if (!from) {
this.logger.error("No sender address configured - cannot send email", emailContext);
throw new Error("EMAIL_FROM is not configured");
}
const message = this.buildMessage(options, from);
this.logger.log("Sending email via SendGrid", emailContext);
try {
const [response] = await sgMail.send(message);
this.logger.log("Email sent successfully via SendGrid", {
...emailContext,
statusCode: response.statusCode,
messageId: (response.headers as Record<string, string>)["x-message-id"],
});
} catch (error) {
const parsed = this.parseError(error);
this.logger.error("Failed to send email via SendGrid", {
...emailContext,
statusCode: parsed.statusCode,
errorMessage: parsed.message,
errors: parsed.errors,
isRetryable: parsed.isRetryable,
});
// Attach metadata to error for upstream handling
const enrichedError = new Error(parsed.message);
(enrichedError as Error & { sendgrid: ParsedSendGridError }).sendgrid = parsed;
throw enrichedError;
}
}
private buildFromAddress(
overrideFrom?: string
): string | { email: string; name: string } | undefined {
const email = overrideFrom || this.defaultFrom;
if (!email) return undefined;
// Always include sender name when configured (regardless of email override)
if (this.defaultFromName) {
return { email, name: this.defaultFromName };
}
return email;
}
private buildMessage(
options: ProviderSendOptions,
from: string | { email: string; name: string }
): MailDataRequired {
const message: MailDataRequired = {
to: options.to,
from,
subject: options.subject,
text: options.text,
html: options.html,
templateId: options.templateId,
dynamicTemplateData: options.dynamicTemplateData,
mailSettings: {
sandboxMode: { enable: this.sandboxMode },
},
} as MailDataRequired;
try {
await sgMail.send(message);
} catch (error) {
this.logger.error("Failed to send email via SendGrid", {
error: error instanceof Error ? error.message : String(error),
});
throw error;
// Content: template or direct HTML/text
if (options.templateId) {
message.templateId = options.templateId;
if (options.dynamicTemplateData) {
message.dynamicTemplateData = options.dynamicTemplateData;
}
} else {
if (options.html) message.html = options.html;
if (options.text) message.text = options.text;
}
// Categories for SendGrid analytics
if (options.category) {
message.categories = [options.category];
}
return message;
}
private parseError(error: unknown): ParsedSendGridError {
// SendGrid errors have a specific structure
if (this.isSendGridError(error)) {
const statusCode = error.code || 500;
const body = error.response?.body;
const responseErrors = this.extractErrorsFromBody(body);
return {
statusCode,
message: responseErrors[0]?.message || error.message || "SendGrid error",
errors: responseErrors,
isRetryable: RETRYABLE_STATUS_CODES.has(statusCode),
};
}
// Generic error fallback
const message = error instanceof Error ? error.message : String(error);
return {
statusCode: 500,
message,
errors: [{ message }],
isRetryable: false,
};
}
private extractErrorsFromBody(body: unknown): SendGridErrorDetail[] {
if (!body || typeof body !== "object") {
return [{ message: "Unknown error" }];
}
// SendGrid returns { errors: [...] } in the body
const bodyObj = body as { errors?: Array<{ message?: string; field?: string; help?: string }> };
if (Array.isArray(bodyObj.errors)) {
return bodyObj.errors.map(e => ({
message: e.message || "Unknown error",
field: e.field,
help: e.help,
}));
}
return [{ message: "Unknown error" }];
}
private isSendGridError(error: unknown): error is ResponseError {
return typeof error === "object" && error !== null && "code" in error && "response" in error;
}
private maskEmail(email: string | string[]): string | string[] {
const mask = (e: string): string => {
const [local, domain] = e.split("@");
if (!domain) return "***";
const maskedLocal = local.length > 2 ? `${local[0]}***${local[local.length - 1]}` : "***";
return `${maskedLocal}@${domain}`;
};
return Array.isArray(email) ? email.map(mask) : mask(email);
}
}

View File

@ -1,22 +1,167 @@
import { Processor, WorkerHost } from "@nestjs/bullmq";
import { Processor, WorkerHost, OnWorkerEvent } from "@nestjs/bullmq";
import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { EmailService } from "../email.service.js";
import { Job } from "bullmq";
import { SendGridEmailProvider } from "../providers/sendgrid.provider.js";
import { CacheService } from "../../cache/cache.service.js";
import type { EmailJobData } from "./email.queue.js";
import { QUEUE_NAMES } from "../../queue/queue.constants.js";
interface ParsedSendGridError {
statusCode: number;
message: string;
errors: { message: string; field?: string; help?: string }[];
isRetryable: boolean;
}
/** TTL for idempotency keys (24 hours) - prevents duplicate sends on delayed retries */
const IDEMPOTENCY_TTL_SECONDS = 86400;
const IDEMPOTENCY_PREFIX = "email:sent:";
@Processor(QUEUE_NAMES.EMAIL)
@Injectable()
export class EmailProcessor extends WorkerHost {
constructor(
private readonly emailService: EmailService,
private readonly provider: SendGridEmailProvider,
private readonly cache: CacheService,
@Inject(Logger) private readonly logger: Logger
) {
super();
}
async process(job: { data: EmailJobData }): Promise<void> {
await this.emailService.sendEmail(job.data);
this.logger.debug("Processed email job");
async process(job: Job<EmailJobData>): Promise<void> {
const jobContext = {
service: "email-processor",
jobId: job.id,
attempt: job.attemptsMade + 1,
maxAttempts: job.opts.attempts || 3,
category: job.data.category || "transactional",
subject: job.data.subject,
recipientCount: Array.isArray(job.data.to) ? job.data.to.length : 1,
};
this.logger.log("Processing email job", jobContext);
// Idempotency check: prevent duplicate sends on retries
const idempotencyKey = `${IDEMPOTENCY_PREFIX}${job.id}`;
const isFirstAttempt = await this.cache.setIfNotExists(
idempotencyKey,
{ sentAt: Date.now(), subject: job.data.subject },
IDEMPOTENCY_TTL_SECONDS
);
if (!isFirstAttempt) {
this.logger.warn("Email already sent - skipping duplicate", {
...jobContext,
idempotencyKey,
});
return; // Job completes successfully without sending again
}
try {
// Send directly via provider (bypasses EmailService to avoid recursion)
await this.provider.send(job.data);
this.logger.log("Email job completed successfully", {
...jobContext,
duration: Date.now() - job.timestamp,
});
} catch (error) {
// Clear idempotency key on failure to allow retry
await this.cache.del(idempotencyKey);
const errorInfo = this.extractErrorInfo(error);
this.logger.error("Email job failed", {
...jobContext,
error: errorInfo.message,
statusCode: errorInfo.statusCode,
isRetryable: errorInfo.isRetryable,
willRetry: errorInfo.isRetryable && jobContext.attempt < jobContext.maxAttempts,
});
// Re-throw to let BullMQ handle retry logic
throw error;
}
}
@OnWorkerEvent("completed")
onCompleted(job: Job<EmailJobData>): void {
this.logger.debug("Email job marked complete", {
service: "email-processor",
jobId: job.id,
category: job.data.category,
});
}
@OnWorkerEvent("failed")
onFailed(job: Job<EmailJobData> | undefined, error: Error): void {
if (!job) {
this.logger.error("Email job failed (job undefined)", {
service: "email-processor",
error: error.message,
});
return;
}
const errorInfo = this.extractErrorInfo(error);
const isLastAttempt = job.attemptsMade >= (job.opts.attempts || 3);
if (isLastAttempt) {
this.logger.error("Email job permanently failed - all retries exhausted", {
service: "email-processor",
jobId: job.id,
category: job.data.category,
subject: job.data.subject,
totalAttempts: job.attemptsMade,
error: errorInfo.message,
statusCode: errorInfo.statusCode,
});
} else {
this.logger.warn("Email job failed - will retry", {
service: "email-processor",
jobId: job.id,
category: job.data.category,
attempt: job.attemptsMade,
maxAttempts: job.opts.attempts || 3,
error: errorInfo.message,
isRetryable: errorInfo.isRetryable,
});
}
}
@OnWorkerEvent("error")
onError(error: Error): void {
this.logger.error("Email processor worker error", {
service: "email-processor",
error: error.message,
stack: error.stack,
});
}
private extractErrorInfo(error: unknown): {
message: string;
statusCode?: number;
isRetryable: boolean;
} {
// Check for SendGrid-enriched error
if (
error instanceof Error &&
"sendgrid" in error &&
typeof (error as Error & { sendgrid?: ParsedSendGridError }).sendgrid === "object"
) {
const sgError = (error as Error & { sendgrid: ParsedSendGridError }).sendgrid;
return {
message: sgError.message,
statusCode: sgError.statusCode,
isRetryable: sgError.isRetryable,
};
}
// Fallback for generic errors
return {
message: error instanceof Error ? error.message : String(error),
isRetryable: false,
};
}
}

View File

@ -1,11 +1,30 @@
import { Injectable, Inject } from "@nestjs/common";
import { InjectQueue } from "@nestjs/bullmq";
import { Queue } from "bullmq";
import { Queue, Job } from "bullmq";
import { Logger } from "nestjs-pino";
import type { SendEmailOptions } from "../email.service.js";
import type { SendEmailOptions, EmailCategory } from "../email.service.js";
import { QUEUE_NAMES } from "../../queue/queue.constants.js";
export type EmailJobData = SendEmailOptions & { category?: string };
export type EmailJobData = SendEmailOptions & {
category?: EmailCategory;
};
export interface EmailJobResult {
jobId: string;
queued: boolean;
}
/** Queue configuration constants */
const QUEUE_CONFIG = {
/** Keep last N completed jobs for debugging */
REMOVE_ON_COMPLETE: 100,
/** Keep last N failed jobs for investigation */
REMOVE_ON_FAIL: 200,
/** Max retry attempts */
MAX_ATTEMPTS: 3,
/** Initial backoff delay in ms */
BACKOFF_DELAY: 2000,
} as const;
@Injectable()
export class EmailQueueService {
@ -14,17 +33,92 @@ export class EmailQueueService {
@Inject(Logger) private readonly logger: Logger
) {}
async enqueueEmail(data: EmailJobData): Promise<void> {
await this.queue.add("send", data, {
removeOnComplete: 50,
removeOnFail: 50,
attempts: 3,
backoff: { type: "exponential", delay: 2000 },
});
this.logger.debug("Queued email", {
to: data.to,
subject: data.subject,
category: data.category,
async enqueueEmail(data: EmailJobData): Promise<EmailJobResult> {
const jobContext = {
service: "email-queue",
queue: QUEUE_NAMES.EMAIL,
category: data.category || "transactional",
recipientCount: Array.isArray(data.to) ? data.to.length : 1,
hasTemplate: !!data.templateId,
};
try {
const job = await this.queue.add("send", data, {
removeOnComplete: QUEUE_CONFIG.REMOVE_ON_COMPLETE,
removeOnFail: QUEUE_CONFIG.REMOVE_ON_FAIL,
attempts: QUEUE_CONFIG.MAX_ATTEMPTS,
backoff: {
type: "exponential",
delay: QUEUE_CONFIG.BACKOFF_DELAY,
},
});
this.logger.log("Email job queued", {
...jobContext,
jobId: job.id,
subject: data.subject,
});
return { jobId: job.id || "unknown", queued: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error("Failed to queue email job", {
...jobContext,
subject: data.subject,
error: errorMessage,
});
throw new Error(`Failed to queue email: ${errorMessage}`);
}
}
/**
* Get queue health metrics for monitoring
*/
async getQueueHealth(): Promise<{
waiting: number;
active: number;
completed: number;
failed: number;
delayed: number;
}> {
const [waiting, active, completed, failed, delayed] = await Promise.all([
this.queue.getWaitingCount(),
this.queue.getActiveCount(),
this.queue.getCompletedCount(),
this.queue.getFailedCount(),
this.queue.getDelayedCount(),
]);
return { waiting, active, completed, failed, delayed };
}
/**
* Get failed jobs for debugging/retry
*/
async getFailedJobs(limit = 10): Promise<Job<EmailJobData>[]> {
return this.queue.getFailed(0, limit - 1);
}
/**
* Retry a specific failed job
*/
async retryJob(jobId: string): Promise<void> {
const job = await this.queue.getJob(jobId);
if (!job) {
this.logger.warn("Job not found for retry", {
service: "email-queue",
jobId,
});
return;
}
await job.retry();
this.logger.log("Job retry triggered", {
service: "email-queue",
jobId,
category: job.data.category,
});
}
}

View File

@ -28,11 +28,15 @@ export class JapanPostAddressService {
* Lookup address by ZIP code
*
* @param zipCode - ZIP code (with or without hyphen, e.g., "100-0001" or "1000001")
* @param clientIp - Client IP address for API request (defaults to 127.0.0.1)
* @returns Domain AddressLookupResult with Japanese and romanized address data
* @throws BadRequestException if ZIP code format is invalid
* @throws ServiceUnavailableException if Japan Post API is unavailable
*/
async lookupByZipCode(zipCode: string): Promise<AddressLookupResult> {
async lookupByZipCode(
zipCode: string,
clientIp: string = "127.0.0.1"
): Promise<AddressLookupResult> {
// Normalize ZIP code (remove hyphen)
const normalizedZip = zipCode.replace(/-/g, "");
@ -57,8 +61,27 @@ export class JapanPostAddressService {
const startTime = Date.now();
let rawResponse: unknown;
try {
const rawResponse = await this.connection.searchByZipCode(normalizedZip);
rawResponse = await this.connection.searchByZipCode(normalizedZip, clientIp);
// Check if response is an API error (returned with 200 status)
if (this.isApiErrorResponse(rawResponse)) {
const errorResponse = rawResponse as {
error_code?: string;
message?: string;
request_id?: string;
};
this.logger.error("Japan Post API returned error in response body", {
zipCode: normalizedZip,
durationMs: Date.now() - startTime,
errorCode: errorResponse.error_code,
message: errorResponse.message,
requestId: errorResponse.request_id,
});
throw new ServiceUnavailableException("Address lookup service error. Please try again.");
}
// Use domain mapper for transformation (single transformation point)
const result = JapanPost.transformJapanPostSearchResponse(rawResponse);
@ -91,13 +114,27 @@ export class JapanPostAddressService {
errorMessage.includes("HTTP") ||
errorMessage.includes("timed out");
// Check if this is a Zod validation error
const isZodError = error instanceof Error && error.name === "ZodError";
if (!isConnectionError) {
// Only log unexpected errors (e.g., transformation failures)
// Include raw response for debugging schema mismatches
this.logger.error("Address lookup transformation error", {
zipCode: normalizedZip,
durationMs,
errorType: error instanceof Error ? error.constructor.name : "Unknown",
errorName: error instanceof Error ? error.name : "Unknown",
isZodError,
error: errorMessage,
// For Zod errors, show the full error for debugging
zodIssues: isZodError
? JSON.stringify((error as { issues?: unknown }).issues)
: undefined,
// Log raw response to debug schema mismatch (truncated for safety)
rawResponse: rawResponse
? JSON.stringify(rawResponse).slice(0, 500)
: "undefined (error before API response)",
});
}
@ -111,4 +148,16 @@ export class JapanPostAddressService {
isAvailable(): boolean {
return this.connection.isConfigured();
}
/**
* Check if response is an API error (has error_code field instead of addresses)
*/
private isApiErrorResponse(response: unknown): boolean {
if (!response || typeof response !== "object") {
return false;
}
const obj = response as Record<string, unknown>;
// API error responses have error_code field, valid responses have addresses array
return "error_code" in obj || ("message" in obj && !("addresses" in obj));
}
}

View File

@ -295,9 +295,10 @@ export class JapanPostConnectionService implements OnModuleInit {
* Search addresses by ZIP code
*
* @param zipCode - 7-digit ZIP code (no hyphen)
* @param clientIp - Client IP address for x-forwarded-for header
* @returns Raw Japan Post API response
*/
async searchByZipCode(zipCode: string): Promise<unknown> {
async searchByZipCode(zipCode: string, clientIp: string = "127.0.0.1"): Promise<unknown> {
const token = await this.getAccessToken();
const controller = new AbortController();
@ -313,6 +314,7 @@ export class JapanPostConnectionService implements OnModuleInit {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"x-forwarded-for": clientIp,
},
signal: controller.signal,
});

View File

@ -8,13 +8,16 @@ import {
Controller,
Get,
Param,
Req,
UseGuards,
UseInterceptors,
ClassSerializerInterceptor,
} from "@nestjs/common";
import type { Request } from "express";
import { createZodDto } from "nestjs-zod";
import { Public } from "@bff/modules/auth/decorators/public.decorator.js";
import { RateLimitGuard, RateLimit } from "@bff/core/rate-limiting/index.js";
import { extractClientIp } from "@bff/core/http/request-context.util.js";
import { JapanPostAddressService } from "@bff/integrations/japanpost/services/japanpost-address.service.js";
import {
addressLookupResultSchema,
@ -75,8 +78,12 @@ export class AddressController {
@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);
async lookupByZipCode(
@Param() params: ZipCodeParamDto,
@Req() req: Request
): Promise<AddressLookupResultDto> {
const clientIp = extractClientIp(req);
return this.japanPostService.lookupByZipCode(params.zipCode, clientIp);
}
/**

View File

@ -12,7 +12,6 @@ import { TokenBlacklistService } from "./infra/token/token-blacklist.service.js"
import { TokenStorageService } from "./infra/token/token-storage.service.js";
import { TokenRevocationService } from "./infra/token/token-revocation.service.js";
import { PasswordResetTokenService } from "./infra/token/password-reset-token.service.js";
import { EmailModule } from "@bff/infra/email/email.module.js";
import { CacheModule } from "@bff/infra/cache/cache.module.js";
import { AuthTokenService } from "./infra/token/token.service.js";
import { JoseJwtService } from "./infra/token/jose-jwt.service.js";
@ -33,7 +32,7 @@ import { GetStartedWorkflowService } from "./infra/workflows/get-started-workflo
import { GetStartedController } from "./presentation/http/get-started.controller.js";
@Module({
imports: [UsersModule, MappingsModule, IntegrationsModule, EmailModule, CacheModule],
imports: [UsersModule, MappingsModule, IntegrationsModule, CacheModule],
controllers: [AuthController, GetStartedController],
providers: [
// Application services

View File

@ -4,11 +4,10 @@ import { InternetController } from "./internet.controller.js";
import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module.js";
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
import { SalesforceModule } from "@bff/integrations/salesforce/salesforce.module.js";
import { EmailModule } from "@bff/infra/email/email.module.js";
import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js";
@Module({
imports: [WhmcsModule, MappingsModule, SalesforceModule, EmailModule, NotificationsModule],
imports: [WhmcsModule, MappingsModule, SalesforceModule, NotificationsModule],
controllers: [InternetController],
providers: [InternetCancellationService],
exports: [InternetCancellationService],

View File

@ -3,7 +3,6 @@ import { FreebitModule } from "@bff/integrations/freebit/freebit.module.js";
import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module.js";
import { SalesforceModule } from "@bff/integrations/salesforce/salesforce.module.js";
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
import { EmailModule } from "@bff/infra/email/email.module.js";
import { SftpModule } from "@bff/integrations/sftp/sftp.module.js";
import { SecurityModule } from "@bff/core/security/security.module.js";
import { SimUsageStoreService } from "../sim-usage-store.service.js";
@ -39,7 +38,6 @@ import { VoiceOptionsModule, VoiceOptionsService } from "@bff/modules/voice-opti
WhmcsModule,
SalesforceModule,
MappingsModule,
EmailModule,
ServicesModule,
SftpModule,
NotificationsModule,

View File

@ -9,7 +9,6 @@ import { SecurityModule } from "@bff/core/security/security.module.js";
import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module.js";
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
import { FreebitModule } from "@bff/integrations/freebit/freebit.module.js";
import { EmailModule } from "@bff/infra/email/email.module.js";
import { SimManagementModule } from "./sim-management/sim-management.module.js";
import { InternetManagementModule } from "./internet-management/internet-management.module.js";
import { CallHistoryModule } from "./call-history/call-history.module.js";
@ -25,7 +24,6 @@ import { CancellationController } from "./cancellation/cancellation.controller.j
WhmcsModule,
MappingsModule,
FreebitModule,
EmailModule,
SimManagementModule,
InternetManagementModule,
CallHistoryModule,

View File

@ -12,102 +12,43 @@ import {
Wrench,
Tv,
} from "lucide-react";
import { cn } from "@/shared/utils";
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 (
<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="flex items-start gap-4 mb-4">
<div
className={cn(
"flex h-12 w-12 items-center justify-center rounded-xl border",
accentStyles[accentColor]
)}
>
{icon}
</div>
<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>
<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>
);
}
import { ServiceCard } from "@/components/molecules/ServiceCard";
export default function ServicesPage() {
return (
<div className="space-y-12 pb-16">
{/* Hero */}
<section className="text-center pt-8">
<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" />
Full English Support
</span>
<div
className="animate-in fade-in slide-in-from-bottom-4 duration-500"
style={{ animationDelay: "0ms" }}
>
<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" />
Full English Support
</span>
</div>
<h1 className="text-display-lg font-display font-bold text-foreground mb-4">
<h1
className="text-display-lg font-display font-bold text-foreground mb-4 animate-in fade-in slide-in-from-bottom-6 duration-700"
style={{ animationDelay: "100ms" }}
>
Our Services
</h1>
<p className="text-lg text-muted-foreground max-w-xl mx-auto">
<p
className="text-lg text-muted-foreground max-w-xl mx-auto animate-in fade-in slide-in-from-bottom-8 duration-700"
style={{ animationDelay: "200ms" }}
>
Connectivity and support solutions for Japan&apos;s international community.
</p>
</section>
{/* Value Props - Compact */}
<section className="flex flex-wrap justify-center gap-6 text-sm">
<section
className="flex flex-wrap justify-center gap-6 text-sm animate-in fade-in slide-in-from-bottom-8 duration-700"
style={{ animationDelay: "300ms" }}
>
<div className="flex items-center gap-2 text-muted-foreground">
<Globe className="h-4 w-4 text-primary" />
<span>One provider, all services</span>
@ -123,7 +64,10 @@ export default function ServicesPage() {
</section>
{/* All Services - Clean Grid */}
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
<section
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 animate-in fade-in slide-in-from-bottom-8 duration-700"
style={{ animationDelay: "400ms" }}
>
<ServiceCard
href="/services/internet"
icon={<Wifi className="h-6 w-6" />}
@ -178,7 +122,10 @@ export default function ServicesPage() {
</section>
{/* CTA */}
<section className="rounded-2xl bg-gradient-to-br from-muted/50 to-muted/80 p-8 text-center">
<section
className="rounded-2xl bg-gradient-to-br from-muted/50 to-muted/80 p-8 text-center animate-in fade-in slide-in-from-bottom-8 duration-700"
style={{ animationDelay: "500ms" }}
>
<h2 className="text-xl font-bold text-foreground font-display mb-3">Need help choosing?</h2>
<p className="text-muted-foreground mb-6 max-w-md mx-auto">
Our bilingual team can help you find the right solution.
@ -195,8 +142,9 @@ export default function ServicesPage() {
<a
href="tel:0120660470"
className="inline-flex items-center gap-2 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
aria-label="Call us toll free at 0120-660-470"
>
<Phone className="h-4 w-4" />
<Phone className="h-4 w-4" aria-hidden="true" />
0120-660-470 (Toll Free)
</a>
</div>

View File

@ -0,0 +1,515 @@
import Link from "next/link";
import { cn } from "@/shared/utils";
import { ArrowRight } from "lucide-react";
import type { ReactNode } from "react";
import React from "react";
/**
* Accent color options for ServiceCard
*/
export type ServiceCardAccentColor =
| "blue"
| "green"
| "purple"
| "orange"
| "cyan"
| "pink"
| "amber"
| "rose";
/**
* Variant options for ServiceCard
* - default: Standard card with icon, title, description, price
* - featured: Premium styling with gradient accents and more lift
* - minimal: Compact icon + title only (no description)
* - bento-sm: Small bento grid card
* - bento-md: Medium bento grid card
* - bento-lg: Large bento grid card with decorative elements
*/
export type ServiceCardVariant =
| "default"
| "featured"
| "minimal"
| "bento-sm"
| "bento-md"
| "bento-lg";
export interface ServiceCardProps {
/** Link destination */
href: string;
/**
* Icon element to display.
* Pass a pre-styled JSX element: `icon={<Wifi className="h-6 w-6" />}`
* Or a component reference (className will be applied): `icon={Wifi}`
*/
icon: ReactNode;
/** Card title */
title: string;
/** Card description (optional for minimal variant) */
description?: string;
/** Starting price text e.g. "¥3,200/mo" */
price?: string;
/** Badge text shown at top-right e.g. "1st month free" */
badge?: string;
/** Highlight text shown as a pill (alternative to badge) */
highlight?: string;
/** Accent color for icon background */
accentColor?: ServiceCardAccentColor;
/** Card variant */
variant?: ServiceCardVariant;
/** Additional CSS classes */
className?: string;
}
/**
* Accent color styles mapping
* All classes must be complete strings (no dynamic interpolation) for Tailwind JIT
*/
const accentColorStyles: Record<
ServiceCardAccentColor,
{
bg: string;
text: string;
border: string;
gradient: string;
hoverBorder: string;
cardBg: string;
}
> = {
blue: {
bg: "bg-blue-500/10",
text: "text-blue-600 dark:text-blue-400",
border: "border-blue-500/20",
gradient: "from-blue-500/30 to-transparent",
hoverBorder: "hover:border-blue-500/40",
cardBg: "from-blue-500/10 via-card to-card",
},
green: {
bg: "bg-green-500/10",
text: "text-green-600 dark:text-green-400",
border: "border-green-500/20",
gradient: "from-green-500/30 to-transparent",
hoverBorder: "hover:border-green-500/40",
cardBg: "from-green-500/10 via-card to-card",
},
purple: {
bg: "bg-purple-500/10",
text: "text-purple-600 dark:text-purple-400",
border: "border-purple-500/20",
gradient: "from-purple-500/30 to-transparent",
hoverBorder: "hover:border-purple-500/40",
cardBg: "from-purple-500/10 via-card to-card",
},
orange: {
bg: "bg-orange-500/10",
text: "text-orange-600 dark:text-orange-400",
border: "border-orange-500/20",
gradient: "from-orange-500/30 to-transparent",
hoverBorder: "hover:border-orange-500/40",
cardBg: "from-orange-500/10 via-card to-card",
},
cyan: {
bg: "bg-cyan-500/10",
text: "text-cyan-600 dark:text-cyan-400",
border: "border-cyan-500/20",
gradient: "from-cyan-500/30 to-transparent",
hoverBorder: "hover:border-cyan-500/40",
cardBg: "from-cyan-500/10 via-card to-card",
},
pink: {
bg: "bg-pink-500/10",
text: "text-pink-600 dark:text-pink-400",
border: "border-pink-500/20",
gradient: "from-pink-500/30 to-transparent",
hoverBorder: "hover:border-pink-500/40",
cardBg: "from-pink-500/10 via-card to-card",
},
amber: {
bg: "bg-amber-500/10",
text: "text-amber-600 dark:text-amber-400",
border: "border-amber-500/20",
gradient: "from-amber-500/30 to-transparent",
hoverBorder: "hover:border-amber-500/40",
cardBg: "from-amber-500/10 via-card to-card",
},
rose: {
bg: "bg-rose-500/10",
text: "text-rose-600 dark:text-rose-400",
border: "border-rose-500/20",
gradient: "from-rose-500/30 to-transparent",
hoverBorder: "hover:border-rose-500/40",
cardBg: "from-rose-500/10 via-card to-card",
},
};
/**
* Render icon - handles both component references and pre-styled JSX elements
* @param icon - Either a component reference (function) or a ReactNode (JSX element)
* @param className - Applied only when icon is a component reference
*/
function renderIcon(icon: ReactNode, className: string): ReactNode {
// If icon is a component reference (function), instantiate it with className
if (typeof icon === "function") {
const IconComponent = icon as React.ComponentType<{ className?: string }>;
return <IconComponent className={className} />;
}
// Otherwise, return the pre-styled JSX element as-is
return icon;
}
/**
* Default variant - Standard service card
*/
function DefaultCard({
href,
icon,
title,
description,
price,
badge,
accentColor = "blue",
className,
}: ServiceCardProps) {
const colors = accentColorStyles[accentColor];
return (
<Link href={href} className={cn("group block", className)}>
<div
className={cn(
"relative h-full flex flex-col rounded-2xl border bg-card p-6",
"transition-all duration-[var(--cp-duration-normal)]",
"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="flex items-start gap-4 mb-4">
<div
className={cn(
"flex h-12 w-12 items-center justify-center rounded-xl border",
colors.bg,
colors.text,
colors.border
)}
>
{renderIcon(icon, "h-6 w-6")}
</div>
<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>
{description && (
<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>
);
}
/**
* Featured variant - Premium styling with enhanced shadows
*/
function FeaturedCard({ href, icon, title, description, highlight, className }: ServiceCardProps) {
return (
<Link href={href} className={cn("group block h-full", className)}>
<div
className={cn(
"h-full flex flex-col",
"rounded-xl border bg-card",
"p-6",
"transition-all duration-[var(--cp-duration-normal)]",
"border-primary/20",
"shadow-md shadow-primary/5",
"hover:border-primary/40 hover:shadow-xl hover:shadow-primary/10",
"hover:-translate-y-1"
)}
>
{/* Icon with solid primary background */}
<div
className={cn(
"flex h-12 w-12 items-center justify-center rounded-xl mb-4",
"transition-all group-hover:scale-105",
"bg-primary shadow-md shadow-primary/20 group-hover:shadow-lg group-hover:shadow-primary/30"
)}
>
{renderIcon(icon, "h-6 w-6 text-primary-foreground")}
</div>
{/* Content */}
<h3 className="text-lg font-semibold text-foreground mb-2 font-display">{title}</h3>
{description && (
<p className="text-sm text-muted-foreground leading-relaxed flex-grow">{description}</p>
)}
{/* Highlight badge */}
{highlight && (
<span
className={cn(
"inline-flex self-start mt-3 rounded-full px-3 py-1 text-xs font-semibold",
"bg-success text-success-foreground shadow-sm"
)}
>
{highlight}
</span>
)}
{/* Link indicator */}
<div
className={cn(
"flex items-center gap-1.5 mt-4 pt-4 border-t",
"text-sm font-medium text-primary",
"transition-colors group-hover:text-primary-hover",
"border-primary/10"
)}
>
Learn more
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</div>
</div>
</Link>
);
}
/**
* Minimal variant - Compact icon + title only
*/
function MinimalCard({ href, icon, title, className }: ServiceCardProps) {
return (
<Link href={href} className={cn("group block", className)}>
<div
className={cn(
"flex flex-col items-center text-center",
"rounded-xl border border-border bg-card",
"p-6",
"transition-all duration-[var(--cp-duration-normal)]",
"hover:border-primary/30 hover:shadow-md",
"hover:-translate-y-0.5"
)}
>
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10 mb-3 transition-all group-hover:bg-primary/15">
{renderIcon(icon, "h-6 w-6 text-primary")}
</div>
<h3 className="text-sm font-semibold text-foreground font-display">{title}</h3>
</div>
</Link>
);
}
/**
* Bento Small variant - Compact inline card
*/
function BentoSmallCard({ href, icon, title, accentColor = "blue", className }: ServiceCardProps) {
const colors = accentColorStyles[accentColor];
return (
<Link
href={href}
className={cn(
"group rounded-xl bg-card/80 backdrop-blur-sm border border-border/50",
"p-4",
"transition-all duration-[var(--cp-duration-normal)]",
"hover:bg-card hover:border-border",
className
)}
>
<div className="flex items-center gap-3">
<div className={cn("h-10 w-10 rounded-lg flex items-center justify-center", colors.text)}>
{renderIcon(icon, "h-5 w-5")}
</div>
<span className="font-semibold text-foreground">{title}</span>
</div>
</Link>
);
}
/**
* Bento Medium variant - Standard bento card
*/
function BentoMediumCard({
href,
icon,
title,
description,
accentColor = "blue",
className,
}: ServiceCardProps) {
const colors = accentColorStyles[accentColor];
return (
<Link
href={href}
className={cn(
"group rounded-xl bg-card border",
colors.border,
colors.hoverBorder,
"p-6",
"transition-all duration-[var(--cp-duration-normal)]",
"hover:-translate-y-0.5",
className
)}
>
<div
className={cn(
"inline-flex items-center justify-center h-12 w-12 rounded-lg mb-4",
"bg-gradient-to-br from-card to-muted",
colors.text
)}
>
{renderIcon(icon, "h-6 w-6")}
</div>
<h3 className="text-lg font-bold text-foreground mb-2 font-display">{title}</h3>
{description && (
<p className="text-sm text-muted-foreground leading-relaxed">{description}</p>
)}
</Link>
);
}
/**
* Bento Large variant - Hero-style bento card with decorative elements
*/
function BentoLargeCard({
href,
icon,
title,
description,
accentColor = "blue",
className,
}: ServiceCardProps) {
const colors = accentColorStyles[accentColor];
return (
<Link
href={href}
className={cn(
"group relative overflow-hidden rounded-2xl",
"bg-gradient-to-br",
colors.cardBg,
"border",
colors.border,
colors.hoverBorder,
"p-8",
"transition-all duration-[var(--cp-duration-normal)]",
"hover:-translate-y-1",
className
)}
>
{/* Decorative gradient orb */}
<div
className={cn(
"absolute -top-20 -right-20 w-64 h-64 rounded-full",
"bg-gradient-to-br opacity-30",
colors.gradient,
"blur-2xl"
)}
aria-hidden="true"
/>
<div className="relative">
<div
className={cn(
"inline-flex items-center justify-center h-14 w-14 rounded-xl mb-6",
"bg-background/50 backdrop-blur-sm border border-border/50",
colors.text
)}
>
{renderIcon(icon, "h-7 w-7")}
</div>
<h3 className="text-2xl font-bold text-foreground mb-3 font-display">{title}</h3>
{description && (
<p className="text-muted-foreground leading-relaxed max-w-sm mb-6">{description}</p>
)}
<span
className={cn(
"inline-flex items-center gap-2 font-semibold",
colors.text,
"transition-transform duration-[var(--cp-duration-normal)]",
"group-hover:translate-x-1"
)}
>
Learn more
<ArrowRight className="h-4 w-4" />
</span>
</div>
</Link>
);
}
/**
* ServiceCard - Unified service card component
*
* A flexible card component for displaying services with multiple variants:
* - default: Standard card with icon, title, description, price, badge
* - featured: Premium styling with enhanced shadows and highlights
* - minimal: Compact icon + title only
* - bento-sm/md/lg: Bento grid cards in different sizes
*
* @example
* ```tsx
* // Default variant
* <ServiceCard
* href="/services/internet"
* icon={<Wifi className="h-6 w-6" />}
* title="Internet"
* description="High-speed fiber internet"
* price="¥3,200/mo"
* accentColor="blue"
* />
*
* // Featured variant
* <ServiceCard
* variant="featured"
* href="/services/sim"
* icon={Smartphone}
* title="SIM & eSIM"
* description="Mobile data plans"
* highlight="1st month free"
* />
*
* // Minimal variant
* <ServiceCard
* variant="minimal"
* href="/services/vpn"
* icon={ShieldCheck}
* title="VPN"
* />
* ```
*/
export function ServiceCard(props: ServiceCardProps) {
const { variant = "default" } = props;
switch (variant) {
case "featured":
return <FeaturedCard {...props} />;
case "minimal":
return <MinimalCard {...props} />;
case "bento-sm":
return <BentoSmallCard {...props} />;
case "bento-md":
return <BentoMediumCard {...props} />;
case "bento-lg":
return <BentoLargeCard {...props} />;
default:
return <DefaultCard {...props} />;
}
}
export default ServiceCard;

View File

@ -0,0 +1,2 @@
export { ServiceCard, default } from "./ServiceCard";
export type { ServiceCardProps, ServiceCardVariant, ServiceCardAccentColor } from "./ServiceCard";

View File

@ -21,6 +21,7 @@ export * from "./SectionHeader/SectionHeader";
export * from "./ProgressSteps/ProgressSteps";
export * from "./SubCard/SubCard";
export * from "./AnimatedCard/AnimatedCard";
export * from "./ServiceCard/ServiceCard";
// Performance and lazy loading utilities
export { ErrorBoundary } from "./error-boundary";

View File

@ -6,14 +6,14 @@
* 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
* - Progressive disclosure with smooth animations
* - 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, useRef } from "react";
import { Home, Building2, CheckCircle } from "lucide-react";
import { Home, Building2, CheckCircle2, MapPin, ChevronRight, Sparkles } from "lucide-react";
import { Input } from "@/components/atoms";
import { FormField } from "@/components/molecules/FormField/FormField";
import { cn } from "@/shared/utils";
@ -62,16 +62,99 @@ const DEFAULT_ADDRESS: Omit<JapanAddressFormData, "residenceType"> & {
cityJa: "",
town: "",
townJa: "",
streetAddress: "",
buildingName: "",
roomNumber: "",
residenceType: "", // User must explicitly choose
residenceType: "",
};
// ============================================================================
// Component
// Animation Wrapper Component
// ============================================================================
function AnimatedSection({
show,
children,
delay = 0,
}: {
show: boolean;
children: React.ReactNode;
delay?: number;
}) {
return (
<div
className={cn(
"grid transition-all duration-500 ease-out",
show ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
)}
style={{ transitionDelay: show ? `${delay}ms` : "0ms" }}
>
<div className="overflow-hidden">{children}</div>
</div>
);
}
// ============================================================================
// Progress Step Indicator
// ============================================================================
function ProgressIndicator({
currentStep,
totalSteps,
}: {
currentStep: number;
totalSteps: number;
}) {
return (
<div className="flex items-center gap-1.5 mb-6">
{Array.from({ length: totalSteps }).map((_, i) => (
<div
key={i}
className={cn(
"h-1 rounded-full transition-all duration-500",
i < currentStep
? "bg-primary flex-[2]"
: i === currentStep
? "bg-primary/40 flex-[2] animate-pulse"
: "bg-border flex-1"
)}
/>
))}
</div>
);
}
// ============================================================================
// Bilingual Field Display
// ============================================================================
function BilingualValue({
romaji,
japanese,
placeholder,
verified,
}: {
romaji: string;
japanese?: string;
placeholder: string;
verified: boolean;
}) {
if (!verified) {
return <span className="text-muted-foreground/60 italic text-sm">{placeholder}</span>;
}
return (
<div className="flex items-baseline gap-2">
<span className="text-foreground font-medium">{romaji}</span>
{japanese && <span className="text-muted-foreground text-sm">({japanese})</span>}
</div>
);
}
// ============================================================================
// Main Component
// ============================================================================
// Internal form state allows empty residenceType for "not selected yet"
type InternalFormState = Omit<JapanAddressFormData, "residenceType"> & {
residenceType: ResidenceType | "";
};
@ -85,27 +168,38 @@ export function JapanAddressForm({
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>("");
const [showSuccess, setShowSuccess] = useState(false);
// Store onChange in ref to avoid it triggering useEffect re-runs
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
// Update address when initialValues change
const streetAddressRef = useRef<HTMLInputElement>(null);
const focusTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hasInitializedRef = useRef(false);
// Calculate current step for progress
const getCurrentStep = () => {
if (!isAddressVerified) return 0;
if (!address.streetAddress.trim()) return 1;
if (!address.residenceType) return 2;
if (address.residenceType === RESIDENCE_TYPE.APARTMENT && !address.roomNumber?.trim()) return 3;
return 4;
};
const currentStep = getCurrentStep();
// Only apply initialValues on first mount to avoid resetting user edits
useEffect(() => {
if (initialValues) {
if (initialValues && !hasInitializedRef.current) {
hasInitializedRef.current = true;
setAddress(prev => ({ ...prev, ...initialValues }));
// If initialValues have address data, consider it verified
if (initialValues.prefecture && initialValues.city && initialValues.town) {
setIsAddressVerified(true);
setVerifiedZipCode(initialValues.postcode || "");
@ -113,36 +207,52 @@ export function JapanAddressForm({
}
}, [initialValues]);
// Get error for a field (only show if touched)
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (focusTimeoutRef.current) {
clearTimeout(focusTimeoutRef.current);
}
};
}, []);
const getError = (field: keyof JapanAddressFormData): string | undefined => {
return touched[field] ? errors[field] : undefined;
};
// Notify parent of address changes via useEffect (avoids setState during render)
// Calculate form completion status
const hasResidenceType =
address.residenceType === RESIDENCE_TYPE.HOUSE ||
address.residenceType === RESIDENCE_TYPE.APARTMENT;
const baseFieldsFilled =
address.postcode.trim() !== "" &&
address.prefecture.trim() !== "" &&
address.city.trim() !== "" &&
address.town.trim() !== "" &&
address.streetAddress.trim() !== "";
const roomNumberOk =
address.residenceType !== RESIDENCE_TYPE.APARTMENT || (address.roomNumber?.trim() ?? "") !== "";
const isComplete = isAddressVerified && hasResidenceType && baseFieldsFilled && roomNumberOk;
// Notify parent of changes - only send valid typed address when residenceType is set
useEffect(() => {
const hasResidenceType =
address.residenceType === RESIDENCE_TYPE.HOUSE ||
address.residenceType === RESIDENCE_TYPE.APARTMENT;
if (hasResidenceType) {
// Safe to cast since we verified residenceType is valid
onChangeRef.current?.(address as JapanAddressFormData, isComplete);
} else {
// Send incomplete state with partial data (parent should check isComplete flag)
onChangeRef.current?.(address as JapanAddressFormData, false);
}
}, [address, isAddressVerified, hasResidenceType, isComplete]);
const baseFieldsFilled =
address.postcode.trim() !== "" &&
address.prefecture.trim() !== "" &&
address.city.trim() !== "" &&
address.town.trim() !== "";
// Manage success animation separately to avoid callback double-firing
useEffect(() => {
setShowSuccess(isComplete);
}, [isComplete]);
// Room number is required for apartments
const roomNumberOk =
address.residenceType !== RESIDENCE_TYPE.APARTMENT ||
(address.roomNumber?.trim() ?? "") !== "";
// Must have verified address from ZIP lookup
const isComplete = isAddressVerified && hasResidenceType && baseFieldsFilled && roomNumberOk;
// Use ref to avoid infinite loops when onChange changes reference
onChangeRef.current?.(address as JapanAddressFormData, isComplete);
}, [address, isAddressVerified]);
// Handle ZIP code change - reset verification when ZIP changes
const handleZipChange = useCallback(
(value: string) => {
const normalizedNew = value.replace(/-/g, "");
@ -150,8 +260,8 @@ export function JapanAddressForm({
const shouldReset = normalizedNew !== normalizedVerified;
if (shouldReset) {
// Reset address fields when ZIP changes
setIsAddressVerified(false);
setShowSuccess(false);
setAddress(prev => ({
...prev,
postcode: value,
@ -161,42 +271,44 @@ export function JapanAddressForm({
cityJa: "",
town: "",
townJa: "",
// Keep user-entered fields
buildingName: prev.buildingName,
roomNumber: prev.roomNumber,
residenceType: prev.residenceType,
}));
} else {
// Just update postcode formatting
setAddress(prev => ({ ...prev, postcode: value }));
}
},
[verifiedZipCode]
);
// Handle address found from ZIP lookup
const handleAddressFound = useCallback((found: JapanPostAddress) => {
setAddress(prev => {
setIsAddressVerified(true);
setVerifiedZipCode(prev.postcode);
return {
...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,
};
});
// Focus street address input after lookup (with cleanup tracking)
if (focusTimeoutRef.current) {
clearTimeout(focusTimeoutRef.current);
}
focusTimeoutRef.current = setTimeout(() => {
streetAddressRef.current?.focus();
focusTimeoutRef.current = null;
}, 300);
}, []);
// Handle lookup completion (success or failure)
const handleLookupComplete = useCallback((found: boolean) => {
if (!found) {
// Clear address fields on failed lookup
setIsAddressVerified(false);
setAddress(prev => ({
...prev,
@ -210,196 +322,367 @@ export function JapanAddressForm({
}
}, []);
// Handle residence type change
const handleResidenceTypeChange = useCallback((type: ResidenceType) => {
setAddress(prev => ({
...prev,
residenceType: type,
// Clear room number when switching to house
roomNumber: type === RESIDENCE_TYPE.HOUSE ? "" : prev.roomNumber,
}));
}, []);
// Handle building name change
const handleStreetAddressChange = useCallback((value: string) => {
setAddress(prev => ({ ...prev, streetAddress: value }));
}, []);
const handleBuildingNameChange = useCallback((value: string) => {
setAddress(prev => ({ ...prev, buildingName: value }));
}, []);
// Handle room number change
const handleRoomNumberChange = useCallback((value: string) => {
setAddress(prev => ({ ...prev, roomNumber: value }));
}, []);
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
/>
<div className={cn("space-y-6", className)}>
{/* Progress Indicator */}
<ProgressIndicator currentStep={currentStep} totalSteps={4} />
{/* 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
{/* Step 1: ZIP Code Lookup */}
<div className="relative">
<div
className={cn(
"absolute -left-3 top-0 bottom-0 w-1 rounded-full transition-all duration-500",
isAddressVerified ? "bg-success" : "bg-primary/30"
)}
/>
<div className="pl-4">
<div className="flex items-center gap-2 mb-3">
<div
className={cn(
"flex items-center justify-center w-6 h-6 rounded-full text-xs font-semibold transition-all duration-300",
isAddressVerified
? "bg-success text-success-foreground"
: "bg-primary/10 text-primary"
)}
>
{isAddressVerified ? <CheckCircle2 className="w-4 h-4" /> : "1"}
</div>
<span className="text-sm font-medium text-foreground">Enter ZIP Code</span>
{isAddressVerified && (
<span className="text-xs text-success font-medium ml-auto flex items-center gap-1">
<Sparkles className="w-3 h-3" />
Verified
</span>
)}
</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"
<ZipCodeInput
value={address.postcode}
onChange={handleZipChange}
onAddressFound={handleAddressFound}
onLookupComplete={handleLookupComplete}
error={getError("postcode")}
required
disabled={disabled}
autoFocus
/>
</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>
</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}
{/* Verified Address Display */}
<AnimatedSection show={isAddressVerified}>
<div className="relative">
<div className="absolute -left-3 top-0 bottom-0 w-1 rounded-full bg-success/30" />
<div className="pl-4">
<div
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"
"rounded-xl border transition-all duration-500",
"bg-gradient-to-br from-success/5 via-success/[0.02] to-transparent",
"border-success/20"
)}
>
<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 className="p-4 space-y-3">
<div className="flex items-center gap-2 text-success">
<MapPin className="w-4 h-4" />
<span className="text-sm font-semibold">Address from Japan Post</span>
</div>
<div className="grid gap-2">
{/* Prefecture */}
<div className="flex items-center gap-3 py-2 px-3 rounded-lg bg-background/50">
<span className="text-xs text-muted-foreground w-20 shrink-0">Prefecture</span>
<ChevronRight className="w-3 h-3 text-muted-foreground/50" />
<BilingualValue
romaji={address.prefecture}
japanese={address.prefectureJa}
placeholder="—"
verified={isAddressVerified}
/>
</div>
{/* City */}
<div className="flex items-center gap-3 py-2 px-3 rounded-lg bg-background/50">
<span className="text-xs text-muted-foreground w-20 shrink-0">City / Ward</span>
<ChevronRight className="w-3 h-3 text-muted-foreground/50" />
<BilingualValue
romaji={address.city}
japanese={address.cityJa}
placeholder="—"
verified={isAddressVerified}
/>
</div>
{/* Town */}
<div className="flex items-center gap-3 py-2 px-3 rounded-lg bg-background/50">
<span className="text-xs text-muted-foreground w-20 shrink-0">Town</span>
<ChevronRight className="w-3 h-3 text-muted-foreground/50" />
<BilingualValue
romaji={address.town}
japanese={address.townJa}
placeholder="—"
verified={isAddressVerified}
/>
</div>
</div>
</div>
</div>
</div>
{!hasResidenceTypeSelected && getError("residenceType") && (
<p className="text-sm text-danger">{getError("residenceType")}</p>
)}
</div>
)}
</AnimatedSection>
{/* 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>
{/* Step 2: Street Address */}
<AnimatedSection show={isAddressVerified} delay={100}>
<div className="relative">
<div
className={cn(
"absolute -left-3 top-0 bottom-0 w-1 rounded-full transition-all duration-500",
address.streetAddress.trim() ? "bg-success" : "bg-primary/30"
)}
/>
<div className="pl-4">
<div className="flex items-center gap-2 mb-3">
<div
className={cn(
"flex items-center justify-center w-6 h-6 rounded-full text-xs font-semibold transition-all duration-300",
address.streetAddress.trim()
? "bg-success text-success-foreground"
: "bg-primary/10 text-primary"
)}
>
{address.streetAddress.trim() ? <CheckCircle2 className="w-4 h-4" /> : "2"}
</div>
<span className="text-sm font-medium text-foreground">Street Address</span>
</div>
{/* Room Number - Only for apartments */}
{isApartment && (
<FormField
label="Room Number"
error={getError("roomNumber")}
label=""
error={getError("streetAddress")}
required
helperText="Required for apartments"
helperText="Enter chome-banchi-go (e.g., 1-5-3)"
>
<Input
value={address.roomNumber ?? ""}
onChange={e => handleRoomNumberChange(e.target.value)}
onBlur={() => onBlur?.("roomNumber")}
placeholder="201"
ref={streetAddressRef}
value={address.streetAddress}
onChange={e => handleStreetAddressChange(e.target.value)}
onBlur={() => onBlur?.("streetAddress")}
placeholder="1-5-3"
disabled={disabled}
data-field="address.roomNumber"
className="font-mono text-lg tracking-wider"
data-field="address.streetAddress"
/>
</FormField>
)}
</>
)}
</div>
</div>
</AnimatedSection>
{/* Step 3: Residence Type */}
<AnimatedSection show={isAddressVerified && !!address.streetAddress.trim()} delay={150}>
<div className="relative">
<div
className={cn(
"absolute -left-3 top-0 bottom-0 w-1 rounded-full transition-all duration-500",
hasResidenceType ? "bg-success" : "bg-primary/30"
)}
/>
<div className="pl-4">
<div className="flex items-center gap-2 mb-3">
<div
className={cn(
"flex items-center justify-center w-6 h-6 rounded-full text-xs font-semibold transition-all duration-300",
hasResidenceType
? "bg-success text-success-foreground"
: "bg-primary/10 text-primary"
)}
>
{hasResidenceType ? <CheckCircle2 className="w-4 h-4" /> : "3"}
</div>
<span className="text-sm font-medium text-foreground">Residence Type</span>
</div>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => handleResidenceTypeChange(RESIDENCE_TYPE.HOUSE)}
disabled={disabled}
className={cn(
"group relative flex flex-col items-center gap-2 p-4 rounded-xl border-2 transition-all duration-300",
"hover:scale-[1.02] active:scale-[0.98]",
address.residenceType === RESIDENCE_TYPE.HOUSE
? "border-primary bg-primary/5 shadow-[0_0_0_4px] shadow-primary/10"
: "border-border bg-card hover:border-primary/50 hover:bg-primary/[0.02]",
disabled && "opacity-50 cursor-not-allowed hover:scale-100"
)}
>
<div
className={cn(
"w-12 h-12 rounded-xl flex items-center justify-center transition-all duration-300",
address.residenceType === RESIDENCE_TYPE.HOUSE
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground group-hover:bg-primary/10 group-hover:text-primary"
)}
>
<Home className="w-6 h-6" />
</div>
<span
className={cn(
"text-sm font-semibold transition-colors",
address.residenceType === RESIDENCE_TYPE.HOUSE
? "text-primary"
: "text-foreground"
)}
>
House
</span>
<span className="text-xs text-muted-foreground"></span>
</button>
<button
type="button"
onClick={() => handleResidenceTypeChange(RESIDENCE_TYPE.APARTMENT)}
disabled={disabled}
className={cn(
"group relative flex flex-col items-center gap-2 p-4 rounded-xl border-2 transition-all duration-300",
"hover:scale-[1.02] active:scale-[0.98]",
address.residenceType === RESIDENCE_TYPE.APARTMENT
? "border-primary bg-primary/5 shadow-[0_0_0_4px] shadow-primary/10"
: "border-border bg-card hover:border-primary/50 hover:bg-primary/[0.02]",
disabled && "opacity-50 cursor-not-allowed hover:scale-100"
)}
>
<div
className={cn(
"w-12 h-12 rounded-xl flex items-center justify-center transition-all duration-300",
address.residenceType === RESIDENCE_TYPE.APARTMENT
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground group-hover:bg-primary/10 group-hover:text-primary"
)}
>
<Building2 className="w-6 h-6" />
</div>
<span
className={cn(
"text-sm font-semibold transition-colors",
address.residenceType === RESIDENCE_TYPE.APARTMENT
? "text-primary"
: "text-foreground"
)}
>
Apartment
</span>
<span className="text-xs text-muted-foreground"></span>
</button>
</div>
{!hasResidenceType && getError("residenceType") && (
<p className="text-sm text-danger mt-2">{getError("residenceType")}</p>
)}
</div>
</div>
</AnimatedSection>
{/* Step 4: Building Details */}
<AnimatedSection show={isAddressVerified && hasResidenceType} delay={200}>
<div className="relative">
<div
className={cn(
"absolute -left-3 top-0 bottom-0 w-1 rounded-full transition-all duration-500",
showSuccess ? "bg-success" : "bg-primary/30"
)}
/>
<div className="pl-4 space-y-4">
<div className="flex items-center gap-2 mb-3">
<div
className={cn(
"flex items-center justify-center w-6 h-6 rounded-full text-xs font-semibold transition-all duration-300",
showSuccess ? "bg-success text-success-foreground" : "bg-primary/10 text-primary"
)}
>
{showSuccess ? <CheckCircle2 className="w-4 h-4" /> : "4"}
</div>
<span className="text-sm font-medium text-foreground">Building Details</span>
{!isApartment && (
<span className="text-xs text-muted-foreground ml-auto">Optional for houses</span>
)}
</div>
{/* Building Name */}
<FormField
label="Building Name"
error={getError("buildingName")}
required={false}
helperText={
isApartment ? "e.g., Sunshine Mansion (サンシャインマンション)" : "Optional"
}
>
<Input
value={address.buildingName ?? ""}
onChange={e => handleBuildingNameChange(e.target.value)}
onBlur={() => onBlur?.("buildingName")}
placeholder={isApartment ? "Sunshine Mansion" : "Building name (optional)"}
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}
className="font-mono"
data-field="address.roomNumber"
/>
</FormField>
)}
</div>
</div>
</AnimatedSection>
{/* Success State */}
<AnimatedSection show={showSuccess} delay={250}>
<div className="rounded-xl bg-gradient-to-br from-success/10 via-success/5 to-transparent border border-success/20 p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-success/20 flex items-center justify-center">
<CheckCircle2 className="w-5 h-5 text-success" />
</div>
<div>
<p className="text-sm font-semibold text-foreground">Address Complete</p>
<p className="text-xs text-muted-foreground">Ready to save your Japanese address</p>
</div>
</div>
</div>
</AnimatedSection>
</div>
);
}

View File

@ -1,192 +0,0 @@
import Link from "next/link";
import { cn } from "@/shared/utils";
import type { LucideIcon } from "lucide-react";
import { ArrowRight } from "lucide-react";
type ServiceCardSize = "small" | "medium" | "large";
interface BentoServiceCardProps {
href: string;
icon: LucideIcon;
title: string;
description?: string;
/** Accent color for the card - e.g., "blue", "green", "purple" */
accentColor: string;
size?: ServiceCardSize;
className?: string;
}
const accentColorMap: Record<
string,
{ bg: string; border: string; text: string; hoverBorder: string }
> = {
blue: {
bg: "from-blue-500/10 via-card to-card",
border: "border-blue-500/20",
text: "text-blue-500",
hoverBorder: "hover:border-blue-500/40",
},
green: {
bg: "from-green-500/10 via-card to-card",
border: "border-green-500/20",
text: "text-green-500",
hoverBorder: "hover:border-green-500/40",
},
purple: {
bg: "from-purple-500/10 via-card to-card",
border: "border-purple-500/20",
text: "text-purple-500",
hoverBorder: "hover:border-purple-500/40",
},
amber: {
bg: "from-amber-500/10 via-card to-card",
border: "border-amber-500/20",
text: "text-amber-500",
hoverBorder: "hover:border-amber-500/40",
},
rose: {
bg: "from-rose-500/10 via-card to-card",
border: "border-rose-500/20",
text: "text-rose-500",
hoverBorder: "hover:border-rose-500/40",
},
cyan: {
bg: "from-cyan-500/10 via-card to-card",
border: "border-cyan-500/20",
text: "text-cyan-500",
hoverBorder: "hover:border-cyan-500/40",
},
};
/**
* Bento grid service card with size variants
*/
export function BentoServiceCard({
href,
icon: Icon,
title,
description,
accentColor,
size = "medium",
className,
}: BentoServiceCardProps) {
const colors = accentColorMap[accentColor] || accentColorMap.blue;
if (size === "large") {
return (
<Link
href={href}
className={cn(
"group relative overflow-hidden rounded-2xl",
"bg-gradient-to-br",
colors.bg,
"border",
colors.border,
colors.hoverBorder,
"p-8",
"transition-all duration-[var(--cp-duration-normal)]",
"hover:-translate-y-1",
className
)}
>
{/* Decorative gradient orb */}
<div
className={cn(
"absolute -top-20 -right-20 w-64 h-64 rounded-full",
"bg-gradient-to-br opacity-30",
accentColor === "blue" && "from-blue-500/30 to-transparent",
accentColor === "green" && "from-green-500/30 to-transparent",
accentColor === "purple" && "from-purple-500/30 to-transparent",
accentColor === "amber" && "from-amber-500/30 to-transparent",
accentColor === "rose" && "from-rose-500/30 to-transparent",
accentColor === "cyan" && "from-cyan-500/30 to-transparent",
"blur-2xl"
)}
aria-hidden="true"
/>
<div className="relative">
<div
className={cn(
"inline-flex items-center justify-center h-14 w-14 rounded-xl mb-6",
"bg-background/50 backdrop-blur-sm border border-border/50",
colors.text
)}
>
<Icon className="h-7 w-7" />
</div>
<h3 className="text-2xl font-bold text-foreground mb-3 font-display">{title}</h3>
{description && (
<p className="text-muted-foreground leading-relaxed max-w-sm mb-6">{description}</p>
)}
<span
className={cn(
"inline-flex items-center gap-2 font-semibold",
colors.text,
"transition-transform duration-[var(--cp-duration-normal)]",
"group-hover:translate-x-1"
)}
>
Learn more
<ArrowRight className="h-4 w-4" />
</span>
</div>
</Link>
);
}
if (size === "medium") {
return (
<Link
href={href}
className={cn(
"group rounded-xl bg-card border",
colors.border,
colors.hoverBorder,
"p-6",
"transition-all duration-[var(--cp-duration-normal)]",
"hover:-translate-y-0.5",
className
)}
>
<div
className={cn(
"inline-flex items-center justify-center h-12 w-12 rounded-lg mb-4",
"bg-gradient-to-br from-card to-muted",
colors.text
)}
>
<Icon className="h-6 w-6" />
</div>
<h3 className="text-lg font-bold text-foreground mb-2 font-display">{title}</h3>
{description && (
<p className="text-sm text-muted-foreground leading-relaxed">{description}</p>
)}
</Link>
);
}
// Small
return (
<Link
href={href}
className={cn(
"group rounded-xl bg-card/80 backdrop-blur-sm border border-border/50",
"p-4",
"transition-all duration-[var(--cp-duration-normal)]",
"hover:bg-card hover:border-border",
className
)}
>
<div className="flex items-center gap-3">
<div className={cn("h-10 w-10 rounded-lg flex items-center justify-center", colors.text)}>
<Icon className="h-5 w-5" />
</div>
<span className="font-semibold text-foreground">{title}</span>
</div>
</Link>
);
}

View File

@ -1,61 +0,0 @@
import Link from "next/link";
import { cn } from "@/shared/utils";
import type { ReactNode } from "react";
interface CtaButtonProps {
href: string;
children: ReactNode;
className?: string;
variant?: "primary" | "secondary" | "cta";
size?: "default" | "lg";
}
/**
* Professional CTA button with clear hierarchy
* - primary: Brand blue for standard actions
* - cta: Warm amber for high-conversion actions
* - secondary: Outline style for secondary actions
*/
export function CtaButton({
href,
children,
className,
variant = "primary",
size = "default",
}: CtaButtonProps) {
const sizeClasses = {
default: "px-6 py-3 text-base",
lg: "px-8 py-4 text-lg",
};
const variantClasses = {
primary: cn(
"bg-primary text-primary-foreground",
"hover:bg-primary-hover",
"shadow-sm hover:shadow-md"
),
cta: cn("bg-cta text-cta-foreground", "hover:bg-cta-hover", "shadow-sm hover:shadow-md"),
secondary: cn(
"bg-transparent text-foreground",
"border border-border",
"hover:bg-muted hover:border-muted-foreground/20"
),
};
return (
<Link
href={href}
className={cn(
"inline-flex items-center justify-center gap-2",
"rounded-lg font-semibold",
"transition-all duration-[var(--cp-duration-normal)]",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
sizeClasses[size],
variantClasses[variant],
className
)}
>
{children}
</Link>
);
}

View File

@ -1,107 +0,0 @@
import Link from "next/link";
import { cn } from "@/shared/utils";
import { ArrowRight, type LucideIcon } from "lucide-react";
interface FeaturedServiceCardProps {
href: string;
icon: LucideIcon;
title: string;
description: string;
highlights: string[];
startingPrice?: string;
priceNote?: string;
className?: string;
}
/**
* Featured service card - prominent display for primary services
* Used for Internet service on landing page
*/
export function FeaturedServiceCard({
href,
icon: Icon,
title,
description,
highlights,
startingPrice,
priceNote,
className,
}: FeaturedServiceCardProps) {
return (
<Link href={href} className={cn("group block", className)}>
<div
className={cn(
"relative overflow-hidden rounded-2xl",
"bg-gradient-to-br from-navy to-primary",
"p-8 sm:p-10",
"transition-all duration-[var(--cp-duration-normal)]",
"hover:shadow-xl hover:shadow-primary/20",
"hover:-translate-y-1"
)}
>
{/* Background pattern */}
<div
className="absolute inset-0 opacity-10"
style={{
backgroundImage: `radial-gradient(circle at 70% 30%, rgba(255,255,255,0.3) 0%, transparent 50%),
radial-gradient(circle at 30% 70%, rgba(255,255,255,0.2) 0%, transparent 40%)`,
}}
/>
<div className="relative z-10 grid gap-6 lg:grid-cols-2 lg:gap-12 items-center">
{/* Content */}
<div className="space-y-4">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-white/15">
<Icon className="h-6 w-6 text-white" />
</div>
<span className="text-sm font-medium text-white/80 uppercase tracking-wider">
Featured Service
</span>
</div>
<h3 className="text-3xl sm:text-4xl font-bold text-white font-display">{title}</h3>
<p className="text-lg text-white/85 leading-relaxed max-w-md">{description}</p>
{/* Highlights */}
<div className="flex flex-wrap gap-2 pt-2">
{highlights.map((highlight, idx) => (
<span
key={idx}
className="inline-flex items-center rounded-full bg-white/15 px-3 py-1 text-sm font-medium text-white"
>
{highlight}
</span>
))}
</div>
</div>
{/* Pricing & CTA */}
<div className="flex flex-col items-start lg:items-end gap-4">
{startingPrice && (
<div className="text-white">
<span className="text-sm text-white/70">Starting from</span>
<div className="text-4xl sm:text-5xl font-bold">{startingPrice}</div>
{priceNote && <span className="text-sm text-white/70">{priceNote}</span>}
</div>
)}
<div
className={cn(
"inline-flex items-center gap-2",
"rounded-lg bg-white px-6 py-3",
"text-primary font-semibold",
"transition-all duration-[var(--cp-duration-normal)]",
"group-hover:bg-white/95 group-hover:shadow-lg"
)}
>
View Plans
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</div>
</div>
</div>
</div>
</Link>
);
}

View File

@ -1,62 +0,0 @@
import Link from "next/link";
import { cn } from "@/shared/utils";
import type { ReactNode } from "react";
interface GlowButtonProps {
href: string;
children: ReactNode;
className?: string;
variant?: "primary" | "secondary";
}
/**
* Premium button with animated glow effect on hover
*/
export function GlowButton({ href, children, className, variant = "primary" }: GlowButtonProps) {
if (variant === "secondary") {
return (
<Link
href={href}
className={cn(
"inline-flex items-center justify-center gap-2 rounded-lg px-8 py-4 text-lg font-semibold",
"border border-border bg-background text-foreground",
"hover:bg-accent hover:text-accent-foreground",
"transition-all duration-[var(--cp-duration-normal)]",
"hover:-translate-y-0.5 active:translate-y-0",
className
)}
>
{children}
</Link>
);
}
return (
<Link href={href} className={cn("relative group", className)}>
{/* Glow layer */}
<span
className={cn(
"absolute -inset-1 rounded-xl blur-md",
"bg-gradient-to-r from-primary via-accent-gradient to-primary",
"opacity-50 group-hover:opacity-80",
"transition-opacity duration-[var(--cp-duration-slow)]"
)}
aria-hidden="true"
/>
{/* Button */}
<span
className={cn(
"relative inline-flex items-center justify-center gap-2 rounded-lg px-8 py-4 text-lg font-semibold",
"bg-primary text-primary-foreground",
"shadow-lg shadow-primary/20",
"transition-all duration-[var(--cp-duration-normal)]",
"group-hover:-translate-y-0.5 group-hover:shadow-primary/30",
"group-active:translate-y-0"
)}
>
{children}
</span>
</Link>
);
}

View File

@ -1,126 +0,0 @@
import Link from "next/link";
import { cn } from "@/shared/utils";
import { ArrowRight, type LucideIcon } from "lucide-react";
interface ServiceCardProps {
href: string;
icon: LucideIcon;
title: string;
description?: string;
highlight?: string;
featured?: boolean;
minimal?: boolean;
className?: string;
}
/**
* Service card with multiple variants:
* - Default: Standard card with description
* - Featured: Premium styling with gradient accents
* - Minimal: Compact icon + title only
*/
export function ServiceCard({
href,
icon: Icon,
title,
description,
highlight,
featured = false,
minimal = false,
className,
}: ServiceCardProps) {
// Minimal variant - compact icon + title card
if (minimal) {
return (
<Link href={href} className={cn("group block", className)}>
<div
className={cn(
"flex flex-col items-center text-center",
"rounded-xl border border-border bg-card",
"p-6",
"transition-all duration-[var(--cp-duration-normal)]",
"hover:border-primary/30 hover:shadow-md",
"hover:-translate-y-0.5"
)}
>
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10 mb-3 transition-all group-hover:bg-primary/15">
<Icon className="h-6 w-6 text-primary" />
</div>
<h3 className="text-sm font-semibold text-foreground font-display">{title}</h3>
</div>
</Link>
);
}
// Standard / Featured variant
return (
<Link href={href} className={cn("group block h-full", className)}>
<div
className={cn(
"h-full flex flex-col",
"rounded-xl border bg-card",
"p-6",
"transition-all duration-[var(--cp-duration-normal)]",
featured
? [
"border-primary/20",
"shadow-md shadow-primary/5",
"hover:border-primary/40 hover:shadow-xl hover:shadow-primary/10",
"hover:-translate-y-1",
]
: [
"border-border",
"hover:border-primary/30 hover:shadow-lg hover:shadow-primary/5",
"hover:-translate-y-0.5",
]
)}
>
{/* Icon */}
<div
className={cn(
"flex h-12 w-12 items-center justify-center rounded-xl mb-4",
"transition-all group-hover:scale-105",
featured
? "bg-primary shadow-md shadow-primary/20 group-hover:shadow-lg group-hover:shadow-primary/30"
: "bg-primary/10"
)}
>
<Icon className={cn("h-6 w-6", featured ? "text-primary-foreground" : "text-primary")} />
</div>
{/* Content */}
<h3 className="text-lg font-semibold text-foreground mb-2 font-display">{title}</h3>
{description && (
<p className="text-sm text-muted-foreground leading-relaxed flex-grow">{description}</p>
)}
{/* Highlight badge */}
{highlight && (
<span
className={cn(
"inline-flex self-start mt-3 rounded-full px-3 py-1 text-xs font-semibold",
featured
? "bg-success text-success-foreground shadow-sm"
: "bg-success/10 text-success"
)}
>
{highlight}
</span>
)}
{/* Link indicator */}
<div
className={cn(
"flex items-center gap-1.5 mt-4 pt-4 border-t",
"text-sm font-medium text-primary",
"transition-colors group-hover:text-primary-hover",
featured ? "border-primary/10" : "border-border/50"
)}
>
Learn more
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</div>
</div>
</Link>
);
}

View File

@ -1,15 +1,12 @@
// Core components
export { CtaButton } from "./CtaButton";
// Trust indicators
export { TrustBadge } from "./TrustBadge";
export { TrustIndicators } from "./TrustIndicators";
// Service display
export { FeaturedServiceCard } from "./FeaturedServiceCard";
export { ServiceCard } from "./ServiceCard";
// Legacy (kept for compatibility, can be removed later)
export { GlowButton } from "./GlowButton";
// Decorative/visual components (kept for potential future use)
export { ValuePropCard } from "./ValuePropCard";
export { BentoServiceCard } from "./BentoServiceCard";
export { FloatingGlassCard } from "./FloatingGlassCard";
export { AnimatedBackground } from "./AnimatedBackground";
// NOTE: ServiceCard components have been consolidated into @/components/molecules/ServiceCard
// Use: import { ServiceCard } from "@/components/molecules/ServiceCard"
// The unified ServiceCard supports variants: 'default' | 'featured' | 'minimal' | 'bento-sm' | 'bento-md' | 'bento-lg'

View File

@ -15,7 +15,7 @@ import {
Phone,
} from "lucide-react";
import Link from "next/link";
import { cn } from "@/shared/utils";
import { ServiceCard } from "@/components/molecules/ServiceCard";
/**
* PublicLandingView - Clean Landing Page
@ -24,104 +24,45 @@ import { cn } from "@/shared/utils";
* - Clean, centered layout
* - Consistent card styling with colored accents
* - Simple value propositions
* - Staggered entrance animations
*/
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 (
<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="flex items-start gap-4 mb-4">
<div
className={cn(
"flex h-12 w-12 items-center justify-center rounded-xl border",
accentStyles[accentColor]
)}
>
{icon}
</div>
<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>
<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>
<div
className="animate-in fade-in slide-in-from-bottom-4 duration-500"
style={{ animationDelay: "0ms" }}
>
<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>
</div>
<h1 className="text-display-lg sm:text-display-xl font-display font-bold text-foreground mb-5">
<h1
className="text-display-lg sm:text-display-xl font-display font-bold text-foreground mb-5 animate-in fade-in slide-in-from-bottom-6 duration-700"
style={{ animationDelay: "100ms" }}
>
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">
<p
className="text-lg text-muted-foreground max-w-xl mx-auto mb-8 animate-in fade-in slide-in-from-bottom-8 duration-700"
style={{ animationDelay: "200ms" }}
>
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">
<div
className="flex flex-col sm:flex-row items-center justify-center gap-4 mb-12 animate-in fade-in slide-in-from-bottom-8 duration-700"
style={{ animationDelay: "300ms" }}
>
<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"
@ -138,7 +79,10 @@ export function PublicLandingView() {
</div>
{/* Trust Stats */}
<div className="flex flex-wrap justify-center gap-8 pt-8 border-t border-border/50">
<div
className="flex flex-wrap justify-center gap-8 pt-8 border-t border-border/50 animate-in fade-in slide-in-from-bottom-8 duration-700"
style={{ animationDelay: "400ms" }}
>
<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" />
@ -170,7 +114,10 @@ export function PublicLandingView() {
</section>
{/* ===== WHY CHOOSE US ===== */}
<section>
<section
className="animate-in fade-in slide-in-from-bottom-8 duration-700"
style={{ animationDelay: "500ms" }}
>
<div className="text-center mb-10">
<h2 className="text-display-sm font-display font-bold text-foreground mb-3">
Why Choose Us
@ -212,7 +159,11 @@ export function PublicLandingView() {
</section>
{/* ===== OUR SERVICES ===== */}
<section id="services">
<section
id="services"
className="animate-in fade-in slide-in-from-bottom-8 duration-700"
style={{ animationDelay: "600ms" }}
>
<div className="text-center mb-10">
<h2 className="text-display-sm font-display font-bold text-foreground mb-3">
Our Services
@ -305,7 +256,10 @@ export function PublicLandingView() {
</section>
{/* ===== CTA ===== */}
<section className="rounded-2xl bg-gradient-to-br from-muted/50 to-muted/80 p-8 sm:p-10 text-center">
<section
className="rounded-2xl bg-gradient-to-br from-muted/50 to-muted/80 p-8 sm:p-10 text-center animate-in fade-in slide-in-from-bottom-8 duration-700"
style={{ animationDelay: "700ms" }}
>
<h2 className="text-xl font-bold text-foreground font-display mb-3">
Ready to get connected?
</h2>
@ -324,8 +278,9 @@ export function PublicLandingView() {
<a
href="tel:0120660470"
className="inline-flex items-center gap-2 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
aria-label="Call us toll free at 0120-660-470"
>
<Phone className="h-4 w-4" />
<Phone className="h-4 w-4" aria-hidden="true" />
0120-660-470 (Toll Free)
</a>
</div>

View File

@ -22,20 +22,31 @@ export function AboutUsView() {
<div className="max-w-4xl mx-auto space-y-12">
{/* Header */}
<div className="text-center">
<h1 className="text-3xl sm:text-4xl font-bold text-foreground mb-4">About Us</h1>
<p className="text-lg text-muted-foreground max-w-2xl mx-auto leading-relaxed">
<h1
className="text-display-lg font-display font-bold text-foreground mb-4 animate-in fade-in slide-in-from-bottom-6 duration-700"
style={{ animationDelay: "0ms" }}
>
About Us
</h1>
<p
className="text-lg text-muted-foreground max-w-2xl mx-auto leading-relaxed animate-in fade-in slide-in-from-bottom-8 duration-700"
style={{ animationDelay: "100ms" }}
>
We specialize in serving Japan&apos;s international community with the most reliable and
cost-efficient IT solutions available.
</p>
</div>
{/* Who We Are Section */}
<section className="bg-card rounded-2xl border border-border p-8 sm:p-10">
<section
className="bg-card rounded-2xl border border-border p-8 sm:p-10 animate-in fade-in slide-in-from-bottom-8 duration-700"
style={{ animationDelay: "200ms" }}
>
<div className="flex items-center gap-3 mb-6">
<div className="h-12 w-12 rounded-xl bg-primary/10 border border-primary/20 flex items-center justify-center">
<Building2 className="h-6 w-6 text-primary" />
</div>
<h2 className="text-2xl font-bold text-foreground">Who We Are</h2>
<h2 className="text-display-sm font-display font-bold text-foreground">Who We Are</h2>
</div>
<div className="space-y-4 text-muted-foreground leading-relaxed">
<p>
@ -53,12 +64,15 @@ export function AboutUsView() {
</section>
{/* Corporate Data Section */}
<section className="bg-card rounded-2xl border border-border p-8 sm:p-10">
<section
className="bg-card rounded-2xl border border-border p-8 sm:p-10 animate-in fade-in slide-in-from-bottom-8 duration-700"
style={{ animationDelay: "300ms" }}
>
<div className="flex items-center gap-3 mb-6">
<div className="h-12 w-12 rounded-xl bg-primary/10 border border-primary/20 flex items-center justify-center">
<Users className="h-6 w-6 text-primary" />
</div>
<h2 className="text-2xl font-bold text-foreground">Corporate Data</h2>
<h2 className="text-display-sm font-display font-bold text-foreground">Corporate Data</h2>
</div>
<p className="text-muted-foreground mb-6">
Assist Solutions is a privately-owned entrepreneurial IT supporting company, focused on
@ -150,8 +164,13 @@ export function AboutUsView() {
</section>
{/* Business Activities Section */}
<section className="bg-card rounded-2xl border border-border p-8 sm:p-10">
<h2 className="text-2xl font-bold text-foreground mb-6">Business Activities</h2>
<section
className="bg-card rounded-2xl border border-border p-8 sm:p-10 animate-in fade-in slide-in-from-bottom-8 duration-700"
style={{ animationDelay: "400ms" }}
>
<h2 className="text-display-sm font-display font-bold text-foreground mb-6">
Business Activities
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{[
"IT Consulting Services",
@ -175,8 +194,13 @@ export function AboutUsView() {
</section>
{/* Mission Statement Section */}
<section className="bg-gradient-to-br from-primary/5 to-transparent rounded-2xl border border-primary/20 p-8 sm:p-10">
<h2 className="text-2xl font-bold text-foreground mb-6">Mission Statement</h2>
<section
className="bg-gradient-to-br from-primary/5 to-transparent rounded-2xl border border-primary/20 p-8 sm:p-10 animate-in fade-in slide-in-from-bottom-8 duration-700"
style={{ animationDelay: "500ms" }}
>
<h2 className="text-display-sm font-display font-bold text-foreground mb-6">
Mission Statement
</h2>
<p className="text-muted-foreground mb-6 leading-relaxed">
We will achieve business success by pursuing the following:
</p>

View File

@ -11,13 +11,17 @@ import { accountService } from "@/features/account/api/account.api";
import { logger } from "@/core/logger";
import { StatusPill } from "@/components/atoms/status-pill";
import { MapPinIcon, PencilIcon, CheckIcon, XMarkIcon } from "@heroicons/react/24/outline";
import { COUNTRY_OPTIONS, getCountryName } from "@/shared/constants";
import { getCountryName } from "@/shared/constants";
import { queryKeys } from "@/core/api";
// Use canonical Address type from domain
import type { Address } from "@customer-portal/domain/customer";
import type { BilingualAddress } from "@customer-portal/domain/address";
import { ORDER_TYPE } from "@customer-portal/domain/orders";
// Japan address form with ZIP lookup
import { JapanAddressForm } from "@/features/address/components/JapanAddressForm";
interface BillingInfo {
company: string | null;
email: string;
@ -47,9 +51,11 @@ export function AddressConfirmation({
const [billingInfo, setBillingInfo] = useState<BillingInfo | null>(null);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(false);
const [editedAddress, setEditedAddress] = useState<Address | null>(null);
const [bilingualAddress, setBilingualAddress] = useState<Partial<BilingualAddress> | null>(null);
const [isAddressComplete, setIsAddressComplete] = useState(false);
const [error, setError] = useState<string | null>(null);
const [addressConfirmed, setAddressConfirmed] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const onAddressConfirmedRef = useRef(onAddressConfirmed);
const onAddressIncompleteRef = useRef(onAddressIncomplete);
@ -119,55 +125,40 @@ export function AddressConfirmation({
e?.stopPropagation();
setEditing(true);
setEditedAddress(
billingInfo?.address ?? {
address1: "",
address2: "",
city: "",
state: "",
postcode: "",
country: "",
countryCode: "",
}
);
setError(null);
// Initialize with empty bilingual address - user will use ZIP lookup
setBilingualAddress(null);
setIsAddressComplete(false);
};
// Handle JapanAddressForm changes
const handleBilingualAddressChange = useCallback(
(address: BilingualAddress, complete: boolean) => {
setBilingualAddress(address);
setIsAddressComplete(complete);
setError(null);
},
[]
);
const handleSave = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
if (!editedAddress) return;
// Validate required fields
const isComplete = !!(
editedAddress.address1?.trim() &&
editedAddress.city?.trim() &&
editedAddress.state?.trim() &&
editedAddress.postcode?.trim() &&
editedAddress.country?.trim()
);
if (!isComplete) {
setError("Please fill in all required address fields");
if (!bilingualAddress || !isAddressComplete) {
setError("Please complete all required address fields");
return;
}
void (async () => {
try {
setError(null);
setIsSaving(true);
const sanitizedAddress: Address = {
address1: editedAddress.address1?.trim() || null,
address2: editedAddress.address2?.trim() || null,
city: editedAddress.city?.trim() || null,
state: editedAddress.state?.trim() || null,
postcode: editedAddress.postcode?.trim() || null,
country: editedAddress.country?.trim() || null,
countryCode: editedAddress.country?.trim() || null,
};
// Persist to server (WHMCS via BFF)
const updatedAddress = await accountService.updateAddress(sanitizedAddress);
// Dual-write: English to WHMCS, Japanese to Salesforce
const updatedAddress = await accountService.updateBilingualAddress(
bilingualAddress as BilingualAddress
);
// Address changes can affect server-personalized services results (eligibility).
await queryClient.invalidateQueries({ queryKey: queryKeys.services.all() });
@ -192,8 +183,14 @@ export function AddressConfirmation({
onAddressConfirmed(updatedAddress);
setEditing(false);
setAddressConfirmed(true);
logger.info("Address updated with dual-write (WHMCS + Salesforce)", {
hasJapaneseAddress: !!bilingualAddress.prefectureJa,
});
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to update address");
} finally {
setIsSaving(false);
}
})();
};
@ -221,7 +218,8 @@ export function AddressConfirmation({
e.stopPropagation();
setEditing(false);
setEditedAddress(null);
setBilingualAddress(null);
setIsAddressComplete(false);
setError(null);
};
@ -321,113 +319,27 @@ export function AddressConfirmation({
{editing ? (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
Street / Block (Address 2)
</label>
<input
type="text"
value={editedAddress?.address2 || ""}
onChange={e => {
setError(null);
setEditedAddress(prev => (prev ? { ...prev, address2: e.target.value } : null));
}}
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring transition-colors"
placeholder="2-20-9 Wakabayashi"
/>
</div>
<AlertBanner variant="info" title="Japan Address" size="sm">
Enter your ZIP code to auto-fill address fields from Japan Post.
</AlertBanner>
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
Building / Room (Address 1) *
</label>
<input
type="text"
value={editedAddress?.address1 || ""}
onChange={e => {
setError(null); // Clear error on input
setEditedAddress(prev => (prev ? { ...prev, address1: e.target.value } : null));
}}
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring transition-colors"
placeholder="Gramercy 201"
required
/>
</div>
<JapanAddressForm onChange={handleBilingualAddressChange} disabled={isSaving} />
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">City *</label>
<input
type="text"
value={editedAddress?.city || ""}
onChange={e => {
setError(null);
setEditedAddress(prev => (prev ? { ...prev, city: e.target.value } : null));
}}
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring transition-colors"
placeholder="Tokyo"
/>
</div>
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
State/Prefecture *
</label>
<input
type="text"
value={editedAddress?.state || ""}
onChange={e => {
setError(null);
setEditedAddress(prev => (prev ? { ...prev, state: e.target.value } : null));
}}
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring transition-colors"
placeholder="Tokyo"
/>
</div>
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
Postal Code *
</label>
<input
type="text"
value={editedAddress?.postcode || ""}
onChange={e => {
setError(null);
setEditedAddress(prev => (prev ? { ...prev, postcode: e.target.value } : null));
}}
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring transition-colors"
placeholder="100-0001"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
Country *
</label>
<select
value={editedAddress?.country || ""}
onChange={e => {
setError(null);
const next = e.target.value;
setEditedAddress(prev =>
prev ? { ...prev, country: next, countryCode: next } : null
);
}}
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring transition-colors"
>
<option value="">Select Country</option>
{COUNTRY_OPTIONS.map(option => (
<option key={option.code} value={option.code}>
{option.name}
</option>
))}
</select>
</div>
{error && (
<AlertBanner variant="error" title="Error" size="sm">
{error}
</AlertBanner>
)}
<div className="flex items-center space-x-3 pt-4">
<Button type="button" onClick={handleSave} leftIcon={<CheckIcon className="h-4 w-4" />}>
<Button
type="button"
onClick={handleSave}
leftIcon={<CheckIcon className="h-4 w-4" />}
disabled={!isAddressComplete || isSaving}
isLoading={isSaving}
loadingText="Saving..."
>
Save Address
</Button>
<Button
@ -435,6 +347,7 @@ export function AddressConfirmation({
onClick={handleCancel}
variant="outline"
leftIcon={<XMarkIcon className="h-4 w-4" />}
disabled={isSaving}
>
Cancel
</Button>

View File

@ -20,11 +20,11 @@ export function transformJapanPostAddress(raw: JapanPostAddressRecord): JapanPos
zipCode,
// Japanese
prefecture: raw.pref_name || "",
prefectureKana: raw.pref_kana,
prefectureKana: raw.pref_kana ?? undefined,
city: raw.city_name || "",
cityKana: raw.city_kana,
cityKana: raw.city_kana ?? undefined,
town: raw.town_name || "",
townKana: raw.town_kana,
townKana: raw.town_kana ?? undefined,
// Romanized
prefectureRoma: raw.pref_roma || "",
cityRoma: raw.city_roma || "",

View File

@ -30,39 +30,55 @@ export type JapanPostTokenResponse = z.infer<typeof japanPostTokenResponseSchema
/**
* Single address record from Japan Post API
* Fields from GET /api/v1/searchcode/{search_code}
*
* Note: API returns `null` for empty fields, so we use `.nullish()` instead of `.optional()`
*/
export const japanPostAddressRecordSchema = z.object({
// ZIP code
zipcode: z.string().optional(),
zip_code: z.string().optional(),
zipcode: z.string().nullish(),
zip_code: z.string().nullish(),
// Digital address code (can be null)
dgacode: z.string().nullish(),
// Prefecture
pref_code: z.string().optional(),
pref_name: z.string().optional(),
pref_kana: z.string().optional(),
pref_roma: z.string().optional(),
pref_code: z.string().nullish(),
pref_name: z.string().nullish(),
pref_kana: z.string().nullish(),
pref_roma: z.string().nullish(),
// City
city_code: z.string().optional(),
city_name: z.string().optional(),
city_kana: z.string().optional(),
city_roma: z.string().optional(),
city_code: z.string().nullish(),
city_name: z.string().nullish(),
city_kana: z.string().nullish(),
city_roma: z.string().nullish(),
// Town
town_code: z.string().optional(),
town_name: z.string().optional(),
town_kana: z.string().optional(),
town_roma: z.string().optional(),
town_code: z.string().nullish(),
town_name: z.string().nullish(),
town_kana: z.string().nullish(),
town_roma: z.string().nullish(),
// Additional fields that may be present
block_name: z.string().optional(),
block_kana: z.string().optional(),
block_roma: z.string().optional(),
// Block/street details (often null)
block_name: z.string().nullish(),
block_kana: z.string().nullish(),
block_roma: z.string().nullish(),
// Office/company info (for business ZIP codes)
office_name: z.string().optional(),
office_kana: z.string().optional(),
office_roma: z.string().optional(),
// Business/office info (for business ZIP codes)
biz_name: z.string().nullish(),
biz_kana: z.string().nullish(),
biz_roma: z.string().nullish(),
// Legacy office fields
office_name: z.string().nullish(),
office_kana: z.string().nullish(),
office_roma: z.string().nullish(),
// Additional fields from API
other_name: z.string().nullish(),
address: z.string().nullish(),
longitude: z.union([z.string(), z.number()]).nullish(),
latitude: z.union([z.string(), z.number()]).nullish(),
});
export type JapanPostAddressRecord = z.infer<typeof japanPostAddressRecordSchema>;

View File

@ -59,6 +59,28 @@ export const addressLookupResultSchema = z.object({
count: z.number(),
});
// ============================================================================
// Street Address Detail (Chome/Banchi/Go)
// ============================================================================
/**
* Street address detail schema (chome/banchi/go)
* Only accepts hyphenated number format: "1-5-3", "1-5", "15-3"
*
* Format: {chome}-{banchi}-{go} or {chome}-{banchi}
* - chome: Block district number (1-99)
* - banchi: Block number (1-999)
* - go: Building/house number (1-999, optional)
*/
export const streetAddressDetailSchema = z
.string()
.min(1, "Street address is required")
.max(20, "Street address is too long")
.regex(
/^\d{1,2}-\d{1,3}(-\d{1,3})?$/,
"Use format like 1-5-3 (chome-banchi-go) or 1-5 (chome-banchi)"
);
// ============================================================================
// Bilingual Address Schemas (Extended from customer/addressSchema)
// ============================================================================
@ -90,6 +112,9 @@ export const bilingualAddressSchema = z.object({
cityJa: z.string(),
townJa: z.string(),
// Street address detail (chome/banchi/go) - e.g., "1-5-3" or "1丁目5番3号"
streetAddress: streetAddressDetailSchema,
// Building info (same for both systems)
buildingName: z.string().max(200).optional().nullable(),
roomNumber: z.string().max(50).optional().nullable(),
@ -112,18 +137,27 @@ export const addressUpdateRequestSchema = bilingualAddressSchema.extend({
/**
* Prepare address fields for WHMCS update
* Maps bilingual address to WHMCS field format
*
* WHMCS field mapping:
* - address1: Building name + Room number (e.g., "Sunshine Mansion 101")
* - address2: Town + Street address (e.g., "Higashiazabu 1-5-3")
* - city: City (romanized)
* - state: Prefecture (romanized)
*/
export function prepareWhmcsAddressFields(address: BilingualAddress): WhmcsAddressFields {
const buildingPart = address.buildingName || "";
const roomPart = address.roomNumber || "";
// address1: "{BuildingName} {RoomNumber}" for apartment, "{BuildingName}" for house
// address1: Building + Room (for apartments) or just Building (for houses)
const address1 =
address.residenceType === "apartment" ? `${buildingPart} ${roomPart}`.trim() : buildingPart;
// address2: Town + Street address (romanized)
const address2 = `${address.town} ${address.streetAddress}`.trim();
return {
address1: address1 || undefined,
address2: address.town, // romanized town/street
address2: address2 || undefined,
city: address.city, // romanized city
state: address.prefecture, // romanized prefecture
postcode: address.postcode,
@ -139,12 +173,21 @@ export function prepareWhmcsAddressFields(address: BilingualAddress): WhmcsAddre
/**
* Prepare address fields for Salesforce Contact update
* Maps bilingual address to Salesforce field format
*
* Salesforce field mapping:
* - MailingStreet: Town + Street address (Japanese)
* - MailingCity: City (Japanese)
* - MailingState: Prefecture (Japanese)
*/
export function prepareSalesforceContactAddressFields(
address: BilingualAddress
): SalesforceContactAddressFields {
// Combine town and street address for MailingStreet
// Example: "東麻布1-5-3" or "東麻布1丁目5番3号"
const mailingStreet = `${address.townJa}${address.streetAddress}`;
return {
MailingStreet: address.townJa, // Japanese town/street
MailingStreet: mailingStreet,
MailingCity: address.cityJa, // Japanese city
MailingState: address.prefectureJa, // Japanese prefecture
MailingPostalCode: address.postcode,
@ -162,6 +205,7 @@ 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 StreetAddressDetail = z.infer<typeof streetAddressDetailSchema>;
export type BuildingInfo = z.infer<typeof buildingInfoSchema>;
export type BilingualAddress = z.infer<typeof bilingualAddressSchema>;
export type AddressUpdateRequest = z.infer<typeof addressUpdateRequestSchema>;

View File

@ -341,7 +341,6 @@ start_apps() {
# Build shared package first
log "🔨 Building shared package..."
pnpm --filter @customer-portal/domain build
pnpm --filter @customer-portal/validation build
# Build BFF before watch (ensures dist exists). Use Nest build for correct emit.
log "🔨 Building BFF for initial setup (ts emit)..."
@ -373,7 +372,6 @@ start_apps() {
# Run portal + bff in parallel with hot reload
pnpm --parallel \
--filter @customer-portal/domain \
--filter @customer-portal/validation \
--filter @customer-portal/portal \
--filter @customer-portal/bff run dev
}