This commit is contained in:
barsa 2026-01-15 12:38:16 +09:00
parent 2db3d9ec5d
commit c4abd41ec6
15 changed files with 639 additions and 445 deletions

View File

@ -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 {

View File

@ -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 {

View File

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

View File

@ -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 />

View File

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

View File

@ -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!";

View File

@ -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&apos;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&apos;s international community.
Connectivity solutions designed for Japan&apos;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>

View File

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

View File

@ -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.

View File

@ -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",
},
];

View File

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

View File

@ -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",
},
];

View File

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

View File

@ -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,

View File

@ -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 */