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:
barsa 2026-01-19 15:47:43 +09:00
parent 0a5a33da98
commit 789e2d95a5
31 changed files with 1975 additions and 1428 deletions

View File

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

View File

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

View 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&apos;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>
);
}

View 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";

View File

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

View File

@ -0,0 +1 @@
export { ProfileLoadingSkeleton } from "./ProfileLoadingSkeleton";

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@ -0,0 +1,2 @@
export { TOTAL_FORM_STEPS, DEFAULT_ADDRESS } from "./japan-address.constants";
export { isValidStreetAddress, getStreetAddressError } from "./street-address.validation";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,3 @@
export { useCheckoutEligibility } from "./useCheckoutEligibility";
export { useCheckoutFormState, useCanSubmit } from "./useCheckoutFormState";
export { useCheckoutToast } from "./useCheckoutToast";

View File

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

View 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,
]
);
}

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

View File

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

View File

@ -0,0 +1,6 @@
export {
buildConfigureBackUrl,
buildVerificationRedirectUrl,
buildOrderSuccessUrl,
getShopHref,
} from "./checkout-navigation";

View File

@ -2,3 +2,4 @@ export {
useResidenceCardVerification, useResidenceCardVerification,
useSubmitResidenceCard, useSubmitResidenceCard,
} from "./useResidenceCardVerification"; } from "./useResidenceCardVerification";
export { useVerificationFileUpload } from "./useVerificationFileUpload";

View File

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