Refactor Address Handling in Signup and Profile Components

- Updated address handling in Salesforce account and contact services to remove unnecessary address fields during signup, improving data management.
- Refactored address input fields in the SignupForm and AddressForm components to ensure proper labeling and validation for Japanese address formats.
- Enhanced address display logic in ProfileContainer and AddressConfirmation components for better user experience.
- Standardized address field names and improved placeholder texts for clarity and consistency across the application.
This commit is contained in:
barsa 2026-01-06 16:13:59 +09:00
parent cc0200bd5e
commit 1a3032bbd3
15 changed files with 290 additions and 280 deletions

View File

@ -175,14 +175,8 @@ export class SalesforceAccountService {
): Promise<{ accountId: string; accountNumber: string }> { ): Promise<{ accountId: string; accountNumber: string }> {
this.logger.log("Creating new Salesforce Account", { email: data.email }); this.logger.log("Creating new Salesforce Account", { email: data.email });
const accountPayload = { const accountPayload: Record<string, unknown> = {
Name: `${data.firstName} ${data.lastName}`, Name: `${data.firstName} ${data.lastName}`,
BillingStreet:
data.address.address1 + (data.address.address2 ? `\n${data.address.address2}` : ""),
BillingCity: data.address.city,
BillingState: data.address.state,
BillingPostalCode: data.address.postcode,
BillingCountry: data.address.country,
Phone: data.phone, Phone: data.phone,
// Portal tracking fields // Portal tracking fields
[this.portalStatusField]: "Active", [this.portalStatusField]: "Active",
@ -242,18 +236,12 @@ export class SalesforceAccountService {
email: data.email, email: data.email,
}); });
const contactPayload = { const contactPayload: Record<string, unknown> = {
AccountId: data.accountId, AccountId: data.accountId,
FirstName: data.firstName, FirstName: data.firstName,
LastName: data.lastName, LastName: data.lastName,
Email: data.email, Email: data.email,
Phone: data.phone, Phone: data.phone,
MailingStreet:
data.address.address1 + (data.address.address2 ? `\n${data.address.address2}` : ""),
MailingCity: data.address.city,
MailingState: data.address.state,
MailingPostalCode: data.address.postcode,
MailingCountry: data.address.country,
}; };
try { try {
@ -351,14 +339,6 @@ export interface CreateSalesforceAccountRequest {
lastName: string; lastName: string;
email: string; email: string;
phone: string; phone: string;
address: {
address1: string;
address2?: string;
city: string;
state: string;
postcode: string;
country: string;
};
} }
/** /**
@ -370,12 +350,4 @@ export interface CreateSalesforceContactRequest {
lastName: string; lastName: string;
email: string; email: string;
phone: string; phone: string;
address: {
address1: string;
address2?: string;
city: string;
state: string;
postcode: string;
country: string;
};
} }

View File

@ -271,14 +271,7 @@ export class SignupWorkflowService {
lastName, lastName,
email: normalizedEmail, email: normalizedEmail,
phone, phone,
address: { // Address not added to Salesforce during signup
address1: address.address1,
address2: address.address2 || undefined,
city: address.city,
state: address.state,
postcode: address.postcode,
country: address.country,
},
}); });
await this.salesforceAccountService.createContact({ await this.salesforceAccountService.createContact({
@ -287,14 +280,7 @@ export class SignupWorkflowService {
lastName, lastName,
email: normalizedEmail, email: normalizedEmail,
phone, phone,
address: { // Address not added to Salesforce during signup
address1: address.address1,
address2: address.address2 || undefined,
city: address.city,
state: address.state,
postcode: address.postcode,
country: address.country,
},
}); });
return { return {

View File

@ -78,14 +78,6 @@ export class SignupAccountResolverService {
lastName, lastName,
email: normalizedEmail, email: normalizedEmail,
phone, phone,
address: {
address1: address.address1,
address2: address.address2 || undefined,
city: address.city,
state: address.state,
postcode: address.postcode,
country: address.country,
},
}); });
await this.salesforceAccountService.createContact({ await this.salesforceAccountService.createContact({
@ -94,14 +86,6 @@ export class SignupAccountResolverService {
lastName, lastName,
email: normalizedEmail, email: normalizedEmail,
phone, phone,
address: {
address1: address.address1,
address2: address.address2 || undefined,
city: address.city,
state: address.state,
postcode: address.postcode,
country: address.country,
},
}); });
const snapshot: SignupAccountSnapshot = { const snapshot: SignupAccountSnapshot = {

View File

@ -105,7 +105,7 @@ export class SignupWhmcsService {
companyname: params.company || "", companyname: params.company || "",
phonenumber: params.phone, phonenumber: params.phone,
address1: params.address.address1, address1: params.address.address1,
address2: params.address.address2 || "", address2: params.address.address2 ?? "",
city: params.address.city, city: params.address.city,
state: params.address.state, state: params.address.state,
postcode: params.address.postcode, postcode: params.address.postcode,

View File

@ -88,8 +88,12 @@ export function AddressCard({
</div> </div>
) : ( ) : (
<div className="space-y-1.5 text-foreground"> <div className="space-y-1.5 text-foreground">
{address.address1 && <p className="font-semibold text-base">{address.address1}</p>} {(address.address2 || address.address1) && (
{address.address2 && <p className="text-muted-foreground">{address.address2}</p>} <p className="font-semibold text-base">{address.address2 || address.address1}</p>
)}
{address.address2 && address.address1 && (
<p className="text-muted-foreground">{address.address1}</p>
)}
{(address.city || address.state || address.postcode) && ( {(address.city || address.state || address.postcode) && (
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{[address.city, address.state, address.postcode].filter(Boolean).join(", ")} {[address.city, address.state, address.postcode].filter(Boolean).join(", ")}

View File

@ -460,11 +460,13 @@ export default function ProfileContainer() {
{address.values.address1 || address.values.city ? ( {address.values.address1 || address.values.city ? (
<div className="bg-card rounded-lg p-5 border border-border shadow-sm"> <div className="bg-card rounded-lg p-5 border border-border shadow-sm">
<div className="text-foreground space-y-1.5"> <div className="text-foreground space-y-1.5">
{address.values.address1 && ( {(address.values.address2 || address.values.address1) && (
<p className="font-medium text-base">{address.values.address1}</p> <p className="font-medium text-base">
{address.values.address2 || address.values.address1}
</p>
)} )}
{address.values.address2 && ( {address.values.address2 && address.values.address1 && (
<p className="text-muted-foreground">{address.values.address2}</p> <p className="text-muted-foreground">{address.values.address1}</p>
)} )}
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{[address.values.city, address.values.state, address.values.postcode] {[address.values.city, address.values.state, address.values.postcode]

View File

@ -160,6 +160,15 @@ export function LoginForm({
Sign up Sign up
</Link> </Link>
</p> </p>
<p className="text-sm text-muted-foreground mt-1">
Existing customer?{" "}
<Link
href={`/auth/migrate${redirectQuery}`}
className="font-medium text-primary hover:text-primary-hover transition-colors duration-200"
>
Migrate your account
</Link>
</p>
</div> </div>
)} )}
</form> </form>

View File

@ -5,16 +5,19 @@
"use client"; "use client";
import { useState, useCallback } from "react"; import { useState, useCallback, useEffect, useRef } from "react";
import { flushSync } from "react-dom";
import Link from "next/link"; import Link from "next/link";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { ErrorMessage } from "@/components/atoms"; import { ErrorMessage } from "@/components/atoms";
import { useSignupWithRedirect } from "../../hooks/use-auth"; import { useSignupWithRedirect } from "../../hooks/use-auth";
import { signupInputSchema, buildSignupRequest } from "@customer-portal/domain/auth"; import { signupInputSchema, buildSignupRequest } from "@customer-portal/domain/auth";
import { genderEnum } from "@customer-portal/domain/common";
import { addressFormSchema } from "@customer-portal/domain/customer"; import { addressFormSchema } from "@customer-portal/domain/customer";
import { useZodForm } from "@/shared/hooks"; import { useZodForm } from "@/shared/hooks";
import { z } from "zod"; import { z } from "zod";
import { getSafeRedirect } from "@/features/auth/utils/route-protection"; import { getSafeRedirect } from "@/features/auth/utils/route-protection";
import { formatJapanesePostalCode } from "@/shared/constants";
import { MultiStepForm } from "./MultiStepForm"; import { MultiStepForm } from "./MultiStepForm";
import { AccountStep } from "./steps/AccountStep"; import { AccountStep } from "./steps/AccountStep";
@ -30,12 +33,20 @@ import { PasswordStep } from "./steps/PasswordStep";
* - dateOfBirth: Required for signup (domain schema makes it optional) * - dateOfBirth: Required for signup (domain schema makes it optional)
* - gender: Required for signup (domain schema makes it optional) * - gender: Required for signup (domain schema makes it optional)
*/ */
const genderSchema = z.enum(["male", "female", "other"]); const genderSchema = genderEnum;
const signupAddressSchema = addressFormSchema.extend({
address2: z
.string()
.min(1, "Address line 2 is required")
.max(200, "Address line 2 is too long")
.trim(),
});
const signupFormBaseSchema = signupInputSchema.omit({ sfNumber: true }).extend({ const signupFormBaseSchema = signupInputSchema.omit({ sfNumber: true }).extend({
confirmPassword: z.string().min(1, "Please confirm your password"), confirmPassword: z.string().min(1, "Please confirm your password"),
phoneCountryCode: z.string().regex(/^\+\d{1,4}$/, "Enter a valid country code (e.g., +81)"), phoneCountryCode: z.string().regex(/^\+\d{1,4}$/, "Enter a valid country code (e.g., +81)"),
address: addressFormSchema, address: signupAddressSchema,
dateOfBirth: z.string().min(1, "Date of birth is required"), dateOfBirth: z.string().min(1, "Date of birth is required"),
gender: genderSchema, gender: genderSchema,
}); });
@ -51,6 +62,7 @@ const signupFormSchema = signupFormBaseSchema
}); });
type SignupFormData = z.infer<typeof signupFormSchema>; type SignupFormData = z.infer<typeof signupFormSchema>;
type SignupAddress = SignupFormData["address"];
interface SignupFormProps { interface SignupFormProps {
onSuccess?: () => void; onSuccess?: () => void;
@ -122,6 +134,7 @@ export function SignupForm({
initialEmail, initialEmail,
showFooterLinks = true, showFooterLinks = true,
}: SignupFormProps) { }: SignupFormProps) {
const formRef = useRef<HTMLFormElement | null>(null);
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { signup, loading, error, clearError } = useSignupWithRedirect({ redirectTo }); const { signup, loading, error, clearError } = useSignupWithRedirect({ redirectTo });
const [step, setStep] = useState(0); const [step, setStep] = useState(0);
@ -163,8 +176,10 @@ export function SignupForm({
const formattedPhone = `+${countryDigits}.${phoneDigits}`; const formattedPhone = `+${countryDigits}.${phoneDigits}`;
// Build request with normalized address and phone // Build request with normalized address and phone
// Exclude UI-only fields (confirmPassword) from the request
const { confirmPassword: _confirmPassword, ...requestData } = data;
const request = buildSignupRequest({ const request = buildSignupRequest({
...data, ...requestData,
phone: formattedPhone, phone: formattedPhone,
dateOfBirth: data.dateOfBirth || undefined, dateOfBirth: data.dateOfBirth || undefined,
gender: data.gender || undefined, gender: data.gender || undefined,
@ -193,6 +208,105 @@ export function SignupForm({
isSubmitting, isSubmitting,
} = form; } = form;
const normalizeAutofillValue = useCallback(
(field: string, value: string) => {
switch (field) {
case "phoneCountryCode": {
let normalized = value.replace(/[^\d+]/g, "");
if (!normalized.startsWith("+")) normalized = "+" + normalized.replace(/\+/g, "");
return normalized.slice(0, 5);
}
case "phone":
return value.replace(/\D/g, "");
case "address.postcode":
return formatJapanesePostalCode(value);
default:
return value;
}
},
[formatJapanesePostalCode]
);
const syncStepValues = useCallback(
(shouldFlush = true) => {
const formNode = formRef.current;
if (!formNode) {
return values;
}
const nextValues: SignupFormData = {
...values,
address: { ...values.address },
};
const fields = formNode.querySelectorAll<HTMLInputElement | HTMLSelectElement>(
"[data-field]"
);
fields.forEach(field => {
const key = field.dataset.field;
if (!key) {
return;
}
const normalized = normalizeAutofillValue(key, field.value);
if (key.startsWith("address.")) {
const addressKey = key.replace("address.", "") as keyof SignupAddress;
nextValues.address[addressKey] = normalized;
} else if (key === "acceptTerms" || key === "marketingConsent") {
// Handle boolean fields separately
const boolValue =
field.type === "checkbox" ? (field as HTMLInputElement).checked : normalized === "true";
(nextValues as Record<string, unknown>)[key] = boolValue;
} else {
// Only assign to string fields
const stringKey = key as keyof Pick<
SignupFormData,
Exclude<keyof SignupFormData, "address" | "acceptTerms" | "marketingConsent">
>;
(nextValues as Record<string, unknown>)[stringKey] = normalized;
}
});
const applySyncedValues = () => {
(Object.keys(nextValues) as Array<keyof SignupFormData>).forEach(key => {
if (key === "address") {
return;
}
if (nextValues[key] !== values[key]) {
setFormValue(key, nextValues[key]);
}
});
const addressChanged = (Object.keys(nextValues.address) as Array<keyof SignupAddress>).some(
key => nextValues.address[key] !== values.address[key]
);
if (addressChanged) {
setFormValue("address", nextValues.address);
}
};
if (shouldFlush) {
flushSync(() => {
applySyncedValues();
});
} else {
applySyncedValues();
}
return nextValues;
},
[normalizeAutofillValue, setFormValue, values]
);
useEffect(() => {
const syncTimer = window.setTimeout(() => {
syncStepValues(false);
}, 0);
return () => {
window.clearTimeout(syncTimer);
};
}, [step, syncStepValues]);
const isLastStep = step === STEPS.length - 1; const isLastStep = step === STEPS.length - 1;
const markStepTouched = useCallback( const markStepTouched = useCallback(
@ -208,7 +322,7 @@ export function SignupForm({
); );
const isStepValid = useCallback( const isStepValid = useCallback(
(stepIndex: number) => { (stepIndex: number, data: SignupFormData = values) => {
const stepKey = STEPS[stepIndex]?.key; const stepKey = STEPS[stepIndex]?.key;
if (!stepKey) { if (!stepKey) {
return true; return true;
@ -217,12 +331,13 @@ export function SignupForm({
if (!schema) { if (!schema) {
return true; return true;
} }
return schema.safeParse(values).success; return schema.safeParse(data).success;
}, },
[values] [values]
); );
const handleNext = useCallback(() => { const handleNext = useCallback(() => {
const syncedValues = syncStepValues();
markStepTouched(step); markStepTouched(step);
if (isLastStep) { if (isLastStep) {
@ -230,12 +345,12 @@ export function SignupForm({
return; return;
} }
if (!isStepValid(step)) { if (!isStepValid(step, syncedValues)) {
return; return;
} }
setStep(s => Math.min(s + 1, STEPS.length - 1)); setStep(s => Math.min(s + 1, STEPS.length - 1));
}, [handleSubmit, isLastStep, isStepValid, markStepTouched, step]); }, [handleSubmit, isLastStep, isStepValid, markStepTouched, step, syncStepValues]);
const handlePrevious = useCallback(() => { const handlePrevious = useCallback(() => {
setStep(s => Math.max(0, s - 1)); setStep(s => Math.max(0, s - 1));
@ -264,15 +379,24 @@ export function SignupForm({
return ( return (
<div className={`w-full ${className}`}> <div className={`w-full ${className}`}>
<MultiStepForm <form
steps={steps} ref={formRef}
currentStep={step} autoComplete="on"
onNext={handleNext} onSubmit={event => {
onPrevious={handlePrevious} event.preventDefault();
isLastStep={isLastStep} handleNext();
isSubmitting={isSubmitting || loading} }}
canProceed={isLastStep || isStepValid(step)} >
/> <MultiStepForm
steps={steps}
currentStep={step}
onNext={handleNext}
onPrevious={handlePrevious}
isLastStep={isLastStep}
isSubmitting={isSubmitting || loading}
canProceed={isLastStep || isStepValid(step)}
/>
</form>
{error && ( {error && (
<ErrorMessage className="mt-4 text-center p-3 bg-danger-soft rounded-lg"> <ErrorMessage className="mt-4 text-center p-3 bg-danger-soft rounded-lg">

View File

@ -6,6 +6,7 @@
import { Input } from "@/components/atoms"; import { Input } from "@/components/atoms";
import { FormField } from "@/components/molecules/FormField/FormField"; import { FormField } from "@/components/molecules/FormField/FormField";
import { genderEnum } from "@customer-portal/domain/common";
interface AccountStepProps { interface AccountStepProps {
form: { form: {
@ -29,6 +30,8 @@ interface AccountStepProps {
export function AccountStep({ form }: AccountStepProps) { export function AccountStep({ form }: AccountStepProps) {
const { values, errors, touched, setValue, setTouchedField } = form; const { values, errors, touched, setValue, setTouchedField } = form;
const getError = (field: string) => (touched[field] ? errors[field] : undefined); const getError = (field: string) => (touched[field] ? errors[field] : undefined);
const genderOptions = genderEnum.options;
const formatGender = (value: string) => value.charAt(0).toUpperCase() + value.slice(1);
return ( return (
<div className="space-y-5"> <div className="space-y-5">
@ -41,8 +44,9 @@ export function AccountStep({ form }: AccountStepProps) {
onChange={e => setValue("firstName", e.target.value)} onChange={e => setValue("firstName", e.target.value)}
onBlur={() => setTouchedField("firstName")} onBlur={() => setTouchedField("firstName")}
placeholder="Taro" placeholder="Taro"
autoComplete="given-name" autoComplete="section-signup given-name"
autoFocus autoFocus
data-field="firstName"
/> />
</FormField> </FormField>
<FormField label="Last Name" error={getError("lastName")} required> <FormField label="Last Name" error={getError("lastName")} required>
@ -52,7 +56,8 @@ export function AccountStep({ form }: AccountStepProps) {
onChange={e => setValue("lastName", e.target.value)} onChange={e => setValue("lastName", e.target.value)}
onBlur={() => setTouchedField("lastName")} onBlur={() => setTouchedField("lastName")}
placeholder="Yamada" placeholder="Yamada"
autoComplete="family-name" autoComplete="section-signup family-name"
data-field="lastName"
/> />
</FormField> </FormField>
</div> </div>
@ -66,7 +71,8 @@ export function AccountStep({ form }: AccountStepProps) {
onChange={e => setValue("email", e.target.value)} onChange={e => setValue("email", e.target.value)}
onBlur={() => setTouchedField("email")} onBlur={() => setTouchedField("email")}
placeholder="taro.yamada@example.com" placeholder="taro.yamada@example.com"
autoComplete="email" autoComplete="section-signup email"
data-field="email"
/> />
</FormField> </FormField>
@ -90,8 +96,9 @@ export function AccountStep({ form }: AccountStepProps) {
}} }}
onBlur={() => setTouchedField("phoneCountryCode")} onBlur={() => setTouchedField("phoneCountryCode")}
placeholder="+81" placeholder="+81"
autoComplete="tel-country-code" autoComplete="section-signup tel-country-code"
className="w-20 text-center" className="w-20 text-center"
data-field="phoneCountryCode"
/> />
<Input <Input
name="tel-national" name="tel-national"
@ -104,8 +111,9 @@ export function AccountStep({ form }: AccountStepProps) {
}} }}
onBlur={() => setTouchedField("phone")} onBlur={() => setTouchedField("phone")}
placeholder="9012345678" placeholder="9012345678"
autoComplete="tel-national" autoComplete="section-signup tel-national"
className="flex-1" className="flex-1"
data-field="phone"
/> />
</div> </div>
</FormField> </FormField>
@ -119,7 +127,8 @@ export function AccountStep({ form }: AccountStepProps) {
value={values.dateOfBirth ?? ""} value={values.dateOfBirth ?? ""}
onChange={e => setValue("dateOfBirth", e.target.value || undefined)} onChange={e => setValue("dateOfBirth", e.target.value || undefined)}
onBlur={() => setTouchedField("dateOfBirth")} onBlur={() => setTouchedField("dateOfBirth")}
autoComplete="bday" autoComplete="section-signup bday"
data-field="dateOfBirth"
/> />
</FormField> </FormField>
@ -129,6 +138,8 @@ export function AccountStep({ form }: AccountStepProps) {
value={values.gender ?? ""} value={values.gender ?? ""}
onChange={e => setValue("gender", e.target.value || undefined)} onChange={e => setValue("gender", e.target.value || undefined)}
onBlur={() => setTouchedField("gender")} onBlur={() => setTouchedField("gender")}
autoComplete="section-signup sex"
data-field="gender"
className={[ className={[
"flex h-10 w-full rounded-md border border-input bg-background text-foreground px-3 py-2 text-sm", "flex h-10 w-full rounded-md border border-input bg-background text-foreground px-3 py-2 text-sm",
"ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none", "ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none",
@ -141,9 +152,11 @@ export function AccountStep({ form }: AccountStepProps) {
aria-invalid={Boolean(getError("gender")) || undefined} aria-invalid={Boolean(getError("gender")) || undefined}
> >
<option value="">Select</option> <option value="">Select</option>
<option value="male">Male</option> {genderOptions.map(option => (
<option value="female">Female</option> <option key={option} value={option}>
<option value="other">Other</option> {formatGender(option)}
</option>
))}
</select> </select>
</FormField> </FormField>
</div> </div>
@ -156,7 +169,8 @@ export function AccountStep({ form }: AccountStepProps) {
onChange={e => setValue("company", e.target.value)} onChange={e => setValue("company", e.target.value)}
onBlur={() => setTouchedField("company")} onBlur={() => setTouchedField("company")}
placeholder="Company name" placeholder="Company name"
autoComplete="organization" autoComplete="section-signup organization"
data-field="company"
/> />
</FormField> </FormField>
</div> </div>

View File

@ -5,21 +5,22 @@
* - postcode postcode * - postcode postcode
* - state state (prefecture) * - state state (prefecture)
* - city city * - city city
* - address1 address1 (street/block address) * - address1 address1 (building/room)
* - address2 address2 (building/room - optional) * - address2 address2 (street/block)
* - country "JP" * - country "JP"
*/ */
"use client"; "use client";
import { useCallback } from "react"; import { useCallback, useEffect } from "react";
import { ChevronDown } from "lucide-react";
import { Input } from "@/components/atoms"; import { Input } from "@/components/atoms";
import { FormField } from "@/components/molecules/FormField/FormField"; import { FormField } from "@/components/molecules/FormField/FormField";
import { JAPAN_PREFECTURES, formatJapanesePostalCode } from "@/shared/constants"; import { JAPAN_PREFECTURES, formatJapanesePostalCode } from "@/shared/constants";
interface AddressData { interface AddressData {
address1: string; address1: string;
address2?: string; address2: string;
city: string; city: string;
state: string; state: string;
postcode: string; postcode: string;
@ -64,9 +65,11 @@ export function AddressStep({ form }: AddressStepProps) {
const markTouched = () => setTouchedField("address"); const markTouched = () => setTouchedField("address");
// Set Japan as default country on mount if empty // Set Japan as default country on mount if empty
if (!address.country) { useEffect(() => {
setValue("address", { ...address, country: "JP", countryCode: "JP" }); if (!address.country) {
} setValue("address", { ...address, country: "JP", countryCode: "JP" });
}
}, [address, setValue]);
return ( return (
<div className="space-y-5"> <div className="space-y-5">
@ -79,33 +82,42 @@ export function AddressStep({ form }: AddressStepProps) {
> >
<Input <Input
name="postal-code" name="postal-code"
type="text"
inputMode="numeric"
value={address.postcode} value={address.postcode}
onChange={handlePostcodeChange} onChange={handlePostcodeChange}
onBlur={markTouched} onBlur={markTouched}
placeholder="100-0001" placeholder="100-0001"
autoComplete="postal-code" autoComplete="section-signup postal-code"
maxLength={8} maxLength={8}
autoFocus autoFocus
data-field="address.postcode"
/> />
</FormField> </FormField>
{/* Prefecture Selection */} {/* Prefecture Selection */}
<FormField label="Prefecture" error={getError("state")} required> <FormField label="Prefecture" error={getError("state")} required>
<select <div className="relative">
name="address-level1" <select
value={address.state} name="address-level1"
onChange={e => updateAddress("state", e.target.value)} value={address.state}
onBlur={markTouched} onChange={e => updateAddress("state", e.target.value)}
className="block w-full h-10 px-3 py-2 border border-input rounded-md shadow-sm bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring transition-colors" onBlur={markTouched}
autoComplete="address-level1" className="block w-full h-11 pl-4 pr-10 py-2.5 border border-border rounded-lg appearance-none bg-card text-foreground text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-ring focus:border-primary transition-colors cursor-pointer"
> autoComplete="section-signup address-level1"
<option value="">Select prefecture</option> data-field="address.state"
{JAPAN_PREFECTURES.map(p => ( >
<option key={p.value} value={p.value}> <option value="">Select prefecture</option>
{p.label} {JAPAN_PREFECTURES.map(p => (
</option> <option key={p.value} value={p.value}>
))} {p.label}
</select> </option>
))}
</select>
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</div>
</div>
</FormField> </FormField>
{/* City/Ward */} {/* City/Ward */}
@ -121,46 +133,50 @@ export function AddressStep({ form }: AddressStepProps) {
onChange={e => updateAddress("city", e.target.value)} onChange={e => updateAddress("city", e.target.value)}
onBlur={markTouched} onBlur={markTouched}
placeholder="Shibuya-ku" placeholder="Shibuya-ku"
autoComplete="address-level2" autoComplete="section-signup address-level2"
data-field="address.city"
/> />
</FormField> </FormField>
{/* Street Address */} {/* Street / Block (Address 2) */}
<FormField <FormField
label="Street Address" label="Street / Block (Address 2)"
error={getError("address1")} error={getError("address2")}
required required
helperText="Block and building number (e.g., 3-8-2 Higashi Azabu)" helperText="e.g., 2-20-9 Wakabayashi"
> >
<Input <Input
name="address-line1" name="address-line1"
value={address.address1} type="text"
onChange={e => updateAddress("address1", e.target.value)} value={address.address2}
onChange={e => updateAddress("address2", e.target.value)}
onBlur={markTouched} onBlur={markTouched}
placeholder="3-8-2 Higashi Azabu" placeholder="2-20-9 Wakabayashi"
autoComplete="address-line1" autoComplete="section-signup address-line1"
required
data-field="address.address2"
/> />
</FormField> </FormField>
{/* Building & Room (Optional - for apartments) */} {/* Building / Room (Address 1) */}
<FormField <FormField
label="Building Name & Room Number" label="Building / Room (Address 1)"
error={getError("address2")} error={getError("address1")}
helperText="e.g., 3F Azabu Maruka Bldg" required
helperText="e.g., Gramercy 201"
> >
<Input <Input
name="address-line2" name="address-line2"
value={address.address2 ?? ""} type="text"
onChange={e => updateAddress("address2", e.target.value)} value={address.address1}
onChange={e => updateAddress("address1", e.target.value)}
onBlur={markTouched} onBlur={markTouched}
placeholder="3F Azabu Maruka Bldg" placeholder="Gramercy 201"
autoComplete="address-line2" autoComplete="section-signup address-line2"
required
data-field="address.address1"
/> />
</FormField> </FormField>
<p className="text-sm text-muted-foreground bg-info-soft border border-info/25 rounded-lg p-3">
Please input your address in Japan. This will be used for service delivery and setup.
</p>
</div> </div>
); );
} }

View File

@ -38,7 +38,7 @@ export function PasswordStep({ form }: PasswordStepProps) {
type="email" type="email"
name="email" name="email"
value={values.email} value={values.email}
autoComplete="username email" autoComplete="section-signup username"
readOnly readOnly
className="sr-only" className="sr-only"
tabIndex={-1} tabIndex={-1}
@ -53,7 +53,8 @@ export function PasswordStep({ form }: PasswordStepProps) {
onChange={e => setValue("password", e.target.value)} onChange={e => setValue("password", e.target.value)}
onBlur={() => setTouchedField("password")} onBlur={() => setTouchedField("password")}
placeholder="Create a secure password" placeholder="Create a secure password"
autoComplete="new-password" autoComplete="section-signup new-password"
data-field="password"
/> />
</FormField> </FormField>
@ -97,7 +98,8 @@ export function PasswordStep({ form }: PasswordStepProps) {
onChange={e => setValue("confirmPassword", e.target.value)} onChange={e => setValue("confirmPassword", e.target.value)}
onBlur={() => setTouchedField("confirmPassword")} onBlur={() => setTouchedField("confirmPassword")}
placeholder="Re-enter your password" placeholder="Re-enter your password"
autoComplete="new-password" autoComplete="section-signup new-password"
data-field="confirmPassword"
/> />
</FormField> </FormField>

View File

@ -73,7 +73,7 @@ interface ReviewStepProps {
gender?: "male" | "female" | "other"; gender?: "male" | "female" | "other";
address: { address: {
address1: string; address1: string;
address2?: string; address2: string;
city: string; city: string;
state: string; state: string;
postcode: string; postcode: string;
@ -95,8 +95,8 @@ export function ReviewStep({ form }: ReviewStepProps) {
// Format address for display // Format address for display
const formattedAddress = [ const formattedAddress = [
address.address1,
address.address2, address.address2,
address.address1,
address.city, address.city,
address.state, address.state,
address.postcode, address.postcode,

View File

@ -305,24 +305,7 @@ export function AddressConfirmation({
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-muted-foreground mb-1"> <label className="block text-sm font-medium text-muted-foreground mb-1">
Street Address * Street / Block (Address 2)
</label>
<input
type="text"
value={editedAddress?.address1 || ""}
onChange={e => {
setError(null); // Clear error on input
setEditedAddress(prev => (prev ? { ...prev, address1: e.target.value } : null));
}}
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring transition-colors"
placeholder="123 Main Street"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
Street Address Line 2
</label> </label>
<input <input
type="text" type="text"
@ -332,7 +315,24 @@ export function AddressConfirmation({
setEditedAddress(prev => (prev ? { ...prev, address2: e.target.value } : null)); setEditedAddress(prev => (prev ? { ...prev, address2: e.target.value } : null));
}} }}
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring transition-colors" className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring transition-colors"
placeholder="Apartment, suite, etc. (optional)" placeholder="2-20-9 Wakabayashi"
/>
</div>
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
Building / Room (Address 1) *
</label>
<input
type="text"
value={editedAddress?.address1 || ""}
onChange={e => {
setError(null); // Clear error on input
setEditedAddress(prev => (prev ? { ...prev, address1: e.target.value } : null));
}}
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring transition-colors"
placeholder="Gramercy 201"
required
/> />
</div> </div>
@ -427,9 +427,11 @@ export function AddressConfirmation({
{address?.address1 ? ( {address?.address1 ? (
<div className="space-y-4"> <div className="space-y-4">
<div className="text-foreground space-y-1"> <div className="text-foreground space-y-1">
<p className="font-semibold text-base">{address.address1}</p> {(address.address2 || address.address1) && (
{address.address2 ? ( <p className="font-semibold text-base">{address.address2 || address.address1}</p>
<p className="text-muted-foreground">{address.address2}</p> )}
{address.address2 && address.address1 ? (
<p className="text-muted-foreground">{address.address1}</p>
) : null} ) : null}
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{[address.city, address.state].filter(Boolean).join(", ")} {[address.city, address.state].filter(Boolean).join(", ")}

View File

@ -45,8 +45,8 @@ const normalizeCountryValue = (value?: string | null) => {
}; };
const DEFAULT_LABELS: Partial<Record<keyof Address, string>> = { const DEFAULT_LABELS: Partial<Record<keyof Address, string>> = {
address1: "Address Line 1", address1: "Building / Room (Address 1)",
address2: "Address Line 2", address2: "Street / Block (Address 2)",
city: "City", city: "City",
state: "State/Prefecture", state: "State/Prefecture",
postcode: "Postcode", postcode: "Postcode",
@ -57,8 +57,8 @@ const DEFAULT_LABELS: Partial<Record<keyof Address, string>> = {
}; };
const DEFAULT_PLACEHOLDERS: Partial<Record<keyof Address, string>> = { const DEFAULT_PLACEHOLDERS: Partial<Record<keyof Address, string>> = {
address1: "123 Main Street", address1: "Gramercy 201",
address2: "Apartment, suite, etc. (optional)", address2: "2-20-9 Wakabayashi",
city: "Tokyo", city: "Tokyo",
state: "Tokyo", state: "Tokyo",
postcode: "100-0001", postcode: "100-0001",
@ -308,12 +308,12 @@ export function AddressForm({
)} )}
<div className="space-y-4"> <div className="space-y-4">
{/* Street Address */} {/* Street / Block (Address 2) */}
{renderField("address1")}
{/* Street Address Line 2 */}
{renderField("address2")} {renderField("address2")}
{/* Building / Room (Address 1) */}
{renderField("address1")}
{/* City, State, Postal Code Row */} {/* City, State, Postal Code Row */}
<div <div
className={`grid gap-4 ${variant === "compact" ? "grid-cols-1 sm:grid-cols-3" : "grid-cols-1 md:grid-cols-3"}`} className={`grid gap-4 ${variant === "compact" ? "grid-cols-1 sm:grid-cols-3" : "grid-cols-1 md:grid-cols-3"}`}

105
pnpm-lock.yaml generated
View File

@ -192,9 +192,6 @@ importers:
"@heroicons/react": "@heroicons/react":
specifier: ^2.2.0 specifier: ^2.2.0
version: 2.2.0(react@19.2.3) version: 2.2.0(react@19.2.3)
"@react-google-maps/api":
specifier: ^2.20.8
version: 2.20.8(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
"@tanstack/react-query": "@tanstack/react-query":
specifier: ^5.90.14 specifier: ^5.90.14
version: 5.90.14(react@19.2.3) version: 5.90.14(react@19.2.3)
@ -849,18 +846,6 @@ packages:
} }
engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
"@googlemaps/js-api-loader@1.16.8":
resolution:
{
integrity: sha512-CROqqwfKotdO6EBjZO/gQGVTbeDps5V7Mt9+8+5Q+jTg5CRMi3Ii/L9PmV3USROrt2uWxtGzJHORmByxyo9pSQ==,
}
"@googlemaps/markerclusterer@2.5.3":
resolution:
{
integrity: sha512-x7lX0R5yYOoiNectr10wLgCBasNcXFHiADIBdmn7jQllF2B5ENQw5XtZK+hIw4xnV0Df0xhN4LN98XqA5jaiOw==,
}
"@grpc/grpc-js@1.14.2": "@grpc/grpc-js@1.14.2":
resolution: resolution:
{ {
@ -2078,27 +2063,6 @@ packages:
integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==, integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==,
} }
"@react-google-maps/api@2.20.8":
resolution:
{
integrity: sha512-wtLYFtCGXK3qbIz1H5to3JxbosPnKsvjDKhqGylXUb859EskhzR7OpuNt0LqdLarXUtZCJTKzPn3BNaekNIahg==,
}
peerDependencies:
react: ^16.8 || ^17 || ^18 || ^19
react-dom: ^16.8 || ^17 || ^18 || ^19
"@react-google-maps/infobox@2.20.0":
resolution:
{
integrity: sha512-03PJHjohhaVLkX6+NHhlr8CIlvUxWaXhryqDjyaZ8iIqqix/nV8GFdz9O3m5OsjtxtNho09F/15j14yV0nuyLQ==,
}
"@react-google-maps/marker-clusterer@2.20.0":
resolution:
{
integrity: sha512-tieX9Va5w1yP88vMgfH1pHTacDQ9TgDTjox3tLlisKDXRQWdjw+QeVVghhf5XqqIxXHgPdcGwBvKY6UP+SIvLw==,
}
"@scarf/scarf@1.4.0": "@scarf/scarf@1.4.0":
resolution: resolution:
{ {
@ -2519,12 +2483,6 @@ packages:
integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==, integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==,
} }
"@types/google.maps@3.58.1":
resolution:
{
integrity: sha512-X9QTSvGJ0nCfMzYOnaVs/k6/4L+7F5uCS+4iUmkLEls6J9S/Phv+m/i3mDeyc49ZBgwab3EFO1HEoBY7k98EGQ==,
}
"@types/http-cache-semantics@4.0.4": "@types/http-cache-semantics@4.0.4":
resolution: resolution:
{ {
@ -4803,12 +4761,6 @@ packages:
integrity: sha512-MAQUJuIo7Xqk8EVNP+6d3CKq9c80hi4tjIbIAT6lmGW9W6WzlHiu9PS8uSuUYU+Do+j1baiFp3H25XEVxDIG2g==, integrity: sha512-MAQUJuIo7Xqk8EVNP+6d3CKq9c80hi4tjIbIAT6lmGW9W6WzlHiu9PS8uSuUYU+Do+j1baiFp3H25XEVxDIG2g==,
} }
invariant@2.2.4:
resolution:
{
integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==,
}
ioredis@5.8.2: ioredis@5.8.2:
resolution: resolution:
{ {
@ -5052,12 +5004,6 @@ packages:
integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==, integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==,
} }
kdbush@4.0.2:
resolution:
{
integrity: sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==,
}
keyv@4.5.4: keyv@4.5.4:
resolution: resolution:
{ {
@ -5289,13 +5235,6 @@ packages:
integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==, integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==,
} }
loose-envify@1.4.0:
resolution:
{
integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==,
}
hasBin: true
lowercase-keys@3.0.0: lowercase-keys@3.0.0:
resolution: resolution:
{ {
@ -6915,12 +6854,6 @@ packages:
babel-plugin-macros: babel-plugin-macros:
optional: true optional: true
supercluster@8.0.1:
resolution:
{
integrity: sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==,
}
supports-color@7.2.0: supports-color@7.2.0:
resolution: resolution:
{ {
@ -7919,13 +7852,6 @@ snapshots:
"@eslint/core": 0.17.0 "@eslint/core": 0.17.0
levn: 0.4.1 levn: 0.4.1
"@googlemaps/js-api-loader@1.16.8": {}
"@googlemaps/markerclusterer@2.5.3":
dependencies:
fast-deep-equal: 3.1.3
supercluster: 8.0.1
"@grpc/grpc-js@1.14.2": "@grpc/grpc-js@1.14.2":
dependencies: dependencies:
"@grpc/proto-loader": 0.8.0 "@grpc/proto-loader": 0.8.0
@ -8636,21 +8562,6 @@ snapshots:
"@protobufjs/utf8@1.1.0": {} "@protobufjs/utf8@1.1.0": {}
"@react-google-maps/api@2.20.8(react-dom@19.2.3(react@19.2.3))(react@19.2.3)":
dependencies:
"@googlemaps/js-api-loader": 1.16.8
"@googlemaps/markerclusterer": 2.5.3
"@react-google-maps/infobox": 2.20.0
"@react-google-maps/marker-clusterer": 2.20.0
"@types/google.maps": 3.58.1
invariant: 2.2.4
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
"@react-google-maps/infobox@2.20.0": {}
"@react-google-maps/marker-clusterer@2.20.0": {}
"@scarf/scarf@1.4.0": {} "@scarf/scarf@1.4.0": {}
"@sendgrid/client@8.1.6": "@sendgrid/client@8.1.6":
@ -8903,8 +8814,6 @@ snapshots:
"@types/express-serve-static-core": 5.1.0 "@types/express-serve-static-core": 5.1.0
"@types/serve-static": 2.2.0 "@types/serve-static": 2.2.0
"@types/google.maps@3.58.1": {}
"@types/http-cache-semantics@4.0.4": "@types/http-cache-semantics@4.0.4":
optional: true optional: true
@ -10412,10 +10321,6 @@ snapshots:
kind-of: 6.0.3 kind-of: 6.0.3
optional: true optional: true
invariant@2.2.4:
dependencies:
loose-envify: 1.4.0
ioredis@5.8.2: ioredis@5.8.2:
dependencies: dependencies:
"@ioredis/commands": 1.4.0 "@ioredis/commands": 1.4.0
@ -10541,8 +10446,6 @@ snapshots:
optionalDependencies: optionalDependencies:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
kdbush@4.0.2: {}
keyv@4.5.4: keyv@4.5.4:
dependencies: dependencies:
json-buffer: 3.0.1 json-buffer: 3.0.1
@ -10663,10 +10566,6 @@ snapshots:
long@5.3.2: {} long@5.3.2: {}
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
lowercase-keys@3.0.0: lowercase-keys@3.0.0:
optional: true optional: true
@ -11619,10 +11518,6 @@ snapshots:
optionalDependencies: optionalDependencies:
"@babel/core": 7.28.5 "@babel/core": 7.28.5
supercluster@8.0.1:
dependencies:
kdbush: 4.0.2
supports-color@7.2.0: supports-color@7.2.0:
dependencies: dependencies:
has-flag: 4.0.0 has-flag: 4.0.0