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";
|
"use client";
|
||||||
|
|
||||||
import { SubCard } from "@/components/molecules/SubCard/SubCard";
|
|
||||||
import { MapPinIcon, PencilIcon, CheckIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
import { MapPinIcon, PencilIcon, CheckIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||||
import { AddressForm, type AddressFormProps } from "@/features/services/components";
|
import { AddressForm, type AddressFormProps } from "@/features/services/components";
|
||||||
import type { Address } from "@customer-portal/domain/customer";
|
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";
|
import { getCountryName } from "@/shared/constants";
|
||||||
|
|
||||||
function AddressDisplay({ address }: { address: Address }) {
|
function AddressDisplay({ address }: { address: Address }) {
|
||||||
@ -15,34 +16,26 @@ function AddressDisplay({ address }: { address: Address }) {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1.5 text-foreground">
|
<div className="bg-card rounded-lg p-5 border border-border shadow-sm">
|
||||||
{primaryLine && <p className="font-semibold text-base">{primaryLine}</p>}
|
<div className="text-foreground space-y-1.5">
|
||||||
{secondaryLine && <p className="text-muted-foreground">{secondaryLine}</p>}
|
{primaryLine && <p className="font-medium text-base">{primaryLine}</p>}
|
||||||
{cityStateZip && <p className="text-muted-foreground">{cityStateZip}</p>}
|
{secondaryLine && <p className="text-muted-foreground">{secondaryLine}</p>}
|
||||||
{countryLabel && <p className="text-muted-foreground font-medium">{countryLabel}</p>}
|
{cityStateZip && <p className="text-muted-foreground">{cityStateZip}</p>}
|
||||||
|
{countryLabel && <p className="text-muted-foreground">{countryLabel}</p>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SaveButton({ isSaving, onClick }: { isSaving: boolean; onClick: () => void }) {
|
function EmptyAddressState({ onEdit }: { onEdit: () => void }) {
|
||||||
return (
|
return (
|
||||||
<button
|
<div className="text-center py-12">
|
||||||
onClick={onClick}
|
<MapPinIcon className="h-12 w-12 text-muted-foreground/60 mx-auto mb-4" />
|
||||||
disabled={isSaving}
|
<p className="text-muted-foreground mb-4">No address on file</p>
|
||||||
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"
|
<Button onClick={onEdit} leftIcon={<PencilIcon className="h-4 w-4" />}>
|
||||||
>
|
Add Address
|
||||||
{isSaving ? (
|
</Button>
|
||||||
<>
|
</div>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -67,49 +60,78 @@ export function AddressCard({
|
|||||||
onSave,
|
onSave,
|
||||||
onAddressChange,
|
onAddressChange,
|
||||||
}: AddressCardProps) {
|
}: AddressCardProps) {
|
||||||
|
const hasAddress = Boolean(address.address1 || address.city);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SubCard>
|
<div className="bg-card text-card-foreground rounded-xl border border-border shadow-[var(--cp-shadow-1)]">
|
||||||
<div className="pb-5 border-b border-border/60">
|
<div className="px-6 py-5 border-b border-border">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-3">
|
<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-6 w-6 text-primary" />
|
||||||
<MapPinIcon className="h-5 w-5 text-primary" />
|
<h2 className="text-xl font-semibold text-foreground">Address Information</h2>
|
||||||
</div>
|
|
||||||
<h2 className="text-lg font-semibold text-foreground">Address Information</h2>
|
|
||||||
</div>
|
</div>
|
||||||
{!isEditing && (
|
{!isEditing && hasAddress && (
|
||||||
<button
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
onClick={onEdit}
|
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
|
Edit
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-5">
|
<div className="p-6">
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<AddressForm initialAddress={address} onChange={addr => onAddressChange(addr, true)} />
|
<AddressForm
|
||||||
{error && <div className="text-sm text-danger">{error}</div>}
|
initialAddress={{
|
||||||
<div className="flex items-center justify-end space-x-3 pt-4 border-t border-border/60">
|
address1: address.address1,
|
||||||
<button
|
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}
|
onClick={onCancel}
|
||||||
disabled={isSaving}
|
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
|
Cancel
|
||||||
</button>
|
</Button>
|
||||||
<SaveButton isSaving={isSaving} onClick={onSave} />
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={onSave}
|
||||||
|
isLoading={isSaving}
|
||||||
|
leftIcon={isSaving ? undefined : <CheckIcon className="h-4 w-4" />}
|
||||||
|
>
|
||||||
|
{isSaving ? "Saving..." : "Save Address"}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{error && (
|
||||||
|
<AlertBanner variant="error" title="Address Error">
|
||||||
|
{error}
|
||||||
|
</AlertBanner>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : hasAddress ? (
|
||||||
<AddressDisplay address={address} />
|
<AddressDisplay address={address} />
|
||||||
|
) : (
|
||||||
|
<EmptyAddressState onEdit={onEdit} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SubCard>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,27 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { SubCard } from "@/components/molecules/SubCard/SubCard";
|
|
||||||
import { UserIcon, PencilIcon, CheckIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
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 { Button } from "@/components/atoms/button";
|
||||||
import { Input } from "@/components/atoms/input";
|
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 {
|
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;
|
isEditing: boolean;
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
@ -16,8 +30,32 @@ interface PersonalInfoCardProps {
|
|||||||
onSave: () => void;
|
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({
|
export function PersonalInfoCard({
|
||||||
data,
|
data,
|
||||||
|
editEmail,
|
||||||
|
editPhoneNumber,
|
||||||
isEditing,
|
isEditing,
|
||||||
isSaving,
|
isSaving,
|
||||||
onEdit,
|
onEdit,
|
||||||
@ -26,8 +64,8 @@ export function PersonalInfoCard({
|
|||||||
onSave,
|
onSave,
|
||||||
}: PersonalInfoCardProps) {
|
}: PersonalInfoCardProps) {
|
||||||
return (
|
return (
|
||||||
<SubCard>
|
<div className="bg-card text-card-foreground rounded-xl border border-border shadow-[var(--cp-shadow-1)]">
|
||||||
<div className="pb-5 border-b border-border">
|
<div className="px-6 py-5 border-b border-border">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<UserIcon className="h-6 w-6 text-primary" />
|
<UserIcon className="h-6 w-6 text-primary" />
|
||||||
@ -46,52 +84,32 @@ export function PersonalInfoCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-5">
|
<div className="p-6">
|
||||||
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||||
<div>
|
<ReadOnlyField
|
||||||
<label className="block text-sm font-medium text-muted-foreground mb-2">
|
label="First Name"
|
||||||
First Name
|
value={data.firstname}
|
||||||
</label>
|
hint="Name cannot be changed from the portal."
|
||||||
<div className="bg-muted rounded-lg p-4 border border-border">
|
/>
|
||||||
<p className="text-sm text-foreground font-medium">
|
<ReadOnlyField
|
||||||
{data.firstname || (
|
label="Last Name"
|
||||||
<span className="text-muted-foreground italic">Not provided</span>
|
value={data.lastname}
|
||||||
)}
|
hint="Name cannot be changed from the portal."
|
||||||
</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="sm:col-span-2">
|
<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
|
Email Address
|
||||||
</label>
|
</label>
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
value={data.email}
|
value={editEmail}
|
||||||
onChange={e => onChange("email", e.target.value)}
|
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">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-base text-foreground font-medium">{data.email}</p>
|
<p className="text-base text-foreground font-medium">{data.email}</p>
|
||||||
</div>
|
</div>
|
||||||
@ -101,6 +119,44 @@ export function PersonalInfoCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{isEditing && (
|
{isEditing && (
|
||||||
@ -117,16 +173,14 @@ export function PersonalInfoCard({
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onSave}
|
onClick={onSave}
|
||||||
disabled={isSaving}
|
|
||||||
isLoading={isSaving}
|
isLoading={isSaving}
|
||||||
loadingText="Saving…"
|
|
||||||
leftIcon={isSaving ? undefined : <CheckIcon className="h-4 w-4" />}
|
leftIcon={isSaving ? undefined : <CheckIcon className="h-4 w-4" />}
|
||||||
>
|
>
|
||||||
Save Changes
|
{isSaving ? "Saving..." : "Save Changes"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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 { useProfileData } from "./useProfileData";
|
||||||
export { useProfileEdit } from "./useProfileEdit";
|
export { useProfileEdit } from "./useProfileEdit";
|
||||||
export { useAddressEdit } from "./useAddressEdit";
|
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";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useState } from "react";
|
||||||
import { Skeleton } from "@/components/atoms/loading-skeleton";
|
import { UserIcon } from "@heroicons/react/24/outline";
|
||||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||||
import {
|
import { PageLayout } from "@/components/templates";
|
||||||
MapPinIcon,
|
|
||||||
PencilIcon,
|
|
||||||
CheckIcon,
|
|
||||||
XMarkIcon,
|
|
||||||
UserIcon,
|
|
||||||
ShieldCheckIcon,
|
|
||||||
} from "@heroicons/react/24/outline";
|
|
||||||
import { useAuthStore } from "@/features/auth/stores/auth.store";
|
import { useAuthStore } from "@/features/auth/stores/auth.store";
|
||||||
import { accountService } from "@/features/account/api/account.api";
|
import { useProfileDataLoading } from "@/features/account/hooks";
|
||||||
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 {
|
import {
|
||||||
useResidenceCardVerification,
|
useResidenceCardVerification,
|
||||||
useSubmitResidenceCard,
|
useVerificationFileUpload,
|
||||||
} from "@/features/verification/hooks/useResidenceCardVerification";
|
} from "@/features/verification/hooks";
|
||||||
import { PageLayout } from "@/components/templates";
|
import {
|
||||||
import { formatIsoDate } from "@/shared/utils";
|
PersonalInfoCard,
|
||||||
|
AddressCard,
|
||||||
|
VerificationCard,
|
||||||
|
ProfileLoadingSkeleton,
|
||||||
|
} from "@/features/account/components";
|
||||||
|
|
||||||
export default function ProfileContainer() {
|
export default function ProfileContainer() {
|
||||||
const { user } = useAuthStore();
|
const { user } = useAuthStore();
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [editingProfile, setEditingProfile] = useState(false);
|
const [editingProfile, setEditingProfile] = useState(false);
|
||||||
const [editingAddress, setEditingAddress] = 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 || "",
|
email: user?.email || "",
|
||||||
phonenumber: user?.phonenumber || "",
|
phonenumber: user?.phonenumber || "",
|
||||||
});
|
});
|
||||||
|
|
||||||
const address = useAddressEdit({
|
const verificationQuery = useResidenceCardVerification();
|
||||||
address1: "",
|
const fileUpload = useVerificationFileUpload({
|
||||||
address2: "",
|
verificationStatus: verificationQuery.data?.status,
|
||||||
city: "",
|
|
||||||
state: "",
|
|
||||||
postcode: "",
|
|
||||||
country: "",
|
|
||||||
countryCode: "",
|
|
||||||
phoneNumber: "",
|
|
||||||
phoneCountryCode: "",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ID Verification status from Salesforce
|
if (isLoading) {
|
||||||
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) {
|
|
||||||
return (
|
return (
|
||||||
<PageLayout
|
<PageLayout
|
||||||
icon={<UserIcon />}
|
icon={<UserIcon />}
|
||||||
@ -189,69 +40,7 @@ export default function ProfileContainer() {
|
|||||||
description="Manage your account information"
|
description="Manage your account information"
|
||||||
loading
|
loading
|
||||||
>
|
>
|
||||||
<div className="space-y-8">
|
<ProfileLoadingSkeleton />
|
||||||
<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>
|
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -262,7 +51,7 @@ export default function ProfileContainer() {
|
|||||||
title="Profile"
|
title="Profile"
|
||||||
description="Manage your account information"
|
description="Manage your account information"
|
||||||
error={error}
|
error={error}
|
||||||
onRetry={() => setReloadKey(k => k + 1)}
|
onRetry={reload}
|
||||||
>
|
>
|
||||||
{error && (
|
{error && (
|
||||||
<AlertBanner variant="error" title="Unable to load profile" className="mb-6" elevated>
|
<AlertBanner variant="error" title="Unable to load profile" className="mb-6" elevated>
|
||||||
@ -270,435 +59,76 @@ export default function ProfileContainer() {
|
|||||||
</AlertBanner>
|
</AlertBanner>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="bg-card text-card-foreground rounded-xl border border-border shadow-[var(--cp-shadow-1)]">
|
<PersonalInfoCard
|
||||||
<div className="px-6 py-5 border-b border-border">
|
data={{
|
||||||
<div className="flex items-center justify-between">
|
firstname: user?.firstname ?? null,
|
||||||
<div className="flex items-center space-x-3">
|
lastname: user?.lastname ?? null,
|
||||||
<UserIcon className="h-6 w-6 text-primary" />
|
email: user?.email ?? "",
|
||||||
<h2 className="text-xl font-semibold text-foreground">Personal Information</h2>
|
phonenumber: user?.phonenumber ?? null,
|
||||||
</div>
|
sfNumber: user?.sfNumber ?? null,
|
||||||
{!editingProfile && (
|
dateOfBirth: user?.dateOfBirth ?? null,
|
||||||
<Button
|
gender: user?.gender ?? null,
|
||||||
variant="outline"
|
}}
|
||||||
size="sm"
|
editEmail={profile.values.email ?? ""}
|
||||||
onClick={() => setEditingProfile(true)}
|
editPhoneNumber={profile.values.phonenumber ?? ""}
|
||||||
leftIcon={<PencilIcon className="h-4 w-4" />}
|
isEditing={editingProfile}
|
||||||
>
|
isSaving={profile.isSubmitting}
|
||||||
Edit
|
onEdit={() => setEditingProfile(true)}
|
||||||
</Button>
|
onCancel={() => setEditingProfile(false)}
|
||||||
)}
|
onChange={(field, value) => profile.setValue(field, value)}
|
||||||
</div>
|
onSave={() => {
|
||||||
</div>
|
void profile
|
||||||
|
.handleSubmit()
|
||||||
|
.then(() => {
|
||||||
|
setEditingProfile(false);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Error is handled by useZodForm
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="p-6">
|
<AddressCard
|
||||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
address={{
|
||||||
<div>
|
address1: address.values.address1,
|
||||||
<label className="block text-sm font-medium text-muted-foreground mb-2">
|
address2: address.values.address2,
|
||||||
First Name
|
city: address.values.city,
|
||||||
</label>
|
state: address.values.state,
|
||||||
<div className="bg-card rounded-lg p-4 border border-border shadow-sm">
|
postcode: address.values.postcode,
|
||||||
<p className="text-base text-foreground font-medium">
|
country: address.values.country,
|
||||||
{user?.firstname || (
|
countryCode: address.values.countryCode,
|
||||||
<span className="text-muted-foreground italic">Not provided</span>
|
phoneNumber: address.values.phoneNumber,
|
||||||
)}
|
phoneCountryCode: address.values.phoneCountryCode,
|
||||||
</p>
|
}}
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
isEditing={editingAddress}
|
||||||
Name cannot be changed from the portal.
|
isSaving={address.isSubmitting}
|
||||||
</p>
|
error={address.submitError}
|
||||||
</div>
|
onEdit={() => setEditingAddress(true)}
|
||||||
</div>
|
onCancel={() => setEditingAddress(false)}
|
||||||
<div>
|
onAddressChange={a => {
|
||||||
<label className="block text-sm font-medium text-muted-foreground mb-2">
|
address.setValue("address1", a.address1 ?? "");
|
||||||
Last Name
|
address.setValue("address2", a.address2 ?? "");
|
||||||
</label>
|
address.setValue("city", a.city ?? "");
|
||||||
<div className="bg-card rounded-lg p-4 border border-border shadow-sm">
|
address.setValue("state", a.state ?? "");
|
||||||
<p className="text-base text-foreground font-medium">
|
address.setValue("postcode", a.postcode ?? "");
|
||||||
{user?.lastname || (
|
address.setValue("country", a.country ?? "");
|
||||||
<span className="text-muted-foreground italic">Not provided</span>
|
address.setValue("countryCode", a.countryCode ?? "");
|
||||||
)}
|
address.setValue("phoneNumber", a.phoneNumber ?? "");
|
||||||
</p>
|
address.setValue("phoneCountryCode", a.phoneCountryCode ?? "");
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
}}
|
||||||
Name cannot be changed from the portal.
|
onSave={() => {
|
||||||
</p>
|
void address
|
||||||
</div>
|
.handleSubmit()
|
||||||
</div>
|
.then(() => {
|
||||||
<div className="sm:col-span-2">
|
setEditingAddress(false);
|
||||||
<label className="block text-sm font-medium text-muted-foreground mb-2">
|
})
|
||||||
Email Address
|
.catch(() => {
|
||||||
</label>
|
// Error is handled by useZodForm
|
||||||
{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>
|
|
||||||
|
|
||||||
<div>
|
<VerificationCard verificationQuery={verificationQuery} fileUpload={fileUpload} />
|
||||||
<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>
|
|
||||||
</PageLayout>
|
</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
|
* - 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 { Home, Building2, CheckCircle2, MapPin, ChevronRight, Sparkles } from "lucide-react";
|
||||||
import { Input } from "@/components/atoms";
|
import { Input } from "@/components/atoms";
|
||||||
import { FormField } from "@/components/molecules/FormField/FormField";
|
import { FormField } from "@/components/molecules/FormField/FormField";
|
||||||
import { cn } from "@/shared/utils";
|
import { cn } from "@/shared/utils";
|
||||||
import { ZipCodeInput } from "./ZipCodeInput";
|
import { ZipCodeInput } from "./ZipCodeInput";
|
||||||
|
import { AnimatedSection } from "./AnimatedSection";
|
||||||
|
import { ProgressIndicator } from "./ProgressIndicator";
|
||||||
|
import { BilingualValue } from "./BilingualValue";
|
||||||
import {
|
import {
|
||||||
type BilingualAddress,
|
type BilingualAddress,
|
||||||
type JapanPostAddress,
|
|
||||||
RESIDENCE_TYPE,
|
RESIDENCE_TYPE,
|
||||||
type ResidenceType,
|
type ResidenceType,
|
||||||
} from "@customer-portal/domain/address";
|
} from "@customer-portal/domain/address";
|
||||||
|
import { useJapanAddressForm } from "@/features/address/hooks";
|
||||||
|
import {
|
||||||
|
TOTAL_FORM_STEPS,
|
||||||
|
isValidStreetAddress,
|
||||||
|
getStreetAddressError,
|
||||||
|
} from "@/features/address/utils";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Types
|
// Types
|
||||||
@ -33,8 +40,6 @@ export type JapanAddressFormData = BilingualAddress;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Type for partial initial values that allows undefined residenceType.
|
* 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"> & {
|
type JapanAddressFormInitialValues = Omit<JapanAddressFormData, "residenceType"> & {
|
||||||
residenceType?: ResidenceType | undefined;
|
residenceType?: ResidenceType | undefined;
|
||||||
@ -60,140 +65,193 @@ export interface JapanAddressFormProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Default Values
|
// Step Header Component
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
const DEFAULT_ADDRESS: Omit<JapanAddressFormData, "residenceType"> & {
|
function StepHeader({
|
||||||
residenceType: ResidenceType | "";
|
stepNumber,
|
||||||
} = {
|
label,
|
||||||
postcode: "",
|
isComplete,
|
||||||
prefecture: "",
|
badge,
|
||||||
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,
|
|
||||||
}: {
|
}: {
|
||||||
show: boolean;
|
stepNumber: number;
|
||||||
children: React.ReactNode;
|
label: string;
|
||||||
delay?: number;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid transition-all duration-500 ease-out",
|
"rounded-xl border transition-all duration-500",
|
||||||
show ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
|
"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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Progress Step Indicator
|
// Residence Type Selector Component
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
function ProgressIndicator({
|
function ResidenceTypeSelector({
|
||||||
currentStep,
|
value,
|
||||||
totalSteps,
|
onChange,
|
||||||
|
disabled,
|
||||||
|
error,
|
||||||
}: {
|
}: {
|
||||||
currentStep: number;
|
value: ResidenceType | "";
|
||||||
totalSteps: number;
|
onChange: (type: ResidenceType) => void;
|
||||||
|
disabled: boolean;
|
||||||
|
error?: string | undefined;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1.5 mb-6">
|
<div>
|
||||||
{Array.from({ length: totalSteps }).map((_, i) => (
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div
|
<button
|
||||||
key={i}
|
type="button"
|
||||||
|
onClick={() => onChange(RESIDENCE_TYPE.HOUSE)}
|
||||||
|
disabled={disabled}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-1 rounded-full transition-all duration-500",
|
"group relative flex flex-col items-center gap-2 p-4 rounded-xl border-2 transition-all duration-300",
|
||||||
i < currentStep
|
"hover:scale-[1.02] active:scale-[0.98]",
|
||||||
? "bg-primary flex-[2]"
|
value === RESIDENCE_TYPE.HOUSE
|
||||||
: i === currentStep
|
? "border-primary bg-primary/5 shadow-[0_0_0_4px] shadow-primary/10"
|
||||||
? "bg-primary/40 flex-[2] animate-pulse"
|
: "border-border bg-card hover:border-primary/50 hover:bg-primary/[0.02]",
|
||||||
: "bg-border flex-1"
|
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>
|
||||||
|
|
||||||
// ============================================================================
|
<button
|
||||||
// Bilingual Field Display
|
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({
|
{error && <p className="text-sm text-danger mt-2">{error}</p>}
|
||||||
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>}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -202,10 +260,6 @@ function BilingualValue({
|
|||||||
// Main Component
|
// Main Component
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
type InternalFormState = Omit<JapanAddressFormData, "residenceType"> & {
|
|
||||||
residenceType: ResidenceType | "";
|
|
||||||
};
|
|
||||||
|
|
||||||
export function JapanAddressForm({
|
export function JapanAddressForm({
|
||||||
initialValues,
|
initialValues,
|
||||||
onChange,
|
onChange,
|
||||||
@ -216,229 +270,42 @@ export function JapanAddressForm({
|
|||||||
className,
|
className,
|
||||||
completionContent,
|
completionContent,
|
||||||
}: JapanAddressFormProps) {
|
}: JapanAddressFormProps) {
|
||||||
const [address, setAddress] = useState<InternalFormState>(() => ({
|
const form = useJapanAddressForm({
|
||||||
...DEFAULT_ADDRESS,
|
initialValues,
|
||||||
...initialValues,
|
onChange,
|
||||||
// Convert undefined residenceType to empty string for internal state
|
errors,
|
||||||
residenceType: initialValues?.residenceType ?? DEFAULT_ADDRESS.residenceType,
|
touched,
|
||||||
}));
|
disabled,
|
||||||
|
});
|
||||||
|
|
||||||
const [isAddressVerified, setIsAddressVerified] = useState(false);
|
const streetAddressError = getStreetAddressError(form.address.streetAddress);
|
||||||
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;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("space-y-6", className)}>
|
<div className={cn("space-y-6", className)}>
|
||||||
{/* Progress Indicator */}
|
<ProgressIndicator currentStep={form.completion.currentStep} totalSteps={TOTAL_FORM_STEPS} />
|
||||||
<ProgressIndicator currentStep={currentStep} totalSteps={4} />
|
|
||||||
|
|
||||||
{/* Step 1: ZIP Code Lookup */}
|
{/* Step 1: ZIP Code Lookup */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<StepHeader
|
||||||
<div
|
stepNumber={1}
|
||||||
className={cn(
|
label="Enter ZIP Code"
|
||||||
"flex items-center justify-center w-6 h-6 rounded-full text-xs font-semibold transition-all duration-300",
|
isComplete={form.isAddressVerified}
|
||||||
isAddressVerified
|
badge={
|
||||||
? "bg-success text-success-foreground"
|
form.isAddressVerified && (
|
||||||
: "bg-primary/10 text-primary"
|
<span className="text-xs text-success font-medium ml-auto flex items-center gap-1">
|
||||||
)}
|
<Sparkles className="w-3 h-3" />
|
||||||
>
|
Verified
|
||||||
{isAddressVerified ? <CheckCircle2 className="w-4 h-4" /> : "1"}
|
</span>
|
||||||
</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>
|
|
||||||
|
|
||||||
<ZipCodeInput
|
<ZipCodeInput
|
||||||
value={address.postcode}
|
value={form.address.postcode}
|
||||||
onChange={handleZipChange}
|
onChange={form.handlers.handleZipChange}
|
||||||
onAddressFound={handleAddressFound}
|
onAddressFound={form.handlers.handleAddressFound}
|
||||||
onLookupComplete={handleLookupComplete}
|
onLookupComplete={form.handlers.handleLookupComplete}
|
||||||
error={getError("postcode")}
|
error={form.getError("postcode")}
|
||||||
required
|
required
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
autoFocus
|
autoFocus
|
||||||
@ -446,90 +313,28 @@ export function JapanAddressForm({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Verified Address Display */}
|
{/* Verified Address Display */}
|
||||||
<AnimatedSection show={isAddressVerified}>
|
<AnimatedSection show={form.isAddressVerified}>
|
||||||
<div
|
<VerifiedAddressDisplay address={form.address} isVerified={form.isAddressVerified} />
|
||||||
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>
|
</AnimatedSection>
|
||||||
|
|
||||||
{/* Step 2: Street Address */}
|
{/* Step 2: Street Address */}
|
||||||
<AnimatedSection show={isAddressVerified} delay={100}>
|
<AnimatedSection show={form.isAddressVerified} delay={100}>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<StepHeader
|
||||||
<div
|
stepNumber={2}
|
||||||
className={cn(
|
label="Street Address"
|
||||||
"flex items-center justify-center w-6 h-6 rounded-full text-xs font-semibold transition-all duration-300",
|
isComplete={isValidStreetAddress(form.address.streetAddress)}
|
||||||
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>
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label=""
|
label=""
|
||||||
error={
|
error={
|
||||||
getError("streetAddress") || (address.streetAddress.trim() && streetAddressError)
|
form.getError("streetAddress") ||
|
||||||
|
(form.address.streetAddress.trim() && streetAddressError)
|
||||||
}
|
}
|
||||||
required
|
required
|
||||||
helperText={
|
helperText={
|
||||||
address.streetAddress.trim()
|
form.address.streetAddress.trim()
|
||||||
? streetAddressError
|
? streetAddressError
|
||||||
? undefined
|
? undefined
|
||||||
: "Valid format"
|
: "Valid format"
|
||||||
@ -537,9 +342,9 @@ export function JapanAddressForm({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
ref={streetAddressRef}
|
ref={form.streetAddressRef}
|
||||||
value={address.streetAddress}
|
value={form.address.streetAddress}
|
||||||
onChange={e => handleStreetAddressChange(e.target.value)}
|
onChange={e => form.handlers.handleStreetAddressChange(e.target.value)}
|
||||||
onBlur={() => onBlur?.("streetAddress")}
|
onBlur={() => onBlur?.("streetAddress")}
|
||||||
placeholder="1-5-3"
|
placeholder="1-5-3"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@ -552,149 +357,63 @@ export function JapanAddressForm({
|
|||||||
|
|
||||||
{/* Step 3: Residence Type */}
|
{/* Step 3: Residence Type */}
|
||||||
<AnimatedSection
|
<AnimatedSection
|
||||||
show={isAddressVerified && isValidStreetAddress(address.streetAddress)}
|
show={form.isAddressVerified && isValidStreetAddress(form.address.streetAddress)}
|
||||||
delay={150}
|
delay={150}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<StepHeader
|
||||||
<div
|
stepNumber={3}
|
||||||
className={cn(
|
label="Residence Type"
|
||||||
"flex items-center justify-center w-6 h-6 rounded-full text-xs font-semibold transition-all duration-300",
|
isComplete={form.completion.hasResidenceType}
|
||||||
hasResidenceType
|
/>
|
||||||
? "bg-success text-success-foreground"
|
|
||||||
: "bg-primary/10 text-primary"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{hasResidenceType ? <CheckCircle2 className="w-4 h-4" /> : "3"}
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-medium text-foreground">Residence Type</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<ResidenceTypeSelector
|
||||||
<button
|
value={form.address.residenceType}
|
||||||
type="button"
|
onChange={form.handlers.handleResidenceTypeChange}
|
||||||
onClick={() => handleResidenceTypeChange(RESIDENCE_TYPE.HOUSE)}
|
disabled={disabled}
|
||||||
disabled={disabled}
|
error={form.completion.hasResidenceType ? undefined : form.getError("residenceType")}
|
||||||
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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</AnimatedSection>
|
</AnimatedSection>
|
||||||
|
|
||||||
{/* Step 4: Building Details */}
|
{/* 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="space-y-4">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<StepHeader stepNumber={4} label="Building Details" isComplete={form.showSuccess} />
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex items-center justify-center w-6 h-6 rounded-full text-xs font-semibold transition-all duration-300",
|
|
||||||
showSuccess ? "bg-success text-success-foreground" : "bg-primary/10 text-primary"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{showSuccess ? <CheckCircle2 className="w-4 h-4" /> : "4"}
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-medium text-foreground">Building Details</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Building Name */}
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Building Name"
|
label="Building Name"
|
||||||
error={getError("buildingName")}
|
error={form.getError("buildingName")}
|
||||||
required
|
required
|
||||||
helperText={
|
helperText={
|
||||||
isApartment
|
form.isApartment
|
||||||
? "e.g., Sunshine Mansion (サンシャインマンション)"
|
? "e.g., Sunshine Mansion (サンシャインマンション)"
|
||||||
: "e.g., Tanaka Residence (田中邸)"
|
: "e.g., Tanaka Residence (田中邸)"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
value={address.buildingName ?? ""}
|
value={form.address.buildingName ?? ""}
|
||||||
onChange={e => handleBuildingNameChange(e.target.value)}
|
onChange={e => form.handlers.handleBuildingNameChange(e.target.value)}
|
||||||
onBlur={() => onBlur?.("buildingName")}
|
onBlur={() => onBlur?.("buildingName")}
|
||||||
placeholder={isApartment ? "Sunshine Mansion" : "Tanaka Residence"}
|
placeholder={form.isApartment ? "Sunshine Mansion" : "Tanaka Residence"}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
data-field="address.buildingName"
|
data-field="address.buildingName"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
{/* Room Number - Only for apartments */}
|
{form.isApartment && (
|
||||||
{isApartment && (
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Room Number"
|
label="Room Number"
|
||||||
error={getError("roomNumber")}
|
error={form.getError("roomNumber")}
|
||||||
required
|
required
|
||||||
helperText="Required for apartments (部屋番号)"
|
helperText="Required for apartments (部屋番号)"
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
value={address.roomNumber ?? ""}
|
value={form.address.roomNumber ?? ""}
|
||||||
onChange={e => handleRoomNumberChange(e.target.value)}
|
onChange={e => form.handlers.handleRoomNumberChange(e.target.value)}
|
||||||
onBlur={() => onBlur?.("roomNumber")}
|
onBlur={() => onBlur?.("roomNumber")}
|
||||||
placeholder="201"
|
placeholder="201"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@ -706,8 +425,8 @@ export function JapanAddressForm({
|
|||||||
</div>
|
</div>
|
||||||
</AnimatedSection>
|
</AnimatedSection>
|
||||||
|
|
||||||
{/* Success State - shows custom content or default message */}
|
{/* Success State */}
|
||||||
<AnimatedSection show={showSuccess} delay={250}>
|
<AnimatedSection show={form.showSuccess} delay={250}>
|
||||||
{completionContent ?? (
|
{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="rounded-xl bg-gradient-to-br from-success/10 via-success/5 to-transparent border border-success/20 p-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="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,
|
type JapanAddressFormData,
|
||||||
} from "./JapanAddressForm";
|
} from "./JapanAddressForm";
|
||||||
export { AddressStepJapan } from "./AddressStepJapan";
|
export { AddressStepJapan } from "./AddressStepJapan";
|
||||||
|
export { AnimatedSection } from "./AnimatedSection";
|
||||||
|
export { ProgressIndicator } from "./ProgressIndicator";
|
||||||
|
export { BilingualValue } from "./BilingualValue";
|
||||||
|
|||||||
@ -4,3 +4,5 @@ export {
|
|||||||
getFirstAddress,
|
getFirstAddress,
|
||||||
EMPTY_LOOKUP_RESULT,
|
EMPTY_LOOKUP_RESULT,
|
||||||
} from "./useAddressLookup";
|
} 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";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { ShieldCheck } from "lucide-react";
|
import { ShieldCheck } from "lucide-react";
|
||||||
|
|
||||||
import { PageLayout } from "@/components/templates/PageLayout";
|
import { PageLayout } from "@/components/templates/PageLayout";
|
||||||
import { SubCard } from "@/components/molecules/SubCard/SubCard";
|
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 { InlineToast } from "@/components/atoms/inline-toast";
|
||||||
import { AddressConfirmation } from "@/features/services/components/base/AddressConfirmation";
|
import { AddressConfirmation } from "@/features/services/components/base/AddressConfirmation";
|
||||||
import { useCheckoutStore } from "@/features/checkout/stores/checkout.store";
|
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 { usePaymentRefresh } from "@/features/billing/hooks/usePaymentRefresh";
|
||||||
import { billingService } from "@/features/billing/api/billing.api";
|
import { billingService } from "@/features/billing/api/billing.api";
|
||||||
import { openSsoLink } from "@/features/billing/utils/sso";
|
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 {
|
import {
|
||||||
useResidenceCardVerification,
|
useResidenceCardVerification,
|
||||||
useSubmitResidenceCard,
|
useSubmitResidenceCard,
|
||||||
} from "@/features/verification/hooks/useResidenceCardVerification";
|
} from "@/features/verification/hooks/useResidenceCardVerification";
|
||||||
import { useAuthSession } from "@/features/auth/stores/auth.store";
|
import { useAuthSession } from "@/features/auth/stores/auth.store";
|
||||||
import {
|
import { toOrderTypeValueFromCheckout, type OrderTypeValue } from "@customer-portal/domain/orders";
|
||||||
ORDER_TYPE,
|
|
||||||
type OrderTypeValue,
|
|
||||||
toOrderTypeValueFromCheckout,
|
|
||||||
} from "@customer-portal/domain/orders";
|
|
||||||
import { buildPaymentMethodDisplay, formatAddressLabel } from "@/shared/utils";
|
import { buildPaymentMethodDisplay, formatAddressLabel } from "@/shared/utils";
|
||||||
|
|
||||||
import { CheckoutStatusBanners } from "./CheckoutStatusBanners";
|
import { CheckoutStatusBanners } from "./CheckoutStatusBanners";
|
||||||
import {
|
import {
|
||||||
PaymentMethodSection,
|
PaymentMethodSection,
|
||||||
IdentityVerificationSection,
|
IdentityVerificationSection,
|
||||||
OrderSubmitSection,
|
OrderSubmitSection,
|
||||||
} from "./checkout-sections";
|
} 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() {
|
export function AccountCheckoutContainer() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -48,32 +50,26 @@ export function AccountCheckoutContainer() {
|
|||||||
|
|
||||||
const { cartItem, checkoutSessionId, clear } = useCheckoutStore();
|
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(() => {
|
const orderType: OrderTypeValue | null = useMemo(() => {
|
||||||
return toOrderTypeValueFromCheckout(cartItem?.orderType);
|
return toOrderTypeValueFromCheckout(cartItem?.orderType);
|
||||||
}, [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
|
// Eligibility management
|
||||||
const { data: activeSubs } = useActiveSubscriptions();
|
const { eligibility, eligibilityRequest, activeInternetWarning } = useCheckoutEligibility({
|
||||||
const hasActiveInternetSubscription = useMemo(() => {
|
orderType,
|
||||||
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;
|
|
||||||
|
|
||||||
// Payment methods
|
// Payment methods
|
||||||
const {
|
const {
|
||||||
@ -88,6 +84,8 @@ export function AccountCheckoutContainer() {
|
|||||||
attachFocusListeners: false,
|
attachFocusListeners: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { showToast } = useCheckoutToast({ setToast: paymentRefresh.setToast });
|
||||||
|
|
||||||
const paymentMethodList = paymentMethods?.paymentMethods ?? [];
|
const paymentMethodList = paymentMethods?.paymentMethods ?? [];
|
||||||
const hasPaymentMethod = paymentMethodList.length > 0;
|
const hasPaymentMethod = paymentMethodList.length > 0;
|
||||||
const defaultPaymentMethod =
|
const defaultPaymentMethod =
|
||||||
@ -96,34 +94,6 @@ export function AccountCheckoutContainer() {
|
|||||||
? buildPaymentMethodDisplay(defaultPaymentMethod)
|
? buildPaymentMethodDisplay(defaultPaymentMethod)
|
||||||
: null;
|
: 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
|
// Address
|
||||||
const hasServiceAddress = Boolean(
|
const hasServiceAddress = Boolean(
|
||||||
user?.address?.address1 &&
|
user?.address?.address1 &&
|
||||||
@ -139,29 +109,13 @@ export function AccountCheckoutContainer() {
|
|||||||
const residenceStatus = residenceCardQuery.data?.status;
|
const residenceStatus = residenceCardQuery.data?.status;
|
||||||
const residenceSubmitted = residenceStatus === "pending" || residenceStatus === "verified";
|
const residenceSubmitted = residenceStatus === "pending" || residenceStatus === "verified";
|
||||||
|
|
||||||
// Toast handler
|
// Can submit calculation
|
||||||
const showPaymentToast = useCallback(
|
const canSubmit = useCanSubmit(formState.addressConfirmed, {
|
||||||
(text: string, tone: "info" | "success" | "warning" | "error") => {
|
paymentMethodsLoading,
|
||||||
if (paymentToastTimeoutRef.current) {
|
hasPaymentMethod,
|
||||||
clearTimeout(paymentToastTimeoutRef.current);
|
residenceSubmitted,
|
||||||
paymentToastTimeoutRef.current = null;
|
eligibility,
|
||||||
}
|
});
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const formatDateTime = useCallback((iso?: string | null) => {
|
const formatDateTime = useCallback((iso?: string | null) => {
|
||||||
if (!iso) return null;
|
if (!iso) return null;
|
||||||
@ -173,57 +127,48 @@ export function AccountCheckoutContainer() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const navigateBackToConfigure = useCallback(() => {
|
const navigateBackToConfigure = useCallback(() => {
|
||||||
const params = new URLSearchParams(searchParams?.toString() ?? "");
|
router.push(buildConfigureBackUrl(searchParams));
|
||||||
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, searchParams]);
|
}, [router, searchParams]);
|
||||||
|
|
||||||
const handleSubmitOrder = useCallback(async () => {
|
const handleSubmitOrder = useCallback(async () => {
|
||||||
setSubmitError(null);
|
setError(null);
|
||||||
if (!checkoutSessionId) {
|
if (!checkoutSessionId) {
|
||||||
setSubmitError("Checkout session expired. Please restart checkout from the shop.");
|
setError("Checkout session expired. Please restart checkout from the shop.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setSubmitting(true);
|
startSubmitting();
|
||||||
const result = await ordersService.createOrderFromCheckoutSession(checkoutSessionId);
|
const result = await ordersService.createOrderFromCheckoutSession(checkoutSessionId);
|
||||||
clear();
|
clear();
|
||||||
router.push(`/account/orders/${encodeURIComponent(result.sfOrderId)}?status=success`);
|
router.push(buildOrderSuccessUrl(result.sfOrderId));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Order submission failed";
|
const message = error instanceof Error ? error.message : "Order submission failed";
|
||||||
if (
|
if (
|
||||||
message.toLowerCase().includes("residence card submission required") ||
|
message.toLowerCase().includes("residence card submission required") ||
|
||||||
message.toLowerCase().includes("residence card submission was rejected")
|
message.toLowerCase().includes("residence card submission was rejected")
|
||||||
) {
|
) {
|
||||||
const queryString = searchParams?.toString();
|
router.push(buildVerificationRedirectUrl(pathname, searchParams));
|
||||||
const next = pathname + (queryString ? `?${queryString}` : "");
|
|
||||||
router.push(`/account/settings/verification?returnTo=${encodeURIComponent(next)}`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSubmitError(message);
|
setError(message);
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
stopSubmitting();
|
||||||
}
|
}
|
||||||
}, [checkoutSessionId, clear, pathname, router, searchParams]);
|
}, [
|
||||||
|
checkoutSessionId,
|
||||||
|
clear,
|
||||||
|
pathname,
|
||||||
|
router,
|
||||||
|
searchParams,
|
||||||
|
setError,
|
||||||
|
startSubmitting,
|
||||||
|
stopSubmitting,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleManagePayment = useCallback(async () => {
|
const handleManagePayment = useCallback(async () => {
|
||||||
if (openingPaymentPortal) return;
|
if (formState.openingPaymentPortal) return;
|
||||||
setOpeningPaymentPortal(true);
|
startOpeningPortal();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await billingService.createPaymentMethodsSsoLink();
|
const data = await billingService.createPaymentMethodsSsoLink();
|
||||||
@ -233,11 +178,11 @@ export function AccountCheckoutContainer() {
|
|||||||
openSsoLink(data.url, { newTab: true });
|
openSsoLink(data.url, { newTab: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Unable to open the payment portal";
|
const message = error instanceof Error ? error.message : "Unable to open the payment portal";
|
||||||
showPaymentToast(message, "error");
|
showToast(message, "error");
|
||||||
} finally {
|
} finally {
|
||||||
setOpeningPaymentPortal(false);
|
stopOpeningPortal();
|
||||||
}
|
}
|
||||||
}, [openingPaymentPortal, showPaymentToast]);
|
}, [formState.openingPaymentPortal, showToast, startOpeningPortal, stopOpeningPortal]);
|
||||||
|
|
||||||
const handleSubmitResidenceCard = useCallback(
|
const handleSubmitResidenceCard = useCallback(
|
||||||
(file: File) => {
|
(file: File) => {
|
||||||
@ -246,34 +191,9 @@ export function AccountCheckoutContainer() {
|
|||||||
[submitResidenceCard]
|
[submitResidenceCard]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Calculate if form can be submitted
|
|
||||||
const canSubmit =
|
|
||||||
addressConfirmed &&
|
|
||||||
!paymentMethodsLoading &&
|
|
||||||
hasPaymentMethod &&
|
|
||||||
residenceSubmitted &&
|
|
||||||
isEligible &&
|
|
||||||
!eligibilityLoading &&
|
|
||||||
!eligibilityPending &&
|
|
||||||
!eligibilityNotRequested &&
|
|
||||||
!eligibilityIneligible &&
|
|
||||||
!eligibilityError;
|
|
||||||
|
|
||||||
// Error state - no cart item
|
// Error state - no cart item
|
||||||
if (!cartItem || !orderType) {
|
if (!cartItem || !orderType) {
|
||||||
const shopHref = pathname.startsWith("/account") ? "/account/services" : "/services";
|
return <CheckoutErrorFallback shopHref={getShopHref(pathname)} />;
|
||||||
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 (
|
return (
|
||||||
@ -292,14 +212,14 @@ export function AccountCheckoutContainer() {
|
|||||||
<CheckoutStatusBanners
|
<CheckoutStatusBanners
|
||||||
activeInternetWarning={activeInternetWarning}
|
activeInternetWarning={activeInternetWarning}
|
||||||
eligibility={{
|
eligibility={{
|
||||||
isLoading: eligibilityLoading,
|
isLoading: eligibility.isLoading,
|
||||||
isError: eligibilityError,
|
isError: eligibility.isError,
|
||||||
isPending: eligibilityPending,
|
isPending: eligibility.isPending,
|
||||||
isNotRequested: eligibilityNotRequested,
|
isNotRequested: eligibility.isNotRequested,
|
||||||
isIneligible: eligibilityIneligible,
|
isIneligible: eligibility.isIneligible,
|
||||||
notes: eligibilityNotes ?? null,
|
notes: eligibility.notes,
|
||||||
requestedAt: eligibilityRequestedAt ?? null,
|
requestedAt: eligibility.requestedAt,
|
||||||
refetch: () => void eligibilityQuery.refetch(),
|
refetch: eligibility.refetch,
|
||||||
}}
|
}}
|
||||||
eligibilityRequest={{
|
eligibilityRequest={{
|
||||||
isPending: eligibilityRequest.isPending,
|
isPending: eligibilityRequest.isPending,
|
||||||
@ -330,8 +250,8 @@ export function AccountCheckoutContainer() {
|
|||||||
<SubCard>
|
<SubCard>
|
||||||
<AddressConfirmation
|
<AddressConfirmation
|
||||||
embedded
|
embedded
|
||||||
onAddressConfirmed={() => setAddressConfirmed(true)}
|
onAddressConfirmed={confirmAddress}
|
||||||
onAddressIncomplete={() => setAddressConfirmed(false)}
|
onAddressIncomplete={unconfirmAddress}
|
||||||
orderType={orderType}
|
orderType={orderType}
|
||||||
/>
|
/>
|
||||||
</SubCard>
|
</SubCard>
|
||||||
@ -343,7 +263,7 @@ export function AccountCheckoutContainer() {
|
|||||||
paymentMethodDisplay={paymentMethodDisplay}
|
paymentMethodDisplay={paymentMethodDisplay}
|
||||||
onManagePayment={() => void handleManagePayment()}
|
onManagePayment={() => void handleManagePayment()}
|
||||||
onRefresh={() => void paymentRefresh.triggerRefresh()}
|
onRefresh={() => void paymentRefresh.triggerRefresh()}
|
||||||
isOpeningPortal={openingPaymentPortal}
|
isOpeningPortal={formState.openingPaymentPortal}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<IdentityVerificationSection
|
<IdentityVerificationSection
|
||||||
@ -368,8 +288,8 @@ export function AccountCheckoutContainer() {
|
|||||||
|
|
||||||
<OrderSubmitSection
|
<OrderSubmitSection
|
||||||
pricing={cartItem.pricing}
|
pricing={cartItem.pricing}
|
||||||
submitError={submitError}
|
submitError={formState.submitError}
|
||||||
isSubmitting={submitting}
|
isSubmitting={formState.submitting}
|
||||||
canSubmit={canSubmit}
|
canSubmit={canSubmit}
|
||||||
onSubmit={() => void handleSubmitOrder()}
|
onSubmit={() => void handleSubmitOrder()}
|
||||||
onBack={navigateBackToConfigure}
|
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 { CheckoutErrorBoundary } from "./CheckoutErrorBoundary";
|
||||||
export { CheckoutEntry } from "./CheckoutEntry";
|
export { CheckoutEntry } from "./CheckoutEntry";
|
||||||
export { AccountCheckoutContainer } from "./AccountCheckoutContainer";
|
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,
|
useResidenceCardVerification,
|
||||||
useSubmitResidenceCard,
|
useSubmitResidenceCard,
|
||||||
} from "./useResidenceCardVerification";
|
} 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