diff --git a/apps/portal/src/features/account/components/AddressCard.tsx b/apps/portal/src/features/account/components/AddressCard.tsx index 7d71678c..56b9bd27 100644 --- a/apps/portal/src/features/account/components/AddressCard.tsx +++ b/apps/portal/src/features/account/components/AddressCard.tsx @@ -1,9 +1,10 @@ "use client"; -import { SubCard } from "@/components/molecules/SubCard/SubCard"; import { MapPinIcon, PencilIcon, CheckIcon, XMarkIcon } from "@heroicons/react/24/outline"; import { AddressForm, type AddressFormProps } from "@/features/services/components"; import type { Address } from "@customer-portal/domain/customer"; +import { Button } from "@/components/atoms/button"; +import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; import { getCountryName } from "@/shared/constants"; function AddressDisplay({ address }: { address: Address }) { @@ -15,34 +16,26 @@ function AddressDisplay({ address }: { address: Address }) { : null; return ( -
- {primaryLine &&

{primaryLine}

} - {secondaryLine &&

{secondaryLine}

} - {cityStateZip &&

{cityStateZip}

} - {countryLabel &&

{countryLabel}

} +
+
+ {primaryLine &&

{primaryLine}

} + {secondaryLine &&

{secondaryLine}

} + {cityStateZip &&

{cityStateZip}

} + {countryLabel &&

{countryLabel}

} +
); } -function SaveButton({ isSaving, onClick }: { isSaving: boolean; onClick: () => void }) { +function EmptyAddressState({ onEdit }: { onEdit: () => void }) { return ( - +
+ +

No address on file

+ +
); } @@ -67,49 +60,78 @@ export function AddressCard({ onSave, onAddressChange, }: AddressCardProps) { + const hasAddress = Boolean(address.address1 || address.city); + return ( - -
+
+
-
- -
-

Address Information

+ +

Address Information

- {!isEditing && ( - + )}
-
+
{isEditing ? (
- onAddressChange(addr, true)} /> - {error &&
{error}
} -
- - + +
+ {error && ( + + {error} + + )}
- ) : ( + ) : hasAddress ? ( + ) : ( + )}
- +
); } diff --git a/apps/portal/src/features/account/components/PersonalInfoCard.tsx b/apps/portal/src/features/account/components/PersonalInfoCard.tsx index c2493746..f48720f3 100644 --- a/apps/portal/src/features/account/components/PersonalInfoCard.tsx +++ b/apps/portal/src/features/account/components/PersonalInfoCard.tsx @@ -1,13 +1,27 @@ "use client"; -import { SubCard } from "@/components/molecules/SubCard/SubCard"; import { UserIcon, PencilIcon, CheckIcon, XMarkIcon } from "@heroicons/react/24/outline"; -import type { UserProfile } from "@customer-portal/domain/customer"; import { Button } from "@/components/atoms/button"; import { Input } from "@/components/atoms/input"; +/** Data required for displaying personal info card */ +interface PersonalInfoData { + firstname: string | null | undefined; + lastname: string | null | undefined; + email: string; + phonenumber: string | null | undefined; + sfNumber: string | null | undefined; + dateOfBirth: string | null | undefined; + gender: string | null | undefined; +} + interface PersonalInfoCardProps { - data: UserProfile; + /** User profile data including read-only fields */ + data: PersonalInfoData; + /** Email value for editing (may differ from data.email during edit) */ + editEmail: string; + /** Phone number value for editing (may differ from data.phonenumber during edit) */ + editPhoneNumber: string; isEditing: boolean; isSaving: boolean; onEdit: () => void; @@ -16,8 +30,32 @@ interface PersonalInfoCardProps { onSave: () => void; } +function ReadOnlyField({ + label, + value, + hint, +}: { + label: string; + value: string | null | undefined; + hint: string; +}) { + return ( +
+ +
+

+ {value || Not provided} +

+

{hint}

+
+
+ ); +} + export function PersonalInfoCard({ data, + editEmail, + editPhoneNumber, isEditing, isSaving, onEdit, @@ -26,8 +64,8 @@ export function PersonalInfoCard({ onSave, }: PersonalInfoCardProps) { return ( - -
+
+
@@ -46,52 +84,32 @@ export function PersonalInfoCard({
-
-
-
- -
-

- {data.firstname || ( - Not provided - )} -

-

- Name cannot be changed from the portal. -

-
-
- -
- -
-

- {data.lastname || ( - Not provided - )} -

-

- Name cannot be changed from the portal. -

-
-
+
+
+ +
-
{isEditing && ( @@ -117,16 +173,14 @@ export function PersonalInfoCard({
)}
- +
); } diff --git a/apps/portal/src/features/account/components/VerificationCard.tsx b/apps/portal/src/features/account/components/VerificationCard.tsx new file mode 100644 index 00000000..436ab774 --- /dev/null +++ b/apps/portal/src/features/account/components/VerificationCard.tsx @@ -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; +type FileUpload = ReturnType; + +interface VerificationCardProps { + verificationQuery: VerificationQuery; + fileUpload: FileUpload; +} + +function VerificationStatusPill({ + status, + isLoading, +}: { + status?: string | undefined; + isLoading: boolean; +}) { + if (isLoading) { + return ; + } + switch (status) { + case "verified": + return ; + case "pending": + return ; + case "rejected": + return ; + default: + return ; + } +} + +function VerificationContent({ + data, + status, + isLoading, +}: { + data: ResidenceCardVerification | undefined; + status?: string | undefined; + isLoading: boolean; +}) { + if (isLoading) { + return ( +
+ + +
+ ); + } + + if (status === "verified") { + return ( +
+

+ Your identity has been verified. No further action is needed. +

+ {data?.reviewedAt && ( +

+ Verified on {formatIsoDate(data.reviewedAt, { dateStyle: "medium" })} +

+ )} +
+ ); + } + + if (status === "pending") { + return ( +
+ + Your residence card has been submitted. We'll verify it before activating SIM + service. + + {data?.submittedAt && ( +
+
+ Submission status +
+
+ Submitted on {formatIsoDate(data.submittedAt, { dateStyle: "medium" })} +
+
+ )} +
+ ); + } + + 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 ( +
+ {status === "rejected" ? ( + +
+ {data?.reviewerNotes &&

{data.reviewerNotes}

} +

Please upload a new, clear photo or scan of your residence card.

+
    +
  • Make sure all text is readable and the full card is visible.
  • +
  • Avoid glare/reflections and blurry photos.
  • +
  • Maximum file size: 5MB.
  • +
+
+
+ ) : ( +

+ Upload your residence card to activate SIM services. This is required for SIM orders. +

+ )} + + {(data?.submittedAt || data?.reviewedAt) && ( +
+
+ Latest submission +
+ {data?.submittedAt && ( +
+ Submitted on {formatIsoDate(data.submittedAt, { dateStyle: "medium" })} +
+ )} + {data?.reviewedAt && ( +
+ Reviewed on {formatIsoDate(data.reviewedAt, { dateStyle: "medium" })} +
+ )} +
+ )} + + {fileUpload.canUpload && ( +
+ 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 && ( +
+
+
Selected file
+
+ {fileUpload.file.name} +
+
+ +
+ )} + +
+ +
+ + {fileUpload.isError && ( +

+ {fileUpload.error instanceof Error + ? fileUpload.error.message + : "Failed to submit residence card."} +

+ )} + +

+ Accepted formats: JPG, PNG, or PDF (max 5MB). Make sure all text is readable. +

+
+ )} +
+ ); +} + +export function VerificationCard({ verificationQuery, fileUpload }: VerificationCardProps) { + const verificationStatus = verificationQuery.data?.status; + + return ( +
+
+
+
+ +

Identity Verification

+
+ +
+
+ +
+ + + {!verificationQuery.isLoading && ( + + )} +
+
+ ); +} diff --git a/apps/portal/src/features/account/components/index.ts b/apps/portal/src/features/account/components/index.ts new file mode 100644 index 00000000..217b7b66 --- /dev/null +++ b/apps/portal/src/features/account/components/index.ts @@ -0,0 +1,5 @@ +export { PersonalInfoCard } from "./PersonalInfoCard"; +export { AddressCard } from "./AddressCard"; +export { PasswordChangeCard } from "./PasswordChangeCard"; +export { VerificationCard } from "./VerificationCard"; +export { ProfileLoadingSkeleton } from "./skeletons"; diff --git a/apps/portal/src/features/account/components/skeletons/ProfileLoadingSkeleton.tsx b/apps/portal/src/features/account/components/skeletons/ProfileLoadingSkeleton.tsx new file mode 100644 index 00000000..47023602 --- /dev/null +++ b/apps/portal/src/features/account/components/skeletons/ProfileLoadingSkeleton.tsx @@ -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 ( +
+ {/* Personal Information Card Skeleton */} +
+
+
+
+
+
+
+
+
+
+
+
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ + +
+ ))} +
+ +
+
+ + +
+ +
+
+
+
+ + +
+
+
+ + {/* Address Card Skeleton */} +
+
+
+
+
+
+
+
+
+
+
+
+
+ + + + +
+
+
+ + +
+
+
+ + {/* Verification Card Skeleton */} +
+
+
+
+
+
+
+ +
+
+
+
+ + +
+
+
+
+ ); +} diff --git a/apps/portal/src/features/account/components/skeletons/index.ts b/apps/portal/src/features/account/components/skeletons/index.ts new file mode 100644 index 00000000..07a1a69b --- /dev/null +++ b/apps/portal/src/features/account/components/skeletons/index.ts @@ -0,0 +1 @@ +export { ProfileLoadingSkeleton } from "./ProfileLoadingSkeleton"; diff --git a/apps/portal/src/features/account/hooks/index.ts b/apps/portal/src/features/account/hooks/index.ts index f23520bd..73004639 100644 --- a/apps/portal/src/features/account/hooks/index.ts +++ b/apps/portal/src/features/account/hooks/index.ts @@ -1,3 +1,4 @@ export { useProfileData } from "./useProfileData"; export { useProfileEdit } from "./useProfileEdit"; export { useAddressEdit } from "./useAddressEdit"; +export { useProfileDataLoading } from "./useProfileDataLoading"; diff --git a/apps/portal/src/features/account/hooks/useProfileDataLoading.ts b/apps/portal/src/features/account/hooks/useProfileDataLoading.ts new file mode 100644 index 00000000..6f1a05ac --- /dev/null +++ b/apps/portal/src/features/account/hooks/useProfileDataLoading.ts @@ -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(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, + }; +} diff --git a/apps/portal/src/features/account/views/ProfileContainer.tsx b/apps/portal/src/features/account/views/ProfileContainer.tsx index 7c2ef54e..a4563056 100644 --- a/apps/portal/src/features/account/views/ProfileContainer.tsx +++ b/apps/portal/src/features/account/views/ProfileContainer.tsx @@ -1,187 +1,38 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { Skeleton } from "@/components/atoms/loading-skeleton"; +import { useState } from "react"; +import { UserIcon } from "@heroicons/react/24/outline"; import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; -import { - MapPinIcon, - PencilIcon, - CheckIcon, - XMarkIcon, - UserIcon, - ShieldCheckIcon, -} from "@heroicons/react/24/outline"; +import { PageLayout } from "@/components/templates"; import { useAuthStore } from "@/features/auth/stores/auth.store"; -import { accountService } from "@/features/account/api/account.api"; -import { useProfileEdit } from "@/features/account/hooks/useProfileEdit"; -import { AddressForm } from "@/features/services/components/base/AddressForm"; -import { Button } from "@/components/atoms/button"; -import { StatusPill } from "@/components/atoms/status-pill"; -import { useAddressEdit } from "@/features/account/hooks/useAddressEdit"; +import { useProfileDataLoading } from "@/features/account/hooks"; import { useResidenceCardVerification, - useSubmitResidenceCard, -} from "@/features/verification/hooks/useResidenceCardVerification"; -import { PageLayout } from "@/components/templates"; -import { formatIsoDate } from "@/shared/utils"; + useVerificationFileUpload, +} from "@/features/verification/hooks"; +import { + PersonalInfoCard, + AddressCard, + VerificationCard, + ProfileLoadingSkeleton, +} from "@/features/account/components"; export default function ProfileContainer() { const { user } = useAuthStore(); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); const [editingProfile, setEditingProfile] = useState(false); const [editingAddress, setEditingAddress] = useState(false); - const [reloadKey, setReloadKey] = useState(0); - const profile = useProfileEdit({ + const { isLoading, error, reload, profile, address } = useProfileDataLoading({ email: user?.email || "", phonenumber: user?.phonenumber || "", }); - const address = useAddressEdit({ - address1: "", - address2: "", - city: "", - state: "", - postcode: "", - country: "", - countryCode: "", - phoneNumber: "", - phoneCountryCode: "", + const verificationQuery = useResidenceCardVerification(); + const fileUpload = useVerificationFileUpload({ + verificationStatus: verificationQuery.data?.status, }); - // ID Verification status from Salesforce - const verificationQuery = useResidenceCardVerification(); - const submitResidenceCard = useSubmitResidenceCard(); - const verificationStatus = verificationQuery.data?.status; - const [verificationFile, setVerificationFile] = useState(null); - const verificationFileInputRef = useRef(null); - const canUploadVerification = verificationStatus !== "verified"; - - // Helper to render verification status pill - const renderVerificationStatusPill = () => { - if (verificationQuery.isLoading) { - return ; - } - switch (verificationStatus) { - case "verified": - return ; - case "pending": - return ; - case "rejected": - return ; - default: - return ; - } - }; - - // Helper to render verification content based on status - const renderVerificationContent = () => { - if (verificationQuery.isLoading) { - return ( -
- - -
- ); - } - - if (verificationStatus === "verified") { - return ( -
-

- Your identity has been verified. No further action is needed. -

- {verificationQuery.data?.reviewedAt && ( -

- Verified on{" "} - {formatIsoDate(verificationQuery.data.reviewedAt, { dateStyle: "medium" })} -

- )} -
- ); - } - - if (verificationStatus === "pending") { - return ( -
- - Your residence card has been submitted. We'll verify it before activating SIM - service. - - {verificationQuery.data?.submittedAt && ( -
-
- Submission status -
-
- Submitted on{" "} - {formatIsoDate(verificationQuery.data.submittedAt, { dateStyle: "medium" })} -
-
- )} -
- ); - } - - // Default: rejected or not submitted - return null; - }; - - // Extract stable setValue functions to avoid infinite re-render loop. - // The hook objects (address, profile) are recreated every render, but - // the setValue callbacks inside them are stable (memoized with useCallback). - const setAddressValue = address.setValue; - const setProfileValue = profile.setValue; - - const loadProfile = useCallback(() => { - void (async () => { - try { - setError(null); - setLoading(true); - const [addr, prof] = await Promise.all([ - accountService.getAddress().catch(() => null), - accountService.getProfile().catch(() => null), - ]); - if (addr) { - setAddressValue("address1", addr.address1 ?? ""); - setAddressValue("address2", addr.address2 ?? ""); - setAddressValue("city", addr.city ?? ""); - setAddressValue("state", addr.state ?? ""); - setAddressValue("postcode", addr.postcode ?? ""); - setAddressValue("country", addr.country ?? ""); - setAddressValue("countryCode", addr.countryCode ?? ""); - setAddressValue("phoneNumber", addr.phoneNumber ?? ""); - setAddressValue("phoneCountryCode", addr.phoneCountryCode ?? ""); - } - if (prof) { - setProfileValue("email", prof.email || ""); - setProfileValue("phonenumber", prof.phonenumber || ""); - // Spread all profile fields into the user state, including sfNumber, dateOfBirth, gender - useAuthStore.setState(state => ({ - ...state, - user: state.user - ? { - ...state.user, - ...prof, - } - : prof, - })); - } - } catch (e) { - // Keep message customer-safe (no internal details) - setError(e instanceof Error ? e.message : "Failed to load profile data"); - } finally { - setLoading(false); - } - })(); - }, [setAddressValue, setProfileValue]); - - useEffect(() => { - loadProfile(); - }, [loadProfile, reloadKey]); - - if (loading) { + if (isLoading) { return ( } @@ -189,69 +40,7 @@ export default function ProfileContainer() { description="Manage your account information" loading > -
-
-
-
-
-
-
-
-
-
-
-
-
- {Array.from({ length: 4 }).map((_, i) => ( -
- - -
- ))} -
- -
-
- - -
- -
-
-
-
- - -
-
-
- -
-
-
-
-
-
-
-
-
-
-
-
-
- - - - -
-
-
- - -
-
-
-
+ ); } @@ -262,7 +51,7 @@ export default function ProfileContainer() { title="Profile" description="Manage your account information" error={error} - onRetry={() => setReloadKey(k => k + 1)} + onRetry={reload} > {error && ( @@ -270,435 +59,76 @@ export default function ProfileContainer() { )} -
-
-
-
- -

Personal Information

-
- {!editingProfile && ( - - )} -
-
+ setEditingProfile(true)} + onCancel={() => setEditingProfile(false)} + onChange={(field, value) => profile.setValue(field, value)} + onSave={() => { + void profile + .handleSubmit() + .then(() => { + setEditingProfile(false); + }) + .catch(() => { + // Error is handled by useZodForm + }); + }} + /> -
-
-
- -
-

- {user?.firstname || ( - Not provided - )} -

-

- Name cannot be changed from the portal. -

-
-
-
- -
-

- {user?.lastname || ( - Not provided - )} -

-

- Name cannot be changed from the portal. -

-
-
-
- - {editingProfile ? ( - 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" - /> - ) : ( -
-
-

{user?.email}

-
-

- Email can be updated from the portal. -

-
- )} -
+ setEditingAddress(true)} + onCancel={() => setEditingAddress(false)} + onAddressChange={a => { + address.setValue("address1", a.address1 ?? ""); + address.setValue("address2", a.address2 ?? ""); + address.setValue("city", a.city ?? ""); + address.setValue("state", a.state ?? ""); + address.setValue("postcode", a.postcode ?? ""); + address.setValue("country", a.country ?? ""); + address.setValue("countryCode", a.countryCode ?? ""); + address.setValue("phoneNumber", a.phoneNumber ?? ""); + address.setValue("phoneCountryCode", a.phoneCountryCode ?? ""); + }} + onSave={() => { + void address + .handleSubmit() + .then(() => { + setEditingAddress(false); + }) + .catch(() => { + // Error is handled by useZodForm + }); + }} + /> -
- -
-

- {user?.sfNumber || ( - Not available - )} -

-

Customer number is read-only.

-
-
- -
- -
-

- {user?.dateOfBirth || ( - Not provided - )} -

-

- Date of birth is stored in billing profile. -

-
-
-
- - {editingProfile ? ( - 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" - /> - ) : ( -

- {user?.phonenumber || ( - Not provided - )} -

- )} -
- -
- -
-

- {user?.gender || ( - Not provided - )} -

-

- Gender is stored in billing profile. -

-
-
-
- - {editingProfile && ( -
- - -
- )} -
-
- -
-
-
-
- -

Address Information

-
- {!editingAddress && ( - - )} -
-
- -
- {editingAddress ? ( -
- { - 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" - /> -
- - -
- {address.submitError && ( - - {address.submitError} - - )} -
- ) : ( -
- {address.values.address1 || address.values.city ? ( -
-
- {(address.values.address2 || address.values.address1) && ( -

- {address.values.address2 || address.values.address1} -

- )} - {address.values.address2 && address.values.address1 && ( -

{address.values.address1}

- )} -

- {[address.values.city, address.values.state, address.values.postcode] - .filter(Boolean) - .join(", ")} -

-

{address.values.country}

-
-
- ) : ( -
- -

No address on file

- -
- )} -
- )} -
-
- - {/* ID Verification Card - Integrated Upload */} -
-
-
-
- -

Identity Verification

-
- {renderVerificationStatusPill()} -
-
- -
- {renderVerificationContent()} - - {/* Upload section for rejected or not submitted status */} - {!verificationQuery.isLoading && - verificationStatus !== "verified" && - verificationStatus !== "pending" && ( -
- {verificationStatus === "rejected" ? ( - -
- {verificationQuery.data?.reviewerNotes && ( -

{verificationQuery.data.reviewerNotes}

- )} -

Please upload a new, clear photo or scan of your residence card.

-
    -
  • Make sure all text is readable and the full card is visible.
  • -
  • Avoid glare/reflections and blurry photos.
  • -
  • Maximum file size: 5MB.
  • -
-
-
- ) : ( -

- Upload your residence card to activate SIM services. This is required for SIM - orders. -

- )} - - {(verificationQuery.data?.submittedAt || verificationQuery.data?.reviewedAt) && ( -
-
- Latest submission -
- {verificationQuery.data?.submittedAt && ( -
- Submitted on{" "} - {formatIsoDate(verificationQuery.data.submittedAt, { dateStyle: "medium" })} -
- )} - {verificationQuery.data?.reviewedAt && ( -
- Reviewed on{" "} - {formatIsoDate(verificationQuery.data.reviewedAt, { dateStyle: "medium" })} -
- )} -
- )} - - {canUploadVerification && ( -
- 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 && ( -
-
-
- Selected file -
-
- {verificationFile.name} -
-
- -
- )} - -
- -
- - {submitResidenceCard.isError && ( -

- {submitResidenceCard.error instanceof Error - ? submitResidenceCard.error.message - : "Failed to submit residence card."} -

- )} - -

- Accepted formats: JPG, PNG, or PDF (max 5MB). Make sure all text is readable. -

-
- )} -
- )} -
-
+ ); } diff --git a/apps/portal/src/features/address/components/AnimatedSection.tsx b/apps/portal/src/features/address/components/AnimatedSection.tsx new file mode 100644 index 00000000..59a2f5d7 --- /dev/null +++ b/apps/portal/src/features/address/components/AnimatedSection.tsx @@ -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 ( +
+
{children}
+
+ ); +} diff --git a/apps/portal/src/features/address/components/BilingualValue.tsx b/apps/portal/src/features/address/components/BilingualValue.tsx new file mode 100644 index 00000000..5b06ab73 --- /dev/null +++ b/apps/portal/src/features/address/components/BilingualValue.tsx @@ -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 {placeholder}; + } + + return ( +
+ {romaji} + {japanese && ({japanese})} +
+ ); +} diff --git a/apps/portal/src/features/address/components/JapanAddressForm.tsx b/apps/portal/src/features/address/components/JapanAddressForm.tsx index 6ff11975..a06d68c0 100644 --- a/apps/portal/src/features/address/components/JapanAddressForm.tsx +++ b/apps/portal/src/features/address/components/JapanAddressForm.tsx @@ -12,18 +12,25 @@ * - Compatible with WHMCS and Salesforce field mapping */ -import { useCallback, useState, useEffect, useRef } from "react"; import { Home, Building2, CheckCircle2, MapPin, ChevronRight, Sparkles } from "lucide-react"; import { Input } from "@/components/atoms"; import { FormField } from "@/components/molecules/FormField/FormField"; import { cn } from "@/shared/utils"; import { ZipCodeInput } from "./ZipCodeInput"; +import { AnimatedSection } from "./AnimatedSection"; +import { ProgressIndicator } from "./ProgressIndicator"; +import { BilingualValue } from "./BilingualValue"; import { type BilingualAddress, - type JapanPostAddress, RESIDENCE_TYPE, type ResidenceType, } from "@customer-portal/domain/address"; +import { useJapanAddressForm } from "@/features/address/hooks"; +import { + TOTAL_FORM_STEPS, + isValidStreetAddress, + getStreetAddressError, +} from "@/features/address/utils"; // ============================================================================ // Types @@ -33,8 +40,6 @@ export type JapanAddressFormData = BilingualAddress; /** * Type for partial initial values that allows undefined residenceType. - * This is needed because with exactOptionalPropertyTypes, Partial - * makes properties optional but doesn't allow explicitly setting undefined. */ type JapanAddressFormInitialValues = Omit & { residenceType?: ResidenceType | undefined; @@ -60,140 +65,193 @@ export interface JapanAddressFormProps { } // ============================================================================ -// Default Values +// Step Header Component // ============================================================================ -const DEFAULT_ADDRESS: Omit & { - residenceType: ResidenceType | ""; -} = { - postcode: "", - prefecture: "", - prefectureJa: "", - city: "", - cityJa: "", - town: "", - townJa: "", - streetAddress: "", - buildingName: "", - roomNumber: "", - residenceType: "", -}; - -// ============================================================================ -// Street Address Validation -// ============================================================================ - -/** - * Validates Japanese street address format (chome-ban-go system) - * Valid patterns: - * - "1-2-3" (chome-banchi-go) - * - "1-2" (chome-banchi) - * - "12-34-5" (larger numbers) - * - "1" (single number for some rural areas) - * - * Requirements: - * - Must start with a number - * - Can contain numbers separated by hyphens - * - Minimum 1 digit required - */ -function isValidStreetAddress(value: string): boolean { - const trimmed = value.trim(); - if (!trimmed) return false; - - // Pattern: starts with digit(s), optionally followed by hyphen-digit groups - // Examples: "1", "1-2", "1-2-3", "12-34-5" - const pattern = /^\d+(-\d+)*$/; - return pattern.test(trimmed); -} - -function getStreetAddressError(value: string): string | undefined { - const trimmed = value.trim(); - if (!trimmed) return "Street address is required"; - if (!isValidStreetAddress(trimmed)) { - return "Enter a valid format (e.g., 1-2-3)"; - } - return undefined; -} - -// ============================================================================ -// Animation Wrapper Component -// ============================================================================ - -function AnimatedSection({ - show, - children, - delay = 0, +function StepHeader({ + stepNumber, + label, + isComplete, + badge, }: { - show: boolean; - children: React.ReactNode; - delay?: number; + stepNumber: number; + label: string; + isComplete: boolean; + badge?: React.ReactNode; +}) { + return ( +
+
+ {isComplete ? : stepNumber} +
+ {label} + {badge} +
+ ); +} + +// ============================================================================ +// Verified Address Display Component +// ============================================================================ + +function VerifiedAddressDisplay({ + address, + isVerified, +}: { + address: { + prefecture: string; + prefectureJa: string; + city: string; + cityJa: string; + town: string; + townJa: string; + }; + isVerified: boolean; }) { return (
-
{children}
+
+
+ + Address from Japan Post +
+ +
+
+ Prefecture + + +
+ +
+ City / Ward + + +
+ +
+ Town + + +
+
+
); } // ============================================================================ -// Progress Step Indicator +// Residence Type Selector Component // ============================================================================ -function ProgressIndicator({ - currentStep, - totalSteps, +function ResidenceTypeSelector({ + value, + onChange, + disabled, + error, }: { - currentStep: number; - totalSteps: number; + value: ResidenceType | ""; + onChange: (type: ResidenceType) => void; + disabled: boolean; + error?: string | undefined; }) { return ( -
- {Array.from({ length: totalSteps }).map((_, i) => ( -
+
+
- ); -} + > +
+ +
+ + House + + -// ============================================================================ -// Bilingual Field Display -// ============================================================================ + +
-function BilingualValue({ - romaji, - japanese, - placeholder, - verified, -}: { - romaji: string; - japanese?: string; - placeholder: string; - verified: boolean; -}) { - if (!verified) { - return {placeholder}; - } - - return ( -
- {romaji} - {japanese && ({japanese})} + {error &&

{error}

}
); } @@ -202,10 +260,6 @@ function BilingualValue({ // Main Component // ============================================================================ -type InternalFormState = Omit & { - residenceType: ResidenceType | ""; -}; - export function JapanAddressForm({ initialValues, onChange, @@ -216,229 +270,42 @@ export function JapanAddressForm({ className, completionContent, }: JapanAddressFormProps) { - const [address, setAddress] = useState(() => ({ - ...DEFAULT_ADDRESS, - ...initialValues, - // Convert undefined residenceType to empty string for internal state - residenceType: initialValues?.residenceType ?? DEFAULT_ADDRESS.residenceType, - })); + const form = useJapanAddressForm({ + initialValues, + onChange, + errors, + touched, + disabled, + }); - const [isAddressVerified, setIsAddressVerified] = useState(false); - const [verifiedZipCode, setVerifiedZipCode] = useState(""); - const [showSuccess, setShowSuccess] = useState(false); - - const onChangeRef = useRef(onChange); - onChangeRef.current = onChange; - - const streetAddressRef = useRef(null); - const focusTimeoutRef = useRef | null>(null); - const hasInitializedRef = useRef(false); - - // Calculate current step for progress - const getCurrentStep = () => { - if (!isAddressVerified) return 0; - if (!address.streetAddress.trim()) return 1; - if (!address.residenceType) return 2; - if (address.residenceType === RESIDENCE_TYPE.APARTMENT && !address.roomNumber?.trim()) return 3; - return 4; - }; - - const currentStep = getCurrentStep(); - - // Only apply initialValues on first mount to avoid resetting user edits - useEffect(() => { - if (initialValues && !hasInitializedRef.current) { - hasInitializedRef.current = true; - setAddress(prev => ({ - ...prev, - ...initialValues, - // Convert undefined residenceType to empty string for internal state - residenceType: initialValues.residenceType ?? prev.residenceType, - })); - if (initialValues.prefecture && initialValues.city && initialValues.town) { - setIsAddressVerified(true); - setVerifiedZipCode(initialValues.postcode || ""); - } - } - }, [initialValues]); - - // Cleanup timeout on unmount - useEffect(() => { - return () => { - if (focusTimeoutRef.current) { - clearTimeout(focusTimeoutRef.current); - } - }; - }, []); - - const getError = (field: keyof JapanAddressFormData): string | undefined => { - return touched[field] ? errors[field] : undefined; - }; - - // Calculate form completion status - const hasResidenceType = - address.residenceType === RESIDENCE_TYPE.HOUSE || - address.residenceType === RESIDENCE_TYPE.APARTMENT; - - const baseFieldsFilled = - address.postcode.trim() !== "" && - address.prefecture.trim() !== "" && - address.city.trim() !== "" && - address.town.trim() !== "" && - isValidStreetAddress(address.streetAddress); - - // Get street address validation error for display - const streetAddressError = getStreetAddressError(address.streetAddress); - - const roomNumberOk = - address.residenceType !== RESIDENCE_TYPE.APARTMENT || (address.roomNumber?.trim() ?? "") !== ""; - - // Building name is required for both houses and apartments - const buildingNameOk = (address.buildingName?.trim() ?? "") !== ""; - - const isComplete = - isAddressVerified && hasResidenceType && baseFieldsFilled && buildingNameOk && roomNumberOk; - - // Notify parent of changes - only send valid typed address when residenceType is set - useEffect(() => { - if (hasResidenceType) { - // Safe to cast since we verified residenceType is valid - onChangeRef.current?.(address as JapanAddressFormData, isComplete); - } else { - // Send incomplete state with partial data (parent should check isComplete flag) - onChangeRef.current?.(address as JapanAddressFormData, false); - } - }, [address, isAddressVerified, hasResidenceType, isComplete]); - - // Manage success animation separately to avoid callback double-firing - useEffect(() => { - setShowSuccess(isComplete); - }, [isComplete]); - - const handleZipChange = useCallback( - (value: string) => { - const normalizedNew = value.replace(/-/g, ""); - const normalizedVerified = verifiedZipCode.replace(/-/g, ""); - const shouldReset = normalizedNew !== normalizedVerified; - - if (shouldReset) { - setIsAddressVerified(false); - setShowSuccess(false); - setAddress(prev => ({ - ...prev, - postcode: value, - prefecture: "", - prefectureJa: "", - city: "", - cityJa: "", - town: "", - townJa: "", - buildingName: prev.buildingName, - roomNumber: prev.roomNumber, - residenceType: prev.residenceType, - })); - } else { - setAddress(prev => ({ ...prev, postcode: value })); - } - }, - [verifiedZipCode] - ); - - const handleAddressFound = useCallback((found: JapanPostAddress) => { - setAddress(prev => { - setIsAddressVerified(true); - setVerifiedZipCode(prev.postcode); - return { - ...prev, - prefecture: found.prefectureRoma, - city: found.cityRoma, - town: found.townRoma, - prefectureJa: found.prefecture, - cityJa: found.city, - townJa: found.town, - }; - }); - - // Focus street address input after lookup (with cleanup tracking) - if (focusTimeoutRef.current) { - clearTimeout(focusTimeoutRef.current); - } - focusTimeoutRef.current = setTimeout(() => { - streetAddressRef.current?.focus(); - focusTimeoutRef.current = null; - }, 300); - }, []); - - const handleLookupComplete = useCallback((found: boolean) => { - if (!found) { - setIsAddressVerified(false); - setAddress(prev => ({ - ...prev, - prefecture: "", - prefectureJa: "", - city: "", - cityJa: "", - town: "", - townJa: "", - })); - } - }, []); - - const handleResidenceTypeChange = useCallback((type: ResidenceType) => { - setAddress(prev => ({ - ...prev, - residenceType: type, - roomNumber: type === RESIDENCE_TYPE.HOUSE ? "" : prev.roomNumber, - })); - }, []); - - const handleStreetAddressChange = useCallback((value: string) => { - setAddress(prev => ({ ...prev, streetAddress: value })); - }, []); - - const handleBuildingNameChange = useCallback((value: string) => { - setAddress(prev => ({ ...prev, buildingName: value })); - }, []); - - const handleRoomNumberChange = useCallback((value: string) => { - setAddress(prev => ({ ...prev, roomNumber: value })); - }, []); - - const isApartment = address.residenceType === RESIDENCE_TYPE.APARTMENT; + const streetAddressError = getStreetAddressError(form.address.streetAddress); return (
- {/* Progress Indicator */} - + {/* Step 1: ZIP Code Lookup */}
-
-
- {isAddressVerified ? : "1"} -
- Enter ZIP Code - {isAddressVerified && ( - - - Verified - - )} -
+ + + Verified + + ) + } + /> {/* Verified Address Display */} - -
-
-
- - Address from Japan Post -
- -
- {/* Prefecture */} -
- Prefecture - - -
- - {/* City */} -
- City / Ward - - -
- - {/* Town */} -
- Town - - -
-
-
-
+ + {/* Step 2: Street Address */} - +
-
-
- {isValidStreetAddress(address.streetAddress) ? ( - - ) : ( - "2" - )} -
- Street Address -
+ handleStreetAddressChange(e.target.value)} + ref={form.streetAddressRef} + value={form.address.streetAddress} + onChange={e => form.handlers.handleStreetAddressChange(e.target.value)} onBlur={() => onBlur?.("streetAddress")} placeholder="1-5-3" disabled={disabled} @@ -552,149 +357,63 @@ export function JapanAddressForm({ {/* Step 3: Residence Type */}
-
-
- {hasResidenceType ? : "3"} -
- Residence Type -
+ -
- - - -
- - {!hasResidenceType && getError("residenceType") && ( -

{getError("residenceType")}

- )} +
{/* Step 4: Building Details */} - +
-
-
- {showSuccess ? : "4"} -
- Building Details -
+ - {/* Building Name */} handleBuildingNameChange(e.target.value)} + value={form.address.buildingName ?? ""} + onChange={e => form.handlers.handleBuildingNameChange(e.target.value)} onBlur={() => onBlur?.("buildingName")} - placeholder={isApartment ? "Sunshine Mansion" : "Tanaka Residence"} + placeholder={form.isApartment ? "Sunshine Mansion" : "Tanaka Residence"} disabled={disabled} data-field="address.buildingName" /> - {/* Room Number - Only for apartments */} - {isApartment && ( + {form.isApartment && ( handleRoomNumberChange(e.target.value)} + value={form.address.roomNumber ?? ""} + onChange={e => form.handlers.handleRoomNumberChange(e.target.value)} onBlur={() => onBlur?.("roomNumber")} placeholder="201" disabled={disabled} @@ -706,8 +425,8 @@ export function JapanAddressForm({
- {/* Success State - shows custom content or default message */} - + {/* Success State */} + {completionContent ?? (
diff --git a/apps/portal/src/features/address/components/ProgressIndicator.tsx b/apps/portal/src/features/address/components/ProgressIndicator.tsx new file mode 100644 index 00000000..48a3f4a8 --- /dev/null +++ b/apps/portal/src/features/address/components/ProgressIndicator.tsx @@ -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 ( +
+ {Array.from({ length: totalSteps }).map((_, i) => ( +
+ ))} +
+ ); +} diff --git a/apps/portal/src/features/address/components/index.ts b/apps/portal/src/features/address/components/index.ts index 81f81376..de3c9eb5 100644 --- a/apps/portal/src/features/address/components/index.ts +++ b/apps/portal/src/features/address/components/index.ts @@ -5,3 +5,6 @@ export { type JapanAddressFormData, } from "./JapanAddressForm"; export { AddressStepJapan } from "./AddressStepJapan"; +export { AnimatedSection } from "./AnimatedSection"; +export { ProgressIndicator } from "./ProgressIndicator"; +export { BilingualValue } from "./BilingualValue"; diff --git a/apps/portal/src/features/address/hooks/index.ts b/apps/portal/src/features/address/hooks/index.ts index 4223fd6e..48168162 100644 --- a/apps/portal/src/features/address/hooks/index.ts +++ b/apps/portal/src/features/address/hooks/index.ts @@ -4,3 +4,5 @@ export { getFirstAddress, EMPTY_LOOKUP_RESULT, } from "./useAddressLookup"; +export { useAddressCompletion } from "./useAddressCompletion"; +export { useJapanAddressForm } from "./useJapanAddressForm"; diff --git a/apps/portal/src/features/address/hooks/useAddressCompletion.ts b/apps/portal/src/features/address/hooks/useAddressCompletion.ts new file mode 100644 index 00000000..d6d41ea5 --- /dev/null +++ b/apps/portal/src/features/address/hooks/useAddressCompletion.ts @@ -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]); +} diff --git a/apps/portal/src/features/address/hooks/useJapanAddressForm.ts b/apps/portal/src/features/address/hooks/useJapanAddressForm.ts new file mode 100644 index 00000000..fdc9da1b --- /dev/null +++ b/apps/portal/src/features/address/hooks/useJapanAddressForm.ts @@ -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 & { + residenceType?: ResidenceType | undefined; +}; + +/** + * Internal form state type where residenceType can be empty string. + */ +type InternalFormState = Omit & { + residenceType: ResidenceType | ""; +}; + +interface UseJapanAddressFormOptions { + /** Initial address values */ + initialValues?: Partial | undefined; + /** Callback when address changes */ + onChange?: ((address: BilingualAddress, isComplete: boolean) => void) | undefined; + /** Field-level errors */ + errors?: Partial> | undefined; + /** Fields that have been touched */ + touched?: Partial> | 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(() => ({ + ...DEFAULT_ADDRESS, + ...initialValues, + residenceType: initialValues?.residenceType ?? DEFAULT_ADDRESS.residenceType, + })); + + const [isAddressVerified, setIsAddressVerified] = useState(false); + const [verifiedZipCode, setVerifiedZipCode] = useState(""); + const [showSuccess, setShowSuccess] = useState(false); + + const onChangeRef = useRef(onChange); + onChangeRef.current = onChange; + + const streetAddressRef = useRef(null); + const focusTimeoutRef = useRef | 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, + }, + }; +} diff --git a/apps/portal/src/features/address/utils/index.ts b/apps/portal/src/features/address/utils/index.ts new file mode 100644 index 00000000..9fc303c8 --- /dev/null +++ b/apps/portal/src/features/address/utils/index.ts @@ -0,0 +1,2 @@ +export { TOTAL_FORM_STEPS, DEFAULT_ADDRESS } from "./japan-address.constants"; +export { isValidStreetAddress, getStreetAddressError } from "./street-address.validation"; diff --git a/apps/portal/src/features/address/utils/japan-address.constants.ts b/apps/portal/src/features/address/utils/japan-address.constants.ts new file mode 100644 index 00000000..daa47304 --- /dev/null +++ b/apps/portal/src/features/address/utils/japan-address.constants.ts @@ -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: "", +}; diff --git a/apps/portal/src/features/address/utils/street-address.validation.ts b/apps/portal/src/features/address/utils/street-address.validation.ts new file mode 100644 index 00000000..7bc919b5 --- /dev/null +++ b/apps/portal/src/features/address/utils/street-address.validation.ts @@ -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; +} diff --git a/apps/portal/src/features/checkout/components/AccountCheckoutContainer.tsx b/apps/portal/src/features/checkout/components/AccountCheckoutContainer.tsx index 7615b65e..38f5b5a9 100644 --- a/apps/portal/src/features/checkout/components/AccountCheckoutContainer.tsx +++ b/apps/portal/src/features/checkout/components/AccountCheckoutContainer.tsx @@ -1,13 +1,11 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo } from "react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { ShieldCheck } from "lucide-react"; import { PageLayout } from "@/components/templates/PageLayout"; import { SubCard } from "@/components/molecules/SubCard/SubCard"; -import { Button } from "@/components/atoms/button"; -import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; import { InlineToast } from "@/components/atoms/inline-toast"; import { AddressConfirmation } from "@/features/services/components/base/AddressConfirmation"; import { useCheckoutStore } from "@/features/checkout/stores/checkout.store"; @@ -16,29 +14,33 @@ import { usePaymentMethods } from "@/features/billing/hooks/useBilling"; import { usePaymentRefresh } from "@/features/billing/hooks/usePaymentRefresh"; import { billingService } from "@/features/billing/api/billing.api"; import { openSsoLink } from "@/features/billing/utils/sso"; -import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscriptions"; -import { ACTIVE_INTERNET_SUBSCRIPTION_WARNING } from "@/features/checkout/constants"; -import { - useInternetEligibility, - useRequestInternetEligibilityCheck, -} from "@/features/services/hooks/useInternetEligibility"; import { useResidenceCardVerification, useSubmitResidenceCard, } from "@/features/verification/hooks/useResidenceCardVerification"; import { useAuthSession } from "@/features/auth/stores/auth.store"; -import { - ORDER_TYPE, - type OrderTypeValue, - toOrderTypeValueFromCheckout, -} from "@customer-portal/domain/orders"; +import { toOrderTypeValueFromCheckout, type OrderTypeValue } from "@customer-portal/domain/orders"; import { buildPaymentMethodDisplay, formatAddressLabel } from "@/shared/utils"; + import { CheckoutStatusBanners } from "./CheckoutStatusBanners"; import { PaymentMethodSection, IdentityVerificationSection, OrderSubmitSection, } from "./checkout-sections"; +import { CheckoutErrorFallback } from "./CheckoutErrorFallback"; +import { + useCheckoutEligibility, + useCheckoutFormState, + useCheckoutToast, + useCanSubmit, +} from "@/features/checkout/hooks"; +import { + buildConfigureBackUrl, + buildVerificationRedirectUrl, + buildOrderSuccessUrl, + getShopHref, +} from "@/features/checkout/utils"; export function AccountCheckoutContainer() { const router = useRouter(); @@ -48,32 +50,26 @@ export function AccountCheckoutContainer() { const { cartItem, checkoutSessionId, clear } = useCheckoutStore(); - const [submitting, setSubmitting] = useState(false); - const [addressConfirmed, setAddressConfirmed] = useState(false); - const [submitError, setSubmitError] = useState(null); - const [openingPaymentPortal, setOpeningPaymentPortal] = useState(false); - const paymentToastTimeoutRef = useRef(null); - const orderType: OrderTypeValue | null = useMemo(() => { return toOrderTypeValueFromCheckout(cartItem?.orderType); }, [cartItem?.orderType]); - const isInternetOrder = orderType === ORDER_TYPE.INTERNET; + // Form state management + const { + formState, + confirmAddress, + unconfirmAddress, + startSubmitting, + stopSubmitting, + setError, + startOpeningPortal, + stopOpeningPortal, + } = useCheckoutFormState(); - // Active subscriptions check - const { data: activeSubs } = useActiveSubscriptions(); - const hasActiveInternetSubscription = useMemo(() => { - if (!Array.isArray(activeSubs)) return false; - return activeSubs.some( - subscription => - String(subscription.groupName || subscription.productName || "") - .toLowerCase() - .includes("internet") && String(subscription.status || "").toLowerCase() === "active" - ); - }, [activeSubs]); - - const activeInternetWarning = - isInternetOrder && hasActiveInternetSubscription ? ACTIVE_INTERNET_SUBSCRIPTION_WARNING : null; + // Eligibility management + const { eligibility, eligibilityRequest, activeInternetWarning } = useCheckoutEligibility({ + orderType, + }); // Payment methods const { @@ -88,6 +84,8 @@ export function AccountCheckoutContainer() { attachFocusListeners: false, }); + const { showToast } = useCheckoutToast({ setToast: paymentRefresh.setToast }); + const paymentMethodList = paymentMethods?.paymentMethods ?? []; const hasPaymentMethod = paymentMethodList.length > 0; const defaultPaymentMethod = @@ -96,34 +94,6 @@ export function AccountCheckoutContainer() { ? buildPaymentMethodDisplay(defaultPaymentMethod) : null; - // Eligibility - const eligibilityQuery = useInternetEligibility({ enabled: isInternetOrder }); - const eligibilityData = eligibilityQuery.data as - | { eligibility?: string; status?: string; requestedAt?: string; notes?: string } - | null - | undefined; - const eligibilityValue = eligibilityData?.eligibility; - const eligibilityStatus = eligibilityData?.status; - const eligibilityRequestedAt = eligibilityData?.requestedAt; - const eligibilityNotes = eligibilityData?.notes; - const eligibilityRequest = useRequestInternetEligibilityCheck(); - const eligibilityLoading = Boolean(isInternetOrder && eligibilityQuery.isLoading); - const eligibilityNotRequested = Boolean( - isInternetOrder && eligibilityQuery.isSuccess && eligibilityStatus === "not_requested" - ); - const eligibilityPending = Boolean( - isInternetOrder && eligibilityQuery.isSuccess && eligibilityStatus === "pending" - ); - const eligibilityIneligible = Boolean( - isInternetOrder && eligibilityQuery.isSuccess && eligibilityStatus === "ineligible" - ); - const eligibilityError = Boolean(isInternetOrder && eligibilityQuery.isError); - const isEligible = - !isInternetOrder || - (eligibilityStatus === "eligible" && - typeof eligibilityValue === "string" && - eligibilityValue.trim().length > 0); - // Address const hasServiceAddress = Boolean( user?.address?.address1 && @@ -139,29 +109,13 @@ export function AccountCheckoutContainer() { const residenceStatus = residenceCardQuery.data?.status; const residenceSubmitted = residenceStatus === "pending" || residenceStatus === "verified"; - // Toast handler - const showPaymentToast = useCallback( - (text: string, tone: "info" | "success" | "warning" | "error") => { - if (paymentToastTimeoutRef.current) { - clearTimeout(paymentToastTimeoutRef.current); - paymentToastTimeoutRef.current = null; - } - paymentRefresh.setToast({ visible: true, text, tone }); - paymentToastTimeoutRef.current = window.setTimeout(() => { - paymentRefresh.setToast(current => ({ ...current, visible: false })); - paymentToastTimeoutRef.current = null; - }, 2200); - }, - [paymentRefresh] - ); - - useEffect(() => { - return () => { - if (paymentToastTimeoutRef.current) { - clearTimeout(paymentToastTimeoutRef.current); - } - }; - }, []); + // Can submit calculation + const canSubmit = useCanSubmit(formState.addressConfirmed, { + paymentMethodsLoading, + hasPaymentMethod, + residenceSubmitted, + eligibility, + }); const formatDateTime = useCallback((iso?: string | null) => { if (!iso) return null; @@ -173,57 +127,48 @@ export function AccountCheckoutContainer() { }, []); const navigateBackToConfigure = useCallback(() => { - const params = new URLSearchParams(searchParams?.toString() ?? ""); - const type = (params.get("type") ?? "").toLowerCase(); - params.delete("type"); - const planSku = params.get("planSku")?.trim(); - if (!planSku) { - params.delete("planSku"); - } - - if (type === "sim") { - router.push(`/account/services/sim/configure?${params.toString()}`); - return; - } - if (type === "internet" || type === "") { - router.push(`/account/services/internet/configure?${params.toString()}`); - return; - } - router.push("/account/services"); + router.push(buildConfigureBackUrl(searchParams)); }, [router, searchParams]); const handleSubmitOrder = useCallback(async () => { - setSubmitError(null); + setError(null); if (!checkoutSessionId) { - setSubmitError("Checkout session expired. Please restart checkout from the shop."); + setError("Checkout session expired. Please restart checkout from the shop."); return; } try { - setSubmitting(true); + startSubmitting(); const result = await ordersService.createOrderFromCheckoutSession(checkoutSessionId); clear(); - router.push(`/account/orders/${encodeURIComponent(result.sfOrderId)}?status=success`); + router.push(buildOrderSuccessUrl(result.sfOrderId)); } catch (error) { const message = error instanceof Error ? error.message : "Order submission failed"; if ( message.toLowerCase().includes("residence card submission required") || message.toLowerCase().includes("residence card submission was rejected") ) { - const queryString = searchParams?.toString(); - const next = pathname + (queryString ? `?${queryString}` : ""); - router.push(`/account/settings/verification?returnTo=${encodeURIComponent(next)}`); + router.push(buildVerificationRedirectUrl(pathname, searchParams)); return; } - setSubmitError(message); + setError(message); } finally { - setSubmitting(false); + stopSubmitting(); } - }, [checkoutSessionId, clear, pathname, router, searchParams]); + }, [ + checkoutSessionId, + clear, + pathname, + router, + searchParams, + setError, + startSubmitting, + stopSubmitting, + ]); const handleManagePayment = useCallback(async () => { - if (openingPaymentPortal) return; - setOpeningPaymentPortal(true); + if (formState.openingPaymentPortal) return; + startOpeningPortal(); try { const data = await billingService.createPaymentMethodsSsoLink(); @@ -233,11 +178,11 @@ export function AccountCheckoutContainer() { openSsoLink(data.url, { newTab: true }); } catch (error) { const message = error instanceof Error ? error.message : "Unable to open the payment portal"; - showPaymentToast(message, "error"); + showToast(message, "error"); } finally { - setOpeningPaymentPortal(false); + stopOpeningPortal(); } - }, [openingPaymentPortal, showPaymentToast]); + }, [formState.openingPaymentPortal, showToast, startOpeningPortal, stopOpeningPortal]); const handleSubmitResidenceCard = useCallback( (file: File) => { @@ -246,34 +191,9 @@ export function AccountCheckoutContainer() { [submitResidenceCard] ); - // Calculate if form can be submitted - const canSubmit = - addressConfirmed && - !paymentMethodsLoading && - hasPaymentMethod && - residenceSubmitted && - isEligible && - !eligibilityLoading && - !eligibilityPending && - !eligibilityNotRequested && - !eligibilityIneligible && - !eligibilityError; - // Error state - no cart item if (!cartItem || !orderType) { - const shopHref = pathname.startsWith("/account") ? "/account/services" : "/services"; - return ( -
- -
- Checkout data is not available - -
-
-
- ); + return ; } return ( @@ -292,14 +212,14 @@ export function AccountCheckoutContainer() { void eligibilityQuery.refetch(), + isLoading: eligibility.isLoading, + isError: eligibility.isError, + isPending: eligibility.isPending, + isNotRequested: eligibility.isNotRequested, + isIneligible: eligibility.isIneligible, + notes: eligibility.notes, + requestedAt: eligibility.requestedAt, + refetch: eligibility.refetch, }} eligibilityRequest={{ isPending: eligibilityRequest.isPending, @@ -330,8 +250,8 @@ export function AccountCheckoutContainer() { setAddressConfirmed(true)} - onAddressIncomplete={() => setAddressConfirmed(false)} + onAddressConfirmed={confirmAddress} + onAddressIncomplete={unconfirmAddress} orderType={orderType} /> @@ -343,7 +263,7 @@ export function AccountCheckoutContainer() { paymentMethodDisplay={paymentMethodDisplay} onManagePayment={() => void handleManagePayment()} onRefresh={() => void paymentRefresh.triggerRefresh()} - isOpeningPortal={openingPaymentPortal} + isOpeningPortal={formState.openingPaymentPortal} /> void handleSubmitOrder()} onBack={navigateBackToConfigure} diff --git a/apps/portal/src/features/checkout/components/CheckoutErrorFallback.tsx b/apps/portal/src/features/checkout/components/CheckoutErrorFallback.tsx new file mode 100644 index 00000000..e9eb0918 --- /dev/null +++ b/apps/portal/src/features/checkout/components/CheckoutErrorFallback.tsx @@ -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 ( +
+ +
+ Checkout data is not available + +
+
+
+ ); +} diff --git a/apps/portal/src/features/checkout/components/index.ts b/apps/portal/src/features/checkout/components/index.ts index 6c17ec82..2a8a5615 100644 --- a/apps/portal/src/features/checkout/components/index.ts +++ b/apps/portal/src/features/checkout/components/index.ts @@ -4,3 +4,4 @@ export { OrderConfirmation } from "./OrderConfirmation"; export { CheckoutErrorBoundary } from "./CheckoutErrorBoundary"; export { CheckoutEntry } from "./CheckoutEntry"; export { AccountCheckoutContainer } from "./AccountCheckoutContainer"; +export { CheckoutErrorFallback } from "./CheckoutErrorFallback"; diff --git a/apps/portal/src/features/checkout/hooks/index.ts b/apps/portal/src/features/checkout/hooks/index.ts new file mode 100644 index 00000000..64ee6aed --- /dev/null +++ b/apps/portal/src/features/checkout/hooks/index.ts @@ -0,0 +1,3 @@ +export { useCheckoutEligibility } from "./useCheckoutEligibility"; +export { useCheckoutFormState, useCanSubmit } from "./useCheckoutFormState"; +export { useCheckoutToast } from "./useCheckoutToast"; diff --git a/apps/portal/src/features/checkout/hooks/useCheckoutEligibility.ts b/apps/portal/src/features/checkout/hooks/useCheckoutEligibility.ts new file mode 100644 index 00000000..4dc06b9b --- /dev/null +++ b/apps/portal/src/features/checkout/hooks/useCheckoutEligibility.ts @@ -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, + }; +} diff --git a/apps/portal/src/features/checkout/hooks/useCheckoutFormState.ts b/apps/portal/src/features/checkout/hooks/useCheckoutFormState.ts new file mode 100644 index 00000000..328c9fce --- /dev/null +++ b/apps/portal/src/features/checkout/hooks/useCheckoutFormState.ts @@ -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(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 & { 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, + ] + ); +} diff --git a/apps/portal/src/features/checkout/hooks/useCheckoutToast.ts b/apps/portal/src/features/checkout/hooks/useCheckoutToast.ts new file mode 100644 index 00000000..5e232c4f --- /dev/null +++ b/apps/portal/src/features/checkout/hooks/useCheckoutToast.ts @@ -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(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, + }; +} diff --git a/apps/portal/src/features/checkout/utils/checkout-navigation.ts b/apps/portal/src/features/checkout/utils/checkout-navigation.ts new file mode 100644 index 00000000..f89ae6e4 --- /dev/null +++ b/apps/portal/src/features/checkout/utils/checkout-navigation.ts @@ -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"; +} diff --git a/apps/portal/src/features/checkout/utils/index.ts b/apps/portal/src/features/checkout/utils/index.ts new file mode 100644 index 00000000..92863c00 --- /dev/null +++ b/apps/portal/src/features/checkout/utils/index.ts @@ -0,0 +1,6 @@ +export { + buildConfigureBackUrl, + buildVerificationRedirectUrl, + buildOrderSuccessUrl, + getShopHref, +} from "./checkout-navigation"; diff --git a/apps/portal/src/features/verification/hooks/index.ts b/apps/portal/src/features/verification/hooks/index.ts index 760df1f3..57f6bdd5 100644 --- a/apps/portal/src/features/verification/hooks/index.ts +++ b/apps/portal/src/features/verification/hooks/index.ts @@ -2,3 +2,4 @@ export { useResidenceCardVerification, useSubmitResidenceCard, } from "./useResidenceCardVerification"; +export { useVerificationFileUpload } from "./useVerificationFileUpload"; diff --git a/apps/portal/src/features/verification/hooks/useVerificationFileUpload.ts b/apps/portal/src/features/verification/hooks/useVerificationFileUpload.ts new file mode 100644 index 00000000..96edfbcb --- /dev/null +++ b/apps/portal/src/features/verification/hooks/useVerificationFileUpload.ts @@ -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(null); + const inputRef = useRef(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, + }; +}