From c4abd41ec604be357a7e81d3332a63f49683f152 Mon Sep 17 00:00:00 2001 From: barsa Date: Thu, 15 Jan 2026 12:38:16 +0900 Subject: [PATCH] --- .../salesforce/salesforce.service.ts | 19 + .../workflows/get-started-workflow.service.ts | 130 +++- apps/portal/src/app/globals.css | 2 +- apps/portal/src/app/layout.tsx | 6 +- .../address/components/JapanAddressForm.tsx | 576 +++++++++--------- .../address/components/ZipCodeInput.tsx | 4 +- .../landing-page/views/PublicLandingView.tsx | 149 ++--- .../eligibility-check/steps/FormStep.tsx | 101 +-- .../eligibility-check/steps/SuccessStep.tsx | 14 +- .../components/sim/SimPlansContent.tsx | 12 +- .../stores/eligibility-check.store.ts | 27 +- .../services/views/PublicInternetPlans.tsx | 14 +- packages/domain/get-started/contract.ts | 2 + packages/domain/get-started/index.ts | 2 + packages/domain/get-started/schema.ts | 26 +- 15 files changed, 639 insertions(+), 445 deletions(-) diff --git a/apps/bff/src/integrations/salesforce/salesforce.service.ts b/apps/bff/src/integrations/salesforce/salesforce.service.ts index 454816dd..fcda69ed 100644 --- a/apps/bff/src/integrations/salesforce/salesforce.service.ts +++ b/apps/bff/src/integrations/salesforce/salesforce.service.ts @@ -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( + 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 { diff --git a/apps/bff/src/modules/auth/infra/workflows/get-started-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/get-started-workflow.service.ts index 135ab719..98e20858 100644 --- a/apps/bff/src/modules/auth/infra/workflows/get-started-workflow.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/get-started-workflow.service.ts @@ -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 { const appBase = this.config.get("APP_BASE_URL", "http://localhost:3000"); const templateId = this.config.get("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: `

Hi ${firstName},

We received your request to check internet availability.

We'll review this and email you the results within 1-2 business days.

To create an account and view your request status, visit: ${appBase}/auth/get-started

-

Reference ID: ${requestId}

+

Case#: ${caseNumber}

`, }); } } + /** + * Generate Email-to-Case thread reference for Salesforce + * Format: ref:_._:ref + * This enables automatic linking of customer email replies to the case + */ + private async getEmailToCaseThreadReference(caseId: string): Promise { + 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:_._: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 { + 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 { diff --git a/apps/portal/src/app/globals.css b/apps/portal/src/app/globals.css index 8f1e158d..87d135d2 100644 --- a/apps/portal/src/app/globals.css +++ b/apps/portal/src/app/globals.css @@ -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); diff --git a/apps/portal/src/app/layout.tsx b/apps/portal/src/app/layout.tsx index 29c96c14..5d05d8b9 100644 --- a/apps/portal/src/app/layout.tsx +++ b/apps/portal/src/app/layout.tsx @@ -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 ( - + {children} diff --git a/apps/portal/src/features/address/components/JapanAddressForm.tsx b/apps/portal/src/features/address/components/JapanAddressForm.tsx index fb7df639..6ff11975 100644 --- a/apps/portal/src/features/address/components/JapanAddressForm.tsx +++ b/apps/portal/src/features/address/components/JapanAddressForm.tsx @@ -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 & { 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(() => ({ ...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({ {/* Step 1: ZIP Code Lookup */} -
-
-
-
-
- {isAddressVerified ? : "1"} -
- Enter ZIP Code - {isAddressVerified && ( - - - Verified - +
+
+
+ {isAddressVerified ? : "1"}
- - + Enter ZIP Code + {isAddressVerified && ( + + + Verified + + )}
+ +
{/* Verified Address Display */} -
-
-
-
-
-
- - Address from Japan Post -
+
+
+
+ + Address from Japan Post +
-
- {/* Prefecture */} -
- Prefecture - - -
+
+ {/* Prefecture */} +
+ Prefecture + + +
- {/* City */} -
- City / Ward - - -
+ {/* City */} +
+ City / Ward + + +
- {/* Town */} -
- Town - - -
-
+ {/* Town */} +
+ Town + +
@@ -474,233 +503,224 @@ export function JapanAddressForm({ {/* Step 2: Street Address */} -
-
-
-
-
- {address.streetAddress.trim() ? : "2"} -
- Street Address -
- - +
+
- handleStreetAddressChange(e.target.value)} - onBlur={() => onBlur?.("streetAddress")} - placeholder="1-5-3" - disabled={disabled} - className="font-mono text-lg tracking-wider" - data-field="address.streetAddress" - /> - + {isValidStreetAddress(address.streetAddress) ? ( + + ) : ( + "2" + )} +
+ Street Address
+ + + handleStreetAddressChange(e.target.value)} + onBlur={() => onBlur?.("streetAddress")} + placeholder="1-5-3" + disabled={disabled} + className="font-mono text-lg tracking-wider" + data-field="address.streetAddress" + /> +
{/* Step 3: Residence Type */} - -
-
-
-
+ +
+
+
+ {hasResidenceType ? : "3"} +
+ Residence Type +
+ +
+
- -
- - -
+ -
- -
- - Apartment - - マンション - -
+ House + + - {!hasResidenceType && getError("residenceType") && ( -

{getError("residenceType")}

- )} +
+ + {!hasResidenceType && getError("residenceType") && ( +

{getError("residenceType")}

+ )}
{/* Step 4: Building Details */} -
-
-
-
-
- {showSuccess ? : "4"} -
- Building Details +
+
+
+ {showSuccess ? : "4"}
+ Building Details +
- {/* Building Name */} + {/* Building Name */} + + handleBuildingNameChange(e.target.value)} + onBlur={() => onBlur?.("buildingName")} + placeholder={isApartment ? "Sunshine Mansion" : "Tanaka Residence"} + disabled={disabled} + data-field="address.buildingName" + /> + + + {/* Room Number - Only for apartments */} + {isApartment && ( 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" /> - - {/* Room Number - Only for apartments */} - {isApartment && ( - - handleRoomNumberChange(e.target.value)} - onBlur={() => onBlur?.("roomNumber")} - placeholder="201" - disabled={disabled} - className="font-mono" - data-field="address.roomNumber" - /> - - )} -
+ )}
- {/* Success State */} + {/* Success State - shows custom content or default message */} -
-
-
- -
-
-

Address Complete

-

Ready to save your Japanese address

+ {completionContent ?? ( +
+
+
+ +
+
+

Address Complete

+

All address fields have been filled

+
-
+ )}
); diff --git a/apps/portal/src/features/address/components/ZipCodeInput.tsx b/apps/portal/src/features/address/components/ZipCodeInput.tsx index 2531b4b4..178b9a46 100644 --- a/apps/portal/src/features/address/components/ZipCodeInput.tsx +++ b/apps/portal/src/features/address/components/ZipCodeInput.tsx @@ -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!"; diff --git a/apps/portal/src/features/landing-page/views/PublicLandingView.tsx b/apps/portal/src/features/landing-page/views/PublicLandingView.tsx index ee052483..2c2396c9 100644 --- a/apps/portal/src/features/landing-page/views/PublicLandingView.tsx +++ b/apps/portal/src/features/landing-page/views/PublicLandingView.tsx @@ -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 ( -
+
{/* ===== HERO SECTION ===== */} -
+
+ {/* Badge */}
- - - 20+ Years Serving Japan + + + 20+ Years Serving Japan's International Community
+ {/* Headline */}

Your One Stop Solution @@ -50,63 +51,66 @@ export function PublicLandingView() { for Connectivity in Japan

+ {/* Subheadline */}

- Full English support for all your connectivity needs — from setup to billing to technical - assistance. + Internet, mobile, TV — all in English. +
+ Full support from setup to billing to technical assistance.

{/* CTAs */}
Browse Services - + + Contact Us
{/* Trust Stats */}
-
+
-
20+
+
20+
Years in Japan
-
- +
+
-
10,000+
+
10,000+
Customers Served
-
- +
+
-
NTT
+
NTT
Authorized Partner
@@ -123,37 +127,42 @@ export function PublicLandingView() { Why Choose Us

- We understand the unique challenges of living in Japan as an expat. + We understand the challenges of living in Japan as an expat.

-
-
-
- -
-
-
One Stop Solution
-
We handle everything for you
+
+ {/* Card 1 */} +
+
+
+

One Stop Solution

+

+ Internet, mobile, TV, VPN — all from one provider. One bill, one support team. +

-
-
- -
-
-
English Support
-
No language barrier
+ + {/* Card 2 */} +
+
+
+

Full English Support

+

+ No language barrier. Our bilingual team handles everything in English. +

-
-
- -
-
-
Onsite Support
-
We come to you
+ + {/* Card 3 */} +
+
+
+

Onsite Support

+

+ Our technicians come to you for setup, troubleshooting, and maintenance. +

@@ -168,27 +177,27 @@ export function PublicLandingView() { Our Services

- Connectivity and support solutions for Japan's international community. + Connectivity solutions designed for Japan's international community.

{/* Value Props */}
- - One provider, all services + + No hidden fees
- + English support
- - No hidden fees + + Fast setup
- {/* Services Grid - uses cp-stagger-children for consistent card animations */} + {/* Services Grid */}
-

- Ready to get connected? +

+ Ready to Get Connected?

-

+

Our bilingual team is here to help you find the right solution for your needs.

-
+
- Contact Us - + Get Started + -
diff --git a/apps/portal/src/features/services/components/eligibility-check/steps/FormStep.tsx b/apps/portal/src/features/services/components/eligibility-check/steps/FormStep.tsx index a88914c7..abef65f2 100644 --- a/apps/portal/src/features/services/components/eligibility-check/steps/FormStep.tsx +++ b/apps/portal/src/features/services/components/eligibility-check/steps/FormStep.tsx @@ -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() { - + + {/* API Error */} + {error && ( +
+ {error} +
+ )} + + {/* Validation message - shown when name/email incomplete */} + {!isNameEmailComplete && ( +

+ Please complete your name and email above +

+ )} + + {/* Action buttons - always clickable, validation on click */} + +

+ Recommended — Get faster service and track your request +

+ +
+
+ or +
+
+ + +
+ } + /> {formErrors.address}
- - {/* API Error */} - {error && ( -
- {error} -
- )} - - {/* Two buttons - Primary on top, Secondary below */} -
- - - - -

- Creating an account lets you track your request and order services faster. -

-
); } diff --git a/apps/portal/src/features/services/components/eligibility-check/steps/SuccessStep.tsx b/apps/portal/src/features/services/components/eligibility-check/steps/SuccessStep.tsx index 878555b0..ee7f9703 100644 --- a/apps/portal/src/features/services/components/eligibility-check/steps/SuccessStep.tsx +++ b/apps/portal/src/features/services/components/eligibility-check/steps/SuccessStep.tsx @@ -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 | 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() {
- {/* Request ID */} - {requestId && ( -
-

Request ID

-

{requestId}

-
- )} - {/* Email notification */}

@@ -124,7 +116,7 @@ export function SuccessStep() { leftIcon={} className="w-full" > - Back to Internet Plans + View Internet Plans

Create an account to track your request and order services faster. diff --git a/apps/portal/src/features/services/components/sim/SimPlansContent.tsx b/apps/portal/src/features/services/components/sim/SimPlansContent.tsx index 671e4edd..c01ef1e8 100644 --- a/apps/portal/src/features/services/components/sim/SimPlansContent.tsx +++ b/apps/portal/src/features/services/components/sim/SimPlansContent.tsx @@ -79,22 +79,22 @@ const simSteps: HowItWorksStep[] = [ { icon: , title: "Choose Plan", - description: "Pick your data and voice options", + description: "Select your data and voice options", }, { icon: , - title: "Sign Up & Verify", - description: "Create account and upload ID", + title: "Create Account", + description: "Sign up with email verification", }, { icon: , - title: "Order & Receive", - description: "eSIM instant or SIM shipped", + title: "Place Order", + description: "Configure SIM type and pay", }, { icon: , title: "Get Connected", - description: "Activate and start using", + description: "Receive SIM and activate", }, ]; diff --git a/apps/portal/src/features/services/stores/eligibility-check.store.ts b/apps/portal/src/features/services/stores/eligibility-check.store.ts index a8fc91ef..dff4883c 100644 --- a/apps/portal/src/features/services/stores/eligibility-check.store.ts +++ b/apps/portal/src/features/services/stores/eligibility-check.store.ts @@ -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 | 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()((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()((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()((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()((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()((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()((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; } }, diff --git a/apps/portal/src/features/services/views/PublicInternetPlans.tsx b/apps/portal/src/features/services/views/PublicInternetPlans.tsx index 229a3156..51332b6c 100644 --- a/apps/portal/src/features/services/views/PublicInternetPlans.tsx +++ b/apps/portal/src/features/services/views/PublicInternetPlans.tsx @@ -78,23 +78,23 @@ const faqItems: FAQItem[] = [ const internetSteps: HowItWorksStep[] = [ { icon: , - title: "Sign Up", - description: "Create account with your address", + title: "Enter Address", + description: "Submit your address for coverage check", }, { icon: , - title: "We Check NTT", - description: "We verify availability (1-2 days)", + title: "We Verify", + description: "Our team checks with NTT (1-2 days)", }, { icon: , - title: "Choose & Order", - description: "Pick your plan and complete checkout", + title: "Sign Up & Order", + description: "Create account and select your plan", }, { icon: , title: "Get Connected", - description: "NTT installation at your home", + description: "NTT installs fiber at your home", }, ]; diff --git a/packages/domain/get-started/contract.ts b/packages/domain/get-started/contract.ts index bf959f12..d9fcbfc0 100644 --- a/packages/domain/get-started/contract.ts +++ b/packages/domain/get-started/contract.ts @@ -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; export type VerifyCodeRequest = z.infer; export type QuickEligibilityRequest = z.infer; +export type BilingualEligibilityAddress = z.infer; export type GuestEligibilityRequest = z.infer; export type CompleteAccountRequest = z.infer; export type MaybeLaterRequest = z.infer; diff --git a/packages/domain/get-started/index.ts b/packages/domain/get-started/index.ts index 2b8713c1..7c5d95ef 100644 --- a/packages/domain/get-started/index.ts +++ b/packages/domain/get-started/index.ts @@ -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, diff --git a/packages/domain/get-started/schema.ts b/packages/domain/get-started/schema.ts index 4bd6b9d9..1ba1f8ad 100644 --- a/packages/domain/get-started/schema.ts +++ b/packages/domain/get-started/schema.ts @@ -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 */