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_BASE_URL=
|
||||||
# FREEBIT_OEM_KEY=
|
# FREEBIT_OEM_KEY=
|
||||||
|
|
||||||
# --- SendGrid (Email) ---
|
# --- Email (SendGrid) ---
|
||||||
# SENDGRID_API_KEY=
|
# SENDGRID_API_KEY= # Required: Your SendGrid API key
|
||||||
# EMAIL_FROM=no-reply@example.com
|
# 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;
|
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
|
* Build a structured cache key
|
||||||
* @param prefix Key prefix (e.g., "orders", "catalog")
|
* @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 { ConfigModule } from "@nestjs/config";
|
||||||
import { EmailService } from "./email.service.js";
|
import { EmailService } from "./email.service.js";
|
||||||
import { SendGridEmailProvider } from "./providers/sendgrid.provider.js";
|
import { SendGridEmailProvider } from "./providers/sendgrid.provider.js";
|
||||||
import { LoggingModule } from "@bff/core/logging/logging.module.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 { EmailQueueService } from "./queue/email.queue.js";
|
||||||
import { EmailProcessor } from "./queue/email.processor.js";
|
import { EmailProcessor } from "./queue/email.processor.js";
|
||||||
|
|
||||||
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ConfigModule, LoggingModule],
|
imports: [ConfigModule, LoggingModule, QueueModule],
|
||||||
providers: [EmailService, SendGridEmailProvider, EmailQueueService, EmailProcessor],
|
providers: [EmailService, SendGridEmailProvider, EmailQueueService, EmailProcessor],
|
||||||
exports: [EmailService, EmailQueueService],
|
exports: [EmailService, EmailQueueService],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -13,31 +13,173 @@ export interface SendEmailOptions {
|
|||||||
html?: string;
|
html?: string;
|
||||||
templateId?: string;
|
templateId?: string;
|
||||||
dynamicTemplateData?: Record<string, unknown>;
|
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()
|
@Injectable()
|
||||||
export class EmailService {
|
export class EmailService {
|
||||||
|
private readonly emailEnabled: boolean;
|
||||||
|
private readonly useQueue: boolean;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
private readonly provider: SendGridEmailProvider,
|
private readonly provider: SendGridEmailProvider,
|
||||||
private readonly queue: EmailQueueService,
|
private readonly queue: EmailQueueService,
|
||||||
@Inject(Logger) private readonly logger: Logger
|
@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> {
|
this.logger.log("EmailService initialized", {
|
||||||
const enabled = this.config.get("EMAIL_ENABLED", "true") === "true";
|
service: "email",
|
||||||
if (!enabled) {
|
enabled: this.emailEnabled,
|
||||||
this.logger.log("Email sending disabled; skipping", {
|
useQueue: this.useQueue,
|
||||||
to: options.to,
|
});
|
||||||
subject: options.subject,
|
}
|
||||||
|
|
||||||
|
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) {
|
// Check if email is enabled
|
||||||
await this.queue.enqueueEmail(options as EmailJobData);
|
if (!this.emailEnabled) {
|
||||||
} else {
|
this.logger.log("Email sending disabled - skipping", emailContext);
|
||||||
await this.provider.send(options);
|
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 { Injectable, Inject } from "@nestjs/common";
|
||||||
|
import type { OnModuleInit } from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import sgMail from "@sendgrid/mail";
|
import sgMail from "@sendgrid/mail";
|
||||||
import type { MailDataRequired } from "@sendgrid/mail";
|
import type { MailDataRequired, ResponseError } from "@sendgrid/mail";
|
||||||
|
|
||||||
export interface ProviderSendOptions {
|
export interface ProviderSendOptions {
|
||||||
to: string | string[];
|
to: string | string[];
|
||||||
@ -12,44 +13,213 @@ export interface ProviderSendOptions {
|
|||||||
html?: string;
|
html?: string;
|
||||||
templateId?: string;
|
templateId?: string;
|
||||||
dynamicTemplateData?: Record<string, unknown>;
|
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()
|
@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(
|
constructor(
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
@Inject(Logger) private readonly logger: Logger
|
@Inject(Logger) private readonly logger: Logger
|
||||||
) {
|
) {
|
||||||
const apiKey = this.config.get<string>("SENDGRID_API_KEY");
|
this.sandboxMode = this.config.get("SENDGRID_SANDBOX", "false") === "true";
|
||||||
if (apiKey) {
|
this.defaultFrom = this.config.get<string>("EMAIL_FROM");
|
||||||
sgMail.setApiKey(apiKey);
|
this.defaultFromName = this.config.get<string>("EMAIL_FROM_NAME");
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async send(options: ProviderSendOptions): Promise<void> {
|
onModuleInit(): void {
|
||||||
const from = options.from || this.config.get<string>("EMAIL_FROM");
|
const apiKey = this.config.get<string>("SENDGRID_API_KEY");
|
||||||
if (!from) {
|
|
||||||
this.logger.warn("EMAIL_FROM is not configured; email not sent");
|
if (!apiKey) {
|
||||||
|
this.logger.warn("SendGrid API key not configured - email sending will fail", {
|
||||||
|
provider: "sendgrid",
|
||||||
|
});
|
||||||
return;
|
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 = {
|
const message: MailDataRequired = {
|
||||||
to: options.to,
|
to: options.to,
|
||||||
from,
|
from,
|
||||||
subject: options.subject,
|
subject: options.subject,
|
||||||
text: options.text,
|
mailSettings: {
|
||||||
html: options.html,
|
sandboxMode: { enable: this.sandboxMode },
|
||||||
templateId: options.templateId,
|
},
|
||||||
dynamicTemplateData: options.dynamicTemplateData,
|
|
||||||
} as MailDataRequired;
|
} as MailDataRequired;
|
||||||
|
|
||||||
try {
|
// Content: template or direct HTML/text
|
||||||
await sgMail.send(message);
|
if (options.templateId) {
|
||||||
} catch (error) {
|
message.templateId = options.templateId;
|
||||||
this.logger.error("Failed to send email via SendGrid", {
|
if (options.dynamicTemplateData) {
|
||||||
error: error instanceof Error ? error.message : String(error),
|
message.dynamicTemplateData = options.dynamicTemplateData;
|
||||||
});
|
}
|
||||||
throw error;
|
} 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 { Injectable, Inject } from "@nestjs/common";
|
||||||
import { Logger } from "nestjs-pino";
|
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 type { EmailJobData } from "./email.queue.js";
|
||||||
import { QUEUE_NAMES } from "../../queue/queue.constants.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)
|
@Processor(QUEUE_NAMES.EMAIL)
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EmailProcessor extends WorkerHost {
|
export class EmailProcessor extends WorkerHost {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly emailService: EmailService,
|
private readonly provider: SendGridEmailProvider,
|
||||||
|
private readonly cache: CacheService,
|
||||||
@Inject(Logger) private readonly logger: Logger
|
@Inject(Logger) private readonly logger: Logger
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
async process(job: { data: EmailJobData }): Promise<void> {
|
async process(job: Job<EmailJobData>): Promise<void> {
|
||||||
await this.emailService.sendEmail(job.data);
|
const jobContext = {
|
||||||
this.logger.debug("Processed email job");
|
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 { Injectable, Inject } from "@nestjs/common";
|
||||||
import { InjectQueue } from "@nestjs/bullmq";
|
import { InjectQueue } from "@nestjs/bullmq";
|
||||||
import { Queue } from "bullmq";
|
import { Queue, Job } from "bullmq";
|
||||||
import { Logger } from "nestjs-pino";
|
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";
|
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()
|
@Injectable()
|
||||||
export class EmailQueueService {
|
export class EmailQueueService {
|
||||||
@ -14,17 +33,92 @@ export class EmailQueueService {
|
|||||||
@Inject(Logger) private readonly logger: Logger
|
@Inject(Logger) private readonly logger: Logger
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async enqueueEmail(data: EmailJobData): Promise<void> {
|
async enqueueEmail(data: EmailJobData): Promise<EmailJobResult> {
|
||||||
await this.queue.add("send", data, {
|
const jobContext = {
|
||||||
removeOnComplete: 50,
|
service: "email-queue",
|
||||||
removeOnFail: 50,
|
queue: QUEUE_NAMES.EMAIL,
|
||||||
attempts: 3,
|
category: data.category || "transactional",
|
||||||
backoff: { type: "exponential", delay: 2000 },
|
recipientCount: Array.isArray(data.to) ? data.to.length : 1,
|
||||||
});
|
hasTemplate: !!data.templateId,
|
||||||
this.logger.debug("Queued email", {
|
};
|
||||||
to: data.to,
|
|
||||||
subject: data.subject,
|
try {
|
||||||
category: data.category,
|
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
|
* Lookup address by ZIP code
|
||||||
*
|
*
|
||||||
* @param zipCode - ZIP code (with or without hyphen, e.g., "100-0001" or "1000001")
|
* @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
|
* @returns Domain AddressLookupResult with Japanese and romanized address data
|
||||||
* @throws BadRequestException if ZIP code format is invalid
|
* @throws BadRequestException if ZIP code format is invalid
|
||||||
* @throws ServiceUnavailableException if Japan Post API is unavailable
|
* @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)
|
// Normalize ZIP code (remove hyphen)
|
||||||
const normalizedZip = zipCode.replace(/-/g, "");
|
const normalizedZip = zipCode.replace(/-/g, "");
|
||||||
|
|
||||||
@ -57,8 +61,27 @@ export class JapanPostAddressService {
|
|||||||
|
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
let rawResponse: unknown;
|
||||||
|
|
||||||
try {
|
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)
|
// Use domain mapper for transformation (single transformation point)
|
||||||
const result = JapanPost.transformJapanPostSearchResponse(rawResponse);
|
const result = JapanPost.transformJapanPostSearchResponse(rawResponse);
|
||||||
@ -91,13 +114,27 @@ export class JapanPostAddressService {
|
|||||||
errorMessage.includes("HTTP") ||
|
errorMessage.includes("HTTP") ||
|
||||||
errorMessage.includes("timed out");
|
errorMessage.includes("timed out");
|
||||||
|
|
||||||
|
// Check if this is a Zod validation error
|
||||||
|
const isZodError = error instanceof Error && error.name === "ZodError";
|
||||||
|
|
||||||
if (!isConnectionError) {
|
if (!isConnectionError) {
|
||||||
// Only log unexpected errors (e.g., transformation failures)
|
// Only log unexpected errors (e.g., transformation failures)
|
||||||
|
// Include raw response for debugging schema mismatches
|
||||||
this.logger.error("Address lookup transformation error", {
|
this.logger.error("Address lookup transformation error", {
|
||||||
zipCode: normalizedZip,
|
zipCode: normalizedZip,
|
||||||
durationMs,
|
durationMs,
|
||||||
errorType: error instanceof Error ? error.constructor.name : "Unknown",
|
errorType: error instanceof Error ? error.constructor.name : "Unknown",
|
||||||
|
errorName: error instanceof Error ? error.name : "Unknown",
|
||||||
|
isZodError,
|
||||||
error: errorMessage,
|
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 {
|
isAvailable(): boolean {
|
||||||
return this.connection.isConfigured();
|
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
|
* Search addresses by ZIP code
|
||||||
*
|
*
|
||||||
* @param zipCode - 7-digit ZIP code (no hyphen)
|
* @param zipCode - 7-digit ZIP code (no hyphen)
|
||||||
|
* @param clientIp - Client IP address for x-forwarded-for header
|
||||||
* @returns Raw Japan Post API response
|
* @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 token = await this.getAccessToken();
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@ -313,6 +314,7 @@ export class JapanPostConnectionService implements OnModuleInit {
|
|||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
"x-forwarded-for": clientIp,
|
||||||
},
|
},
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -8,13 +8,16 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
|
Req,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
ClassSerializerInterceptor,
|
ClassSerializerInterceptor,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
|
import type { Request } from "express";
|
||||||
import { createZodDto } from "nestjs-zod";
|
import { createZodDto } from "nestjs-zod";
|
||||||
import { Public } from "@bff/modules/auth/decorators/public.decorator.js";
|
import { Public } from "@bff/modules/auth/decorators/public.decorator.js";
|
||||||
import { RateLimitGuard, RateLimit } from "@bff/core/rate-limiting/index.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 { JapanPostAddressService } from "@bff/integrations/japanpost/services/japanpost-address.service.js";
|
||||||
import {
|
import {
|
||||||
addressLookupResultSchema,
|
addressLookupResultSchema,
|
||||||
@ -75,8 +78,12 @@ export class AddressController {
|
|||||||
@Get("lookup/zip/:zipCode")
|
@Get("lookup/zip/:zipCode")
|
||||||
@UseGuards(RateLimitGuard)
|
@UseGuards(RateLimitGuard)
|
||||||
@RateLimit({ limit: 30, ttl: 60 }) // 30 requests per minute
|
@RateLimit({ limit: 30, ttl: 60 }) // 30 requests per minute
|
||||||
async lookupByZipCode(@Param() params: ZipCodeParamDto): Promise<AddressLookupResultDto> {
|
async lookupByZipCode(
|
||||||
return this.japanPostService.lookupByZipCode(params.zipCode);
|
@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 { TokenStorageService } from "./infra/token/token-storage.service.js";
|
||||||
import { TokenRevocationService } from "./infra/token/token-revocation.service.js";
|
import { TokenRevocationService } from "./infra/token/token-revocation.service.js";
|
||||||
import { PasswordResetTokenService } from "./infra/token/password-reset-token.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 { CacheModule } from "@bff/infra/cache/cache.module.js";
|
||||||
import { AuthTokenService } from "./infra/token/token.service.js";
|
import { AuthTokenService } from "./infra/token/token.service.js";
|
||||||
import { JoseJwtService } from "./infra/token/jose-jwt.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";
|
import { GetStartedController } from "./presentation/http/get-started.controller.js";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [UsersModule, MappingsModule, IntegrationsModule, EmailModule, CacheModule],
|
imports: [UsersModule, MappingsModule, IntegrationsModule, CacheModule],
|
||||||
controllers: [AuthController, GetStartedController],
|
controllers: [AuthController, GetStartedController],
|
||||||
providers: [
|
providers: [
|
||||||
// Application services
|
// Application services
|
||||||
|
|||||||
@ -4,11 +4,10 @@ import { InternetController } from "./internet.controller.js";
|
|||||||
import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module.js";
|
import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module.js";
|
||||||
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
|
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
|
||||||
import { SalesforceModule } from "@bff/integrations/salesforce/salesforce.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";
|
import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [WhmcsModule, MappingsModule, SalesforceModule, EmailModule, NotificationsModule],
|
imports: [WhmcsModule, MappingsModule, SalesforceModule, NotificationsModule],
|
||||||
controllers: [InternetController],
|
controllers: [InternetController],
|
||||||
providers: [InternetCancellationService],
|
providers: [InternetCancellationService],
|
||||||
exports: [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 { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module.js";
|
||||||
import { SalesforceModule } from "@bff/integrations/salesforce/salesforce.module.js";
|
import { SalesforceModule } from "@bff/integrations/salesforce/salesforce.module.js";
|
||||||
import { MappingsModule } from "@bff/modules/id-mappings/mappings.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 { SftpModule } from "@bff/integrations/sftp/sftp.module.js";
|
||||||
import { SecurityModule } from "@bff/core/security/security.module.js";
|
import { SecurityModule } from "@bff/core/security/security.module.js";
|
||||||
import { SimUsageStoreService } from "../sim-usage-store.service.js";
|
import { SimUsageStoreService } from "../sim-usage-store.service.js";
|
||||||
@ -39,7 +38,6 @@ import { VoiceOptionsModule, VoiceOptionsService } from "@bff/modules/voice-opti
|
|||||||
WhmcsModule,
|
WhmcsModule,
|
||||||
SalesforceModule,
|
SalesforceModule,
|
||||||
MappingsModule,
|
MappingsModule,
|
||||||
EmailModule,
|
|
||||||
ServicesModule,
|
ServicesModule,
|
||||||
SftpModule,
|
SftpModule,
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
|
|||||||
@ -9,7 +9,6 @@ import { SecurityModule } from "@bff/core/security/security.module.js";
|
|||||||
import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module.js";
|
import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module.js";
|
||||||
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
|
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
|
||||||
import { FreebitModule } from "@bff/integrations/freebit/freebit.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 { SimManagementModule } from "./sim-management/sim-management.module.js";
|
||||||
import { InternetManagementModule } from "./internet-management/internet-management.module.js";
|
import { InternetManagementModule } from "./internet-management/internet-management.module.js";
|
||||||
import { CallHistoryModule } from "./call-history/call-history.module.js";
|
import { CallHistoryModule } from "./call-history/call-history.module.js";
|
||||||
@ -25,7 +24,6 @@ import { CancellationController } from "./cancellation/cancellation.controller.j
|
|||||||
WhmcsModule,
|
WhmcsModule,
|
||||||
MappingsModule,
|
MappingsModule,
|
||||||
FreebitModule,
|
FreebitModule,
|
||||||
EmailModule,
|
|
||||||
SimManagementModule,
|
SimManagementModule,
|
||||||
InternetManagementModule,
|
InternetManagementModule,
|
||||||
CallHistoryModule,
|
CallHistoryModule,
|
||||||
|
|||||||
@ -12,102 +12,43 @@ import {
|
|||||||
Wrench,
|
Wrench,
|
||||||
Tv,
|
Tv,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/shared/utils";
|
import { ServiceCard } from "@/components/molecules/ServiceCard";
|
||||||
|
|
||||||
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 default function ServicesPage() {
|
export default function ServicesPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-12 pb-16">
|
<div className="space-y-12 pb-16">
|
||||||
{/* Hero */}
|
{/* Hero */}
|
||||||
<section className="text-center pt-8">
|
<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">
|
<div
|
||||||
<CheckCircle2 className="h-4 w-4" />
|
className="animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||||
Full English Support
|
style={{ animationDelay: "0ms" }}
|
||||||
</span>
|
>
|
||||||
|
<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
|
Our Services
|
||||||
</h1>
|
</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.
|
Connectivity and support solutions for Japan's international community.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Value Props - Compact */}
|
{/* 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">
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
<Globe className="h-4 w-4 text-primary" />
|
<Globe className="h-4 w-4 text-primary" />
|
||||||
<span>One provider, all services</span>
|
<span>One provider, all services</span>
|
||||||
@ -123,7 +64,10 @@ export default function ServicesPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* All Services - Clean Grid */}
|
{/* 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
|
<ServiceCard
|
||||||
href="/services/internet"
|
href="/services/internet"
|
||||||
icon={<Wifi className="h-6 w-6" />}
|
icon={<Wifi className="h-6 w-6" />}
|
||||||
@ -178,7 +122,10 @@ export default function ServicesPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* CTA */}
|
{/* 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>
|
<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">
|
<p className="text-muted-foreground mb-6 max-w-md mx-auto">
|
||||||
Our bilingual team can help you find the right solution.
|
Our bilingual team can help you find the right solution.
|
||||||
@ -195,8 +142,9 @@ export default function ServicesPage() {
|
|||||||
<a
|
<a
|
||||||
href="tel:0120660470"
|
href="tel:0120660470"
|
||||||
className="inline-flex items-center gap-2 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
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)
|
0120-660-470 (Toll Free)
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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 "./ProgressSteps/ProgressSteps";
|
||||||
export * from "./SubCard/SubCard";
|
export * from "./SubCard/SubCard";
|
||||||
export * from "./AnimatedCard/AnimatedCard";
|
export * from "./AnimatedCard/AnimatedCard";
|
||||||
|
export * from "./ServiceCard/ServiceCard";
|
||||||
|
|
||||||
// Performance and lazy loading utilities
|
// Performance and lazy loading utilities
|
||||||
export { ErrorBoundary } from "./error-boundary";
|
export { ErrorBoundary } from "./error-boundary";
|
||||||
|
|||||||
@ -6,14 +6,14 @@
|
|||||||
* Features:
|
* Features:
|
||||||
* - ZIP code lookup via Japan Post API (required)
|
* - ZIP code lookup via Japan Post API (required)
|
||||||
* - Auto-fill prefecture, city, town from ZIP (read-only)
|
* - 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
|
* - House/Apartment toggle with conditional room number
|
||||||
* - Captures both Japanese and English (romanized) addresses
|
* - Captures both Japanese and English (romanized) addresses
|
||||||
* - Compatible with WHMCS and Salesforce field mapping
|
* - Compatible with WHMCS and Salesforce field mapping
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useState, useEffect, useRef } from "react";
|
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 { Input } from "@/components/atoms";
|
||||||
import { FormField } from "@/components/molecules/FormField/FormField";
|
import { FormField } from "@/components/molecules/FormField/FormField";
|
||||||
import { cn } from "@/shared/utils";
|
import { cn } from "@/shared/utils";
|
||||||
@ -62,16 +62,99 @@ const DEFAULT_ADDRESS: Omit<JapanAddressFormData, "residenceType"> & {
|
|||||||
cityJa: "",
|
cityJa: "",
|
||||||
town: "",
|
town: "",
|
||||||
townJa: "",
|
townJa: "",
|
||||||
|
streetAddress: "",
|
||||||
buildingName: "",
|
buildingName: "",
|
||||||
roomNumber: "",
|
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"> & {
|
type InternalFormState = Omit<JapanAddressFormData, "residenceType"> & {
|
||||||
residenceType: ResidenceType | "";
|
residenceType: ResidenceType | "";
|
||||||
};
|
};
|
||||||
@ -85,27 +168,38 @@ export function JapanAddressForm({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
className,
|
className,
|
||||||
}: JapanAddressFormProps) {
|
}: JapanAddressFormProps) {
|
||||||
// Form state - residenceType can be empty until user selects
|
|
||||||
const [address, setAddress] = useState<InternalFormState>(() => ({
|
const [address, setAddress] = useState<InternalFormState>(() => ({
|
||||||
...DEFAULT_ADDRESS,
|
...DEFAULT_ADDRESS,
|
||||||
...initialValues,
|
...initialValues,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Track if ZIP lookup has verified the address (required for form completion)
|
|
||||||
const [isAddressVerified, setIsAddressVerified] = useState(false);
|
const [isAddressVerified, setIsAddressVerified] = useState(false);
|
||||||
|
|
||||||
// Track the ZIP code that was last looked up (to detect changes)
|
|
||||||
const [verifiedZipCode, setVerifiedZipCode] = useState<string>("");
|
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);
|
const onChangeRef = useRef(onChange);
|
||||||
onChangeRef.current = 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(() => {
|
useEffect(() => {
|
||||||
if (initialValues) {
|
if (initialValues && !hasInitializedRef.current) {
|
||||||
|
hasInitializedRef.current = true;
|
||||||
setAddress(prev => ({ ...prev, ...initialValues }));
|
setAddress(prev => ({ ...prev, ...initialValues }));
|
||||||
// If initialValues have address data, consider it verified
|
|
||||||
if (initialValues.prefecture && initialValues.city && initialValues.town) {
|
if (initialValues.prefecture && initialValues.city && initialValues.town) {
|
||||||
setIsAddressVerified(true);
|
setIsAddressVerified(true);
|
||||||
setVerifiedZipCode(initialValues.postcode || "");
|
setVerifiedZipCode(initialValues.postcode || "");
|
||||||
@ -113,36 +207,52 @@ export function JapanAddressForm({
|
|||||||
}
|
}
|
||||||
}, [initialValues]);
|
}, [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 => {
|
const getError = (field: keyof JapanAddressFormData): string | undefined => {
|
||||||
return touched[field] ? errors[field] : 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(() => {
|
useEffect(() => {
|
||||||
const hasResidenceType =
|
if (hasResidenceType) {
|
||||||
address.residenceType === RESIDENCE_TYPE.HOUSE ||
|
// Safe to cast since we verified residenceType is valid
|
||||||
address.residenceType === RESIDENCE_TYPE.APARTMENT;
|
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 =
|
// Manage success animation separately to avoid callback double-firing
|
||||||
address.postcode.trim() !== "" &&
|
useEffect(() => {
|
||||||
address.prefecture.trim() !== "" &&
|
setShowSuccess(isComplete);
|
||||||
address.city.trim() !== "" &&
|
}, [isComplete]);
|
||||||
address.town.trim() !== "";
|
|
||||||
|
|
||||||
// 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(
|
const handleZipChange = useCallback(
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
const normalizedNew = value.replace(/-/g, "");
|
const normalizedNew = value.replace(/-/g, "");
|
||||||
@ -150,8 +260,8 @@ export function JapanAddressForm({
|
|||||||
const shouldReset = normalizedNew !== normalizedVerified;
|
const shouldReset = normalizedNew !== normalizedVerified;
|
||||||
|
|
||||||
if (shouldReset) {
|
if (shouldReset) {
|
||||||
// Reset address fields when ZIP changes
|
|
||||||
setIsAddressVerified(false);
|
setIsAddressVerified(false);
|
||||||
|
setShowSuccess(false);
|
||||||
setAddress(prev => ({
|
setAddress(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
postcode: value,
|
postcode: value,
|
||||||
@ -161,42 +271,44 @@ export function JapanAddressForm({
|
|||||||
cityJa: "",
|
cityJa: "",
|
||||||
town: "",
|
town: "",
|
||||||
townJa: "",
|
townJa: "",
|
||||||
// Keep user-entered fields
|
|
||||||
buildingName: prev.buildingName,
|
buildingName: prev.buildingName,
|
||||||
roomNumber: prev.roomNumber,
|
roomNumber: prev.roomNumber,
|
||||||
residenceType: prev.residenceType,
|
residenceType: prev.residenceType,
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
// Just update postcode formatting
|
|
||||||
setAddress(prev => ({ ...prev, postcode: value }));
|
setAddress(prev => ({ ...prev, postcode: value }));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[verifiedZipCode]
|
[verifiedZipCode]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle address found from ZIP lookup
|
|
||||||
const handleAddressFound = useCallback((found: JapanPostAddress) => {
|
const handleAddressFound = useCallback((found: JapanPostAddress) => {
|
||||||
setAddress(prev => {
|
setAddress(prev => {
|
||||||
setIsAddressVerified(true);
|
setIsAddressVerified(true);
|
||||||
setVerifiedZipCode(prev.postcode);
|
setVerifiedZipCode(prev.postcode);
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
// English (romanized) fields - for WHMCS
|
|
||||||
prefecture: found.prefectureRoma,
|
prefecture: found.prefectureRoma,
|
||||||
city: found.cityRoma,
|
city: found.cityRoma,
|
||||||
town: found.townRoma,
|
town: found.townRoma,
|
||||||
// Japanese fields - for Salesforce
|
|
||||||
prefectureJa: found.prefecture,
|
prefectureJa: found.prefecture,
|
||||||
cityJa: found.city,
|
cityJa: found.city,
|
||||||
townJa: found.town,
|
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) => {
|
const handleLookupComplete = useCallback((found: boolean) => {
|
||||||
if (!found) {
|
if (!found) {
|
||||||
// Clear address fields on failed lookup
|
|
||||||
setIsAddressVerified(false);
|
setIsAddressVerified(false);
|
||||||
setAddress(prev => ({
|
setAddress(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
@ -210,196 +322,367 @@ export function JapanAddressForm({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Handle residence type change
|
|
||||||
const handleResidenceTypeChange = useCallback((type: ResidenceType) => {
|
const handleResidenceTypeChange = useCallback((type: ResidenceType) => {
|
||||||
setAddress(prev => ({
|
setAddress(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
residenceType: type,
|
residenceType: type,
|
||||||
// Clear room number when switching to house
|
|
||||||
roomNumber: type === RESIDENCE_TYPE.HOUSE ? "" : prev.roomNumber,
|
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) => {
|
const handleBuildingNameChange = useCallback((value: string) => {
|
||||||
setAddress(prev => ({ ...prev, buildingName: value }));
|
setAddress(prev => ({ ...prev, buildingName: value }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Handle room number change
|
|
||||||
const handleRoomNumberChange = useCallback((value: string) => {
|
const handleRoomNumberChange = useCallback((value: string) => {
|
||||||
setAddress(prev => ({ ...prev, roomNumber: value }));
|
setAddress(prev => ({ ...prev, roomNumber: value }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const isApartment = address.residenceType === RESIDENCE_TYPE.APARTMENT;
|
const isApartment = address.residenceType === RESIDENCE_TYPE.APARTMENT;
|
||||||
const hasResidenceTypeSelected =
|
|
||||||
address.residenceType === RESIDENCE_TYPE.HOUSE ||
|
|
||||||
address.residenceType === RESIDENCE_TYPE.APARTMENT;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("space-y-5", className)}>
|
<div className={cn("space-y-6", className)}>
|
||||||
{/* ZIP Code with auto-lookup */}
|
{/* Progress Indicator */}
|
||||||
<ZipCodeInput
|
<ProgressIndicator currentStep={currentStep} totalSteps={4} />
|
||||||
value={address.postcode}
|
|
||||||
onChange={handleZipChange}
|
|
||||||
onAddressFound={handleAddressFound}
|
|
||||||
onLookupComplete={handleLookupComplete}
|
|
||||||
error={getError("postcode")}
|
|
||||||
required
|
|
||||||
disabled={disabled}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Address fields - Read-only, populated by ZIP lookup */}
|
{/* Step 1: ZIP Code Lookup */}
|
||||||
<div
|
<div className="relative">
|
||||||
className={cn(
|
<div
|
||||||
"space-y-4 p-4 rounded-lg border transition-all",
|
className={cn(
|
||||||
isAddressVerified ? "border-success/50 bg-success/5" : "border-border bg-muted/30"
|
"absolute -left-3 top-0 bottom-0 w-1 rounded-full transition-all duration-500",
|
||||||
)}
|
isAddressVerified ? "bg-success" : "bg-primary/30"
|
||||||
>
|
)}
|
||||||
{isAddressVerified && (
|
/>
|
||||||
<div className="flex items-center gap-2 text-sm text-success font-medium">
|
<div className="pl-4">
|
||||||
<CheckCircle className="h-4 w-4" />
|
<div className="flex items-center gap-2 mb-3">
|
||||||
Address verified
|
<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>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Prefecture - Read-only */}
|
<ZipCodeInput
|
||||||
<FormField
|
value={address.postcode}
|
||||||
label="Prefecture"
|
onChange={handleZipChange}
|
||||||
required
|
onAddressFound={handleAddressFound}
|
||||||
helperText={isAddressVerified && address.prefectureJa ? address.prefectureJa : undefined}
|
onLookupComplete={handleLookupComplete}
|
||||||
>
|
error={getError("postcode")}
|
||||||
<Input
|
required
|
||||||
value={isAddressVerified ? address.prefecture : ""}
|
disabled={disabled}
|
||||||
placeholder={isAddressVerified ? "" : "Enter ZIP code above"}
|
autoFocus
|
||||||
disabled
|
|
||||||
readOnly
|
|
||||||
className={cn("bg-transparent", isAddressVerified && "text-foreground")}
|
|
||||||
data-field="address.state"
|
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</div>
|
||||||
|
|
||||||
{/* 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 */}
|
{/* Verified Address Display */}
|
||||||
{isAddressVerified && (
|
<AnimatedSection show={isAddressVerified}>
|
||||||
<div className="space-y-2">
|
<div className="relative">
|
||||||
<label className="block text-sm font-medium text-muted-foreground">
|
<div className="absolute -left-3 top-0 bottom-0 w-1 rounded-full bg-success/30" />
|
||||||
Residence Type <span className="text-danger">*</span>
|
<div className="pl-4">
|
||||||
</label>
|
<div
|
||||||
<div className="flex gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleResidenceTypeChange(RESIDENCE_TYPE.HOUSE)}
|
|
||||||
disabled={disabled}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg border-2 transition-all",
|
"rounded-xl border transition-all duration-500",
|
||||||
"text-sm font-medium",
|
"bg-gradient-to-br from-success/5 via-success/[0.02] to-transparent",
|
||||||
address.residenceType === RESIDENCE_TYPE.HOUSE
|
"border-success/20"
|
||||||
? "border-primary bg-primary/5 text-primary"
|
|
||||||
: "border-border bg-card text-muted-foreground hover:border-muted-foreground/50",
|
|
||||||
!hasResidenceTypeSelected && getError("residenceType") && "border-danger",
|
|
||||||
disabled && "opacity-50 cursor-not-allowed"
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Home className="h-4 w-4" />
|
<div className="p-4 space-y-3">
|
||||||
House
|
<div className="flex items-center gap-2 text-success">
|
||||||
</button>
|
<MapPin className="w-4 h-4" />
|
||||||
<button
|
<span className="text-sm font-semibold">Address from Japan Post</span>
|
||||||
type="button"
|
</div>
|
||||||
onClick={() => handleResidenceTypeChange(RESIDENCE_TYPE.APARTMENT)}
|
|
||||||
disabled={disabled}
|
<div className="grid gap-2">
|
||||||
className={cn(
|
{/* Prefecture */}
|
||||||
"flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg border-2 transition-all",
|
<div className="flex items-center gap-3 py-2 px-3 rounded-lg bg-background/50">
|
||||||
"text-sm font-medium",
|
<span className="text-xs text-muted-foreground w-20 shrink-0">Prefecture</span>
|
||||||
address.residenceType === RESIDENCE_TYPE.APARTMENT
|
<ChevronRight className="w-3 h-3 text-muted-foreground/50" />
|
||||||
? "border-primary bg-primary/5 text-primary"
|
<BilingualValue
|
||||||
: "border-border bg-card text-muted-foreground hover:border-muted-foreground/50",
|
romaji={address.prefecture}
|
||||||
!hasResidenceTypeSelected && getError("residenceType") && "border-danger",
|
japanese={address.prefectureJa}
|
||||||
disabled && "opacity-50 cursor-not-allowed"
|
placeholder="—"
|
||||||
)}
|
verified={isAddressVerified}
|
||||||
>
|
/>
|
||||||
<Building2 className="h-4 w-4" />
|
</div>
|
||||||
Apartment / Mansion
|
|
||||||
</button>
|
{/* 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>
|
</div>
|
||||||
{!hasResidenceTypeSelected && getError("residenceType") && (
|
|
||||||
<p className="text-sm text-danger">{getError("residenceType")}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</AnimatedSection>
|
||||||
|
|
||||||
{/* Building fields - Only show after residence type is selected */}
|
{/* Step 2: Street Address */}
|
||||||
{isAddressVerified && hasResidenceTypeSelected && (
|
<AnimatedSection show={isAddressVerified} delay={100}>
|
||||||
<>
|
<div className="relative">
|
||||||
{/* Building Name */}
|
<div
|
||||||
<FormField
|
className={cn(
|
||||||
label="Building Name"
|
"absolute -left-3 top-0 bottom-0 w-1 rounded-full transition-all duration-500",
|
||||||
error={getError("buildingName")}
|
address.streetAddress.trim() ? "bg-success" : "bg-primary/30"
|
||||||
required={false}
|
)}
|
||||||
helperText="e.g., Gramercy Heights"
|
/>
|
||||||
>
|
<div className="pl-4">
|
||||||
<Input
|
<div className="flex items-center gap-2 mb-3">
|
||||||
value={address.buildingName ?? ""}
|
<div
|
||||||
onChange={e => handleBuildingNameChange(e.target.value)}
|
className={cn(
|
||||||
onBlur={() => onBlur?.("buildingName")}
|
"flex items-center justify-center w-6 h-6 rounded-full text-xs font-semibold transition-all duration-300",
|
||||||
placeholder="Gramercy Heights"
|
address.streetAddress.trim()
|
||||||
disabled={disabled}
|
? "bg-success text-success-foreground"
|
||||||
data-field="address.buildingName"
|
: "bg-primary/10 text-primary"
|
||||||
/>
|
)}
|
||||||
</FormField>
|
>
|
||||||
|
{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
|
<FormField
|
||||||
label="Room Number"
|
label=""
|
||||||
error={getError("roomNumber")}
|
error={getError("streetAddress")}
|
||||||
required
|
required
|
||||||
helperText="Required for apartments"
|
helperText="Enter chome-banchi-go (e.g., 1-5-3)"
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
value={address.roomNumber ?? ""}
|
ref={streetAddressRef}
|
||||||
onChange={e => handleRoomNumberChange(e.target.value)}
|
value={address.streetAddress}
|
||||||
onBlur={() => onBlur?.("roomNumber")}
|
onChange={e => handleStreetAddressChange(e.target.value)}
|
||||||
placeholder="201"
|
onBlur={() => onBlur?.("streetAddress")}
|
||||||
|
placeholder="1-5-3"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
data-field="address.roomNumber"
|
className="font-mono text-lg tracking-wider"
|
||||||
|
data-field="address.streetAddress"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</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>
|
</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
|
// Trust indicators
|
||||||
export { CtaButton } from "./CtaButton";
|
|
||||||
export { TrustBadge } from "./TrustBadge";
|
export { TrustBadge } from "./TrustBadge";
|
||||||
export { TrustIndicators } from "./TrustIndicators";
|
export { TrustIndicators } from "./TrustIndicators";
|
||||||
|
|
||||||
// Service display
|
// Decorative/visual components (kept for potential future use)
|
||||||
export { FeaturedServiceCard } from "./FeaturedServiceCard";
|
|
||||||
export { ServiceCard } from "./ServiceCard";
|
|
||||||
|
|
||||||
// Legacy (kept for compatibility, can be removed later)
|
|
||||||
export { GlowButton } from "./GlowButton";
|
|
||||||
export { ValuePropCard } from "./ValuePropCard";
|
export { ValuePropCard } from "./ValuePropCard";
|
||||||
export { BentoServiceCard } from "./BentoServiceCard";
|
|
||||||
export { FloatingGlassCard } from "./FloatingGlassCard";
|
export { FloatingGlassCard } from "./FloatingGlassCard";
|
||||||
export { AnimatedBackground } from "./AnimatedBackground";
|
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,
|
Phone,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { cn } from "@/shared/utils";
|
import { ServiceCard } from "@/components/molecules/ServiceCard";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PublicLandingView - Clean Landing Page
|
* PublicLandingView - Clean Landing Page
|
||||||
@ -24,104 +24,45 @@ import { cn } from "@/shared/utils";
|
|||||||
* - Clean, centered layout
|
* - Clean, centered layout
|
||||||
* - Consistent card styling with colored accents
|
* - Consistent card styling with colored accents
|
||||||
* - Simple value propositions
|
* - 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() {
|
export function PublicLandingView() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-16 pb-16">
|
<div className="space-y-16 pb-16">
|
||||||
{/* ===== HERO SECTION ===== */}
|
{/* ===== HERO SECTION ===== */}
|
||||||
<section className="text-center pt-12 sm:pt-16">
|
<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">
|
<div
|
||||||
<CheckCircle2 className="h-4 w-4" />
|
className="animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||||
20+ Years Serving Japan
|
style={{ animationDelay: "0ms" }}
|
||||||
</span>
|
>
|
||||||
|
<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
|
Your One Stop Solution
|
||||||
<br />
|
<br />
|
||||||
<span className="text-primary">for Connectivity in Japan</span>
|
<span className="text-primary">for Connectivity in Japan</span>
|
||||||
</h1>
|
</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
|
Full English support for all your connectivity needs — from setup to billing to technical
|
||||||
assistance.
|
assistance.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* CTAs */}
|
{/* 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
|
<Link
|
||||||
href="#services"
|
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"
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Trust Stats */}
|
{/* 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 items-center gap-3">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/8">
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/8">
|
||||||
<Calendar className="h-5 w-5 text-primary" />
|
<Calendar className="h-5 w-5 text-primary" />
|
||||||
@ -170,7 +114,10 @@ export function PublicLandingView() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ===== WHY CHOOSE US ===== */}
|
{/* ===== 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">
|
<div className="text-center mb-10">
|
||||||
<h2 className="text-display-sm font-display font-bold text-foreground mb-3">
|
<h2 className="text-display-sm font-display font-bold text-foreground mb-3">
|
||||||
Why Choose Us
|
Why Choose Us
|
||||||
@ -212,7 +159,11 @@ export function PublicLandingView() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ===== OUR SERVICES ===== */}
|
{/* ===== 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">
|
<div className="text-center mb-10">
|
||||||
<h2 className="text-display-sm font-display font-bold text-foreground mb-3">
|
<h2 className="text-display-sm font-display font-bold text-foreground mb-3">
|
||||||
Our Services
|
Our Services
|
||||||
@ -305,7 +256,10 @@ export function PublicLandingView() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ===== CTA ===== */}
|
{/* ===== 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">
|
<h2 className="text-xl font-bold text-foreground font-display mb-3">
|
||||||
Ready to get connected?
|
Ready to get connected?
|
||||||
</h2>
|
</h2>
|
||||||
@ -324,8 +278,9 @@ export function PublicLandingView() {
|
|||||||
<a
|
<a
|
||||||
href="tel:0120660470"
|
href="tel:0120660470"
|
||||||
className="inline-flex items-center gap-2 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
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)
|
0120-660-470 (Toll Free)
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -22,20 +22,31 @@ export function AboutUsView() {
|
|||||||
<div className="max-w-4xl mx-auto space-y-12">
|
<div className="max-w-4xl mx-auto space-y-12">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h1 className="text-3xl sm:text-4xl font-bold text-foreground mb-4">About Us</h1>
|
<h1
|
||||||
<p className="text-lg text-muted-foreground max-w-2xl mx-auto leading-relaxed">
|
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
|
We specialize in serving Japan's international community with the most reliable and
|
||||||
cost-efficient IT solutions available.
|
cost-efficient IT solutions available.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Who We Are Section */}
|
{/* 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="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">
|
<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" />
|
<Building2 className="h-6 w-6 text-primary" />
|
||||||
</div>
|
</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>
|
||||||
<div className="space-y-4 text-muted-foreground leading-relaxed">
|
<div className="space-y-4 text-muted-foreground leading-relaxed">
|
||||||
<p>
|
<p>
|
||||||
@ -53,12 +64,15 @@ export function AboutUsView() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Corporate Data 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="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">
|
<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" />
|
<Users className="h-6 w-6 text-primary" />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<p className="text-muted-foreground mb-6">
|
<p className="text-muted-foreground mb-6">
|
||||||
Assist Solutions is a privately-owned entrepreneurial IT supporting company, focused on
|
Assist Solutions is a privately-owned entrepreneurial IT supporting company, focused on
|
||||||
@ -150,8 +164,13 @@ export function AboutUsView() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Business Activities Section */}
|
{/* Business Activities Section */}
|
||||||
<section className="bg-card rounded-2xl border border-border p-8 sm:p-10">
|
<section
|
||||||
<h2 className="text-2xl font-bold text-foreground mb-6">Business Activities</h2>
|
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">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
{[
|
{[
|
||||||
"IT Consulting Services",
|
"IT Consulting Services",
|
||||||
@ -175,8 +194,13 @@ export function AboutUsView() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Mission Statement 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">
|
<section
|
||||||
<h2 className="text-2xl font-bold text-foreground mb-6">Mission Statement</h2>
|
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">
|
<p className="text-muted-foreground mb-6 leading-relaxed">
|
||||||
We will achieve business success by pursuing the following:
|
We will achieve business success by pursuing the following:
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -11,13 +11,17 @@ import { accountService } from "@/features/account/api/account.api";
|
|||||||
import { logger } from "@/core/logger";
|
import { logger } from "@/core/logger";
|
||||||
import { StatusPill } from "@/components/atoms/status-pill";
|
import { StatusPill } from "@/components/atoms/status-pill";
|
||||||
import { MapPinIcon, PencilIcon, CheckIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
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";
|
import { queryKeys } from "@/core/api";
|
||||||
|
|
||||||
// Use canonical Address type from domain
|
// Use canonical Address type from domain
|
||||||
import type { Address } from "@customer-portal/domain/customer";
|
import type { Address } from "@customer-portal/domain/customer";
|
||||||
|
import type { BilingualAddress } from "@customer-portal/domain/address";
|
||||||
import { ORDER_TYPE } from "@customer-portal/domain/orders";
|
import { ORDER_TYPE } from "@customer-portal/domain/orders";
|
||||||
|
|
||||||
|
// Japan address form with ZIP lookup
|
||||||
|
import { JapanAddressForm } from "@/features/address/components/JapanAddressForm";
|
||||||
|
|
||||||
interface BillingInfo {
|
interface BillingInfo {
|
||||||
company: string | null;
|
company: string | null;
|
||||||
email: string;
|
email: string;
|
||||||
@ -47,9 +51,11 @@ export function AddressConfirmation({
|
|||||||
const [billingInfo, setBillingInfo] = useState<BillingInfo | null>(null);
|
const [billingInfo, setBillingInfo] = useState<BillingInfo | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [editing, setEditing] = useState(false);
|
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 [error, setError] = useState<string | null>(null);
|
||||||
const [addressConfirmed, setAddressConfirmed] = useState(false);
|
const [addressConfirmed, setAddressConfirmed] = useState(false);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const onAddressConfirmedRef = useRef(onAddressConfirmed);
|
const onAddressConfirmedRef = useRef(onAddressConfirmed);
|
||||||
const onAddressIncompleteRef = useRef(onAddressIncomplete);
|
const onAddressIncompleteRef = useRef(onAddressIncomplete);
|
||||||
|
|
||||||
@ -119,55 +125,40 @@ export function AddressConfirmation({
|
|||||||
e?.stopPropagation();
|
e?.stopPropagation();
|
||||||
|
|
||||||
setEditing(true);
|
setEditing(true);
|
||||||
setEditedAddress(
|
setError(null);
|
||||||
billingInfo?.address ?? {
|
// Initialize with empty bilingual address - user will use ZIP lookup
|
||||||
address1: "",
|
setBilingualAddress(null);
|
||||||
address2: "",
|
setIsAddressComplete(false);
|
||||||
city: "",
|
|
||||||
state: "",
|
|
||||||
postcode: "",
|
|
||||||
country: "",
|
|
||||||
countryCode: "",
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle JapanAddressForm changes
|
||||||
|
const handleBilingualAddressChange = useCallback(
|
||||||
|
(address: BilingualAddress, complete: boolean) => {
|
||||||
|
setBilingualAddress(address);
|
||||||
|
setIsAddressComplete(complete);
|
||||||
|
setError(null);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const handleSave = (e: React.MouseEvent<HTMLButtonElement>) => {
|
const handleSave = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
if (!editedAddress) return;
|
if (!bilingualAddress || !isAddressComplete) {
|
||||||
|
setError("Please complete all required address fields");
|
||||||
// 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");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setIsSaving(true);
|
||||||
|
|
||||||
const sanitizedAddress: Address = {
|
// Dual-write: English to WHMCS, Japanese to Salesforce
|
||||||
address1: editedAddress.address1?.trim() || null,
|
const updatedAddress = await accountService.updateBilingualAddress(
|
||||||
address2: editedAddress.address2?.trim() || null,
|
bilingualAddress as BilingualAddress
|
||||||
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);
|
|
||||||
|
|
||||||
// Address changes can affect server-personalized services results (eligibility).
|
// Address changes can affect server-personalized services results (eligibility).
|
||||||
await queryClient.invalidateQueries({ queryKey: queryKeys.services.all() });
|
await queryClient.invalidateQueries({ queryKey: queryKeys.services.all() });
|
||||||
@ -192,8 +183,14 @@ export function AddressConfirmation({
|
|||||||
onAddressConfirmed(updatedAddress);
|
onAddressConfirmed(updatedAddress);
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
setAddressConfirmed(true);
|
setAddressConfirmed(true);
|
||||||
|
|
||||||
|
logger.info("Address updated with dual-write (WHMCS + Salesforce)", {
|
||||||
|
hasJapaneseAddress: !!bilingualAddress.prefectureJa,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to update address");
|
setError(err instanceof Error ? err.message : "Failed to update address");
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
};
|
};
|
||||||
@ -221,7 +218,8 @@ export function AddressConfirmation({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
setEditedAddress(null);
|
setBilingualAddress(null);
|
||||||
|
setIsAddressComplete(false);
|
||||||
setError(null);
|
setError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -321,113 +319,27 @@ export function AddressConfirmation({
|
|||||||
|
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<AlertBanner variant="info" title="Japan Address" size="sm">
|
||||||
<label className="block text-sm font-medium text-muted-foreground mb-1">
|
Enter your ZIP code to auto-fill address fields from Japan Post.
|
||||||
Street / Block (Address 2)
|
</AlertBanner>
|
||||||
</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>
|
|
||||||
|
|
||||||
<div>
|
<JapanAddressForm onChange={handleBilingualAddressChange} disabled={isSaving} />
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
{error && (
|
||||||
<div>
|
<AlertBanner variant="error" title="Error" size="sm">
|
||||||
<label className="block text-sm font-medium text-muted-foreground mb-1">City *</label>
|
{error}
|
||||||
<input
|
</AlertBanner>
|
||||||
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>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-3 pt-4">
|
<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
|
Save Address
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@ -435,6 +347,7 @@ export function AddressConfirmation({
|
|||||||
onClick={handleCancel}
|
onClick={handleCancel}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
leftIcon={<XMarkIcon className="h-4 w-4" />}
|
leftIcon={<XMarkIcon className="h-4 w-4" />}
|
||||||
|
disabled={isSaving}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -20,11 +20,11 @@ export function transformJapanPostAddress(raw: JapanPostAddressRecord): JapanPos
|
|||||||
zipCode,
|
zipCode,
|
||||||
// Japanese
|
// Japanese
|
||||||
prefecture: raw.pref_name || "",
|
prefecture: raw.pref_name || "",
|
||||||
prefectureKana: raw.pref_kana,
|
prefectureKana: raw.pref_kana ?? undefined,
|
||||||
city: raw.city_name || "",
|
city: raw.city_name || "",
|
||||||
cityKana: raw.city_kana,
|
cityKana: raw.city_kana ?? undefined,
|
||||||
town: raw.town_name || "",
|
town: raw.town_name || "",
|
||||||
townKana: raw.town_kana,
|
townKana: raw.town_kana ?? undefined,
|
||||||
// Romanized
|
// Romanized
|
||||||
prefectureRoma: raw.pref_roma || "",
|
prefectureRoma: raw.pref_roma || "",
|
||||||
cityRoma: raw.city_roma || "",
|
cityRoma: raw.city_roma || "",
|
||||||
|
|||||||
@ -30,39 +30,55 @@ export type JapanPostTokenResponse = z.infer<typeof japanPostTokenResponseSchema
|
|||||||
/**
|
/**
|
||||||
* Single address record from Japan Post API
|
* Single address record from Japan Post API
|
||||||
* Fields from GET /api/v1/searchcode/{search_code}
|
* 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({
|
export const japanPostAddressRecordSchema = z.object({
|
||||||
// ZIP code
|
// ZIP code
|
||||||
zipcode: z.string().optional(),
|
zipcode: z.string().nullish(),
|
||||||
zip_code: z.string().optional(),
|
zip_code: z.string().nullish(),
|
||||||
|
|
||||||
|
// Digital address code (can be null)
|
||||||
|
dgacode: z.string().nullish(),
|
||||||
|
|
||||||
// Prefecture
|
// Prefecture
|
||||||
pref_code: z.string().optional(),
|
pref_code: z.string().nullish(),
|
||||||
pref_name: z.string().optional(),
|
pref_name: z.string().nullish(),
|
||||||
pref_kana: z.string().optional(),
|
pref_kana: z.string().nullish(),
|
||||||
pref_roma: z.string().optional(),
|
pref_roma: z.string().nullish(),
|
||||||
|
|
||||||
// City
|
// City
|
||||||
city_code: z.string().optional(),
|
city_code: z.string().nullish(),
|
||||||
city_name: z.string().optional(),
|
city_name: z.string().nullish(),
|
||||||
city_kana: z.string().optional(),
|
city_kana: z.string().nullish(),
|
||||||
city_roma: z.string().optional(),
|
city_roma: z.string().nullish(),
|
||||||
|
|
||||||
// Town
|
// Town
|
||||||
town_code: z.string().optional(),
|
town_code: z.string().nullish(),
|
||||||
town_name: z.string().optional(),
|
town_name: z.string().nullish(),
|
||||||
town_kana: z.string().optional(),
|
town_kana: z.string().nullish(),
|
||||||
town_roma: z.string().optional(),
|
town_roma: z.string().nullish(),
|
||||||
|
|
||||||
// Additional fields that may be present
|
// Block/street details (often null)
|
||||||
block_name: z.string().optional(),
|
block_name: z.string().nullish(),
|
||||||
block_kana: z.string().optional(),
|
block_kana: z.string().nullish(),
|
||||||
block_roma: z.string().optional(),
|
block_roma: z.string().nullish(),
|
||||||
|
|
||||||
// Office/company info (for business ZIP codes)
|
// Business/office info (for business ZIP codes)
|
||||||
office_name: z.string().optional(),
|
biz_name: z.string().nullish(),
|
||||||
office_kana: z.string().optional(),
|
biz_kana: z.string().nullish(),
|
||||||
office_roma: z.string().optional(),
|
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>;
|
export type JapanPostAddressRecord = z.infer<typeof japanPostAddressRecordSchema>;
|
||||||
|
|||||||
@ -59,6 +59,28 @@ export const addressLookupResultSchema = z.object({
|
|||||||
count: z.number(),
|
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)
|
// Bilingual Address Schemas (Extended from customer/addressSchema)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -90,6 +112,9 @@ export const bilingualAddressSchema = z.object({
|
|||||||
cityJa: z.string(),
|
cityJa: z.string(),
|
||||||
townJa: 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)
|
// Building info (same for both systems)
|
||||||
buildingName: z.string().max(200).optional().nullable(),
|
buildingName: z.string().max(200).optional().nullable(),
|
||||||
roomNumber: z.string().max(50).optional().nullable(),
|
roomNumber: z.string().max(50).optional().nullable(),
|
||||||
@ -112,18 +137,27 @@ export const addressUpdateRequestSchema = bilingualAddressSchema.extend({
|
|||||||
/**
|
/**
|
||||||
* Prepare address fields for WHMCS update
|
* Prepare address fields for WHMCS update
|
||||||
* Maps bilingual address to WHMCS field format
|
* 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 {
|
export function prepareWhmcsAddressFields(address: BilingualAddress): WhmcsAddressFields {
|
||||||
const buildingPart = address.buildingName || "";
|
const buildingPart = address.buildingName || "";
|
||||||
const roomPart = address.roomNumber || "";
|
const roomPart = address.roomNumber || "";
|
||||||
|
|
||||||
// address1: "{BuildingName} {RoomNumber}" for apartment, "{BuildingName}" for house
|
// address1: Building + Room (for apartments) or just Building (for houses)
|
||||||
const address1 =
|
const address1 =
|
||||||
address.residenceType === "apartment" ? `${buildingPart} ${roomPart}`.trim() : buildingPart;
|
address.residenceType === "apartment" ? `${buildingPart} ${roomPart}`.trim() : buildingPart;
|
||||||
|
|
||||||
|
// address2: Town + Street address (romanized)
|
||||||
|
const address2 = `${address.town} ${address.streetAddress}`.trim();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
address1: address1 || undefined,
|
address1: address1 || undefined,
|
||||||
address2: address.town, // romanized town/street
|
address2: address2 || undefined,
|
||||||
city: address.city, // romanized city
|
city: address.city, // romanized city
|
||||||
state: address.prefecture, // romanized prefecture
|
state: address.prefecture, // romanized prefecture
|
||||||
postcode: address.postcode,
|
postcode: address.postcode,
|
||||||
@ -139,12 +173,21 @@ export function prepareWhmcsAddressFields(address: BilingualAddress): WhmcsAddre
|
|||||||
/**
|
/**
|
||||||
* Prepare address fields for Salesforce Contact update
|
* Prepare address fields for Salesforce Contact update
|
||||||
* Maps bilingual address to Salesforce field format
|
* Maps bilingual address to Salesforce field format
|
||||||
|
*
|
||||||
|
* Salesforce field mapping:
|
||||||
|
* - MailingStreet: Town + Street address (Japanese)
|
||||||
|
* - MailingCity: City (Japanese)
|
||||||
|
* - MailingState: Prefecture (Japanese)
|
||||||
*/
|
*/
|
||||||
export function prepareSalesforceContactAddressFields(
|
export function prepareSalesforceContactAddressFields(
|
||||||
address: BilingualAddress
|
address: BilingualAddress
|
||||||
): SalesforceContactAddressFields {
|
): SalesforceContactAddressFields {
|
||||||
|
// Combine town and street address for MailingStreet
|
||||||
|
// Example: "東麻布1-5-3" or "東麻布1丁目5番3号"
|
||||||
|
const mailingStreet = `${address.townJa}${address.streetAddress}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
MailingStreet: address.townJa, // Japanese town/street
|
MailingStreet: mailingStreet,
|
||||||
MailingCity: address.cityJa, // Japanese city
|
MailingCity: address.cityJa, // Japanese city
|
||||||
MailingState: address.prefectureJa, // Japanese prefecture
|
MailingState: address.prefectureJa, // Japanese prefecture
|
||||||
MailingPostalCode: address.postcode,
|
MailingPostalCode: address.postcode,
|
||||||
@ -162,6 +205,7 @@ export type ZipCode = z.input<typeof zipCodeSchema>;
|
|||||||
export type ZipCodeLookupRequest = z.infer<typeof zipCodeLookupRequestSchema>;
|
export type ZipCodeLookupRequest = z.infer<typeof zipCodeLookupRequestSchema>;
|
||||||
export type JapanPostAddress = z.infer<typeof japanPostAddressSchema>;
|
export type JapanPostAddress = z.infer<typeof japanPostAddressSchema>;
|
||||||
export type AddressLookupResult = z.infer<typeof addressLookupResultSchema>;
|
export type AddressLookupResult = z.infer<typeof addressLookupResultSchema>;
|
||||||
|
export type StreetAddressDetail = z.infer<typeof streetAddressDetailSchema>;
|
||||||
export type BuildingInfo = z.infer<typeof buildingInfoSchema>;
|
export type BuildingInfo = z.infer<typeof buildingInfoSchema>;
|
||||||
export type BilingualAddress = z.infer<typeof bilingualAddressSchema>;
|
export type BilingualAddress = z.infer<typeof bilingualAddressSchema>;
|
||||||
export type AddressUpdateRequest = z.infer<typeof addressUpdateRequestSchema>;
|
export type AddressUpdateRequest = z.infer<typeof addressUpdateRequestSchema>;
|
||||||
|
|||||||
@ -341,7 +341,6 @@ start_apps() {
|
|||||||
# Build shared package first
|
# Build shared package first
|
||||||
log "🔨 Building shared package..."
|
log "🔨 Building shared package..."
|
||||||
pnpm --filter @customer-portal/domain build
|
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.
|
# Build BFF before watch (ensures dist exists). Use Nest build for correct emit.
|
||||||
log "🔨 Building BFF for initial setup (ts emit)..."
|
log "🔨 Building BFF for initial setup (ts emit)..."
|
||||||
@ -373,7 +372,6 @@ start_apps() {
|
|||||||
# Run portal + bff in parallel with hot reload
|
# Run portal + bff in parallel with hot reload
|
||||||
pnpm --parallel \
|
pnpm --parallel \
|
||||||
--filter @customer-portal/domain \
|
--filter @customer-portal/domain \
|
||||||
--filter @customer-portal/validation \
|
|
||||||
--filter @customer-portal/portal \
|
--filter @customer-portal/portal \
|
||||||
--filter @customer-portal/bff run dev
|
--filter @customer-portal/bff run dev
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user