This commit is contained in:
parent
2db3d9ec5d
commit
c4abd41ec6
@ -171,6 +171,25 @@ export class SalesforceService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
// === GENERIC QUERY ===
|
||||
|
||||
/**
|
||||
* Execute a SOQL query against Salesforce
|
||||
* This is a thin wrapper around the connection's query method for direct access
|
||||
*/
|
||||
async query<T>(
|
||||
soql: string,
|
||||
options: { label?: string } = {}
|
||||
): Promise<{ records: T[]; totalSize: number }> {
|
||||
if (!this.connection.isConnected()) {
|
||||
throw new SalesforceOperationException("Salesforce connection not available", {
|
||||
operation: "query",
|
||||
soql: soql.slice(0, 100),
|
||||
});
|
||||
}
|
||||
return this.connection.query(soql, options) as Promise<{ records: T[]; totalSize: number }>;
|
||||
}
|
||||
|
||||
// === HEALTH CHECK ===
|
||||
|
||||
healthCheck(): boolean {
|
||||
|
||||
@ -14,6 +14,7 @@ import {
|
||||
type VerifyCodeResponse,
|
||||
type QuickEligibilityRequest,
|
||||
type QuickEligibilityResponse,
|
||||
type BilingualEligibilityAddress,
|
||||
type GuestEligibilityRequest,
|
||||
type GuestEligibilityResponse,
|
||||
type CompleteAccountRequest,
|
||||
@ -240,7 +241,7 @@ export class GetStartedWorkflowService {
|
||||
}
|
||||
|
||||
// Create eligibility case
|
||||
const requestId = await this.createEligibilityCase(sfAccountId, address);
|
||||
const { caseId } = await this.createEligibilityCase(sfAccountId, address);
|
||||
|
||||
// Update session with SF account info (clean address to remove undefined values)
|
||||
await this.sessionService.updateWithQuickCheckData(request.sessionToken, {
|
||||
@ -253,7 +254,7 @@ export class GetStartedWorkflowService {
|
||||
|
||||
return {
|
||||
submitted: true,
|
||||
requestId,
|
||||
requestId: caseId,
|
||||
sfAccountId,
|
||||
message: "Eligibility check submitted. We'll notify you of the results.",
|
||||
};
|
||||
@ -358,8 +359,22 @@ export class GetStartedWorkflowService {
|
||||
);
|
||||
}
|
||||
|
||||
// Create eligibility case
|
||||
const requestId = await this.createEligibilityCase(sfAccountId, address);
|
||||
// Save Japanese address to SF Contact (if Japanese address fields provided)
|
||||
if (address.prefectureJa || address.cityJa || address.townJa) {
|
||||
await this.salesforceAccountService.updateContactAddress(sfAccountId, {
|
||||
mailingStreet: `${address.townJa || ""}${address.streetAddress || ""}`.trim(),
|
||||
mailingCity: address.cityJa || address.city,
|
||||
mailingState: address.prefectureJa || address.state,
|
||||
mailingPostalCode: address.postcode,
|
||||
mailingCountry: "Japan",
|
||||
buildingName: address.buildingName ?? null,
|
||||
roomNumber: address.roomNumber ?? null,
|
||||
});
|
||||
this.logger.debug({ sfAccountId }, "Updated SF Contact with Japanese address");
|
||||
}
|
||||
|
||||
// Create eligibility case (returns both caseId and caseNumber for email threading)
|
||||
const { caseId, caseNumber } = await this.createEligibilityCase(sfAccountId, address);
|
||||
|
||||
// Update Account eligibility status to Pending
|
||||
this.updateAccountEligibilityStatus(sfAccountId);
|
||||
@ -380,12 +395,17 @@ export class GetStartedWorkflowService {
|
||||
);
|
||||
}
|
||||
|
||||
// Send confirmation email
|
||||
await this.sendGuestEligibilityConfirmationEmail(normalizedEmail, firstName, requestId);
|
||||
// Send confirmation email with case info for Email-to-Case threading
|
||||
await this.sendGuestEligibilityConfirmationEmail(
|
||||
normalizedEmail,
|
||||
firstName,
|
||||
caseId,
|
||||
caseNumber
|
||||
);
|
||||
|
||||
return {
|
||||
submitted: true,
|
||||
requestId,
|
||||
requestId: caseId,
|
||||
sfAccountId,
|
||||
handoffToken,
|
||||
message: "Eligibility check submitted. We'll notify you of the results.",
|
||||
@ -644,7 +664,10 @@ export class GetStartedWorkflowService {
|
||||
}
|
||||
|
||||
// Create eligibility case
|
||||
const eligibilityRequestId = await this.createEligibilityCase(sfAccountId, address);
|
||||
const { caseId: eligibilityRequestId } = await this.createEligibilityCase(
|
||||
sfAccountId,
|
||||
address
|
||||
);
|
||||
|
||||
// Hash password
|
||||
const passwordHash = await argon2.hash(password);
|
||||
@ -810,38 +833,76 @@ export class GetStartedWorkflowService {
|
||||
private async sendGuestEligibilityConfirmationEmail(
|
||||
email: string,
|
||||
firstName: string,
|
||||
requestId: string
|
||||
caseId: string,
|
||||
caseNumber: string
|
||||
): Promise<void> {
|
||||
const appBase = this.config.get<string>("APP_BASE_URL", "http://localhost:3000");
|
||||
const templateId = this.config.get<string>("EMAIL_TEMPLATE_ELIGIBILITY_SUBMITTED");
|
||||
|
||||
// Generate Email-to-Case thread reference for auto-linking customer replies
|
||||
const threadRef = await this.getEmailToCaseThreadReference(caseId);
|
||||
|
||||
// Subject with thread reference enables SF Email-to-Case to link replies to the case
|
||||
const subject = threadRef
|
||||
? `Internet Availability Check Submitted [ ${threadRef} ]`
|
||||
: `Internet Availability Check Submitted (Case# ${caseNumber})`;
|
||||
|
||||
if (templateId) {
|
||||
await this.emailService.sendEmail({
|
||||
to: email,
|
||||
subject: "We're checking internet availability at your address",
|
||||
subject,
|
||||
templateId,
|
||||
dynamicTemplateData: {
|
||||
firstName,
|
||||
portalUrl: appBase,
|
||||
email,
|
||||
requestId,
|
||||
caseNumber,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await this.emailService.sendEmail({
|
||||
to: email,
|
||||
subject: "We're checking internet availability at your address",
|
||||
subject,
|
||||
html: `
|
||||
<p>Hi ${firstName},</p>
|
||||
<p>We received your request to check internet availability.</p>
|
||||
<p>We'll review this and email you the results within 1-2 business days.</p>
|
||||
<p>To create an account and view your request status, visit: <a href="${appBase}/auth/get-started?email=${encodeURIComponent(email)}">${appBase}/auth/get-started</a></p>
|
||||
<p>Reference ID: ${requestId}</p>
|
||||
<p>Case#: ${caseNumber}</p>
|
||||
`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Email-to-Case thread reference for Salesforce
|
||||
* Format: ref:_<OrgId18>._<CaseId18>:ref
|
||||
* This enables automatic linking of customer email replies to the case
|
||||
*/
|
||||
private async getEmailToCaseThreadReference(caseId: string): Promise<string | null> {
|
||||
try {
|
||||
// Query Org ID from Salesforce
|
||||
const orgResult = await this.salesforceService.query<{ Id: string }>(
|
||||
"SELECT Id FROM Organization LIMIT 1",
|
||||
{ label: "auth:getOrgId" }
|
||||
);
|
||||
const orgId = orgResult.records?.[0]?.Id;
|
||||
if (!orgId) {
|
||||
this.logger.warn("Could not retrieve Salesforce Org ID for Email-to-Case threading");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build thread reference format: ref:_<OrgId18>._<CaseId18>:ref
|
||||
return `ref:_${orgId}._${caseId}:ref`;
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
{ error: extractErrorMessage(error) },
|
||||
"Failed to generate Email-to-Case thread reference"
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async sendWelcomeWithEligibilityEmail(
|
||||
email: string,
|
||||
firstName: string,
|
||||
@ -933,36 +994,61 @@ export class GetStartedWorkflowService {
|
||||
|
||||
private async createEligibilityCase(
|
||||
sfAccountId: string,
|
||||
address: QuickEligibilityRequest["address"]
|
||||
): Promise<string> {
|
||||
address: BilingualEligibilityAddress | QuickEligibilityRequest["address"]
|
||||
): Promise<{ caseId: string; caseNumber: string }> {
|
||||
// Find or create Opportunity for Internet eligibility
|
||||
const { opportunityId } =
|
||||
await this.opportunityResolution.findOrCreateForInternetEligibility(sfAccountId);
|
||||
|
||||
// Build case description
|
||||
const addressString = [
|
||||
// Format English address
|
||||
const englishAddress = [
|
||||
address.address1,
|
||||
address.address2,
|
||||
address.city,
|
||||
address.state,
|
||||
address.postcode,
|
||||
address.country,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
|
||||
const { id: caseId } = await this.caseService.createCase({
|
||||
// Format Japanese address (if available)
|
||||
const bilingualAddr = address as BilingualEligibilityAddress;
|
||||
const hasJapaneseAddress =
|
||||
bilingualAddr.prefectureJa || bilingualAddr.cityJa || bilingualAddr.townJa;
|
||||
|
||||
const japaneseAddress = hasJapaneseAddress
|
||||
? [
|
||||
`〒${address.postcode}`,
|
||||
`${bilingualAddr.prefectureJa || ""}${bilingualAddr.cityJa || ""}${bilingualAddr.townJa || ""}${bilingualAddr.streetAddress || ""}`,
|
||||
bilingualAddr.buildingName
|
||||
? `${bilingualAddr.buildingName} ${bilingualAddr.roomNumber || ""}`.trim()
|
||||
: "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
: null;
|
||||
|
||||
// Build case description with both addresses
|
||||
const description = [
|
||||
"Customer requested to check if internet service is available at the following address:",
|
||||
"",
|
||||
"【English Address】",
|
||||
englishAddress,
|
||||
...(japaneseAddress ? ["", "【Japanese Address】", japaneseAddress] : []),
|
||||
].join("\n");
|
||||
|
||||
const { id: caseId, caseNumber } = await this.caseService.createCase({
|
||||
accountId: sfAccountId,
|
||||
opportunityId,
|
||||
subject: "Internet availability check request (Portal - Quick Check)",
|
||||
description: `Customer requested to check if internet service is available at the following address:\n\n${addressString}`,
|
||||
subject: "Internet availability check request (Portal)",
|
||||
description,
|
||||
origin: SALESFORCE_CASE_ORIGIN.PORTAL_NOTIFICATION,
|
||||
});
|
||||
|
||||
// Update Account eligibility status to Pending
|
||||
this.updateAccountEligibilityStatus(sfAccountId);
|
||||
|
||||
return caseId;
|
||||
return { caseId, caseNumber };
|
||||
}
|
||||
|
||||
private updateAccountEligibilityStatus(sfAccountId: string): void {
|
||||
|
||||
@ -18,7 +18,7 @@
|
||||
|
||||
/* Typography */
|
||||
--font-sans: var(--font-geist-sans, system-ui, sans-serif);
|
||||
--font-display: var(--font-plus-jakarta-sans, var(--font-sans));
|
||||
--font-display: var(--font-sans);
|
||||
|
||||
/* Core Surfaces */
|
||||
--background: oklch(1 0 0);
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
import type { Metadata } from "next";
|
||||
import { headers } from "next/headers";
|
||||
import { Plus_Jakarta_Sans } from "next/font/google";
|
||||
import { Sora } from "next/font/google";
|
||||
import { GeistSans } from "geist/font/sans";
|
||||
import "./globals.css";
|
||||
import { QueryProvider } from "@/core/providers";
|
||||
import { SessionTimeoutWarning } from "@/features/auth/components/SessionTimeoutWarning";
|
||||
|
||||
// Display font for headlines and hero text
|
||||
const jakarta = Plus_Jakarta_Sans({
|
||||
const sora = Sora({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-display",
|
||||
display: "swap",
|
||||
@ -34,7 +34,7 @@ export default async function RootLayout({
|
||||
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={`${GeistSans.variable} ${jakarta.variable} antialiased`}>
|
||||
<body className={`${GeistSans.variable} ${sora.variable} antialiased`}>
|
||||
<QueryProvider nonce={nonce}>
|
||||
{children}
|
||||
<SessionTimeoutWarning />
|
||||
|
||||
@ -55,6 +55,8 @@ export interface JapanAddressFormProps {
|
||||
disabled?: boolean | undefined;
|
||||
/** Custom class name for container */
|
||||
className?: string | undefined;
|
||||
/** Custom content to render when address is complete (replaces default success message) */
|
||||
completionContent?: React.ReactNode | undefined;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@ -77,6 +79,42 @@ const DEFAULT_ADDRESS: Omit<JapanAddressFormData, "residenceType"> & {
|
||||
residenceType: "",
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Street Address Validation
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Validates Japanese street address format (chome-ban-go system)
|
||||
* Valid patterns:
|
||||
* - "1-2-3" (chome-banchi-go)
|
||||
* - "1-2" (chome-banchi)
|
||||
* - "12-34-5" (larger numbers)
|
||||
* - "1" (single number for some rural areas)
|
||||
*
|
||||
* Requirements:
|
||||
* - Must start with a number
|
||||
* - Can contain numbers separated by hyphens
|
||||
* - Minimum 1 digit required
|
||||
*/
|
||||
function isValidStreetAddress(value: string): boolean {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return false;
|
||||
|
||||
// Pattern: starts with digit(s), optionally followed by hyphen-digit groups
|
||||
// Examples: "1", "1-2", "1-2-3", "12-34-5"
|
||||
const pattern = /^\d+(-\d+)*$/;
|
||||
return pattern.test(trimmed);
|
||||
}
|
||||
|
||||
function getStreetAddressError(value: string): string | undefined {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return "Street address is required";
|
||||
if (!isValidStreetAddress(trimmed)) {
|
||||
return "Enter a valid format (e.g., 1-2-3)";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Animation Wrapper Component
|
||||
// ============================================================================
|
||||
@ -176,6 +214,7 @@ export function JapanAddressForm({
|
||||
onBlur,
|
||||
disabled = false,
|
||||
className,
|
||||
completionContent,
|
||||
}: JapanAddressFormProps) {
|
||||
const [address, setAddress] = useState<InternalFormState>(() => ({
|
||||
...DEFAULT_ADDRESS,
|
||||
@ -246,7 +285,10 @@ export function JapanAddressForm({
|
||||
address.prefecture.trim() !== "" &&
|
||||
address.city.trim() !== "" &&
|
||||
address.town.trim() !== "" &&
|
||||
address.streetAddress.trim() !== "";
|
||||
isValidStreetAddress(address.streetAddress);
|
||||
|
||||
// Get street address validation error for display
|
||||
const streetAddressError = getStreetAddressError(address.streetAddress);
|
||||
|
||||
const roomNumberOk =
|
||||
address.residenceType !== RESIDENCE_TYPE.APARTMENT || (address.roomNumber?.trim() ?? "") !== "";
|
||||
@ -370,102 +412,89 @@ export function JapanAddressForm({
|
||||
<ProgressIndicator currentStep={currentStep} totalSteps={4} />
|
||||
|
||||
{/* Step 1: ZIP Code Lookup */}
|
||||
<div className="relative">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute -left-3 top-0 bottom-0 w-1 rounded-full transition-all duration-500",
|
||||
isAddressVerified ? "bg-success" : "bg-primary/30"
|
||||
)}
|
||||
/>
|
||||
<div className="pl-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center w-6 h-6 rounded-full text-xs font-semibold transition-all duration-300",
|
||||
isAddressVerified
|
||||
? "bg-success text-success-foreground"
|
||||
: "bg-primary/10 text-primary"
|
||||
)}
|
||||
>
|
||||
{isAddressVerified ? <CheckCircle2 className="w-4 h-4" /> : "1"}
|
||||
</div>
|
||||
<span className="text-sm font-medium text-foreground">Enter ZIP Code</span>
|
||||
{isAddressVerified && (
|
||||
<span className="text-xs text-success font-medium ml-auto flex items-center gap-1">
|
||||
<Sparkles className="w-3 h-3" />
|
||||
Verified
|
||||
</span>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center w-6 h-6 rounded-full text-xs font-semibold transition-all duration-300",
|
||||
isAddressVerified
|
||||
? "bg-success text-success-foreground"
|
||||
: "bg-primary/10 text-primary"
|
||||
)}
|
||||
>
|
||||
{isAddressVerified ? <CheckCircle2 className="w-4 h-4" /> : "1"}
|
||||
</div>
|
||||
|
||||
<ZipCodeInput
|
||||
value={address.postcode}
|
||||
onChange={handleZipChange}
|
||||
onAddressFound={handleAddressFound}
|
||||
onLookupComplete={handleLookupComplete}
|
||||
error={getError("postcode")}
|
||||
required
|
||||
disabled={disabled}
|
||||
autoFocus
|
||||
/>
|
||||
<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>
|
||||
|
||||
<ZipCodeInput
|
||||
value={address.postcode}
|
||||
onChange={handleZipChange}
|
||||
onAddressFound={handleAddressFound}
|
||||
onLookupComplete={handleLookupComplete}
|
||||
error={getError("postcode")}
|
||||
required
|
||||
disabled={disabled}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Verified Address Display */}
|
||||
<AnimatedSection show={isAddressVerified}>
|
||||
<div className="relative">
|
||||
<div className="absolute -left-3 top-0 bottom-0 w-1 rounded-full bg-success/30" />
|
||||
<div className="pl-4">
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border transition-all duration-500",
|
||||
"bg-gradient-to-br from-success/5 via-success/[0.02] to-transparent",
|
||||
"border-success/20"
|
||||
)}
|
||||
>
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-center gap-2 text-success">
|
||||
<MapPin className="w-4 h-4" />
|
||||
<span className="text-sm font-semibold">Address from Japan Post</span>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border transition-all duration-500",
|
||||
"bg-gradient-to-br from-success/5 via-success/[0.02] to-transparent",
|
||||
"border-success/20"
|
||||
)}
|
||||
>
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-center gap-2 text-success">
|
||||
<MapPin className="w-4 h-4" />
|
||||
<span className="text-sm font-semibold">Address from Japan Post</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
{/* Prefecture */}
|
||||
<div className="flex items-center gap-3 py-2 px-3 rounded-lg bg-background/50">
|
||||
<span className="text-xs text-muted-foreground w-20 shrink-0">Prefecture</span>
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground/50" />
|
||||
<BilingualValue
|
||||
romaji={address.prefecture}
|
||||
japanese={address.prefectureJa}
|
||||
placeholder="—"
|
||||
verified={isAddressVerified}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
{/* Prefecture */}
|
||||
<div className="flex items-center gap-3 py-2 px-3 rounded-lg bg-background/50">
|
||||
<span className="text-xs text-muted-foreground w-20 shrink-0">Prefecture</span>
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground/50" />
|
||||
<BilingualValue
|
||||
romaji={address.prefecture}
|
||||
japanese={address.prefectureJa}
|
||||
placeholder="—"
|
||||
verified={isAddressVerified}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* City */}
|
||||
<div className="flex items-center gap-3 py-2 px-3 rounded-lg bg-background/50">
|
||||
<span className="text-xs text-muted-foreground w-20 shrink-0">City / Ward</span>
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground/50" />
|
||||
<BilingualValue
|
||||
romaji={address.city}
|
||||
japanese={address.cityJa}
|
||||
placeholder="—"
|
||||
verified={isAddressVerified}
|
||||
/>
|
||||
</div>
|
||||
{/* 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>
|
||||
{/* 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>
|
||||
@ -474,233 +503,224 @@ export function JapanAddressForm({
|
||||
|
||||
{/* Step 2: Street Address */}
|
||||
<AnimatedSection show={isAddressVerified} delay={100}>
|
||||
<div className="relative">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute -left-3 top-0 bottom-0 w-1 rounded-full transition-all duration-500",
|
||||
address.streetAddress.trim() ? "bg-success" : "bg-primary/30"
|
||||
)}
|
||||
/>
|
||||
<div className="pl-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center w-6 h-6 rounded-full text-xs font-semibold transition-all duration-300",
|
||||
address.streetAddress.trim()
|
||||
? "bg-success text-success-foreground"
|
||||
: "bg-primary/10 text-primary"
|
||||
)}
|
||||
>
|
||||
{address.streetAddress.trim() ? <CheckCircle2 className="w-4 h-4" /> : "2"}
|
||||
</div>
|
||||
<span className="text-sm font-medium text-foreground">Street Address</span>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
label=""
|
||||
error={getError("streetAddress")}
|
||||
required
|
||||
helperText="Enter chome-banchi-go (e.g., 1-5-3)"
|
||||
<div>
|
||||
<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",
|
||||
isValidStreetAddress(address.streetAddress)
|
||||
? "bg-success text-success-foreground"
|
||||
: "bg-primary/10 text-primary"
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
ref={streetAddressRef}
|
||||
value={address.streetAddress}
|
||||
onChange={e => handleStreetAddressChange(e.target.value)}
|
||||
onBlur={() => onBlur?.("streetAddress")}
|
||||
placeholder="1-5-3"
|
||||
disabled={disabled}
|
||||
className="font-mono text-lg tracking-wider"
|
||||
data-field="address.streetAddress"
|
||||
/>
|
||||
</FormField>
|
||||
{isValidStreetAddress(address.streetAddress) ? (
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
) : (
|
||||
"2"
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm font-medium text-foreground">Street Address</span>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
label=""
|
||||
error={
|
||||
getError("streetAddress") || (address.streetAddress.trim() && streetAddressError)
|
||||
}
|
||||
required
|
||||
helperText={
|
||||
address.streetAddress.trim()
|
||||
? streetAddressError
|
||||
? undefined
|
||||
: "Valid format"
|
||||
: "Enter chome-banchi-go (e.g., 1-5-3)"
|
||||
}
|
||||
>
|
||||
<Input
|
||||
ref={streetAddressRef}
|
||||
value={address.streetAddress}
|
||||
onChange={e => handleStreetAddressChange(e.target.value)}
|
||||
onBlur={() => onBlur?.("streetAddress")}
|
||||
placeholder="1-5-3"
|
||||
disabled={disabled}
|
||||
className="font-mono text-lg tracking-wider"
|
||||
data-field="address.streetAddress"
|
||||
/>
|
||||
</FormField>
|
||||
</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">
|
||||
<AnimatedSection
|
||||
show={isAddressVerified && isValidStreetAddress(address.streetAddress)}
|
||||
delay={150}
|
||||
>
|
||||
<div>
|
||||
<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(
|
||||
"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]",
|
||||
"w-12 h-12 rounded-xl flex items-center justify-center transition-all duration-300",
|
||||
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"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground group-hover:bg-primary/10 group-hover:text-primary"
|
||||
)}
|
||||
>
|
||||
<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}
|
||||
<Home className="w-6 h-6" />
|
||||
</div>
|
||||
<span
|
||||
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"
|
||||
"text-sm font-semibold transition-colors",
|
||||
address.residenceType === RESIDENCE_TYPE.HOUSE
|
||||
? "text-primary"
|
||||
: "text-foreground"
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
House
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{!hasResidenceType && getError("residenceType") && (
|
||||
<p className="text-sm text-danger mt-2">{getError("residenceType")}</p>
|
||||
)}
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!hasResidenceType && getError("residenceType") && (
|
||||
<p className="text-sm text-danger mt-2">{getError("residenceType")}</p>
|
||||
)}
|
||||
</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>
|
||||
<div className="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>
|
||||
</div>
|
||||
|
||||
{/* Building Name */}
|
||||
{/* Building Name */}
|
||||
<FormField
|
||||
label="Building Name"
|
||||
error={getError("buildingName")}
|
||||
required
|
||||
helperText={
|
||||
isApartment
|
||||
? "e.g., Sunshine Mansion (サンシャインマンション)"
|
||||
: "e.g., Tanaka Residence (田中邸)"
|
||||
}
|
||||
>
|
||||
<Input
|
||||
value={address.buildingName ?? ""}
|
||||
onChange={e => handleBuildingNameChange(e.target.value)}
|
||||
onBlur={() => onBlur?.("buildingName")}
|
||||
placeholder={isApartment ? "Sunshine Mansion" : "Tanaka Residence"}
|
||||
disabled={disabled}
|
||||
data-field="address.buildingName"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* Room Number - Only for apartments */}
|
||||
{isApartment && (
|
||||
<FormField
|
||||
label="Building Name"
|
||||
error={getError("buildingName")}
|
||||
label="Room Number"
|
||||
error={getError("roomNumber")}
|
||||
required
|
||||
helperText={
|
||||
isApartment
|
||||
? "e.g., Sunshine Mansion (サンシャインマンション)"
|
||||
: "e.g., Tanaka Residence (田中邸)"
|
||||
}
|
||||
helperText="Required for apartments (部屋番号)"
|
||||
>
|
||||
<Input
|
||||
value={address.buildingName ?? ""}
|
||||
onChange={e => handleBuildingNameChange(e.target.value)}
|
||||
onBlur={() => onBlur?.("buildingName")}
|
||||
placeholder={isApartment ? "Sunshine Mansion" : "Tanaka Residence"}
|
||||
value={address.roomNumber ?? ""}
|
||||
onChange={e => handleRoomNumberChange(e.target.value)}
|
||||
onBlur={() => onBlur?.("roomNumber")}
|
||||
placeholder="201"
|
||||
disabled={disabled}
|
||||
data-field="address.buildingName"
|
||||
className="font-mono"
|
||||
data-field="address.roomNumber"
|
||||
/>
|
||||
</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 */}
|
||||
{/* Success State - shows custom content or default message */}
|
||||
<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>
|
||||
{completionContent ?? (
|
||||
<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">All address fields have been filled</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatedSection>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -134,12 +134,12 @@ export function ZipCodeInput({
|
||||
}
|
||||
|
||||
if (isError && lookupError) {
|
||||
return "Failed to lookup address. Please try again.";
|
||||
return "Something went wrong. Please try again or contact support.";
|
||||
}
|
||||
|
||||
if (isValidFormat && lookupResult) {
|
||||
if (lookupResult.count === 0) {
|
||||
return "We couldn't find an address for this ZIP code. Please check and try again.";
|
||||
return "No address found. Please check your ZIP code.";
|
||||
}
|
||||
if (lookupResult.count === 1) {
|
||||
return "Address found!";
|
||||
|
||||
@ -9,40 +9,41 @@ import {
|
||||
CheckCircle2,
|
||||
Headphones,
|
||||
Globe,
|
||||
Calendar,
|
||||
Users,
|
||||
Shield,
|
||||
Phone,
|
||||
Calendar,
|
||||
Award,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { ServiceCard } from "@/components/molecules/ServiceCard";
|
||||
|
||||
/**
|
||||
* PublicLandingView - Clean Landing Page
|
||||
* PublicLandingView - Clean, Cohesive Landing Page
|
||||
*
|
||||
* Design Direction: Matches services page style
|
||||
* - Clean, centered layout
|
||||
* - Consistent card styling with colored accents
|
||||
* - Simple value propositions
|
||||
* Design Direction: Professional and trustworthy
|
||||
* - Consistent typography (display font for headings only)
|
||||
* - Primary color accent throughout
|
||||
* - Clean card styling
|
||||
* - Staggered entrance animations
|
||||
*/
|
||||
export function PublicLandingView() {
|
||||
return (
|
||||
<div className="space-y-16 pb-16">
|
||||
<div className="space-y-20 pb-20">
|
||||
{/* ===== HERO SECTION ===== */}
|
||||
<section className="text-center pt-12 sm:pt-16">
|
||||
<section className="text-center pt-12 sm:pt-20">
|
||||
{/* Badge */}
|
||||
<div
|
||||
className="animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||
style={{ animationDelay: "0ms" }}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2 rounded-full bg-primary/8 border border-primary/15 px-4 py-2 text-sm text-primary font-medium mb-6">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
20+ Years Serving Japan
|
||||
<span className="inline-flex items-center gap-2 rounded-full bg-primary/10 border border-primary/20 px-4 py-2 text-sm font-medium text-primary mb-8">
|
||||
<Award className="h-4 w-4" />
|
||||
20+ Years Serving Japan's International Community
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Headline */}
|
||||
<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"
|
||||
className="text-display-xl font-display font-bold text-foreground mb-6 animate-in fade-in slide-in-from-bottom-6 duration-700"
|
||||
style={{ animationDelay: "100ms" }}
|
||||
>
|
||||
Your One Stop Solution
|
||||
@ -50,63 +51,66 @@ export function PublicLandingView() {
|
||||
<span className="text-primary">for Connectivity in Japan</span>
|
||||
</h1>
|
||||
|
||||
{/* Subheadline */}
|
||||
<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"
|
||||
className="text-xl text-muted-foreground max-w-2xl mx-auto mb-10 animate-in fade-in slide-in-from-bottom-8 duration-700"
|
||||
style={{ animationDelay: "200ms" }}
|
||||
>
|
||||
Full English support for all your connectivity needs — from setup to billing to technical
|
||||
assistance.
|
||||
Internet, mobile, TV — all in English.
|
||||
<br />
|
||||
Full support from setup to billing to technical assistance.
|
||||
</p>
|
||||
|
||||
{/* CTAs */}
|
||||
<div
|
||||
className="flex flex-col sm:flex-row items-center justify-center gap-4 mb-12 animate-in fade-in slide-in-from-bottom-8 duration-700"
|
||||
className="flex flex-col sm:flex-row items-center justify-center gap-4 mb-16 animate-in fade-in slide-in-from-bottom-8 duration-700"
|
||||
style={{ animationDelay: "300ms" }}
|
||||
>
|
||||
<Link
|
||||
href="#services"
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-primary px-6 py-3 font-semibold text-primary-foreground hover:bg-primary-hover transition-colors"
|
||||
className="group inline-flex items-center gap-2 rounded-lg bg-primary px-6 py-3.5 font-semibold text-primary-foreground shadow-sm hover:bg-primary-hover hover:shadow-md transition-all"
|
||||
>
|
||||
Browse Services
|
||||
<ArrowRight className="h-5 w-5" />
|
||||
<ArrowRight className="h-5 w-5 transition-transform group-hover:translate-x-0.5" />
|
||||
</Link>
|
||||
<Link
|
||||
href="/contact"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-border px-6 py-3 font-semibold text-foreground hover:bg-muted transition-colors"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-border bg-card px-6 py-3.5 font-semibold text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<Phone className="h-5 w-5 text-muted-foreground" />
|
||||
Contact Us
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Trust Stats */}
|
||||
<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"
|
||||
className="flex flex-wrap justify-center gap-10 sm:gap-16 pt-10 border-t border-border animate-in fade-in slide-in-from-bottom-8 duration-700"
|
||||
style={{ animationDelay: "400ms" }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/8">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Calendar className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="text-lg font-bold text-foreground">20+</div>
|
||||
<div className="text-2xl font-bold text-foreground">20+</div>
|
||||
<div className="text-sm text-muted-foreground">Years in Japan</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-success/10">
|
||||
<Users className="h-5 w-5 text-success" />
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Globe className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="text-lg font-bold text-foreground">10,000+</div>
|
||||
<div className="text-2xl font-bold text-foreground">10,000+</div>
|
||||
<div className="text-sm text-muted-foreground">Customers Served</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-info/10">
|
||||
<Shield className="h-5 w-5 text-info" />
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Award className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="text-lg font-bold text-foreground">NTT</div>
|
||||
<div className="text-2xl font-bold text-foreground">NTT</div>
|
||||
<div className="text-sm text-muted-foreground">Authorized Partner</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -123,37 +127,42 @@ export function PublicLandingView() {
|
||||
Why Choose Us
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-md mx-auto">
|
||||
We understand the unique challenges of living in Japan as an expat.
|
||||
We understand the challenges of living in Japan as an expat.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-6 text-sm">
|
||||
<div className="flex items-center gap-3 rounded-xl border bg-card px-5 py-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-500/10">
|
||||
<CheckCircle2 className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-foreground">One Stop Solution</div>
|
||||
<div className="text-muted-foreground">We handle everything for you</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 max-w-4xl mx-auto">
|
||||
{/* Card 1 */}
|
||||
<div className="rounded-xl border border-border bg-card p-6 hover:shadow-md hover:border-primary/20 transition-all">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10 mb-4">
|
||||
<CheckCircle2 className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">One Stop Solution</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
Internet, mobile, TV, VPN — all from one provider. One bill, one support team.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 rounded-xl border bg-card px-5 py-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-green-500/10">
|
||||
<Headphones className="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-foreground">English Support</div>
|
||||
<div className="text-muted-foreground">No language barrier</div>
|
||||
|
||||
{/* Card 2 */}
|
||||
<div className="rounded-xl border border-border bg-card p-6 hover:shadow-md hover:border-primary/20 transition-all">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10 mb-4">
|
||||
<Headphones className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">Full English Support</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
No language barrier. Our bilingual team handles everything in English.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 rounded-xl border bg-card px-5 py-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-500/10">
|
||||
<Globe className="h-5 w-5 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-foreground">Onsite Support</div>
|
||||
<div className="text-muted-foreground">We come to you</div>
|
||||
|
||||
{/* Card 3 */}
|
||||
<div className="rounded-xl border border-border bg-card p-6 hover:shadow-md hover:border-primary/20 transition-all">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10 mb-4">
|
||||
<Wrench className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">Onsite Support</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
Our technicians come to you for setup, troubleshooting, and maintenance.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@ -168,27 +177,27 @@ export function PublicLandingView() {
|
||||
Our Services
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-md mx-auto">
|
||||
Connectivity and support solutions for Japan's international community.
|
||||
Connectivity solutions designed for Japan's international community.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Value Props */}
|
||||
<div className="flex flex-wrap justify-center gap-6 text-sm mb-10">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Globe className="h-4 w-4 text-primary" />
|
||||
<span>One provider, all services</span>
|
||||
<CheckCircle2 className="h-4 w-4 text-primary" />
|
||||
<span>No hidden fees</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Headphones className="h-4 w-4 text-success" />
|
||||
<CheckCircle2 className="h-4 w-4 text-primary" />
|
||||
<span>English support</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<CheckCircle2 className="h-4 w-4 text-info" />
|
||||
<span>No hidden fees</span>
|
||||
<CheckCircle2 className="h-4 w-4 text-primary" />
|
||||
<span>Fast setup</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Services Grid - uses cp-stagger-children for consistent card animations */}
|
||||
{/* Services Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 cp-stagger-children">
|
||||
<ServiceCard
|
||||
href="/services/internet"
|
||||
@ -256,30 +265,30 @@ export function PublicLandingView() {
|
||||
|
||||
{/* ===== CTA ===== */}
|
||||
<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"
|
||||
className="rounded-2xl bg-primary p-8 sm:p-12 text-center animate-in fade-in slide-in-from-bottom-8 duration-700"
|
||||
style={{ animationDelay: "700ms" }}
|
||||
>
|
||||
<h2 className="text-xl font-bold text-foreground font-display mb-3">
|
||||
Ready to get connected?
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-primary-foreground mb-4">
|
||||
Ready to Get Connected?
|
||||
</h2>
|
||||
<p className="text-muted-foreground mb-6 max-w-md mx-auto">
|
||||
<p className="text-primary-foreground/80 mb-8 max-w-lg mx-auto">
|
||||
Our bilingual team is here to help you find the right solution for your needs.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<Link
|
||||
href="/contact"
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-primary px-5 py-2.5 font-medium text-primary-foreground hover:bg-primary-hover transition-colors"
|
||||
className="group inline-flex items-center gap-2 rounded-lg bg-white px-6 py-3.5 font-semibold text-primary hover:bg-white/90 transition-colors"
|
||||
>
|
||||
Contact Us
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
Get Started
|
||||
<ArrowRight className="h-5 w-5 transition-transform group-hover:translate-x-0.5" />
|
||||
</Link>
|
||||
<a
|
||||
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 font-medium text-primary-foreground/90 hover:text-primary-foreground transition-colors"
|
||||
aria-label="Call us toll free at 0120-660-470"
|
||||
>
|
||||
<Phone className="h-4 w-4" aria-hidden="true" />
|
||||
<Phone className="h-5 w-5" aria-hidden="true" />
|
||||
0120-660-470 (Toll Free)
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { UserPlus, ArrowRight } from "lucide-react";
|
||||
import { Button, Input, Label, ErrorMessage } from "@/components/atoms";
|
||||
import {
|
||||
@ -32,6 +32,7 @@ export function FormStep() {
|
||||
submitOnly,
|
||||
submitAndCreate,
|
||||
loading,
|
||||
submitType,
|
||||
error,
|
||||
clearError,
|
||||
} = useEligibilityCheckStore();
|
||||
@ -43,6 +44,13 @@ export function FormStep() {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||
};
|
||||
|
||||
// Check if name and email are complete (for enabling buttons when address is done)
|
||||
const isNameEmailComplete = useMemo(() => {
|
||||
const hasName = formData.firstName.trim() && formData.lastName.trim();
|
||||
const hasEmail = formData.email.trim() && isValidEmail(formData.email);
|
||||
return hasName && hasEmail;
|
||||
}, [formData.firstName, formData.lastName, formData.email]);
|
||||
|
||||
// Validate form
|
||||
const validateForm = useCallback((): boolean => {
|
||||
const errors: FormErrors = {};
|
||||
@ -172,45 +180,62 @@ export function FormStep() {
|
||||
<Label>
|
||||
Installation Address <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<JapanAddressForm onChange={handleAddressChange} disabled={loading} />
|
||||
<JapanAddressForm
|
||||
onChange={handleAddressChange}
|
||||
disabled={loading}
|
||||
completionContent={
|
||||
<div className="space-y-3 pt-2">
|
||||
{/* API Error */}
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-danger/10 border border-danger/20">
|
||||
<ErrorMessage showIcon>{error}</ErrorMessage>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Validation message - shown when name/email incomplete */}
|
||||
{!isNameEmailComplete && (
|
||||
<p className="text-sm text-warning text-center">
|
||||
Please complete your name and email above
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Action buttons - always clickable, validation on click */}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSubmitAndCreate}
|
||||
disabled={loading}
|
||||
loading={loading && submitType === "create"}
|
||||
leftIcon={<UserPlus className="h-4 w-4" />}
|
||||
className="w-full"
|
||||
>
|
||||
Create Account & Submit
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Recommended — Get faster service and track your request
|
||||
</p>
|
||||
|
||||
<div className="relative flex items-center py-1">
|
||||
<div className="flex-grow border-t border-border/50" />
|
||||
<span className="px-2 text-xs text-muted-foreground">or</span>
|
||||
<div className="flex-grow border-t border-border/50" />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={handleSubmitOnly}
|
||||
disabled={loading}
|
||||
loading={loading && submitType === "check"}
|
||||
leftIcon={<ArrowRight className="h-4 w-4" />}
|
||||
className="w-full"
|
||||
>
|
||||
Just Submit Request
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<ErrorMessage>{formErrors.address}</ErrorMessage>
|
||||
</div>
|
||||
|
||||
{/* API Error */}
|
||||
{error && (
|
||||
<div className="p-4 rounded-lg bg-danger/10 border border-danger/20">
|
||||
<ErrorMessage showIcon>{error}</ErrorMessage>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Two buttons - Primary on top, Secondary below */}
|
||||
<div className="space-y-3 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSubmitAndCreate}
|
||||
disabled={loading}
|
||||
loading={loading}
|
||||
leftIcon={<UserPlus className="h-4 w-4" />}
|
||||
className="w-full"
|
||||
>
|
||||
Create Account & Submit
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleSubmitOnly}
|
||||
disabled={loading}
|
||||
leftIcon={<ArrowRight className="h-4 w-4" />}
|
||||
className="w-full"
|
||||
>
|
||||
Just Submit Request
|
||||
</Button>
|
||||
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Creating an account lets you track your request and order services faster.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -18,7 +18,7 @@ const AUTO_REDIRECT_DELAY = 5; // seconds
|
||||
|
||||
export function SuccessStep() {
|
||||
const router = useRouter();
|
||||
const { hasAccount, requestId, formData, reset } = useEligibilityCheckStore();
|
||||
const { hasAccount, formData, reset } = useEligibilityCheckStore();
|
||||
|
||||
const [countdown, setCountdown] = useState(AUTO_REDIRECT_DELAY);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
@ -58,7 +58,7 @@ export function SuccessStep() {
|
||||
const handleBackToPlans = useCallback(() => {
|
||||
clearTimer(); // Clear timer before navigation
|
||||
reset();
|
||||
router.push("/internet");
|
||||
router.push("/services/internet");
|
||||
}, [clearTimer, reset, router]);
|
||||
|
||||
return (
|
||||
@ -83,14 +83,6 @@ export function SuccessStep() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Request ID */}
|
||||
{requestId && (
|
||||
<div className="p-4 rounded-lg bg-muted/50 border border-border text-center">
|
||||
<p className="text-xs text-muted-foreground mb-1">Request ID</p>
|
||||
<p className="font-mono text-sm font-medium text-foreground">{requestId}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email notification */}
|
||||
<div className="p-4 rounded-lg bg-primary/5 border border-primary/20">
|
||||
<p className="text-sm text-foreground">
|
||||
@ -124,7 +116,7 @@ export function SuccessStep() {
|
||||
leftIcon={<Home className="h-4 w-4" />}
|
||||
className="w-full"
|
||||
>
|
||||
Back to Internet Plans
|
||||
View Internet Plans
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Create an account to track your request and order services faster.
|
||||
|
||||
@ -79,22 +79,22 @@ const simSteps: HowItWorksStep[] = [
|
||||
{
|
||||
icon: <Signal className="h-6 w-6" />,
|
||||
title: "Choose Plan",
|
||||
description: "Pick your data and voice options",
|
||||
description: "Select your data and voice options",
|
||||
},
|
||||
{
|
||||
icon: <FileCheck className="h-6 w-6" />,
|
||||
title: "Sign Up & Verify",
|
||||
description: "Create account and upload ID",
|
||||
title: "Create Account",
|
||||
description: "Sign up with email verification",
|
||||
},
|
||||
{
|
||||
icon: <Send className="h-6 w-6" />,
|
||||
title: "Order & Receive",
|
||||
description: "eSIM instant or SIM shipped",
|
||||
title: "Place Order",
|
||||
description: "Configure SIM type and pay",
|
||||
},
|
||||
{
|
||||
icon: <CheckCircle className="h-6 w-6" />,
|
||||
title: "Get Connected",
|
||||
description: "Activate and start using",
|
||||
description: "Receive SIM and activate",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@ -66,6 +66,9 @@ export interface EligibilityCheckState {
|
||||
error: string | null;
|
||||
otpError: string | null;
|
||||
|
||||
// Submit type tracking (for loading indicator per button)
|
||||
submitType: "create" | "check" | null;
|
||||
|
||||
// Timer reference (internal)
|
||||
_resendTimerId: ReturnType<typeof setInterval> | null;
|
||||
|
||||
@ -133,6 +136,7 @@ const initialState = {
|
||||
loading: false,
|
||||
error: null,
|
||||
otpError: null,
|
||||
submitType: null as "create" | "check" | null,
|
||||
_resendTimerId: null,
|
||||
};
|
||||
|
||||
@ -155,22 +159,32 @@ export const useEligibilityCheckStore = create<EligibilityCheckState>()((set, ge
|
||||
return false;
|
||||
}
|
||||
|
||||
set({ loading: true, error: null });
|
||||
set({ loading: true, error: null, submitType: "check" });
|
||||
|
||||
try {
|
||||
const whmcsAddress = prepareWhmcsAddressFields(formData.address);
|
||||
const addr = formData.address;
|
||||
|
||||
// Send both English (WHMCS) and Japanese (Salesforce) address fields
|
||||
const result = await guestEligibilityCheck({
|
||||
email: formData.email.trim(),
|
||||
firstName: formData.firstName.trim(),
|
||||
lastName: formData.lastName.trim(),
|
||||
address: {
|
||||
// English fields (for WHMCS)
|
||||
address1: whmcsAddress.address1 || "",
|
||||
...(whmcsAddress.address2 && { address2: whmcsAddress.address2 }),
|
||||
city: whmcsAddress.city || "",
|
||||
state: whmcsAddress.state || "",
|
||||
postcode: whmcsAddress.postcode || "",
|
||||
country: "JP",
|
||||
// Japanese fields (for Salesforce)
|
||||
...(addr.prefectureJa && { prefectureJa: addr.prefectureJa }),
|
||||
...(addr.cityJa && { cityJa: addr.cityJa }),
|
||||
...(addr.townJa && { townJa: addr.townJa }),
|
||||
...(addr.streetAddress && { streetAddress: addr.streetAddress }),
|
||||
...(addr.buildingName && { buildingName: addr.buildingName }),
|
||||
...(addr.roomNumber && { roomNumber: addr.roomNumber }),
|
||||
},
|
||||
continueToAccount: false,
|
||||
});
|
||||
@ -178,6 +192,7 @@ export const useEligibilityCheckStore = create<EligibilityCheckState>()((set, ge
|
||||
if (result.submitted) {
|
||||
set({
|
||||
loading: false,
|
||||
submitType: null,
|
||||
requestId: result.requestId || null,
|
||||
hasAccount: false,
|
||||
step: "success",
|
||||
@ -186,6 +201,7 @@ export const useEligibilityCheckStore = create<EligibilityCheckState>()((set, ge
|
||||
} else {
|
||||
set({
|
||||
loading: false,
|
||||
submitType: null,
|
||||
error: result.message || "Failed to submit eligibility check",
|
||||
});
|
||||
return false;
|
||||
@ -193,7 +209,7 @@ export const useEligibilityCheckStore = create<EligibilityCheckState>()((set, ge
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err);
|
||||
logger.error("Failed to submit eligibility check", { error: message, email: formData.email });
|
||||
set({ loading: false, error: message });
|
||||
set({ loading: false, submitType: null, error: message });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
@ -209,18 +225,19 @@ export const useEligibilityCheckStore = create<EligibilityCheckState>()((set, ge
|
||||
return false;
|
||||
}
|
||||
|
||||
set({ loading: true, error: null });
|
||||
set({ loading: true, error: null, submitType: "create" });
|
||||
|
||||
try {
|
||||
const result = await sendVerificationCode({ email: formData.email.trim() });
|
||||
|
||||
if (result.sent) {
|
||||
set({ loading: false, step: "otp" });
|
||||
set({ loading: false, submitType: null, step: "otp" });
|
||||
get().startResendTimer();
|
||||
return true;
|
||||
} else {
|
||||
set({
|
||||
loading: false,
|
||||
submitType: null,
|
||||
error: result.message || "Failed to send verification code",
|
||||
});
|
||||
return false;
|
||||
@ -228,7 +245,7 @@ export const useEligibilityCheckStore = create<EligibilityCheckState>()((set, ge
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err);
|
||||
logger.error("Failed to send OTP", { error: message, email: formData.email });
|
||||
set({ loading: false, error: message });
|
||||
set({ loading: false, submitType: null, error: message });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
@ -78,23 +78,23 @@ const faqItems: FAQItem[] = [
|
||||
const internetSteps: HowItWorksStep[] = [
|
||||
{
|
||||
icon: <MapPin className="h-6 w-6" />,
|
||||
title: "Sign Up",
|
||||
description: "Create account with your address",
|
||||
title: "Enter Address",
|
||||
description: "Submit your address for coverage check",
|
||||
},
|
||||
{
|
||||
icon: <Settings className="h-6 w-6" />,
|
||||
title: "We Check NTT",
|
||||
description: "We verify availability (1-2 days)",
|
||||
title: "We Verify",
|
||||
description: "Our team checks with NTT (1-2 days)",
|
||||
},
|
||||
{
|
||||
icon: <Calendar className="h-6 w-6" />,
|
||||
title: "Choose & Order",
|
||||
description: "Pick your plan and complete checkout",
|
||||
title: "Sign Up & Order",
|
||||
description: "Create account and select your plan",
|
||||
},
|
||||
{
|
||||
icon: <Router className="h-6 w-6" />,
|
||||
title: "Get Connected",
|
||||
description: "NTT installation at your home",
|
||||
description: "NTT installs fiber at your home",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@ -17,6 +17,7 @@ import type {
|
||||
verifyCodeResponseSchema,
|
||||
quickEligibilityRequestSchema,
|
||||
quickEligibilityResponseSchema,
|
||||
bilingualEligibilityAddressSchema,
|
||||
guestEligibilityRequestSchema,
|
||||
guestEligibilityResponseSchema,
|
||||
guestHandoffTokenSchema,
|
||||
@ -89,6 +90,7 @@ export type GetStartedErrorCode =
|
||||
export type SendVerificationCodeRequest = z.infer<typeof sendVerificationCodeRequestSchema>;
|
||||
export type VerifyCodeRequest = z.infer<typeof verifyCodeRequestSchema>;
|
||||
export type QuickEligibilityRequest = z.infer<typeof quickEligibilityRequestSchema>;
|
||||
export type BilingualEligibilityAddress = z.infer<typeof bilingualEligibilityAddressSchema>;
|
||||
export type GuestEligibilityRequest = z.infer<typeof guestEligibilityRequestSchema>;
|
||||
export type CompleteAccountRequest = z.infer<typeof completeAccountRequestSchema>;
|
||||
export type MaybeLaterRequest = z.infer<typeof maybeLaterRequestSchema>;
|
||||
|
||||
@ -26,6 +26,7 @@ export {
|
||||
type VerifyCodeResponse,
|
||||
type QuickEligibilityRequest,
|
||||
type QuickEligibilityResponse,
|
||||
type BilingualEligibilityAddress,
|
||||
type GuestEligibilityRequest,
|
||||
type GuestEligibilityResponse,
|
||||
type GuestHandoffToken,
|
||||
@ -54,6 +55,7 @@ export {
|
||||
quickEligibilityRequestSchema,
|
||||
quickEligibilityResponseSchema,
|
||||
// Guest eligibility schemas (no OTP required)
|
||||
bilingualEligibilityAddressSchema,
|
||||
guestEligibilityRequestSchema,
|
||||
guestEligibilityResponseSchema,
|
||||
guestHandoffTokenSchema,
|
||||
|
||||
@ -141,6 +141,28 @@ export const quickEligibilityResponseSchema = z.object({
|
||||
// Guest Eligibility Check Schemas (No OTP Required)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Bilingual address schema for eligibility requests
|
||||
* Contains both English (for WHMCS) and Japanese (for Salesforce) address fields
|
||||
*/
|
||||
export const bilingualEligibilityAddressSchema = z.object({
|
||||
// English/Romanized fields (for WHMCS)
|
||||
address1: z.string().min(1, "Address is required").max(200).trim(),
|
||||
address2: z.string().max(200).trim().optional(),
|
||||
city: z.string().min(1, "City is required").max(100).trim(),
|
||||
state: z.string().min(1, "Prefecture is required").max(100).trim(),
|
||||
postcode: z.string().min(1, "Postcode is required").max(20).trim(),
|
||||
country: z.string().max(100).trim().optional(),
|
||||
|
||||
// Japanese fields (for Salesforce)
|
||||
prefectureJa: z.string().max(100).trim().optional(),
|
||||
cityJa: z.string().max(100).trim().optional(),
|
||||
townJa: z.string().max(200).trim().optional(),
|
||||
streetAddress: z.string().max(50).trim().optional(),
|
||||
buildingName: z.string().max(200).trim().nullable().optional(),
|
||||
roomNumber: z.string().max(50).trim().nullable().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Request for guest eligibility check - NO email verification required
|
||||
* Allows users to check availability without verifying email first
|
||||
@ -152,8 +174,8 @@ export const guestEligibilityRequestSchema = z.object({
|
||||
firstName: nameSchema,
|
||||
/** Customer last name */
|
||||
lastName: nameSchema,
|
||||
/** Full address for eligibility check */
|
||||
address: addressFormSchema,
|
||||
/** Full address with both English and Japanese fields for eligibility check */
|
||||
address: bilingualEligibilityAddressSchema,
|
||||
/** Optional phone number */
|
||||
phone: phoneSchema.optional(),
|
||||
/** Whether user wants to continue to account creation */
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user