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