refactor: extract hooks and components from high-complexity views
Reduce complexity in ProfileContainer, AccountCheckoutContainer, and JapanAddressForm by extracting reusable hooks, components, and utilities. ProfileContainer (705 → 132 lines): - Extract useProfileDataLoading hook for consolidated data loading - Extract useVerificationFileUpload hook for file upload state - Create VerificationCard component for verification UI - Create ProfileLoadingSkeleton for loading state - Update PersonalInfoCard with phone field and additional fields - Update AddressCard to use Button component AccountCheckoutContainer (382 → 303 lines): - Extract useCheckoutEligibility hook for eligibility state - Extract useCheckoutFormState and useCanSubmit hooks - Extract useCheckoutToast hook for toast timing - Create checkout-navigation utilities - Create CheckoutErrorFallback component JapanAddressForm (727 → 437 lines): - Extract AnimatedSection, ProgressIndicator, BilingualValue components - Extract useAddressCompletion hook for completion state - Extract useJapanAddressForm hook for form state/handlers - Create japan-address.constants and street-address.validation utilities
This commit is contained in:
parent
0a5a33da98
commit
789e2d95a5
@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { SubCard } from "@/components/molecules/SubCard/SubCard";
|
||||
import { MapPinIcon, PencilIcon, CheckIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import { AddressForm, type AddressFormProps } from "@/features/services/components";
|
||||
import type { Address } from "@customer-portal/domain/customer";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||
import { getCountryName } from "@/shared/constants";
|
||||
|
||||
function AddressDisplay({ address }: { address: Address }) {
|
||||
@ -15,34 +16,26 @@ function AddressDisplay({ address }: { address: Address }) {
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5 text-foreground">
|
||||
{primaryLine && <p className="font-semibold text-base">{primaryLine}</p>}
|
||||
{secondaryLine && <p className="text-muted-foreground">{secondaryLine}</p>}
|
||||
{cityStateZip && <p className="text-muted-foreground">{cityStateZip}</p>}
|
||||
{countryLabel && <p className="text-muted-foreground font-medium">{countryLabel}</p>}
|
||||
<div className="bg-card rounded-lg p-5 border border-border shadow-sm">
|
||||
<div className="text-foreground space-y-1.5">
|
||||
{primaryLine && <p className="font-medium text-base">{primaryLine}</p>}
|
||||
{secondaryLine && <p className="text-muted-foreground">{secondaryLine}</p>}
|
||||
{cityStateZip && <p className="text-muted-foreground">{cityStateZip}</p>}
|
||||
{countryLabel && <p className="text-muted-foreground">{countryLabel}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SaveButton({ isSaving, onClick }: { isSaving: boolean; onClick: () => void }) {
|
||||
function EmptyAddressState({ onEdit }: { onEdit: () => void }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={isSaving}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-lg text-primary-foreground bg-primary hover:bg-primary-hover disabled:opacity-50 transition-colors shadow-sm"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary-foreground mr-2" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckIcon className="h-4 w-4 mr-2" />
|
||||
Save Address
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<div className="text-center py-12">
|
||||
<MapPinIcon className="h-12 w-12 text-muted-foreground/60 mx-auto mb-4" />
|
||||
<p className="text-muted-foreground mb-4">No address on file</p>
|
||||
<Button onClick={onEdit} leftIcon={<PencilIcon className="h-4 w-4" />}>
|
||||
Add Address
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -67,49 +60,78 @@ export function AddressCard({
|
||||
onSave,
|
||||
onAddressChange,
|
||||
}: AddressCardProps) {
|
||||
const hasAddress = Boolean(address.address1 || address.city);
|
||||
|
||||
return (
|
||||
<SubCard>
|
||||
<div className="pb-5 border-b border-border/60">
|
||||
<div className="bg-card text-card-foreground rounded-xl border border-border shadow-[var(--cp-shadow-1)]">
|
||||
<div className="px-6 py-5 border-b border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="h-10 w-10 rounded-xl bg-primary/10 flex items-center justify-center">
|
||||
<MapPinIcon className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Address Information</h2>
|
||||
<MapPinIcon className="h-6 w-6 text-primary" />
|
||||
<h2 className="text-xl font-semibold text-foreground">Address Information</h2>
|
||||
</div>
|
||||
{!isEditing && (
|
||||
<button
|
||||
{!isEditing && hasAddress && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onEdit}
|
||||
className="inline-flex items-center px-4 py-2 border border-border text-sm font-medium rounded-lg text-foreground bg-background hover:bg-muted transition-colors"
|
||||
leftIcon={<PencilIcon className="h-4 w-4" />}
|
||||
>
|
||||
<PencilIcon className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-5">
|
||||
<div className="p-6">
|
||||
{isEditing ? (
|
||||
<div className="space-y-6">
|
||||
<AddressForm initialAddress={address} onChange={addr => onAddressChange(addr, true)} />
|
||||
{error && <div className="text-sm text-danger">{error}</div>}
|
||||
<div className="flex items-center justify-end space-x-3 pt-4 border-t border-border/60">
|
||||
<button
|
||||
<AddressForm
|
||||
initialAddress={{
|
||||
address1: address.address1,
|
||||
address2: address.address2,
|
||||
city: address.city,
|
||||
state: address.state,
|
||||
postcode: address.postcode,
|
||||
country: address.country,
|
||||
countryCode: address.countryCode,
|
||||
phoneNumber: address.phoneNumber,
|
||||
phoneCountryCode: address.phoneCountryCode,
|
||||
}}
|
||||
onChange={addr => onAddressChange(addr, true)}
|
||||
title="Mailing Address"
|
||||
/>
|
||||
<div className="flex items-center justify-end space-x-3 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onCancel}
|
||||
disabled={isSaving}
|
||||
className="inline-flex items-center px-4 py-2 border border-border text-sm font-medium rounded-lg text-foreground bg-background hover:bg-muted disabled:opacity-50 transition-colors"
|
||||
leftIcon={<XMarkIcon className="h-4 w-4" />}
|
||||
>
|
||||
<XMarkIcon className="h-4 w-4 mr-2" />
|
||||
Cancel
|
||||
</button>
|
||||
<SaveButton isSaving={isSaving} onClick={onSave} />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onSave}
|
||||
isLoading={isSaving}
|
||||
leftIcon={isSaving ? undefined : <CheckIcon className="h-4 w-4" />}
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save Address"}
|
||||
</Button>
|
||||
</div>
|
||||
{error && (
|
||||
<AlertBanner variant="error" title="Address Error">
|
||||
{error}
|
||||
</AlertBanner>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
) : hasAddress ? (
|
||||
<AddressDisplay address={address} />
|
||||
) : (
|
||||
<EmptyAddressState onEdit={onEdit} />
|
||||
)}
|
||||
</div>
|
||||
</SubCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,13 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { SubCard } from "@/components/molecules/SubCard/SubCard";
|
||||
import { UserIcon, PencilIcon, CheckIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import type { UserProfile } from "@customer-portal/domain/customer";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import { Input } from "@/components/atoms/input";
|
||||
|
||||
/** Data required for displaying personal info card */
|
||||
interface PersonalInfoData {
|
||||
firstname: string | null | undefined;
|
||||
lastname: string | null | undefined;
|
||||
email: string;
|
||||
phonenumber: string | null | undefined;
|
||||
sfNumber: string | null | undefined;
|
||||
dateOfBirth: string | null | undefined;
|
||||
gender: string | null | undefined;
|
||||
}
|
||||
|
||||
interface PersonalInfoCardProps {
|
||||
data: UserProfile;
|
||||
/** User profile data including read-only fields */
|
||||
data: PersonalInfoData;
|
||||
/** Email value for editing (may differ from data.email during edit) */
|
||||
editEmail: string;
|
||||
/** Phone number value for editing (may differ from data.phonenumber during edit) */
|
||||
editPhoneNumber: string;
|
||||
isEditing: boolean;
|
||||
isSaving: boolean;
|
||||
onEdit: () => void;
|
||||
@ -16,8 +30,32 @@ interface PersonalInfoCardProps {
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
function ReadOnlyField({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | null | undefined;
|
||||
hint: string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-muted-foreground mb-2">{label}</label>
|
||||
<div className="bg-card rounded-lg p-4 border border-border shadow-sm">
|
||||
<p className="text-base text-foreground font-medium">
|
||||
{value || <span className="text-muted-foreground italic">Not provided</span>}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">{hint}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PersonalInfoCard({
|
||||
data,
|
||||
editEmail,
|
||||
editPhoneNumber,
|
||||
isEditing,
|
||||
isSaving,
|
||||
onEdit,
|
||||
@ -26,8 +64,8 @@ export function PersonalInfoCard({
|
||||
onSave,
|
||||
}: PersonalInfoCardProps) {
|
||||
return (
|
||||
<SubCard>
|
||||
<div className="pb-5 border-b border-border">
|
||||
<div className="bg-card text-card-foreground rounded-xl border border-border shadow-[var(--cp-shadow-1)]">
|
||||
<div className="px-6 py-5 border-b border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<UserIcon className="h-6 w-6 text-primary" />
|
||||
@ -46,52 +84,32 @@ export function PersonalInfoCard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-5">
|
||||
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-muted-foreground mb-2">
|
||||
First Name
|
||||
</label>
|
||||
<div className="bg-muted rounded-lg p-4 border border-border">
|
||||
<p className="text-sm text-foreground font-medium">
|
||||
{data.firstname || (
|
||||
<span className="text-muted-foreground italic">Not provided</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Name cannot be changed from the portal.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-muted-foreground mb-2">
|
||||
Last Name
|
||||
</label>
|
||||
<div className="bg-muted rounded-lg p-4 border border-border">
|
||||
<p className="text-sm text-foreground font-medium">
|
||||
{data.lastname || (
|
||||
<span className="text-muted-foreground italic">Not provided</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Name cannot be changed from the portal.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
<ReadOnlyField
|
||||
label="First Name"
|
||||
value={data.firstname}
|
||||
hint="Name cannot be changed from the portal."
|
||||
/>
|
||||
<ReadOnlyField
|
||||
label="Last Name"
|
||||
value={data.lastname}
|
||||
hint="Name cannot be changed from the portal."
|
||||
/>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-sm font-medium text-muted-foreground mb-3">
|
||||
<label className="block text-sm font-medium text-muted-foreground mb-2">
|
||||
Email Address
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<Input
|
||||
type="email"
|
||||
value={data.email}
|
||||
value={editEmail}
|
||||
onChange={e => onChange("email", e.target.value)}
|
||||
className="block w-full px-4 py-2.5 border border-input rounded-lg bg-background text-foreground shadow-[var(--cp-shadow-1)] focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring transition-colors"
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-muted rounded-lg p-4 border border-border">
|
||||
<div className="bg-card rounded-lg p-4 border border-border shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-base text-foreground font-medium">{data.email}</p>
|
||||
</div>
|
||||
@ -101,6 +119,44 @@ export function PersonalInfoCard({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ReadOnlyField
|
||||
label="Customer Number"
|
||||
value={data.sfNumber}
|
||||
hint="Customer number is read-only."
|
||||
/>
|
||||
<ReadOnlyField
|
||||
label="Date of Birth"
|
||||
value={data.dateOfBirth}
|
||||
hint="Date of birth is stored in billing profile."
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-muted-foreground mb-2">
|
||||
Phone Number
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<Input
|
||||
type="tel"
|
||||
value={editPhoneNumber}
|
||||
onChange={e => onChange("phonenumber", e.target.value)}
|
||||
placeholder="+81 XX-XXXX-XXXX"
|
||||
className="block w-full px-4 py-2.5 border border-input rounded-lg bg-background text-foreground shadow-[var(--cp-shadow-1)] focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring transition-colors"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-base text-foreground py-2">
|
||||
{data.phonenumber || (
|
||||
<span className="text-muted-foreground italic">Not provided</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ReadOnlyField
|
||||
label="Gender"
|
||||
value={data.gender}
|
||||
hint="Gender is stored in billing profile."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isEditing && (
|
||||
@ -117,16 +173,14 @@ export function PersonalInfoCard({
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onSave}
|
||||
disabled={isSaving}
|
||||
isLoading={isSaving}
|
||||
loadingText="Saving…"
|
||||
leftIcon={isSaving ? undefined : <CheckIcon className="h-4 w-4" />}
|
||||
>
|
||||
Save Changes
|
||||
{isSaving ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SubCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
239
apps/portal/src/features/account/components/VerificationCard.tsx
Normal file
239
apps/portal/src/features/account/components/VerificationCard.tsx
Normal file
@ -0,0 +1,239 @@
|
||||
"use client";
|
||||
|
||||
import type { UseQueryResult } from "@tanstack/react-query";
|
||||
import { ShieldCheckIcon } from "@heroicons/react/24/outline";
|
||||
import { Skeleton } from "@/components/atoms/loading-skeleton";
|
||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import { StatusPill } from "@/components/atoms/status-pill";
|
||||
import { formatIsoDate } from "@/shared/utils";
|
||||
import type { ResidenceCardVerification } from "@customer-portal/domain/customer";
|
||||
import type { useVerificationFileUpload } from "@/features/verification/hooks";
|
||||
|
||||
type VerificationQuery = UseQueryResult<ResidenceCardVerification, Error>;
|
||||
type FileUpload = ReturnType<typeof useVerificationFileUpload>;
|
||||
|
||||
interface VerificationCardProps {
|
||||
verificationQuery: VerificationQuery;
|
||||
fileUpload: FileUpload;
|
||||
}
|
||||
|
||||
function VerificationStatusPill({
|
||||
status,
|
||||
isLoading,
|
||||
}: {
|
||||
status?: string | undefined;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
if (isLoading) {
|
||||
return <Skeleton className="h-6 w-20" />;
|
||||
}
|
||||
switch (status) {
|
||||
case "verified":
|
||||
return <StatusPill label="Verified" variant="success" />;
|
||||
case "pending":
|
||||
return <StatusPill label="Under Review" variant="info" />;
|
||||
case "rejected":
|
||||
return <StatusPill label="Action Needed" variant="warning" />;
|
||||
default:
|
||||
return <StatusPill label="Required for SIM" variant="warning" />;
|
||||
}
|
||||
}
|
||||
|
||||
function VerificationContent({
|
||||
data,
|
||||
status,
|
||||
isLoading,
|
||||
}: {
|
||||
data: ResidenceCardVerification | undefined;
|
||||
status?: string | undefined;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "verified") {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your identity has been verified. No further action is needed.
|
||||
</p>
|
||||
{data?.reviewedAt && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Verified on {formatIsoDate(data.reviewedAt, { dateStyle: "medium" })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "pending") {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<AlertBanner variant="info" title="Under review" size="sm" elevated>
|
||||
Your residence card has been submitted. We'll verify it before activating SIM
|
||||
service.
|
||||
</AlertBanner>
|
||||
{data?.submittedAt && (
|
||||
<div className="rounded-lg border border-border bg-muted/30 px-4 py-3">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Submission status
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
Submitted on {formatIsoDate(data.submittedAt, { dateStyle: "medium" })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function UploadSection({
|
||||
data,
|
||||
status,
|
||||
fileUpload,
|
||||
}: {
|
||||
data: ResidenceCardVerification | undefined;
|
||||
status?: string | undefined;
|
||||
fileUpload: FileUpload;
|
||||
}) {
|
||||
const showUpload = status !== "verified" && status !== "pending";
|
||||
|
||||
if (!showUpload) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{status === "rejected" ? (
|
||||
<AlertBanner variant="warning" title="Verification rejected" size="sm" elevated>
|
||||
<div className="space-y-2">
|
||||
{data?.reviewerNotes && <p>{data.reviewerNotes}</p>}
|
||||
<p>Please upload a new, clear photo or scan of your residence card.</p>
|
||||
<ul className="list-disc space-y-1 pl-5 text-sm text-muted-foreground">
|
||||
<li>Make sure all text is readable and the full card is visible.</li>
|
||||
<li>Avoid glare/reflections and blurry photos.</li>
|
||||
<li>Maximum file size: 5MB.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</AlertBanner>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Upload your residence card to activate SIM services. This is required for SIM orders.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(data?.submittedAt || data?.reviewedAt) && (
|
||||
<div className="rounded-lg border border-border bg-muted/30 px-4 py-3">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Latest submission
|
||||
</div>
|
||||
{data?.submittedAt && (
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
Submitted on {formatIsoDate(data.submittedAt, { dateStyle: "medium" })}
|
||||
</div>
|
||||
)}
|
||||
{data?.reviewedAt && (
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
Reviewed on {formatIsoDate(data.reviewedAt, { dateStyle: "medium" })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fileUpload.canUpload && (
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
ref={fileUpload.inputRef}
|
||||
type="file"
|
||||
accept="image/*,application/pdf"
|
||||
onChange={e => fileUpload.handleFileChange(e.target.files?.[0] ?? null)}
|
||||
className="block w-full text-sm text-foreground file:mr-4 file:py-2 file:px-3 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-muted file:text-foreground hover:file:bg-muted/80"
|
||||
/>
|
||||
|
||||
{fileUpload.file && (
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/30 px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-medium text-muted-foreground">Selected file</div>
|
||||
<div className="text-sm font-medium text-foreground truncate">
|
||||
{fileUpload.file.name}
|
||||
</div>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={fileUpload.clearFile}>
|
||||
Change
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!fileUpload.file || fileUpload.isSubmitting}
|
||||
isLoading={fileUpload.isSubmitting}
|
||||
loadingText="Uploading…"
|
||||
onClick={fileUpload.submit}
|
||||
>
|
||||
Submit Document
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{fileUpload.isError && (
|
||||
<p className="text-sm text-destructive">
|
||||
{fileUpload.error instanceof Error
|
||||
? fileUpload.error.message
|
||||
: "Failed to submit residence card."}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Accepted formats: JPG, PNG, or PDF (max 5MB). Make sure all text is readable.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function VerificationCard({ verificationQuery, fileUpload }: VerificationCardProps) {
|
||||
const verificationStatus = verificationQuery.data?.status;
|
||||
|
||||
return (
|
||||
<div className="bg-card text-card-foreground rounded-xl border border-border shadow-[var(--cp-shadow-1)]">
|
||||
<div className="px-6 py-5 border-b border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<ShieldCheckIcon className="h-6 w-6 text-primary" />
|
||||
<h2 className="text-xl font-semibold text-foreground">Identity Verification</h2>
|
||||
</div>
|
||||
<VerificationStatusPill
|
||||
status={verificationStatus}
|
||||
isLoading={verificationQuery.isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<VerificationContent
|
||||
data={verificationQuery.data}
|
||||
status={verificationStatus}
|
||||
isLoading={verificationQuery.isLoading}
|
||||
/>
|
||||
|
||||
{!verificationQuery.isLoading && (
|
||||
<UploadSection
|
||||
data={verificationQuery.data}
|
||||
status={verificationStatus}
|
||||
fileUpload={fileUpload}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
apps/portal/src/features/account/components/index.ts
Normal file
5
apps/portal/src/features/account/components/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export { PersonalInfoCard } from "./PersonalInfoCard";
|
||||
export { AddressCard } from "./AddressCard";
|
||||
export { PasswordChangeCard } from "./PasswordChangeCard";
|
||||
export { VerificationCard } from "./VerificationCard";
|
||||
export { ProfileLoadingSkeleton } from "./skeletons";
|
||||
@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { Skeleton } from "@/components/atoms/loading-skeleton";
|
||||
|
||||
/**
|
||||
* Loading skeleton displayed while profile data is being fetched.
|
||||
* Matches the layout of PersonalInfoCard, AddressCard, and VerificationCard.
|
||||
*/
|
||||
export function ProfileLoadingSkeleton() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Personal Information Card Skeleton */}
|
||||
<div className="bg-card border border-border rounded-xl shadow-[var(--cp-shadow-1)]">
|
||||
<div className="px-6 py-5 border-b border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="h-6 w-6 bg-muted rounded" />
|
||||
<div className="h-6 w-40 bg-muted rounded" />
|
||||
</div>
|
||||
<div className="h-8 w-20 bg-muted rounded" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
))}
|
||||
<div className="sm:col-span-2">
|
||||
<Skeleton className="h-4 w-28 mb-3" />
|
||||
<div className="bg-card rounded-lg p-4 border border-border shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-5 w-48" />
|
||||
<Skeleton className="h-5 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-3 w-64 mt-2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end space-x-3 pt-6 border-t border-border mt-6">
|
||||
<Skeleton className="h-9 w-24" />
|
||||
<Skeleton className="h-9 w-28" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Address Card Skeleton */}
|
||||
<div className="bg-card border border-border rounded-xl shadow-[var(--cp-shadow-1)]">
|
||||
<div className="px-6 py-5 border-b border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="h-6 w-6 bg-muted rounded" />
|
||||
<div className="h-6 w-48 bg-muted rounded" />
|
||||
</div>
|
||||
<div className="h-8 w-20 bg-muted rounded" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="bg-card rounded-lg p-4 border border-border shadow-sm">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-60" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-4 w-52" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end space-x-3 pt-6">
|
||||
<Skeleton className="h-9 w-24" />
|
||||
<Skeleton className="h-9 w-28" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Verification Card Skeleton */}
|
||||
<div className="bg-card border border-border rounded-xl shadow-[var(--cp-shadow-1)]">
|
||||
<div className="px-6 py-5 border-b border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="h-6 w-6 bg-muted rounded" />
|
||||
<div className="h-6 w-40 bg-muted rounded" />
|
||||
</div>
|
||||
<Skeleton className="h-6 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export { ProfileLoadingSkeleton } from "./ProfileLoadingSkeleton";
|
||||
@ -1,3 +1,4 @@
|
||||
export { useProfileData } from "./useProfileData";
|
||||
export { useProfileEdit } from "./useProfileEdit";
|
||||
export { useAddressEdit } from "./useAddressEdit";
|
||||
export { useProfileDataLoading } from "./useProfileDataLoading";
|
||||
|
||||
106
apps/portal/src/features/account/hooks/useProfileDataLoading.ts
Normal file
106
apps/portal/src/features/account/hooks/useProfileDataLoading.ts
Normal file
@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { accountService } from "@/features/account/api/account.api";
|
||||
import { useAuthStore } from "@/features/auth/stores/auth.store";
|
||||
import { useProfileEdit } from "./useProfileEdit";
|
||||
import { useAddressEdit } from "./useAddressEdit";
|
||||
|
||||
interface UseProfileDataLoadingOptions {
|
||||
/** Initial email from user store */
|
||||
email: string;
|
||||
/** Initial phone number from user store */
|
||||
phonenumber: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that consolidates profile and address data loading.
|
||||
* Composes useProfileEdit and useAddressEdit internally.
|
||||
*/
|
||||
export function useProfileDataLoading({ email, phonenumber }: UseProfileDataLoadingOptions) {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
|
||||
const profile = useProfileEdit({
|
||||
email,
|
||||
phonenumber,
|
||||
});
|
||||
|
||||
const address = useAddressEdit({
|
||||
address1: "",
|
||||
address2: "",
|
||||
city: "",
|
||||
state: "",
|
||||
postcode: "",
|
||||
country: "",
|
||||
countryCode: "",
|
||||
phoneNumber: "",
|
||||
phoneCountryCode: "",
|
||||
});
|
||||
|
||||
// Extract stable setValue functions to avoid infinite re-render loop.
|
||||
// The hook objects (address, profile) are recreated every render, but
|
||||
// the setValue callbacks inside them are stable (memoized with useCallback).
|
||||
const setAddressValue = address.setValue;
|
||||
const setProfileValue = profile.setValue;
|
||||
|
||||
const loadProfile = useCallback(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
const [addr, prof] = await Promise.all([
|
||||
accountService.getAddress().catch(() => null),
|
||||
accountService.getProfile().catch(() => null),
|
||||
]);
|
||||
if (addr) {
|
||||
setAddressValue("address1", addr.address1 ?? "");
|
||||
setAddressValue("address2", addr.address2 ?? "");
|
||||
setAddressValue("city", addr.city ?? "");
|
||||
setAddressValue("state", addr.state ?? "");
|
||||
setAddressValue("postcode", addr.postcode ?? "");
|
||||
setAddressValue("country", addr.country ?? "");
|
||||
setAddressValue("countryCode", addr.countryCode ?? "");
|
||||
setAddressValue("phoneNumber", addr.phoneNumber ?? "");
|
||||
setAddressValue("phoneCountryCode", addr.phoneCountryCode ?? "");
|
||||
}
|
||||
if (prof) {
|
||||
setProfileValue("email", prof.email || "");
|
||||
setProfileValue("phonenumber", prof.phonenumber || "");
|
||||
// Spread all profile fields into the user state, including sfNumber, dateOfBirth, gender
|
||||
useAuthStore.setState(state => ({
|
||||
...state,
|
||||
user: state.user
|
||||
? {
|
||||
...state.user,
|
||||
...prof,
|
||||
}
|
||||
: prof,
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
// Keep message customer-safe (no internal details)
|
||||
setError(e instanceof Error ? e.message : "Failed to load profile data");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [setAddressValue, setProfileValue]);
|
||||
|
||||
useEffect(() => {
|
||||
loadProfile();
|
||||
}, [loadProfile, reloadKey]);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
setReloadKey(k => k + 1);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
error,
|
||||
reload,
|
||||
profile,
|
||||
address,
|
||||
};
|
||||
}
|
||||
@ -1,187 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Skeleton } from "@/components/atoms/loading-skeleton";
|
||||
import { useState } from "react";
|
||||
import { UserIcon } from "@heroicons/react/24/outline";
|
||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||
import {
|
||||
MapPinIcon,
|
||||
PencilIcon,
|
||||
CheckIcon,
|
||||
XMarkIcon,
|
||||
UserIcon,
|
||||
ShieldCheckIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { PageLayout } from "@/components/templates";
|
||||
import { useAuthStore } from "@/features/auth/stores/auth.store";
|
||||
import { accountService } from "@/features/account/api/account.api";
|
||||
import { useProfileEdit } from "@/features/account/hooks/useProfileEdit";
|
||||
import { AddressForm } from "@/features/services/components/base/AddressForm";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import { StatusPill } from "@/components/atoms/status-pill";
|
||||
import { useAddressEdit } from "@/features/account/hooks/useAddressEdit";
|
||||
import { useProfileDataLoading } from "@/features/account/hooks";
|
||||
import {
|
||||
useResidenceCardVerification,
|
||||
useSubmitResidenceCard,
|
||||
} from "@/features/verification/hooks/useResidenceCardVerification";
|
||||
import { PageLayout } from "@/components/templates";
|
||||
import { formatIsoDate } from "@/shared/utils";
|
||||
useVerificationFileUpload,
|
||||
} from "@/features/verification/hooks";
|
||||
import {
|
||||
PersonalInfoCard,
|
||||
AddressCard,
|
||||
VerificationCard,
|
||||
ProfileLoadingSkeleton,
|
||||
} from "@/features/account/components";
|
||||
|
||||
export default function ProfileContainer() {
|
||||
const { user } = useAuthStore();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editingProfile, setEditingProfile] = useState(false);
|
||||
const [editingAddress, setEditingAddress] = useState(false);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
|
||||
const profile = useProfileEdit({
|
||||
const { isLoading, error, reload, profile, address } = useProfileDataLoading({
|
||||
email: user?.email || "",
|
||||
phonenumber: user?.phonenumber || "",
|
||||
});
|
||||
|
||||
const address = useAddressEdit({
|
||||
address1: "",
|
||||
address2: "",
|
||||
city: "",
|
||||
state: "",
|
||||
postcode: "",
|
||||
country: "",
|
||||
countryCode: "",
|
||||
phoneNumber: "",
|
||||
phoneCountryCode: "",
|
||||
const verificationQuery = useResidenceCardVerification();
|
||||
const fileUpload = useVerificationFileUpload({
|
||||
verificationStatus: verificationQuery.data?.status,
|
||||
});
|
||||
|
||||
// ID Verification status from Salesforce
|
||||
const verificationQuery = useResidenceCardVerification();
|
||||
const submitResidenceCard = useSubmitResidenceCard();
|
||||
const verificationStatus = verificationQuery.data?.status;
|
||||
const [verificationFile, setVerificationFile] = useState<File | null>(null);
|
||||
const verificationFileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const canUploadVerification = verificationStatus !== "verified";
|
||||
|
||||
// Helper to render verification status pill
|
||||
const renderVerificationStatusPill = () => {
|
||||
if (verificationQuery.isLoading) {
|
||||
return <Skeleton className="h-6 w-20" />;
|
||||
}
|
||||
switch (verificationStatus) {
|
||||
case "verified":
|
||||
return <StatusPill label="Verified" variant="success" />;
|
||||
case "pending":
|
||||
return <StatusPill label="Under Review" variant="info" />;
|
||||
case "rejected":
|
||||
return <StatusPill label="Action Needed" variant="warning" />;
|
||||
default:
|
||||
return <StatusPill label="Required for SIM" variant="warning" />;
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to render verification content based on status
|
||||
const renderVerificationContent = () => {
|
||||
if (verificationQuery.isLoading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (verificationStatus === "verified") {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your identity has been verified. No further action is needed.
|
||||
</p>
|
||||
{verificationQuery.data?.reviewedAt && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Verified on{" "}
|
||||
{formatIsoDate(verificationQuery.data.reviewedAt, { dateStyle: "medium" })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (verificationStatus === "pending") {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<AlertBanner variant="info" title="Under review" size="sm" elevated>
|
||||
Your residence card has been submitted. We'll verify it before activating SIM
|
||||
service.
|
||||
</AlertBanner>
|
||||
{verificationQuery.data?.submittedAt && (
|
||||
<div className="rounded-lg border border-border bg-muted/30 px-4 py-3">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Submission status
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
Submitted on{" "}
|
||||
{formatIsoDate(verificationQuery.data.submittedAt, { dateStyle: "medium" })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Default: rejected or not submitted
|
||||
return null;
|
||||
};
|
||||
|
||||
// Extract stable setValue functions to avoid infinite re-render loop.
|
||||
// The hook objects (address, profile) are recreated every render, but
|
||||
// the setValue callbacks inside them are stable (memoized with useCallback).
|
||||
const setAddressValue = address.setValue;
|
||||
const setProfileValue = profile.setValue;
|
||||
|
||||
const loadProfile = useCallback(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
const [addr, prof] = await Promise.all([
|
||||
accountService.getAddress().catch(() => null),
|
||||
accountService.getProfile().catch(() => null),
|
||||
]);
|
||||
if (addr) {
|
||||
setAddressValue("address1", addr.address1 ?? "");
|
||||
setAddressValue("address2", addr.address2 ?? "");
|
||||
setAddressValue("city", addr.city ?? "");
|
||||
setAddressValue("state", addr.state ?? "");
|
||||
setAddressValue("postcode", addr.postcode ?? "");
|
||||
setAddressValue("country", addr.country ?? "");
|
||||
setAddressValue("countryCode", addr.countryCode ?? "");
|
||||
setAddressValue("phoneNumber", addr.phoneNumber ?? "");
|
||||
setAddressValue("phoneCountryCode", addr.phoneCountryCode ?? "");
|
||||
}
|
||||
if (prof) {
|
||||
setProfileValue("email", prof.email || "");
|
||||
setProfileValue("phonenumber", prof.phonenumber || "");
|
||||
// Spread all profile fields into the user state, including sfNumber, dateOfBirth, gender
|
||||
useAuthStore.setState(state => ({
|
||||
...state,
|
||||
user: state.user
|
||||
? {
|
||||
...state.user,
|
||||
...prof,
|
||||
}
|
||||
: prof,
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
// Keep message customer-safe (no internal details)
|
||||
setError(e instanceof Error ? e.message : "Failed to load profile data");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [setAddressValue, setProfileValue]);
|
||||
|
||||
useEffect(() => {
|
||||
loadProfile();
|
||||
}, [loadProfile, reloadKey]);
|
||||
|
||||
if (loading) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageLayout
|
||||
icon={<UserIcon />}
|
||||
@ -189,69 +40,7 @@ export default function ProfileContainer() {
|
||||
description="Manage your account information"
|
||||
loading
|
||||
>
|
||||
<div className="space-y-8">
|
||||
<div className="bg-card border border-border rounded-xl shadow-[var(--cp-shadow-1)]">
|
||||
<div className="px-6 py-5 border-b border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="h-6 w-6 bg-muted rounded" />
|
||||
<div className="h-6 w-40 bg-muted rounded" />
|
||||
</div>
|
||||
<div className="h-8 w-20 bg-muted rounded" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
))}
|
||||
<div className="sm:col-span-2">
|
||||
<Skeleton className="h-4 w-28 mb-3" />
|
||||
<div className="bg-card rounded-lg p-4 border border-border shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-5 w-48" />
|
||||
<Skeleton className="h-5 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-3 w-64 mt-2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end space-x-3 pt-6 border-t border-border mt-6">
|
||||
<Skeleton className="h-9 w-24" />
|
||||
<Skeleton className="h-9 w-28" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-card border border-border rounded-xl shadow-[var(--cp-shadow-1)]">
|
||||
<div className="px-6 py-5 border-b border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="h-6 w-6 bg-muted rounded" />
|
||||
<div className="h-6 w-48 bg-muted rounded" />
|
||||
</div>
|
||||
<div className="h-8 w-20 bg-muted rounded" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="bg-card rounded-lg p-4 border border-border shadow-sm">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-60" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-4 w-52" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end space-x-3 pt-6">
|
||||
<Skeleton className="h-9 w-24" />
|
||||
<Skeleton className="h-9 w-28" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ProfileLoadingSkeleton />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@ -262,7 +51,7 @@ export default function ProfileContainer() {
|
||||
title="Profile"
|
||||
description="Manage your account information"
|
||||
error={error}
|
||||
onRetry={() => setReloadKey(k => k + 1)}
|
||||
onRetry={reload}
|
||||
>
|
||||
{error && (
|
||||
<AlertBanner variant="error" title="Unable to load profile" className="mb-6" elevated>
|
||||
@ -270,435 +59,76 @@ export default function ProfileContainer() {
|
||||
</AlertBanner>
|
||||
)}
|
||||
|
||||
<div className="bg-card text-card-foreground rounded-xl border border-border shadow-[var(--cp-shadow-1)]">
|
||||
<div className="px-6 py-5 border-b border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<UserIcon className="h-6 w-6 text-primary" />
|
||||
<h2 className="text-xl font-semibold text-foreground">Personal Information</h2>
|
||||
</div>
|
||||
{!editingProfile && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setEditingProfile(true)}
|
||||
leftIcon={<PencilIcon className="h-4 w-4" />}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<PersonalInfoCard
|
||||
data={{
|
||||
firstname: user?.firstname ?? null,
|
||||
lastname: user?.lastname ?? null,
|
||||
email: user?.email ?? "",
|
||||
phonenumber: user?.phonenumber ?? null,
|
||||
sfNumber: user?.sfNumber ?? null,
|
||||
dateOfBirth: user?.dateOfBirth ?? null,
|
||||
gender: user?.gender ?? null,
|
||||
}}
|
||||
editEmail={profile.values.email ?? ""}
|
||||
editPhoneNumber={profile.values.phonenumber ?? ""}
|
||||
isEditing={editingProfile}
|
||||
isSaving={profile.isSubmitting}
|
||||
onEdit={() => setEditingProfile(true)}
|
||||
onCancel={() => setEditingProfile(false)}
|
||||
onChange={(field, value) => profile.setValue(field, value)}
|
||||
onSave={() => {
|
||||
void profile
|
||||
.handleSubmit()
|
||||
.then(() => {
|
||||
setEditingProfile(false);
|
||||
})
|
||||
.catch(() => {
|
||||
// Error is handled by useZodForm
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-muted-foreground mb-2">
|
||||
First Name
|
||||
</label>
|
||||
<div className="bg-card rounded-lg p-4 border border-border shadow-sm">
|
||||
<p className="text-base text-foreground font-medium">
|
||||
{user?.firstname || (
|
||||
<span className="text-muted-foreground italic">Not provided</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Name cannot be changed from the portal.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-muted-foreground mb-2">
|
||||
Last Name
|
||||
</label>
|
||||
<div className="bg-card rounded-lg p-4 border border-border shadow-sm">
|
||||
<p className="text-base text-foreground font-medium">
|
||||
{user?.lastname || (
|
||||
<span className="text-muted-foreground italic">Not provided</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Name cannot be changed from the portal.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-sm font-medium text-muted-foreground mb-2">
|
||||
Email Address
|
||||
</label>
|
||||
{editingProfile ? (
|
||||
<input
|
||||
type="email"
|
||||
value={profile.values.email}
|
||||
onChange={e => profile.setValue("email", e.target.value)}
|
||||
className="block w-full px-4 py-2.5 border border-input rounded-lg bg-background text-foreground shadow-[var(--cp-shadow-1)] focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring transition-colors"
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-card rounded-lg p-4 border border-border shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-base text-foreground font-medium">{user?.email}</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Email can be updated from the portal.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<AddressCard
|
||||
address={{
|
||||
address1: address.values.address1,
|
||||
address2: address.values.address2,
|
||||
city: address.values.city,
|
||||
state: address.values.state,
|
||||
postcode: address.values.postcode,
|
||||
country: address.values.country,
|
||||
countryCode: address.values.countryCode,
|
||||
phoneNumber: address.values.phoneNumber,
|
||||
phoneCountryCode: address.values.phoneCountryCode,
|
||||
}}
|
||||
isEditing={editingAddress}
|
||||
isSaving={address.isSubmitting}
|
||||
error={address.submitError}
|
||||
onEdit={() => setEditingAddress(true)}
|
||||
onCancel={() => setEditingAddress(false)}
|
||||
onAddressChange={a => {
|
||||
address.setValue("address1", a.address1 ?? "");
|
||||
address.setValue("address2", a.address2 ?? "");
|
||||
address.setValue("city", a.city ?? "");
|
||||
address.setValue("state", a.state ?? "");
|
||||
address.setValue("postcode", a.postcode ?? "");
|
||||
address.setValue("country", a.country ?? "");
|
||||
address.setValue("countryCode", a.countryCode ?? "");
|
||||
address.setValue("phoneNumber", a.phoneNumber ?? "");
|
||||
address.setValue("phoneCountryCode", a.phoneCountryCode ?? "");
|
||||
}}
|
||||
onSave={() => {
|
||||
void address
|
||||
.handleSubmit()
|
||||
.then(() => {
|
||||
setEditingAddress(false);
|
||||
})
|
||||
.catch(() => {
|
||||
// Error is handled by useZodForm
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-muted-foreground mb-2">
|
||||
Customer Number
|
||||
</label>
|
||||
<div className="bg-card rounded-lg p-4 border border-border shadow-sm">
|
||||
<p className="text-base text-foreground font-medium">
|
||||
{user?.sfNumber || (
|
||||
<span className="text-muted-foreground italic">Not available</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">Customer number is read-only.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-muted-foreground mb-2">
|
||||
Date of Birth
|
||||
</label>
|
||||
<div className="bg-card rounded-lg p-4 border border-border shadow-sm">
|
||||
<p className="text-base text-foreground font-medium">
|
||||
{user?.dateOfBirth || (
|
||||
<span className="text-muted-foreground italic">Not provided</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Date of birth is stored in billing profile.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-muted-foreground mb-2">
|
||||
Phone Number
|
||||
</label>
|
||||
{editingProfile ? (
|
||||
<input
|
||||
type="tel"
|
||||
value={profile.values.phonenumber}
|
||||
onChange={e => profile.setValue("phonenumber", e.target.value)}
|
||||
placeholder="+81 XX-XXXX-XXXX"
|
||||
className="block w-full px-4 py-2.5 border border-input rounded-lg bg-background text-foreground shadow-[var(--cp-shadow-1)] focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring transition-colors"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-base text-foreground py-2">
|
||||
{user?.phonenumber || (
|
||||
<span className="text-muted-foreground italic">Not provided</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-muted-foreground mb-2">Gender</label>
|
||||
<div className="bg-card rounded-lg p-4 border border-border shadow-sm">
|
||||
<p className="text-base text-foreground font-medium">
|
||||
{user?.gender || (
|
||||
<span className="text-muted-foreground italic">Not provided</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Gender is stored in billing profile.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editingProfile && (
|
||||
<div className="flex items-center justify-end space-x-3 pt-6 border-t border-border mt-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setEditingProfile(false)}
|
||||
disabled={profile.isSubmitting}
|
||||
leftIcon={<XMarkIcon className="h-4 w-4" />}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void profile
|
||||
.handleSubmit()
|
||||
.then(() => {
|
||||
setEditingProfile(false);
|
||||
})
|
||||
.catch(() => {
|
||||
// Error is handled by useZodForm
|
||||
});
|
||||
}}
|
||||
isLoading={profile.isSubmitting}
|
||||
leftIcon={profile.isSubmitting ? undefined : <CheckIcon className="h-4 w-4" />}
|
||||
>
|
||||
{profile.isSubmitting ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-card text-card-foreground rounded-xl border border-border shadow-[var(--cp-shadow-1)]">
|
||||
<div className="px-6 py-5 border-b border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<MapPinIcon className="h-6 w-6 text-primary" />
|
||||
<h2 className="text-xl font-semibold text-foreground">Address Information</h2>
|
||||
</div>
|
||||
{!editingAddress && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setEditingAddress(true)}
|
||||
leftIcon={<PencilIcon className="h-4 w-4" />}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{editingAddress ? (
|
||||
<div className="space-y-6">
|
||||
<AddressForm
|
||||
initialAddress={{
|
||||
address1: address.values.address1,
|
||||
address2: address.values.address2,
|
||||
city: address.values.city,
|
||||
state: address.values.state,
|
||||
postcode: address.values.postcode,
|
||||
country: address.values.country,
|
||||
countryCode: address.values.countryCode,
|
||||
phoneNumber: address.values.phoneNumber,
|
||||
phoneCountryCode: address.values.phoneCountryCode,
|
||||
}}
|
||||
onChange={a => {
|
||||
address.setValue("address1", a.address1 ?? "");
|
||||
address.setValue("address2", a.address2 ?? "");
|
||||
address.setValue("city", a.city ?? "");
|
||||
address.setValue("state", a.state ?? "");
|
||||
address.setValue("postcode", a.postcode ?? "");
|
||||
address.setValue("country", a.country ?? "");
|
||||
address.setValue("countryCode", a.countryCode ?? "");
|
||||
address.setValue("phoneNumber", a.phoneNumber ?? "");
|
||||
address.setValue("phoneCountryCode", a.phoneCountryCode ?? "");
|
||||
}}
|
||||
title="Mailing Address"
|
||||
/>
|
||||
<div className="flex items-center justify-end space-x-3 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setEditingAddress(false)}
|
||||
disabled={address.isSubmitting}
|
||||
leftIcon={<XMarkIcon className="h-4 w-4" />}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void address
|
||||
.handleSubmit()
|
||||
.then(() => {
|
||||
setEditingAddress(false);
|
||||
})
|
||||
.catch(() => {
|
||||
// Error is handled by useZodForm
|
||||
});
|
||||
}}
|
||||
isLoading={address.isSubmitting}
|
||||
leftIcon={address.isSubmitting ? undefined : <CheckIcon className="h-4 w-4" />}
|
||||
>
|
||||
{address.isSubmitting ? "Saving..." : "Save Address"}
|
||||
</Button>
|
||||
</div>
|
||||
{address.submitError && (
|
||||
<AlertBanner variant="error" title="Address Error">
|
||||
{address.submitError}
|
||||
</AlertBanner>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{address.values.address1 || address.values.city ? (
|
||||
<div className="bg-card rounded-lg p-5 border border-border shadow-sm">
|
||||
<div className="text-foreground space-y-1.5">
|
||||
{(address.values.address2 || address.values.address1) && (
|
||||
<p className="font-medium text-base">
|
||||
{address.values.address2 || address.values.address1}
|
||||
</p>
|
||||
)}
|
||||
{address.values.address2 && address.values.address1 && (
|
||||
<p className="text-muted-foreground">{address.values.address1}</p>
|
||||
)}
|
||||
<p className="text-muted-foreground">
|
||||
{[address.values.city, address.values.state, address.values.postcode]
|
||||
.filter(Boolean)
|
||||
.join(", ")}
|
||||
</p>
|
||||
<p className="text-muted-foreground">{address.values.country}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<MapPinIcon className="h-12 w-12 text-muted-foreground/60 mx-auto mb-4" />
|
||||
<p className="text-muted-foreground mb-4">No address on file</p>
|
||||
<Button
|
||||
onClick={() => setEditingAddress(true)}
|
||||
leftIcon={<PencilIcon className="h-4 w-4" />}
|
||||
>
|
||||
Add Address
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ID Verification Card - Integrated Upload */}
|
||||
<div className="bg-card text-card-foreground rounded-xl border border-border shadow-[var(--cp-shadow-1)]">
|
||||
<div className="px-6 py-5 border-b border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<ShieldCheckIcon className="h-6 w-6 text-primary" />
|
||||
<h2 className="text-xl font-semibold text-foreground">Identity Verification</h2>
|
||||
</div>
|
||||
{renderVerificationStatusPill()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{renderVerificationContent()}
|
||||
|
||||
{/* Upload section for rejected or not submitted status */}
|
||||
{!verificationQuery.isLoading &&
|
||||
verificationStatus !== "verified" &&
|
||||
verificationStatus !== "pending" && (
|
||||
<div className="space-y-4">
|
||||
{verificationStatus === "rejected" ? (
|
||||
<AlertBanner variant="warning" title="Verification rejected" size="sm" elevated>
|
||||
<div className="space-y-2">
|
||||
{verificationQuery.data?.reviewerNotes && (
|
||||
<p>{verificationQuery.data.reviewerNotes}</p>
|
||||
)}
|
||||
<p>Please upload a new, clear photo or scan of your residence card.</p>
|
||||
<ul className="list-disc space-y-1 pl-5 text-sm text-muted-foreground">
|
||||
<li>Make sure all text is readable and the full card is visible.</li>
|
||||
<li>Avoid glare/reflections and blurry photos.</li>
|
||||
<li>Maximum file size: 5MB.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</AlertBanner>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Upload your residence card to activate SIM services. This is required for SIM
|
||||
orders.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(verificationQuery.data?.submittedAt || verificationQuery.data?.reviewedAt) && (
|
||||
<div className="rounded-lg border border-border bg-muted/30 px-4 py-3">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Latest submission
|
||||
</div>
|
||||
{verificationQuery.data?.submittedAt && (
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
Submitted on{" "}
|
||||
{formatIsoDate(verificationQuery.data.submittedAt, { dateStyle: "medium" })}
|
||||
</div>
|
||||
)}
|
||||
{verificationQuery.data?.reviewedAt && (
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
Reviewed on{" "}
|
||||
{formatIsoDate(verificationQuery.data.reviewedAt, { dateStyle: "medium" })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canUploadVerification && (
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
ref={verificationFileInputRef}
|
||||
type="file"
|
||||
accept="image/*,application/pdf"
|
||||
onChange={e => setVerificationFile(e.target.files?.[0] ?? null)}
|
||||
className="block w-full text-sm text-foreground file:mr-4 file:py-2 file:px-3 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-muted file:text-foreground hover:file:bg-muted/80"
|
||||
/>
|
||||
|
||||
{verificationFile && (
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/30 px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
Selected file
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground truncate">
|
||||
{verificationFile.name}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setVerificationFile(null);
|
||||
if (verificationFileInputRef.current) {
|
||||
verificationFileInputRef.current.value = "";
|
||||
}
|
||||
}}
|
||||
>
|
||||
Change
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!verificationFile || submitResidenceCard.isPending}
|
||||
isLoading={submitResidenceCard.isPending}
|
||||
loadingText="Uploading…"
|
||||
onClick={() => {
|
||||
if (!verificationFile) return;
|
||||
submitResidenceCard.mutate(verificationFile, {
|
||||
onSuccess: () => {
|
||||
setVerificationFile(null);
|
||||
if (verificationFileInputRef.current) {
|
||||
verificationFileInputRef.current.value = "";
|
||||
}
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
Submit Document
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{submitResidenceCard.isError && (
|
||||
<p className="text-sm text-destructive">
|
||||
{submitResidenceCard.error instanceof Error
|
||||
? submitResidenceCard.error.message
|
||||
: "Failed to submit residence card."}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Accepted formats: JPG, PNG, or PDF (max 5MB). Make sure all text is readable.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<VerificationCard verificationQuery={verificationQuery} fileUpload={fileUpload} />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/shared/utils";
|
||||
|
||||
interface AnimatedSectionProps {
|
||||
/** Whether to show the section */
|
||||
show: boolean;
|
||||
/** Content to animate */
|
||||
children: React.ReactNode;
|
||||
/** Delay in ms before animation starts (default: 0) */
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper component that provides smooth height and opacity transitions.
|
||||
* Uses CSS grid for smooth height animation.
|
||||
*/
|
||||
export function AnimatedSection({ show, children, delay = 0 }: AnimatedSectionProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid transition-all duration-500 ease-out",
|
||||
show ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
|
||||
)}
|
||||
style={{ transitionDelay: show ? `${delay}ms` : "0ms" }}
|
||||
>
|
||||
<div className="overflow-hidden">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
interface BilingualValueProps {
|
||||
/** Romanized (English) value */
|
||||
romaji: string;
|
||||
/** Japanese value (optional) */
|
||||
japanese?: string;
|
||||
/** Placeholder text when not verified */
|
||||
placeholder: string;
|
||||
/** Whether the address has been verified */
|
||||
verified: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a bilingual value with both romaji and Japanese text.
|
||||
* Shows placeholder when not verified.
|
||||
*/
|
||||
export function BilingualValue({ romaji, japanese, placeholder, verified }: BilingualValueProps) {
|
||||
if (!verified) {
|
||||
return <span className="text-muted-foreground/60 italic text-sm">{placeholder}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-foreground font-medium">{romaji}</span>
|
||||
{japanese && <span className="text-muted-foreground text-sm">({japanese})</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -12,18 +12,25 @@
|
||||
* - Compatible with WHMCS and Salesforce field mapping
|
||||
*/
|
||||
|
||||
import { useCallback, useState, useEffect, useRef } from "react";
|
||||
import { Home, Building2, CheckCircle2, MapPin, ChevronRight, Sparkles } from "lucide-react";
|
||||
import { Input } from "@/components/atoms";
|
||||
import { FormField } from "@/components/molecules/FormField/FormField";
|
||||
import { cn } from "@/shared/utils";
|
||||
import { ZipCodeInput } from "./ZipCodeInput";
|
||||
import { AnimatedSection } from "./AnimatedSection";
|
||||
import { ProgressIndicator } from "./ProgressIndicator";
|
||||
import { BilingualValue } from "./BilingualValue";
|
||||
import {
|
||||
type BilingualAddress,
|
||||
type JapanPostAddress,
|
||||
RESIDENCE_TYPE,
|
||||
type ResidenceType,
|
||||
} from "@customer-portal/domain/address";
|
||||
import { useJapanAddressForm } from "@/features/address/hooks";
|
||||
import {
|
||||
TOTAL_FORM_STEPS,
|
||||
isValidStreetAddress,
|
||||
getStreetAddressError,
|
||||
} from "@/features/address/utils";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
@ -33,8 +40,6 @@ export type JapanAddressFormData = BilingualAddress;
|
||||
|
||||
/**
|
||||
* Type for partial initial values that allows undefined residenceType.
|
||||
* This is needed because with exactOptionalPropertyTypes, Partial<T>
|
||||
* makes properties optional but doesn't allow explicitly setting undefined.
|
||||
*/
|
||||
type JapanAddressFormInitialValues = Omit<JapanAddressFormData, "residenceType"> & {
|
||||
residenceType?: ResidenceType | undefined;
|
||||
@ -60,140 +65,193 @@ export interface JapanAddressFormProps {
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Default Values
|
||||
// Step Header Component
|
||||
// ============================================================================
|
||||
|
||||
const DEFAULT_ADDRESS: Omit<JapanAddressFormData, "residenceType"> & {
|
||||
residenceType: ResidenceType | "";
|
||||
} = {
|
||||
postcode: "",
|
||||
prefecture: "",
|
||||
prefectureJa: "",
|
||||
city: "",
|
||||
cityJa: "",
|
||||
town: "",
|
||||
townJa: "",
|
||||
streetAddress: "",
|
||||
buildingName: "",
|
||||
roomNumber: "",
|
||||
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
|
||||
// ============================================================================
|
||||
|
||||
function AnimatedSection({
|
||||
show,
|
||||
children,
|
||||
delay = 0,
|
||||
function StepHeader({
|
||||
stepNumber,
|
||||
label,
|
||||
isComplete,
|
||||
badge,
|
||||
}: {
|
||||
show: boolean;
|
||||
children: React.ReactNode;
|
||||
delay?: number;
|
||||
stepNumber: number;
|
||||
label: string;
|
||||
isComplete: boolean;
|
||||
badge?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<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",
|
||||
isComplete ? "bg-success text-success-foreground" : "bg-primary/10 text-primary"
|
||||
)}
|
||||
>
|
||||
{isComplete ? <CheckCircle2 className="w-4 h-4" /> : stepNumber}
|
||||
</div>
|
||||
<span className="text-sm font-medium text-foreground">{label}</span>
|
||||
{badge}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Verified Address Display Component
|
||||
// ============================================================================
|
||||
|
||||
function VerifiedAddressDisplay({
|
||||
address,
|
||||
isVerified,
|
||||
}: {
|
||||
address: {
|
||||
prefecture: string;
|
||||
prefectureJa: string;
|
||||
city: string;
|
||||
cityJa: string;
|
||||
town: string;
|
||||
townJa: string;
|
||||
};
|
||||
isVerified: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid transition-all duration-500 ease-out",
|
||||
show ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
|
||||
"rounded-xl border transition-all duration-500",
|
||||
"bg-gradient-to-br from-success/5 via-success/[0.02] to-transparent",
|
||||
"border-success/20"
|
||||
)}
|
||||
style={{ transitionDelay: show ? `${delay}ms` : "0ms" }}
|
||||
>
|
||||
<div className="overflow-hidden">{children}</div>
|
||||
<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">
|
||||
<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={isVerified}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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={isVerified}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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={isVerified}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Progress Step Indicator
|
||||
// Residence Type Selector Component
|
||||
// ============================================================================
|
||||
|
||||
function ProgressIndicator({
|
||||
currentStep,
|
||||
totalSteps,
|
||||
function ResidenceTypeSelector({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
error,
|
||||
}: {
|
||||
currentStep: number;
|
||||
totalSteps: number;
|
||||
value: ResidenceType | "";
|
||||
onChange: (type: ResidenceType) => void;
|
||||
disabled: boolean;
|
||||
error?: string | undefined;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 mb-6">
|
||||
{Array.from({ length: totalSteps }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
<div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(RESIDENCE_TYPE.HOUSE)}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"h-1 rounded-full transition-all duration-500",
|
||||
i < currentStep
|
||||
? "bg-primary flex-[2]"
|
||||
: i === currentStep
|
||||
? "bg-primary/40 flex-[2] animate-pulse"
|
||||
: "bg-border flex-1"
|
||||
"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]",
|
||||
value === 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>
|
||||
);
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-12 h-12 rounded-xl flex items-center justify-center transition-all duration-300",
|
||||
value === 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",
|
||||
value === RESIDENCE_TYPE.HOUSE ? "text-primary" : "text-foreground"
|
||||
)}
|
||||
>
|
||||
House
|
||||
</span>
|
||||
</button>
|
||||
|
||||
// ============================================================================
|
||||
// Bilingual Field Display
|
||||
// ============================================================================
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(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]",
|
||||
value === 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",
|
||||
value === 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",
|
||||
value === RESIDENCE_TYPE.APARTMENT ? "text-primary" : "text-foreground"
|
||||
)}
|
||||
>
|
||||
Apartment
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
function BilingualValue({
|
||||
romaji,
|
||||
japanese,
|
||||
placeholder,
|
||||
verified,
|
||||
}: {
|
||||
romaji: string;
|
||||
japanese?: string;
|
||||
placeholder: string;
|
||||
verified: boolean;
|
||||
}) {
|
||||
if (!verified) {
|
||||
return <span className="text-muted-foreground/60 italic text-sm">{placeholder}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-foreground font-medium">{romaji}</span>
|
||||
{japanese && <span className="text-muted-foreground text-sm">({japanese})</span>}
|
||||
{error && <p className="text-sm text-danger mt-2">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -202,10 +260,6 @@ function BilingualValue({
|
||||
// Main Component
|
||||
// ============================================================================
|
||||
|
||||
type InternalFormState = Omit<JapanAddressFormData, "residenceType"> & {
|
||||
residenceType: ResidenceType | "";
|
||||
};
|
||||
|
||||
export function JapanAddressForm({
|
||||
initialValues,
|
||||
onChange,
|
||||
@ -216,229 +270,42 @@ export function JapanAddressForm({
|
||||
className,
|
||||
completionContent,
|
||||
}: JapanAddressFormProps) {
|
||||
const [address, setAddress] = useState<InternalFormState>(() => ({
|
||||
...DEFAULT_ADDRESS,
|
||||
...initialValues,
|
||||
// Convert undefined residenceType to empty string for internal state
|
||||
residenceType: initialValues?.residenceType ?? DEFAULT_ADDRESS.residenceType,
|
||||
}));
|
||||
const form = useJapanAddressForm({
|
||||
initialValues,
|
||||
onChange,
|
||||
errors,
|
||||
touched,
|
||||
disabled,
|
||||
});
|
||||
|
||||
const [isAddressVerified, setIsAddressVerified] = useState(false);
|
||||
const [verifiedZipCode, setVerifiedZipCode] = useState<string>("");
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
const streetAddressRef = useRef<HTMLInputElement>(null);
|
||||
const focusTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const hasInitializedRef = useRef(false);
|
||||
|
||||
// Calculate current step for progress
|
||||
const getCurrentStep = () => {
|
||||
if (!isAddressVerified) return 0;
|
||||
if (!address.streetAddress.trim()) return 1;
|
||||
if (!address.residenceType) return 2;
|
||||
if (address.residenceType === RESIDENCE_TYPE.APARTMENT && !address.roomNumber?.trim()) return 3;
|
||||
return 4;
|
||||
};
|
||||
|
||||
const currentStep = getCurrentStep();
|
||||
|
||||
// Only apply initialValues on first mount to avoid resetting user edits
|
||||
useEffect(() => {
|
||||
if (initialValues && !hasInitializedRef.current) {
|
||||
hasInitializedRef.current = true;
|
||||
setAddress(prev => ({
|
||||
...prev,
|
||||
...initialValues,
|
||||
// Convert undefined residenceType to empty string for internal state
|
||||
residenceType: initialValues.residenceType ?? prev.residenceType,
|
||||
}));
|
||||
if (initialValues.prefecture && initialValues.city && initialValues.town) {
|
||||
setIsAddressVerified(true);
|
||||
setVerifiedZipCode(initialValues.postcode || "");
|
||||
}
|
||||
}
|
||||
}, [initialValues]);
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (focusTimeoutRef.current) {
|
||||
clearTimeout(focusTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getError = (field: keyof JapanAddressFormData): string | undefined => {
|
||||
return touched[field] ? errors[field] : undefined;
|
||||
};
|
||||
|
||||
// Calculate form completion status
|
||||
const hasResidenceType =
|
||||
address.residenceType === RESIDENCE_TYPE.HOUSE ||
|
||||
address.residenceType === RESIDENCE_TYPE.APARTMENT;
|
||||
|
||||
const baseFieldsFilled =
|
||||
address.postcode.trim() !== "" &&
|
||||
address.prefecture.trim() !== "" &&
|
||||
address.city.trim() !== "" &&
|
||||
address.town.trim() !== "" &&
|
||||
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() ?? "") !== "";
|
||||
|
||||
// Building name is required for both houses and apartments
|
||||
const buildingNameOk = (address.buildingName?.trim() ?? "") !== "";
|
||||
|
||||
const isComplete =
|
||||
isAddressVerified && hasResidenceType && baseFieldsFilled && buildingNameOk && roomNumberOk;
|
||||
|
||||
// Notify parent of changes - only send valid typed address when residenceType is set
|
||||
useEffect(() => {
|
||||
if (hasResidenceType) {
|
||||
// Safe to cast since we verified residenceType is valid
|
||||
onChangeRef.current?.(address as JapanAddressFormData, isComplete);
|
||||
} else {
|
||||
// Send incomplete state with partial data (parent should check isComplete flag)
|
||||
onChangeRef.current?.(address as JapanAddressFormData, false);
|
||||
}
|
||||
}, [address, isAddressVerified, hasResidenceType, isComplete]);
|
||||
|
||||
// Manage success animation separately to avoid callback double-firing
|
||||
useEffect(() => {
|
||||
setShowSuccess(isComplete);
|
||||
}, [isComplete]);
|
||||
|
||||
const handleZipChange = useCallback(
|
||||
(value: string) => {
|
||||
const normalizedNew = value.replace(/-/g, "");
|
||||
const normalizedVerified = verifiedZipCode.replace(/-/g, "");
|
||||
const shouldReset = normalizedNew !== normalizedVerified;
|
||||
|
||||
if (shouldReset) {
|
||||
setIsAddressVerified(false);
|
||||
setShowSuccess(false);
|
||||
setAddress(prev => ({
|
||||
...prev,
|
||||
postcode: value,
|
||||
prefecture: "",
|
||||
prefectureJa: "",
|
||||
city: "",
|
||||
cityJa: "",
|
||||
town: "",
|
||||
townJa: "",
|
||||
buildingName: prev.buildingName,
|
||||
roomNumber: prev.roomNumber,
|
||||
residenceType: prev.residenceType,
|
||||
}));
|
||||
} else {
|
||||
setAddress(prev => ({ ...prev, postcode: value }));
|
||||
}
|
||||
},
|
||||
[verifiedZipCode]
|
||||
);
|
||||
|
||||
const handleAddressFound = useCallback((found: JapanPostAddress) => {
|
||||
setAddress(prev => {
|
||||
setIsAddressVerified(true);
|
||||
setVerifiedZipCode(prev.postcode);
|
||||
return {
|
||||
...prev,
|
||||
prefecture: found.prefectureRoma,
|
||||
city: found.cityRoma,
|
||||
town: found.townRoma,
|
||||
prefectureJa: found.prefecture,
|
||||
cityJa: found.city,
|
||||
townJa: found.town,
|
||||
};
|
||||
});
|
||||
|
||||
// Focus street address input after lookup (with cleanup tracking)
|
||||
if (focusTimeoutRef.current) {
|
||||
clearTimeout(focusTimeoutRef.current);
|
||||
}
|
||||
focusTimeoutRef.current = setTimeout(() => {
|
||||
streetAddressRef.current?.focus();
|
||||
focusTimeoutRef.current = null;
|
||||
}, 300);
|
||||
}, []);
|
||||
|
||||
const handleLookupComplete = useCallback((found: boolean) => {
|
||||
if (!found) {
|
||||
setIsAddressVerified(false);
|
||||
setAddress(prev => ({
|
||||
...prev,
|
||||
prefecture: "",
|
||||
prefectureJa: "",
|
||||
city: "",
|
||||
cityJa: "",
|
||||
town: "",
|
||||
townJa: "",
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleResidenceTypeChange = useCallback((type: ResidenceType) => {
|
||||
setAddress(prev => ({
|
||||
...prev,
|
||||
residenceType: type,
|
||||
roomNumber: type === RESIDENCE_TYPE.HOUSE ? "" : prev.roomNumber,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleStreetAddressChange = useCallback((value: string) => {
|
||||
setAddress(prev => ({ ...prev, streetAddress: value }));
|
||||
}, []);
|
||||
|
||||
const handleBuildingNameChange = useCallback((value: string) => {
|
||||
setAddress(prev => ({ ...prev, buildingName: value }));
|
||||
}, []);
|
||||
|
||||
const handleRoomNumberChange = useCallback((value: string) => {
|
||||
setAddress(prev => ({ ...prev, roomNumber: value }));
|
||||
}, []);
|
||||
|
||||
const isApartment = address.residenceType === RESIDENCE_TYPE.APARTMENT;
|
||||
const streetAddressError = getStreetAddressError(form.address.streetAddress);
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-6", className)}>
|
||||
{/* Progress Indicator */}
|
||||
<ProgressIndicator currentStep={currentStep} totalSteps={4} />
|
||||
<ProgressIndicator currentStep={form.completion.currentStep} totalSteps={TOTAL_FORM_STEPS} />
|
||||
|
||||
{/* Step 1: ZIP Code Lookup */}
|
||||
<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>
|
||||
<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>
|
||||
<StepHeader
|
||||
stepNumber={1}
|
||||
label="Enter ZIP Code"
|
||||
isComplete={form.isAddressVerified}
|
||||
badge={
|
||||
form.isAddressVerified && (
|
||||
<span className="text-xs text-success font-medium ml-auto flex items-center gap-1">
|
||||
<Sparkles className="w-3 h-3" />
|
||||
Verified
|
||||
</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<ZipCodeInput
|
||||
value={address.postcode}
|
||||
onChange={handleZipChange}
|
||||
onAddressFound={handleAddressFound}
|
||||
onLookupComplete={handleLookupComplete}
|
||||
error={getError("postcode")}
|
||||
value={form.address.postcode}
|
||||
onChange={form.handlers.handleZipChange}
|
||||
onAddressFound={form.handlers.handleAddressFound}
|
||||
onLookupComplete={form.handlers.handleLookupComplete}
|
||||
error={form.getError("postcode")}
|
||||
required
|
||||
disabled={disabled}
|
||||
autoFocus
|
||||
@ -446,90 +313,28 @@ export function JapanAddressForm({
|
||||
</div>
|
||||
|
||||
{/* Verified Address Display */}
|
||||
<AnimatedSection show={isAddressVerified}>
|
||||
<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>
|
||||
|
||||
{/* City */}
|
||||
<div className="flex items-center gap-3 py-2 px-3 rounded-lg bg-background/50">
|
||||
<span className="text-xs text-muted-foreground w-20 shrink-0">City / Ward</span>
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground/50" />
|
||||
<BilingualValue
|
||||
romaji={address.city}
|
||||
japanese={address.cityJa}
|
||||
placeholder="—"
|
||||
verified={isAddressVerified}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Town */}
|
||||
<div className="flex items-center gap-3 py-2 px-3 rounded-lg bg-background/50">
|
||||
<span className="text-xs text-muted-foreground w-20 shrink-0">Town</span>
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground/50" />
|
||||
<BilingualValue
|
||||
romaji={address.town}
|
||||
japanese={address.townJa}
|
||||
placeholder="—"
|
||||
verified={isAddressVerified}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AnimatedSection show={form.isAddressVerified}>
|
||||
<VerifiedAddressDisplay address={form.address} isVerified={form.isAddressVerified} />
|
||||
</AnimatedSection>
|
||||
|
||||
{/* Step 2: Street Address */}
|
||||
<AnimatedSection show={isAddressVerified} delay={100}>
|
||||
<AnimatedSection show={form.isAddressVerified} delay={100}>
|
||||
<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"
|
||||
)}
|
||||
>
|
||||
{isValidStreetAddress(address.streetAddress) ? (
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
) : (
|
||||
"2"
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm font-medium text-foreground">Street Address</span>
|
||||
</div>
|
||||
<StepHeader
|
||||
stepNumber={2}
|
||||
label="Street Address"
|
||||
isComplete={isValidStreetAddress(form.address.streetAddress)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label=""
|
||||
error={
|
||||
getError("streetAddress") || (address.streetAddress.trim() && streetAddressError)
|
||||
form.getError("streetAddress") ||
|
||||
(form.address.streetAddress.trim() && streetAddressError)
|
||||
}
|
||||
required
|
||||
helperText={
|
||||
address.streetAddress.trim()
|
||||
form.address.streetAddress.trim()
|
||||
? streetAddressError
|
||||
? undefined
|
||||
: "Valid format"
|
||||
@ -537,9 +342,9 @@ export function JapanAddressForm({
|
||||
}
|
||||
>
|
||||
<Input
|
||||
ref={streetAddressRef}
|
||||
value={address.streetAddress}
|
||||
onChange={e => handleStreetAddressChange(e.target.value)}
|
||||
ref={form.streetAddressRef}
|
||||
value={form.address.streetAddress}
|
||||
onChange={e => form.handlers.handleStreetAddressChange(e.target.value)}
|
||||
onBlur={() => onBlur?.("streetAddress")}
|
||||
placeholder="1-5-3"
|
||||
disabled={disabled}
|
||||
@ -552,149 +357,63 @@ export function JapanAddressForm({
|
||||
|
||||
{/* Step 3: Residence Type */}
|
||||
<AnimatedSection
|
||||
show={isAddressVerified && isValidStreetAddress(address.streetAddress)}
|
||||
show={form.isAddressVerified && isValidStreetAddress(form.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>
|
||||
<StepHeader
|
||||
stepNumber={3}
|
||||
label="Residence Type"
|
||||
isComplete={form.completion.hasResidenceType}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleResidenceTypeChange(RESIDENCE_TYPE.HOUSE)}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"group relative flex flex-col items-center gap-2 p-4 rounded-xl border-2 transition-all duration-300",
|
||||
"hover:scale-[1.02] active:scale-[0.98]",
|
||||
address.residenceType === RESIDENCE_TYPE.HOUSE
|
||||
? "border-primary bg-primary/5 shadow-[0_0_0_4px] shadow-primary/10"
|
||||
: "border-border bg-card hover:border-primary/50 hover:bg-primary/[0.02]",
|
||||
disabled && "opacity-50 cursor-not-allowed hover:scale-100"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-12 h-12 rounded-xl flex items-center justify-center transition-all duration-300",
|
||||
address.residenceType === RESIDENCE_TYPE.HOUSE
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground group-hover:bg-primary/10 group-hover:text-primary"
|
||||
)}
|
||||
>
|
||||
<Home className="w-6 h-6" />
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-semibold transition-colors",
|
||||
address.residenceType === RESIDENCE_TYPE.HOUSE
|
||||
? "text-primary"
|
||||
: "text-foreground"
|
||||
)}
|
||||
>
|
||||
House
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleResidenceTypeChange(RESIDENCE_TYPE.APARTMENT)}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"group relative flex flex-col items-center gap-2 p-4 rounded-xl border-2 transition-all duration-300",
|
||||
"hover:scale-[1.02] active:scale-[0.98]",
|
||||
address.residenceType === RESIDENCE_TYPE.APARTMENT
|
||||
? "border-primary bg-primary/5 shadow-[0_0_0_4px] shadow-primary/10"
|
||||
: "border-border bg-card hover:border-primary/50 hover:bg-primary/[0.02]",
|
||||
disabled && "opacity-50 cursor-not-allowed hover:scale-100"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-12 h-12 rounded-xl flex items-center justify-center transition-all duration-300",
|
||||
address.residenceType === RESIDENCE_TYPE.APARTMENT
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground group-hover:bg-primary/10 group-hover:text-primary"
|
||||
)}
|
||||
>
|
||||
<Building2 className="w-6 h-6" />
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-semibold transition-colors",
|
||||
address.residenceType === RESIDENCE_TYPE.APARTMENT
|
||||
? "text-primary"
|
||||
: "text-foreground"
|
||||
)}
|
||||
>
|
||||
Apartment
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!hasResidenceType && getError("residenceType") && (
|
||||
<p className="text-sm text-danger mt-2">{getError("residenceType")}</p>
|
||||
)}
|
||||
<ResidenceTypeSelector
|
||||
value={form.address.residenceType}
|
||||
onChange={form.handlers.handleResidenceTypeChange}
|
||||
disabled={disabled}
|
||||
error={form.completion.hasResidenceType ? undefined : form.getError("residenceType")}
|
||||
/>
|
||||
</div>
|
||||
</AnimatedSection>
|
||||
|
||||
{/* Step 4: Building Details */}
|
||||
<AnimatedSection show={isAddressVerified && hasResidenceType} delay={200}>
|
||||
<AnimatedSection
|
||||
show={form.isAddressVerified && form.completion.hasResidenceType}
|
||||
delay={200}
|
||||
>
|
||||
<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>
|
||||
<StepHeader stepNumber={4} label="Building Details" isComplete={form.showSuccess} />
|
||||
|
||||
{/* Building Name */}
|
||||
<FormField
|
||||
label="Building Name"
|
||||
error={getError("buildingName")}
|
||||
error={form.getError("buildingName")}
|
||||
required
|
||||
helperText={
|
||||
isApartment
|
||||
form.isApartment
|
||||
? "e.g., Sunshine Mansion (サンシャインマンション)"
|
||||
: "e.g., Tanaka Residence (田中邸)"
|
||||
}
|
||||
>
|
||||
<Input
|
||||
value={address.buildingName ?? ""}
|
||||
onChange={e => handleBuildingNameChange(e.target.value)}
|
||||
value={form.address.buildingName ?? ""}
|
||||
onChange={e => form.handlers.handleBuildingNameChange(e.target.value)}
|
||||
onBlur={() => onBlur?.("buildingName")}
|
||||
placeholder={isApartment ? "Sunshine Mansion" : "Tanaka Residence"}
|
||||
placeholder={form.isApartment ? "Sunshine Mansion" : "Tanaka Residence"}
|
||||
disabled={disabled}
|
||||
data-field="address.buildingName"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* Room Number - Only for apartments */}
|
||||
{isApartment && (
|
||||
{form.isApartment && (
|
||||
<FormField
|
||||
label="Room Number"
|
||||
error={getError("roomNumber")}
|
||||
error={form.getError("roomNumber")}
|
||||
required
|
||||
helperText="Required for apartments (部屋番号)"
|
||||
>
|
||||
<Input
|
||||
value={address.roomNumber ?? ""}
|
||||
onChange={e => handleRoomNumberChange(e.target.value)}
|
||||
value={form.address.roomNumber ?? ""}
|
||||
onChange={e => form.handlers.handleRoomNumberChange(e.target.value)}
|
||||
onBlur={() => onBlur?.("roomNumber")}
|
||||
placeholder="201"
|
||||
disabled={disabled}
|
||||
@ -706,8 +425,8 @@ export function JapanAddressForm({
|
||||
</div>
|
||||
</AnimatedSection>
|
||||
|
||||
{/* Success State - shows custom content or default message */}
|
||||
<AnimatedSection show={showSuccess} delay={250}>
|
||||
{/* Success State */}
|
||||
<AnimatedSection show={form.showSuccess} delay={250}>
|
||||
{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">
|
||||
|
||||
@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/shared/utils";
|
||||
|
||||
interface ProgressIndicatorProps {
|
||||
/** Current step (0-indexed) */
|
||||
currentStep: number;
|
||||
/** Total number of steps */
|
||||
totalSteps: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress step indicator showing completed, current, and remaining steps.
|
||||
* Steps are rendered as horizontal bars with different states.
|
||||
*/
|
||||
export function ProgressIndicator({ currentStep, totalSteps }: ProgressIndicatorProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 mb-6">
|
||||
{Array.from({ length: totalSteps }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"h-1 rounded-full transition-all duration-500",
|
||||
i < currentStep
|
||||
? "bg-primary flex-[2]"
|
||||
: i === currentStep
|
||||
? "bg-primary/40 flex-[2] animate-pulse"
|
||||
: "bg-border flex-1"
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -5,3 +5,6 @@ export {
|
||||
type JapanAddressFormData,
|
||||
} from "./JapanAddressForm";
|
||||
export { AddressStepJapan } from "./AddressStepJapan";
|
||||
export { AnimatedSection } from "./AnimatedSection";
|
||||
export { ProgressIndicator } from "./ProgressIndicator";
|
||||
export { BilingualValue } from "./BilingualValue";
|
||||
|
||||
@ -4,3 +4,5 @@ export {
|
||||
getFirstAddress,
|
||||
EMPTY_LOOKUP_RESULT,
|
||||
} from "./useAddressLookup";
|
||||
export { useAddressCompletion } from "./useAddressCompletion";
|
||||
export { useJapanAddressForm } from "./useJapanAddressForm";
|
||||
|
||||
@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { RESIDENCE_TYPE, type ResidenceType } from "@customer-portal/domain/address";
|
||||
import { isValidStreetAddress } from "@/features/address/utils";
|
||||
|
||||
interface AddressState {
|
||||
postcode: string;
|
||||
prefecture: string;
|
||||
city: string;
|
||||
town: string;
|
||||
streetAddress: string;
|
||||
buildingName?: string | null | undefined;
|
||||
roomNumber?: string | null | undefined;
|
||||
residenceType: ResidenceType | "";
|
||||
}
|
||||
|
||||
interface UseAddressCompletionOptions {
|
||||
/** Current address state */
|
||||
address: AddressState;
|
||||
/** Whether the address has been verified via ZIP lookup */
|
||||
isAddressVerified: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that calculates address completion state.
|
||||
* Returns flags for each completion condition and the current step.
|
||||
*/
|
||||
export function useAddressCompletion({ address, isAddressVerified }: UseAddressCompletionOptions) {
|
||||
return useMemo(() => {
|
||||
// Has valid residence type selected
|
||||
const hasResidenceType =
|
||||
address.residenceType === RESIDENCE_TYPE.HOUSE ||
|
||||
address.residenceType === RESIDENCE_TYPE.APARTMENT;
|
||||
|
||||
// All base fields are filled
|
||||
const baseFieldsFilled =
|
||||
address.postcode.trim() !== "" &&
|
||||
address.prefecture.trim() !== "" &&
|
||||
address.city.trim() !== "" &&
|
||||
address.town.trim() !== "" &&
|
||||
isValidStreetAddress(address.streetAddress);
|
||||
|
||||
// Room number is OK (not required for houses, required for apartments)
|
||||
const roomNumberOk =
|
||||
address.residenceType !== RESIDENCE_TYPE.APARTMENT ||
|
||||
(address.roomNumber?.trim() ?? "") !== "";
|
||||
|
||||
// Building name is required for both houses and apartments
|
||||
const buildingNameOk = (address.buildingName?.trim() ?? "") !== "";
|
||||
|
||||
// Overall completion
|
||||
const isComplete =
|
||||
isAddressVerified && hasResidenceType && baseFieldsFilled && buildingNameOk && roomNumberOk;
|
||||
|
||||
// Calculate current step (0-4)
|
||||
const getCurrentStep = (): number => {
|
||||
if (!isAddressVerified) return 0;
|
||||
if (!address.streetAddress.trim()) return 1;
|
||||
if (!address.residenceType) return 2;
|
||||
if (address.residenceType === RESIDENCE_TYPE.APARTMENT && !address.roomNumber?.trim())
|
||||
return 3;
|
||||
return 4;
|
||||
};
|
||||
|
||||
return {
|
||||
/** Whether all fields are complete */
|
||||
isComplete,
|
||||
/** Whether a valid residence type is selected */
|
||||
hasResidenceType,
|
||||
/** Whether all base fields (postcode, prefecture, city, town, street) are filled */
|
||||
baseFieldsFilled,
|
||||
/** Whether room number requirement is satisfied */
|
||||
roomNumberOk,
|
||||
/** Whether building name is filled */
|
||||
buildingNameOk,
|
||||
/** Current step in the form (0-4) */
|
||||
currentStep: getCurrentStep(),
|
||||
};
|
||||
}, [address, isAddressVerified]);
|
||||
}
|
||||
231
apps/portal/src/features/address/hooks/useJapanAddressForm.ts
Normal file
231
apps/portal/src/features/address/hooks/useJapanAddressForm.ts
Normal file
@ -0,0 +1,231 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
RESIDENCE_TYPE,
|
||||
type BilingualAddress,
|
||||
type JapanPostAddress,
|
||||
type ResidenceType,
|
||||
} from "@customer-portal/domain/address";
|
||||
import { DEFAULT_ADDRESS } from "@/features/address/utils";
|
||||
import { useAddressCompletion } from "./useAddressCompletion";
|
||||
|
||||
/**
|
||||
* Type for partial initial values that allows undefined residenceType.
|
||||
*/
|
||||
type InitialValues = Omit<BilingualAddress, "residenceType"> & {
|
||||
residenceType?: ResidenceType | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Internal form state type where residenceType can be empty string.
|
||||
*/
|
||||
type InternalFormState = Omit<BilingualAddress, "residenceType"> & {
|
||||
residenceType: ResidenceType | "";
|
||||
};
|
||||
|
||||
interface UseJapanAddressFormOptions {
|
||||
/** Initial address values */
|
||||
initialValues?: Partial<InitialValues> | undefined;
|
||||
/** Callback when address changes */
|
||||
onChange?: ((address: BilingualAddress, isComplete: boolean) => void) | undefined;
|
||||
/** Field-level errors */
|
||||
errors?: Partial<Record<keyof BilingualAddress, string | undefined>> | undefined;
|
||||
/** Fields that have been touched */
|
||||
touched?: Partial<Record<keyof BilingualAddress, boolean | undefined>> | undefined;
|
||||
/** Whether the form is disabled */
|
||||
disabled?: boolean | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Comprehensive hook for managing Japan address form state.
|
||||
* Consolidates all state, handlers, and refs for the form.
|
||||
*/
|
||||
export function useJapanAddressForm({
|
||||
initialValues,
|
||||
onChange,
|
||||
errors = {},
|
||||
touched = {},
|
||||
}: UseJapanAddressFormOptions) {
|
||||
const [address, setAddress] = useState<InternalFormState>(() => ({
|
||||
...DEFAULT_ADDRESS,
|
||||
...initialValues,
|
||||
residenceType: initialValues?.residenceType ?? DEFAULT_ADDRESS.residenceType,
|
||||
}));
|
||||
|
||||
const [isAddressVerified, setIsAddressVerified] = useState(false);
|
||||
const [verifiedZipCode, setVerifiedZipCode] = useState<string>("");
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
const streetAddressRef = useRef<HTMLInputElement>(null);
|
||||
const focusTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const hasInitializedRef = useRef(false);
|
||||
|
||||
// Completion calculation
|
||||
const completion = useAddressCompletion({ address, isAddressVerified });
|
||||
|
||||
// Only apply initialValues on first mount to avoid resetting user edits
|
||||
useEffect(() => {
|
||||
if (initialValues && !hasInitializedRef.current) {
|
||||
hasInitializedRef.current = true;
|
||||
setAddress(prev => ({
|
||||
...prev,
|
||||
...initialValues,
|
||||
residenceType: initialValues.residenceType ?? prev.residenceType,
|
||||
}));
|
||||
if (initialValues.prefecture && initialValues.city && initialValues.town) {
|
||||
setIsAddressVerified(true);
|
||||
setVerifiedZipCode(initialValues.postcode || "");
|
||||
}
|
||||
}
|
||||
}, [initialValues]);
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (focusTimeoutRef.current) {
|
||||
clearTimeout(focusTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Notify parent of changes
|
||||
useEffect(() => {
|
||||
if (completion.hasResidenceType) {
|
||||
onChangeRef.current?.(address as BilingualAddress, completion.isComplete);
|
||||
} else {
|
||||
onChangeRef.current?.(address as BilingualAddress, false);
|
||||
}
|
||||
}, [address, completion.hasResidenceType, completion.isComplete]);
|
||||
|
||||
// Manage success animation separately
|
||||
useEffect(() => {
|
||||
setShowSuccess(completion.isComplete);
|
||||
}, [completion.isComplete]);
|
||||
|
||||
const getError = useCallback(
|
||||
(field: keyof BilingualAddress): string | undefined => {
|
||||
return touched[field] ? errors[field] : undefined;
|
||||
},
|
||||
[errors, touched]
|
||||
);
|
||||
|
||||
const handleZipChange = useCallback(
|
||||
(value: string) => {
|
||||
const normalizedNew = value.replace(/-/g, "");
|
||||
const normalizedVerified = verifiedZipCode.replace(/-/g, "");
|
||||
const shouldReset = normalizedNew !== normalizedVerified;
|
||||
|
||||
if (shouldReset) {
|
||||
setIsAddressVerified(false);
|
||||
setShowSuccess(false);
|
||||
setAddress(prev => ({
|
||||
...prev,
|
||||
postcode: value,
|
||||
prefecture: "",
|
||||
prefectureJa: "",
|
||||
city: "",
|
||||
cityJa: "",
|
||||
town: "",
|
||||
townJa: "",
|
||||
buildingName: prev.buildingName,
|
||||
roomNumber: prev.roomNumber,
|
||||
residenceType: prev.residenceType,
|
||||
}));
|
||||
} else {
|
||||
setAddress(prev => ({ ...prev, postcode: value }));
|
||||
}
|
||||
},
|
||||
[verifiedZipCode]
|
||||
);
|
||||
|
||||
const handleAddressFound = useCallback((found: JapanPostAddress) => {
|
||||
setAddress(prev => {
|
||||
setIsAddressVerified(true);
|
||||
setVerifiedZipCode(prev.postcode);
|
||||
return {
|
||||
...prev,
|
||||
prefecture: found.prefectureRoma,
|
||||
city: found.cityRoma,
|
||||
town: found.townRoma,
|
||||
prefectureJa: found.prefecture,
|
||||
cityJa: found.city,
|
||||
townJa: found.town,
|
||||
};
|
||||
});
|
||||
|
||||
// Focus street address input after lookup
|
||||
if (focusTimeoutRef.current) {
|
||||
clearTimeout(focusTimeoutRef.current);
|
||||
}
|
||||
focusTimeoutRef.current = setTimeout(() => {
|
||||
streetAddressRef.current?.focus();
|
||||
focusTimeoutRef.current = null;
|
||||
}, 300);
|
||||
}, []);
|
||||
|
||||
const handleLookupComplete = useCallback((found: boolean) => {
|
||||
if (!found) {
|
||||
setIsAddressVerified(false);
|
||||
setAddress(prev => ({
|
||||
...prev,
|
||||
prefecture: "",
|
||||
prefectureJa: "",
|
||||
city: "",
|
||||
cityJa: "",
|
||||
town: "",
|
||||
townJa: "",
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleResidenceTypeChange = useCallback((type: ResidenceType) => {
|
||||
setAddress(prev => ({
|
||||
...prev,
|
||||
residenceType: type,
|
||||
roomNumber: type === RESIDENCE_TYPE.HOUSE ? "" : prev.roomNumber,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleStreetAddressChange = useCallback((value: string) => {
|
||||
setAddress(prev => ({ ...prev, streetAddress: value }));
|
||||
}, []);
|
||||
|
||||
const handleBuildingNameChange = useCallback((value: string) => {
|
||||
setAddress(prev => ({ ...prev, buildingName: value }));
|
||||
}, []);
|
||||
|
||||
const handleRoomNumberChange = useCallback((value: string) => {
|
||||
setAddress(prev => ({ ...prev, roomNumber: value }));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
/** Current address state */
|
||||
address,
|
||||
/** Whether the address has been verified via ZIP lookup */
|
||||
isAddressVerified,
|
||||
/** Whether to show success state */
|
||||
showSuccess,
|
||||
/** Completion state including current step */
|
||||
completion,
|
||||
/** Ref for street address input (for auto-focus) */
|
||||
streetAddressRef,
|
||||
/** Get error for a field if touched */
|
||||
getError,
|
||||
/** Whether the residence type is apartment */
|
||||
isApartment: address.residenceType === RESIDENCE_TYPE.APARTMENT,
|
||||
/** Handlers */
|
||||
handlers: {
|
||||
handleZipChange,
|
||||
handleAddressFound,
|
||||
handleLookupComplete,
|
||||
handleResidenceTypeChange,
|
||||
handleStreetAddressChange,
|
||||
handleBuildingNameChange,
|
||||
handleRoomNumberChange,
|
||||
},
|
||||
};
|
||||
}
|
||||
2
apps/portal/src/features/address/utils/index.ts
Normal file
2
apps/portal/src/features/address/utils/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { TOTAL_FORM_STEPS, DEFAULT_ADDRESS } from "./japan-address.constants";
|
||||
export { isValidStreetAddress, getStreetAddressError } from "./street-address.validation";
|
||||
@ -0,0 +1,41 @@
|
||||
import type { ResidenceType } from "@customer-portal/domain/address";
|
||||
|
||||
/**
|
||||
* Total number of steps in the Japan address form.
|
||||
* Used by ProgressIndicator component.
|
||||
*/
|
||||
export const TOTAL_FORM_STEPS = 4;
|
||||
|
||||
/**
|
||||
* Default address values for form initialization.
|
||||
*/
|
||||
export const DEFAULT_ADDRESS: Omit<
|
||||
{
|
||||
postcode: string;
|
||||
prefecture: string;
|
||||
prefectureJa: string;
|
||||
city: string;
|
||||
cityJa: string;
|
||||
town: string;
|
||||
townJa: string;
|
||||
streetAddress: string;
|
||||
buildingName: string;
|
||||
roomNumber: string;
|
||||
residenceType: ResidenceType | "";
|
||||
},
|
||||
"residenceType"
|
||||
> & {
|
||||
residenceType: ResidenceType | "";
|
||||
} = {
|
||||
postcode: "",
|
||||
prefecture: "",
|
||||
prefectureJa: "",
|
||||
city: "",
|
||||
cityJa: "",
|
||||
town: "",
|
||||
townJa: "",
|
||||
streetAddress: "",
|
||||
buildingName: "",
|
||||
roomNumber: "",
|
||||
residenceType: "",
|
||||
};
|
||||
@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Street Address Validation Utilities
|
||||
*
|
||||
* Validates Japanese street address format (chome-ban-go system).
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
export 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns validation error message for street address.
|
||||
* Returns undefined if valid.
|
||||
*/
|
||||
export 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;
|
||||
}
|
||||
@ -1,13 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
|
||||
import { PageLayout } from "@/components/templates/PageLayout";
|
||||
import { SubCard } from "@/components/molecules/SubCard/SubCard";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||
import { InlineToast } from "@/components/atoms/inline-toast";
|
||||
import { AddressConfirmation } from "@/features/services/components/base/AddressConfirmation";
|
||||
import { useCheckoutStore } from "@/features/checkout/stores/checkout.store";
|
||||
@ -16,29 +14,33 @@ import { usePaymentMethods } from "@/features/billing/hooks/useBilling";
|
||||
import { usePaymentRefresh } from "@/features/billing/hooks/usePaymentRefresh";
|
||||
import { billingService } from "@/features/billing/api/billing.api";
|
||||
import { openSsoLink } from "@/features/billing/utils/sso";
|
||||
import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscriptions";
|
||||
import { ACTIVE_INTERNET_SUBSCRIPTION_WARNING } from "@/features/checkout/constants";
|
||||
import {
|
||||
useInternetEligibility,
|
||||
useRequestInternetEligibilityCheck,
|
||||
} from "@/features/services/hooks/useInternetEligibility";
|
||||
import {
|
||||
useResidenceCardVerification,
|
||||
useSubmitResidenceCard,
|
||||
} from "@/features/verification/hooks/useResidenceCardVerification";
|
||||
import { useAuthSession } from "@/features/auth/stores/auth.store";
|
||||
import {
|
||||
ORDER_TYPE,
|
||||
type OrderTypeValue,
|
||||
toOrderTypeValueFromCheckout,
|
||||
} from "@customer-portal/domain/orders";
|
||||
import { toOrderTypeValueFromCheckout, type OrderTypeValue } from "@customer-portal/domain/orders";
|
||||
import { buildPaymentMethodDisplay, formatAddressLabel } from "@/shared/utils";
|
||||
|
||||
import { CheckoutStatusBanners } from "./CheckoutStatusBanners";
|
||||
import {
|
||||
PaymentMethodSection,
|
||||
IdentityVerificationSection,
|
||||
OrderSubmitSection,
|
||||
} from "./checkout-sections";
|
||||
import { CheckoutErrorFallback } from "./CheckoutErrorFallback";
|
||||
import {
|
||||
useCheckoutEligibility,
|
||||
useCheckoutFormState,
|
||||
useCheckoutToast,
|
||||
useCanSubmit,
|
||||
} from "@/features/checkout/hooks";
|
||||
import {
|
||||
buildConfigureBackUrl,
|
||||
buildVerificationRedirectUrl,
|
||||
buildOrderSuccessUrl,
|
||||
getShopHref,
|
||||
} from "@/features/checkout/utils";
|
||||
|
||||
export function AccountCheckoutContainer() {
|
||||
const router = useRouter();
|
||||
@ -48,32 +50,26 @@ export function AccountCheckoutContainer() {
|
||||
|
||||
const { cartItem, checkoutSessionId, clear } = useCheckoutStore();
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [addressConfirmed, setAddressConfirmed] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [openingPaymentPortal, setOpeningPaymentPortal] = useState(false);
|
||||
const paymentToastTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
const orderType: OrderTypeValue | null = useMemo(() => {
|
||||
return toOrderTypeValueFromCheckout(cartItem?.orderType);
|
||||
}, [cartItem?.orderType]);
|
||||
|
||||
const isInternetOrder = orderType === ORDER_TYPE.INTERNET;
|
||||
// Form state management
|
||||
const {
|
||||
formState,
|
||||
confirmAddress,
|
||||
unconfirmAddress,
|
||||
startSubmitting,
|
||||
stopSubmitting,
|
||||
setError,
|
||||
startOpeningPortal,
|
||||
stopOpeningPortal,
|
||||
} = useCheckoutFormState();
|
||||
|
||||
// Active subscriptions check
|
||||
const { data: activeSubs } = useActiveSubscriptions();
|
||||
const hasActiveInternetSubscription = useMemo(() => {
|
||||
if (!Array.isArray(activeSubs)) return false;
|
||||
return activeSubs.some(
|
||||
subscription =>
|
||||
String(subscription.groupName || subscription.productName || "")
|
||||
.toLowerCase()
|
||||
.includes("internet") && String(subscription.status || "").toLowerCase() === "active"
|
||||
);
|
||||
}, [activeSubs]);
|
||||
|
||||
const activeInternetWarning =
|
||||
isInternetOrder && hasActiveInternetSubscription ? ACTIVE_INTERNET_SUBSCRIPTION_WARNING : null;
|
||||
// Eligibility management
|
||||
const { eligibility, eligibilityRequest, activeInternetWarning } = useCheckoutEligibility({
|
||||
orderType,
|
||||
});
|
||||
|
||||
// Payment methods
|
||||
const {
|
||||
@ -88,6 +84,8 @@ export function AccountCheckoutContainer() {
|
||||
attachFocusListeners: false,
|
||||
});
|
||||
|
||||
const { showToast } = useCheckoutToast({ setToast: paymentRefresh.setToast });
|
||||
|
||||
const paymentMethodList = paymentMethods?.paymentMethods ?? [];
|
||||
const hasPaymentMethod = paymentMethodList.length > 0;
|
||||
const defaultPaymentMethod =
|
||||
@ -96,34 +94,6 @@ export function AccountCheckoutContainer() {
|
||||
? buildPaymentMethodDisplay(defaultPaymentMethod)
|
||||
: null;
|
||||
|
||||
// Eligibility
|
||||
const eligibilityQuery = useInternetEligibility({ enabled: isInternetOrder });
|
||||
const eligibilityData = eligibilityQuery.data as
|
||||
| { eligibility?: string; status?: string; requestedAt?: string; notes?: string }
|
||||
| null
|
||||
| undefined;
|
||||
const eligibilityValue = eligibilityData?.eligibility;
|
||||
const eligibilityStatus = eligibilityData?.status;
|
||||
const eligibilityRequestedAt = eligibilityData?.requestedAt;
|
||||
const eligibilityNotes = eligibilityData?.notes;
|
||||
const eligibilityRequest = useRequestInternetEligibilityCheck();
|
||||
const eligibilityLoading = Boolean(isInternetOrder && eligibilityQuery.isLoading);
|
||||
const eligibilityNotRequested = Boolean(
|
||||
isInternetOrder && eligibilityQuery.isSuccess && eligibilityStatus === "not_requested"
|
||||
);
|
||||
const eligibilityPending = Boolean(
|
||||
isInternetOrder && eligibilityQuery.isSuccess && eligibilityStatus === "pending"
|
||||
);
|
||||
const eligibilityIneligible = Boolean(
|
||||
isInternetOrder && eligibilityQuery.isSuccess && eligibilityStatus === "ineligible"
|
||||
);
|
||||
const eligibilityError = Boolean(isInternetOrder && eligibilityQuery.isError);
|
||||
const isEligible =
|
||||
!isInternetOrder ||
|
||||
(eligibilityStatus === "eligible" &&
|
||||
typeof eligibilityValue === "string" &&
|
||||
eligibilityValue.trim().length > 0);
|
||||
|
||||
// Address
|
||||
const hasServiceAddress = Boolean(
|
||||
user?.address?.address1 &&
|
||||
@ -139,29 +109,13 @@ export function AccountCheckoutContainer() {
|
||||
const residenceStatus = residenceCardQuery.data?.status;
|
||||
const residenceSubmitted = residenceStatus === "pending" || residenceStatus === "verified";
|
||||
|
||||
// Toast handler
|
||||
const showPaymentToast = useCallback(
|
||||
(text: string, tone: "info" | "success" | "warning" | "error") => {
|
||||
if (paymentToastTimeoutRef.current) {
|
||||
clearTimeout(paymentToastTimeoutRef.current);
|
||||
paymentToastTimeoutRef.current = null;
|
||||
}
|
||||
paymentRefresh.setToast({ visible: true, text, tone });
|
||||
paymentToastTimeoutRef.current = window.setTimeout(() => {
|
||||
paymentRefresh.setToast(current => ({ ...current, visible: false }));
|
||||
paymentToastTimeoutRef.current = null;
|
||||
}, 2200);
|
||||
},
|
||||
[paymentRefresh]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (paymentToastTimeoutRef.current) {
|
||||
clearTimeout(paymentToastTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
// Can submit calculation
|
||||
const canSubmit = useCanSubmit(formState.addressConfirmed, {
|
||||
paymentMethodsLoading,
|
||||
hasPaymentMethod,
|
||||
residenceSubmitted,
|
||||
eligibility,
|
||||
});
|
||||
|
||||
const formatDateTime = useCallback((iso?: string | null) => {
|
||||
if (!iso) return null;
|
||||
@ -173,57 +127,48 @@ export function AccountCheckoutContainer() {
|
||||
}, []);
|
||||
|
||||
const navigateBackToConfigure = useCallback(() => {
|
||||
const params = new URLSearchParams(searchParams?.toString() ?? "");
|
||||
const type = (params.get("type") ?? "").toLowerCase();
|
||||
params.delete("type");
|
||||
const planSku = params.get("planSku")?.trim();
|
||||
if (!planSku) {
|
||||
params.delete("planSku");
|
||||
}
|
||||
|
||||
if (type === "sim") {
|
||||
router.push(`/account/services/sim/configure?${params.toString()}`);
|
||||
return;
|
||||
}
|
||||
if (type === "internet" || type === "") {
|
||||
router.push(`/account/services/internet/configure?${params.toString()}`);
|
||||
return;
|
||||
}
|
||||
router.push("/account/services");
|
||||
router.push(buildConfigureBackUrl(searchParams));
|
||||
}, [router, searchParams]);
|
||||
|
||||
const handleSubmitOrder = useCallback(async () => {
|
||||
setSubmitError(null);
|
||||
setError(null);
|
||||
if (!checkoutSessionId) {
|
||||
setSubmitError("Checkout session expired. Please restart checkout from the shop.");
|
||||
setError("Checkout session expired. Please restart checkout from the shop.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
startSubmitting();
|
||||
const result = await ordersService.createOrderFromCheckoutSession(checkoutSessionId);
|
||||
clear();
|
||||
router.push(`/account/orders/${encodeURIComponent(result.sfOrderId)}?status=success`);
|
||||
router.push(buildOrderSuccessUrl(result.sfOrderId));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Order submission failed";
|
||||
if (
|
||||
message.toLowerCase().includes("residence card submission required") ||
|
||||
message.toLowerCase().includes("residence card submission was rejected")
|
||||
) {
|
||||
const queryString = searchParams?.toString();
|
||||
const next = pathname + (queryString ? `?${queryString}` : "");
|
||||
router.push(`/account/settings/verification?returnTo=${encodeURIComponent(next)}`);
|
||||
router.push(buildVerificationRedirectUrl(pathname, searchParams));
|
||||
return;
|
||||
}
|
||||
setSubmitError(message);
|
||||
setError(message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
stopSubmitting();
|
||||
}
|
||||
}, [checkoutSessionId, clear, pathname, router, searchParams]);
|
||||
}, [
|
||||
checkoutSessionId,
|
||||
clear,
|
||||
pathname,
|
||||
router,
|
||||
searchParams,
|
||||
setError,
|
||||
startSubmitting,
|
||||
stopSubmitting,
|
||||
]);
|
||||
|
||||
const handleManagePayment = useCallback(async () => {
|
||||
if (openingPaymentPortal) return;
|
||||
setOpeningPaymentPortal(true);
|
||||
if (formState.openingPaymentPortal) return;
|
||||
startOpeningPortal();
|
||||
|
||||
try {
|
||||
const data = await billingService.createPaymentMethodsSsoLink();
|
||||
@ -233,11 +178,11 @@ export function AccountCheckoutContainer() {
|
||||
openSsoLink(data.url, { newTab: true });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unable to open the payment portal";
|
||||
showPaymentToast(message, "error");
|
||||
showToast(message, "error");
|
||||
} finally {
|
||||
setOpeningPaymentPortal(false);
|
||||
stopOpeningPortal();
|
||||
}
|
||||
}, [openingPaymentPortal, showPaymentToast]);
|
||||
}, [formState.openingPaymentPortal, showToast, startOpeningPortal, stopOpeningPortal]);
|
||||
|
||||
const handleSubmitResidenceCard = useCallback(
|
||||
(file: File) => {
|
||||
@ -246,34 +191,9 @@ export function AccountCheckoutContainer() {
|
||||
[submitResidenceCard]
|
||||
);
|
||||
|
||||
// Calculate if form can be submitted
|
||||
const canSubmit =
|
||||
addressConfirmed &&
|
||||
!paymentMethodsLoading &&
|
||||
hasPaymentMethod &&
|
||||
residenceSubmitted &&
|
||||
isEligible &&
|
||||
!eligibilityLoading &&
|
||||
!eligibilityPending &&
|
||||
!eligibilityNotRequested &&
|
||||
!eligibilityIneligible &&
|
||||
!eligibilityError;
|
||||
|
||||
// Error state - no cart item
|
||||
if (!cartItem || !orderType) {
|
||||
const shopHref = pathname.startsWith("/account") ? "/account/services" : "/services";
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto py-8">
|
||||
<AlertBanner variant="error" title="Checkout Error" elevated>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Checkout data is not available</span>
|
||||
<Button as="a" href={shopHref} variant="link">
|
||||
Back to Services
|
||||
</Button>
|
||||
</div>
|
||||
</AlertBanner>
|
||||
</div>
|
||||
);
|
||||
return <CheckoutErrorFallback shopHref={getShopHref(pathname)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
@ -292,14 +212,14 @@ export function AccountCheckoutContainer() {
|
||||
<CheckoutStatusBanners
|
||||
activeInternetWarning={activeInternetWarning}
|
||||
eligibility={{
|
||||
isLoading: eligibilityLoading,
|
||||
isError: eligibilityError,
|
||||
isPending: eligibilityPending,
|
||||
isNotRequested: eligibilityNotRequested,
|
||||
isIneligible: eligibilityIneligible,
|
||||
notes: eligibilityNotes ?? null,
|
||||
requestedAt: eligibilityRequestedAt ?? null,
|
||||
refetch: () => void eligibilityQuery.refetch(),
|
||||
isLoading: eligibility.isLoading,
|
||||
isError: eligibility.isError,
|
||||
isPending: eligibility.isPending,
|
||||
isNotRequested: eligibility.isNotRequested,
|
||||
isIneligible: eligibility.isIneligible,
|
||||
notes: eligibility.notes,
|
||||
requestedAt: eligibility.requestedAt,
|
||||
refetch: eligibility.refetch,
|
||||
}}
|
||||
eligibilityRequest={{
|
||||
isPending: eligibilityRequest.isPending,
|
||||
@ -330,8 +250,8 @@ export function AccountCheckoutContainer() {
|
||||
<SubCard>
|
||||
<AddressConfirmation
|
||||
embedded
|
||||
onAddressConfirmed={() => setAddressConfirmed(true)}
|
||||
onAddressIncomplete={() => setAddressConfirmed(false)}
|
||||
onAddressConfirmed={confirmAddress}
|
||||
onAddressIncomplete={unconfirmAddress}
|
||||
orderType={orderType}
|
||||
/>
|
||||
</SubCard>
|
||||
@ -343,7 +263,7 @@ export function AccountCheckoutContainer() {
|
||||
paymentMethodDisplay={paymentMethodDisplay}
|
||||
onManagePayment={() => void handleManagePayment()}
|
||||
onRefresh={() => void paymentRefresh.triggerRefresh()}
|
||||
isOpeningPortal={openingPaymentPortal}
|
||||
isOpeningPortal={formState.openingPaymentPortal}
|
||||
/>
|
||||
|
||||
<IdentityVerificationSection
|
||||
@ -368,8 +288,8 @@ export function AccountCheckoutContainer() {
|
||||
|
||||
<OrderSubmitSection
|
||||
pricing={cartItem.pricing}
|
||||
submitError={submitError}
|
||||
isSubmitting={submitting}
|
||||
submitError={formState.submitError}
|
||||
isSubmitting={formState.submitting}
|
||||
canSubmit={canSubmit}
|
||||
onSubmit={() => void handleSubmitOrder()}
|
||||
onBack={navigateBackToConfigure}
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
|
||||
interface CheckoutErrorFallbackProps {
|
||||
/** The shop href to navigate back to */
|
||||
shopHref: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error fallback displayed when checkout data is not available.
|
||||
* Shows an error banner with a link back to services.
|
||||
*/
|
||||
export function CheckoutErrorFallback({ shopHref }: CheckoutErrorFallbackProps) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto py-8">
|
||||
<AlertBanner variant="error" title="Checkout Error" elevated>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Checkout data is not available</span>
|
||||
<Button as="a" href={shopHref} variant="link">
|
||||
Back to Services
|
||||
</Button>
|
||||
</div>
|
||||
</AlertBanner>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -4,3 +4,4 @@ export { OrderConfirmation } from "./OrderConfirmation";
|
||||
export { CheckoutErrorBoundary } from "./CheckoutErrorBoundary";
|
||||
export { CheckoutEntry } from "./CheckoutEntry";
|
||||
export { AccountCheckoutContainer } from "./AccountCheckoutContainer";
|
||||
export { CheckoutErrorFallback } from "./CheckoutErrorFallback";
|
||||
|
||||
3
apps/portal/src/features/checkout/hooks/index.ts
Normal file
3
apps/portal/src/features/checkout/hooks/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { useCheckoutEligibility } from "./useCheckoutEligibility";
|
||||
export { useCheckoutFormState, useCanSubmit } from "./useCheckoutFormState";
|
||||
export { useCheckoutToast } from "./useCheckoutToast";
|
||||
@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
useInternetEligibility,
|
||||
useRequestInternetEligibilityCheck,
|
||||
} from "@/features/services/hooks/useInternetEligibility";
|
||||
import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscriptions";
|
||||
import { ACTIVE_INTERNET_SUBSCRIPTION_WARNING } from "@/features/checkout/constants";
|
||||
import type { OrderTypeValue } from "@customer-portal/domain/orders";
|
||||
import { ORDER_TYPE } from "@customer-portal/domain/orders";
|
||||
|
||||
interface UseCheckoutEligibilityOptions {
|
||||
orderType: OrderTypeValue | null;
|
||||
}
|
||||
|
||||
interface EligibilityData {
|
||||
eligibility?: string;
|
||||
status?: string;
|
||||
requestedAt?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that consolidates all eligibility-related state for checkout.
|
||||
* Handles internet eligibility checks and active subscription warnings.
|
||||
*/
|
||||
export function useCheckoutEligibility({ orderType }: UseCheckoutEligibilityOptions) {
|
||||
const isInternetOrder = orderType === ORDER_TYPE.INTERNET;
|
||||
|
||||
// Active subscriptions check
|
||||
const { data: activeSubs } = useActiveSubscriptions();
|
||||
const hasActiveInternetSubscription = useMemo(() => {
|
||||
if (!Array.isArray(activeSubs)) return false;
|
||||
return activeSubs.some(
|
||||
subscription =>
|
||||
String(subscription.groupName || subscription.productName || "")
|
||||
.toLowerCase()
|
||||
.includes("internet") && String(subscription.status || "").toLowerCase() === "active"
|
||||
);
|
||||
}, [activeSubs]);
|
||||
|
||||
const activeInternetWarning =
|
||||
isInternetOrder && hasActiveInternetSubscription ? ACTIVE_INTERNET_SUBSCRIPTION_WARNING : null;
|
||||
|
||||
// Internet eligibility
|
||||
const eligibilityQuery = useInternetEligibility({ enabled: isInternetOrder });
|
||||
const eligibilityData = eligibilityQuery.data as EligibilityData | null | undefined;
|
||||
const eligibilityValue = eligibilityData?.eligibility;
|
||||
const eligibilityStatus = eligibilityData?.status;
|
||||
const eligibilityRequestedAt = eligibilityData?.requestedAt;
|
||||
const eligibilityNotes = eligibilityData?.notes;
|
||||
const eligibilityRequest = useRequestInternetEligibilityCheck();
|
||||
|
||||
// Derived eligibility states
|
||||
const isLoading = Boolean(isInternetOrder && eligibilityQuery.isLoading);
|
||||
const isError = Boolean(isInternetOrder && eligibilityQuery.isError);
|
||||
const isNotRequested = Boolean(
|
||||
isInternetOrder && eligibilityQuery.isSuccess && eligibilityStatus === "not_requested"
|
||||
);
|
||||
const isPending = Boolean(
|
||||
isInternetOrder && eligibilityQuery.isSuccess && eligibilityStatus === "pending"
|
||||
);
|
||||
const isIneligible = Boolean(
|
||||
isInternetOrder && eligibilityQuery.isSuccess && eligibilityStatus === "ineligible"
|
||||
);
|
||||
const isEligible =
|
||||
!isInternetOrder ||
|
||||
(eligibilityStatus === "eligible" &&
|
||||
typeof eligibilityValue === "string" &&
|
||||
eligibilityValue.trim().length > 0);
|
||||
|
||||
return {
|
||||
/** Consolidated eligibility state */
|
||||
eligibility: {
|
||||
isLoading,
|
||||
isError,
|
||||
isPending,
|
||||
isNotRequested,
|
||||
isIneligible,
|
||||
isEligible,
|
||||
notes: eligibilityNotes ?? null,
|
||||
requestedAt: eligibilityRequestedAt ?? null,
|
||||
refetch: () => void eligibilityQuery.refetch(),
|
||||
},
|
||||
/** Eligibility request mutation */
|
||||
eligibilityRequest,
|
||||
/** Whether user has active internet subscription */
|
||||
hasActiveInternetSubscription,
|
||||
/** Warning message if user has active internet subscription and is ordering internet */
|
||||
activeInternetWarning,
|
||||
};
|
||||
}
|
||||
135
apps/portal/src/features/checkout/hooks/useCheckoutFormState.ts
Normal file
135
apps/portal/src/features/checkout/hooks/useCheckoutFormState.ts
Normal file
@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
|
||||
interface CanSubmitDependencies {
|
||||
/** Whether payment methods are still loading */
|
||||
paymentMethodsLoading: boolean;
|
||||
/** Whether user has at least one payment method */
|
||||
hasPaymentMethod: boolean;
|
||||
/** Whether residence card has been submitted (pending or verified) */
|
||||
residenceSubmitted: boolean;
|
||||
/** Eligibility state */
|
||||
eligibility: {
|
||||
isLoading: boolean;
|
||||
isPending: boolean;
|
||||
isNotRequested: boolean;
|
||||
isIneligible: boolean;
|
||||
isError: boolean;
|
||||
isEligible: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that consolidates checkout form state.
|
||||
* Manages submission state, address confirmation, errors, and portal opening state.
|
||||
*/
|
||||
export function useCheckoutFormState() {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [addressConfirmed, setAddressConfirmed] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [openingPaymentPortal, setOpeningPaymentPortal] = useState(false);
|
||||
|
||||
const confirmAddress = useCallback(() => {
|
||||
setAddressConfirmed(true);
|
||||
}, []);
|
||||
|
||||
const unconfirmAddress = useCallback(() => {
|
||||
setAddressConfirmed(false);
|
||||
}, []);
|
||||
|
||||
const startSubmitting = useCallback(() => {
|
||||
setSubmitError(null);
|
||||
setSubmitting(true);
|
||||
}, []);
|
||||
|
||||
const stopSubmitting = useCallback(() => {
|
||||
setSubmitting(false);
|
||||
}, []);
|
||||
|
||||
const setError = useCallback((error: string | null) => {
|
||||
setSubmitError(error);
|
||||
}, []);
|
||||
|
||||
const startOpeningPortal = useCallback(() => {
|
||||
setOpeningPaymentPortal(true);
|
||||
}, []);
|
||||
|
||||
const stopOpeningPortal = useCallback(() => {
|
||||
setOpeningPaymentPortal(false);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Computes whether the form can be submitted based on all dependencies.
|
||||
*/
|
||||
const computeCanSubmit = useCallback((deps: CanSubmitDependencies) => {
|
||||
return (
|
||||
deps.eligibility.isEligible &&
|
||||
!deps.paymentMethodsLoading &&
|
||||
deps.hasPaymentMethod &&
|
||||
deps.residenceSubmitted &&
|
||||
!deps.eligibility.isLoading &&
|
||||
!deps.eligibility.isPending &&
|
||||
!deps.eligibility.isNotRequested &&
|
||||
!deps.eligibility.isIneligible &&
|
||||
!deps.eligibility.isError
|
||||
);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
/** Form state values */
|
||||
formState: {
|
||||
submitting,
|
||||
addressConfirmed,
|
||||
submitError,
|
||||
openingPaymentPortal,
|
||||
},
|
||||
/** Address confirmation handlers */
|
||||
confirmAddress,
|
||||
unconfirmAddress,
|
||||
/** Submission state handlers */
|
||||
startSubmitting,
|
||||
stopSubmitting,
|
||||
setError,
|
||||
/** Payment portal state handlers */
|
||||
startOpeningPortal,
|
||||
stopOpeningPortal,
|
||||
/** Compute if form can be submitted */
|
||||
computeCanSubmit,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that provides a memoized canSubmit value given dependencies.
|
||||
* Use this when you want to reactively compute canSubmit based on changing dependencies.
|
||||
*/
|
||||
export function useCanSubmit(
|
||||
addressConfirmed: boolean,
|
||||
deps: Omit<CanSubmitDependencies, "addressConfirmed"> & { addressConfirmed?: never }
|
||||
) {
|
||||
return useMemo(
|
||||
() =>
|
||||
addressConfirmed &&
|
||||
deps.eligibility.isEligible &&
|
||||
!deps.paymentMethodsLoading &&
|
||||
deps.hasPaymentMethod &&
|
||||
deps.residenceSubmitted &&
|
||||
!deps.eligibility.isLoading &&
|
||||
!deps.eligibility.isPending &&
|
||||
!deps.eligibility.isNotRequested &&
|
||||
!deps.eligibility.isIneligible &&
|
||||
!deps.eligibility.isError,
|
||||
[
|
||||
addressConfirmed,
|
||||
deps.eligibility.isEligible,
|
||||
deps.eligibility.isLoading,
|
||||
deps.eligibility.isPending,
|
||||
deps.eligibility.isNotRequested,
|
||||
deps.eligibility.isIneligible,
|
||||
deps.eligibility.isError,
|
||||
deps.paymentMethodsLoading,
|
||||
deps.hasPaymentMethod,
|
||||
deps.residenceSubmitted,
|
||||
]
|
||||
);
|
||||
}
|
||||
70
apps/portal/src/features/checkout/hooks/useCheckoutToast.ts
Normal file
70
apps/portal/src/features/checkout/hooks/useCheckoutToast.ts
Normal file
@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
type ToastTone = "info" | "success" | "warning" | "error";
|
||||
|
||||
interface ToastState {
|
||||
visible: boolean;
|
||||
text: string;
|
||||
tone: ToastTone;
|
||||
}
|
||||
|
||||
type SetToastFn = (state: ToastState | ((current: ToastState) => ToastState)) => void;
|
||||
|
||||
interface UseCheckoutToastOptions {
|
||||
/** External setToast function (e.g., from usePaymentRefresh) */
|
||||
setToast: SetToastFn;
|
||||
/** Duration in ms before toast auto-hides (default: 2200) */
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that encapsulates toast timing logic with auto-hide.
|
||||
* Wraps an external setToast function and manages timeout cleanup.
|
||||
*/
|
||||
export function useCheckoutToast({ setToast, duration = 2200 }: UseCheckoutToastOptions) {
|
||||
const timeoutRef = useRef<number | null>(null);
|
||||
|
||||
const clearTimeoutRef = useCallback(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Show a toast with auto-hide after duration.
|
||||
*/
|
||||
const showToast = useCallback(
|
||||
(text: string, tone: ToastTone) => {
|
||||
clearTimeoutRef();
|
||||
setToast({ visible: true, text, tone });
|
||||
timeoutRef.current = window.setTimeout(() => {
|
||||
setToast(current => ({ ...current, visible: false }));
|
||||
timeoutRef.current = null;
|
||||
}, duration);
|
||||
},
|
||||
[clearTimeoutRef, setToast, duration]
|
||||
);
|
||||
|
||||
/**
|
||||
* Immediately hide the toast.
|
||||
*/
|
||||
const hideToast = useCallback(() => {
|
||||
clearTimeoutRef();
|
||||
setToast(current => ({ ...current, visible: false }));
|
||||
}, [clearTimeoutRef, setToast]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTimeoutRef();
|
||||
};
|
||||
}, [clearTimeoutRef]);
|
||||
|
||||
return {
|
||||
showToast,
|
||||
hideToast,
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Checkout Navigation Utilities
|
||||
*
|
||||
* Provides URL building functions for checkout flow navigation.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Builds the URL to navigate back to the configure page from checkout.
|
||||
* Preserves query parameters except 'type'.
|
||||
*/
|
||||
export function buildConfigureBackUrl(searchParams: URLSearchParams | null): string {
|
||||
const params = new URLSearchParams(searchParams?.toString() ?? "");
|
||||
const type = (params.get("type") ?? "").toLowerCase();
|
||||
params.delete("type");
|
||||
const planSku = params.get("planSku")?.trim();
|
||||
if (!planSku) {
|
||||
params.delete("planSku");
|
||||
}
|
||||
|
||||
const queryString = params.toString();
|
||||
const query = queryString ? `?${queryString}` : "";
|
||||
|
||||
if (type === "sim") {
|
||||
return `/account/services/sim/configure${query}`;
|
||||
}
|
||||
if (type === "internet" || type === "") {
|
||||
return `/account/services/internet/configure${query}`;
|
||||
}
|
||||
return "/account/services";
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the URL to redirect to verification page, preserving the return path.
|
||||
*/
|
||||
export function buildVerificationRedirectUrl(
|
||||
pathname: string,
|
||||
searchParams: URLSearchParams | null
|
||||
): string {
|
||||
const queryString = searchParams?.toString();
|
||||
const next = pathname + (queryString ? `?${queryString}` : "");
|
||||
return `/account/settings/verification?returnTo=${encodeURIComponent(next)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the URL to navigate to order success page.
|
||||
*/
|
||||
export function buildOrderSuccessUrl(sfOrderId: string): string {
|
||||
return `/account/orders/${encodeURIComponent(sfOrderId)}?status=success`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the shop href based on pathname prefix.
|
||||
*/
|
||||
export function getShopHref(pathname: string): string {
|
||||
return pathname.startsWith("/account") ? "/account/services" : "/services";
|
||||
}
|
||||
6
apps/portal/src/features/checkout/utils/index.ts
Normal file
6
apps/portal/src/features/checkout/utils/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export {
|
||||
buildConfigureBackUrl,
|
||||
buildVerificationRedirectUrl,
|
||||
buildOrderSuccessUrl,
|
||||
getShopHref,
|
||||
} from "./checkout-navigation";
|
||||
@ -2,3 +2,4 @@ export {
|
||||
useResidenceCardVerification,
|
||||
useSubmitResidenceCard,
|
||||
} from "./useResidenceCardVerification";
|
||||
export { useVerificationFileUpload } from "./useVerificationFileUpload";
|
||||
|
||||
@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useSubmitResidenceCard } from "./useResidenceCardVerification";
|
||||
import type { ResidenceCardVerificationStatus } from "@customer-portal/domain/customer";
|
||||
|
||||
interface UseVerificationFileUploadOptions {
|
||||
/** Current verification status - upload is disabled when verified */
|
||||
verificationStatus?: ResidenceCardVerificationStatus | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that encapsulates residence card file upload state and logic.
|
||||
* Composes with useSubmitResidenceCard mutation internally.
|
||||
*/
|
||||
export function useVerificationFileUpload(options?: UseVerificationFileUploadOptions) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const submitMutation = useSubmitResidenceCard();
|
||||
|
||||
const canUpload = options?.verificationStatus !== "verified";
|
||||
|
||||
const handleFileChange = useCallback((selectedFile: File | null) => {
|
||||
setFile(selectedFile);
|
||||
}, []);
|
||||
|
||||
const clearFile = useCallback(() => {
|
||||
setFile(null);
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = "";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const submit = useCallback(() => {
|
||||
if (!file) return;
|
||||
submitMutation.mutate(file, {
|
||||
onSuccess: () => {
|
||||
setFile(null);
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = "";
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [file, submitMutation]);
|
||||
|
||||
return {
|
||||
/** Currently selected file */
|
||||
file,
|
||||
/** Ref to attach to the file input element */
|
||||
inputRef,
|
||||
/** Handler for file input change events */
|
||||
handleFileChange,
|
||||
/** Clear the selected file and reset the input */
|
||||
clearFile,
|
||||
/** Submit the selected file for verification */
|
||||
submit,
|
||||
/** Whether a submission is in progress */
|
||||
isSubmitting: submitMutation.isPending,
|
||||
/** Whether upload is allowed (not verified) */
|
||||
canUpload,
|
||||
/** Error from the last submission attempt */
|
||||
error: submitMutation.error,
|
||||
/** Whether the last submission failed */
|
||||
isError: submitMutation.isError,
|
||||
};
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user