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:
parent
7624684e6b
commit
bb4be98444
@ -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
|
||||
|
||||
15
apps/bff/src/infra/cache/cache.service.ts
vendored
15
apps/bff/src/infra/cache/cache.service.ts
vendored
@ -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")
|
||||
|
||||
@ -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],
|
||||
})
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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'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>
|
||||
|
||||
515
apps/portal/src/components/molecules/ServiceCard/ServiceCard.tsx
Normal file
515
apps/portal/src/components/molecules/ServiceCard/ServiceCard.tsx
Normal 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;
|
||||
@ -0,0 +1,2 @@
|
||||
export { ServiceCard, default } from "./ServiceCard";
|
||||
export type { ServiceCardProps, ServiceCardVariant, ServiceCardAccentColor } from "./ServiceCard";
|
||||
@ -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";
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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'
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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'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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 || "",
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user