state/checkout/checkout.state.tstypescript
/**
 * Checkout Store - Zustand store for checkout flow state management
 */

import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { normalizeVoucherCode } from '@/utils/voucher';
import { isExpressPaymentMethod } from '@/utils/payment-method';
import { CHECKOUT_STORAGE_KEY } from '@/core/storage';
import type { CheckoutPaymentMethod } from '@/types/global';

export interface CheckoutState {
  step: number;
  isProcessing: boolean;
  errors: Record<string, string>;
  formData: Record<string, any>;
  paymentToken?: string;
  paymentMethod: CheckoutPaymentMethod;
  shippingMethod?:
    | {
        id: number;
        name: string;
        price: number;
        code: string;
      }
    | undefined;
  billingAddress?:
    | {
        first_name: string;
        last_name: string;
        address1: string;
        address2?: string | undefined;
        city: string;
        province: string;
        postal: string;
        country: string;
        phone: string;
      }
    | undefined;
  sameAsShipping: boolean;
  testMode: boolean;
  vouchers: string[];
}

interface CheckoutActions {
  setStep: (step: number) => void;
  setProcessing: (processing: boolean) => void;
  setError: (field: string, error: string) => void;
  clearError: (field: string) => void;
  clearAllErrors: () => void;
  updateFormData: (data: Record<string, any>) => void;
  setPaymentToken: (token: string) => void;
  setPaymentMethod: (method: CheckoutState['paymentMethod']) => void;
  setShippingMethod: (method: CheckoutState['shippingMethod']) => void;
  setBillingAddress: (address: CheckoutState['billingAddress']) => void;
  setSameAsShipping: (same: boolean) => void;
  setTestMode: (testMode: boolean) => void;
  addVoucher: (code: string) => void;
  removeVoucher: (code: string) => void;
  reset: () => void;
}

/**
 * Every field of `T` spelled out — an optional key becomes a **required** key whose type
 * still allows `undefined`. Annotating {@link initialState} with it is what makes a
 * forgotten field a compile error; see the note there for why that matters.
 */
type AllFieldsOf<T> = { [K in Extract<keyof T, string>]: T[K] };

/**
 * The state a fresh checkout starts from, and the only thing `reset()` writes.
 *
 * Every field is listed, including the three that are optional, because `reset()` calls
 * `set(initialState)` and Zustand's `set` **merges**: a field missing here is a field a
 * reset cannot clear. That is not hypothetical — `billingAddress`, `paymentToken` and
 * `shippingMethod` were once absent, and two of them persist, so finishing an order and
 * starting another in the same tab carried the previous shopper's billing address and card
 * token into the next checkout on a shared or kiosk browser.
 *
 * The {@link AllFieldsOf} annotation is the guard against that happening again: add an
 * optional field to {@link CheckoutState} and forget it here, and this literal stops
 * compiling.
 */
const initialState: AllFieldsOf<CheckoutState> = {
  step: 1,
  isProcessing: false,
  errors: {},
  formData: {},
  paymentToken: undefined,
  paymentMethod: 'credit-card',
  shippingMethod: undefined,
  billingAddress: undefined,
  sameAsShipping: true,
  testMode: false,
  vouchers: [],
};

/**
 * The checkout store — the shopper's in-progress checkout: entered form fields,
 * selected payment/shipping, and processing flags. Read it to prefill or inspect
 * the checkout; it persists across reloads so a shopper doesn't lose progress.
 *
 * @example
 * ```ts
 * const { paymentMethod, sameAsShipping } = useCheckoutStore.getState();
 * ```
 * @category Checkout
 */
export const useCheckoutStore = create<CheckoutState & CheckoutActions>()(
  persist(
    set => ({
      ...initialState,

      setStep: (step: number) => {
        set({ step });
      },

      setProcessing: (isProcessing: boolean) => {
        set({ isProcessing });
      },

      setError: (field: string, error: string) => {
        set(state => ({
          errors: { ...state.errors, [field]: error },
        }));
      },

      clearError: (field: string) => {
        set(state => {
          const { [field]: _, ...errors } = state.errors;
          return { errors };
        });
      },

      clearAllErrors: () => {
        set({ errors: {} });
      },

      updateFormData: (data: Record<string, any>) => {
        set(state => ({
          formData: { ...state.formData, ...data },
        }));
      },

      setPaymentToken: (paymentToken: string) => {
        set({ paymentToken });
      },

      setPaymentMethod: (paymentMethod: CheckoutState['paymentMethod']) => {
        set({ paymentMethod });
      },

      setShippingMethod: (shippingMethod: CheckoutState['shippingMethod']) => {
        set({ shippingMethod });
      },

      setBillingAddress: (billingAddress: CheckoutState['billingAddress']) => {
        set({ billingAddress });
      },

      setSameAsShipping: (sameAsShipping: boolean) => {
        set({ sameAsShipping });
      },

      setTestMode: (testMode: boolean) => {
        set({ testMode });
      },

      addVoucher: (code: string) => {
        set(state => ({
          vouchers: [...state.vouchers, code],
        }));
      },

      removeVoucher: (code: string) => {
        const normalizedCode = normalizeVoucherCode(code);
        set(state => ({
          vouchers: state.vouchers.filter(
            v => normalizeVoucherCode(v) !== normalizedCode
          ),
        }));
      },

      /**
       * Returns every field to its initial value — step 1, empty form, no coupons,
       * `credit-card`, and no billing address, card token or shipping method.
       *
       * Deliberately a **merge** rather than a replace (`set(initialState, true)`): the
       * setters above sit on the same object as the state, so replacing would take them
       * with it and leave a store with no methods. What a merge needs instead is an
       * {@link initialState} that names every field — which its annotation enforces.
       */
      reset: () => {
        set(initialState);
      },
    }),
    {
      name: CHECKOUT_STORAGE_KEY,
      storage: {
        getItem: name => {
          const str = sessionStorage.getItem(name);
          return str ? JSON.parse(str) : null;
        },
        setItem: (name, value) => {
          sessionStorage.setItem(name, JSON.stringify(value));
        },
        removeItem: name => {
          sessionStorage.removeItem(name);
        },
      },
      // Exclude transient state from persistence
      partialize: state => {
        // Don't persist express payment methods (they should reset to credit-card on page load/navigation)
        const paymentMethod = isExpressPaymentMethod(state.paymentMethod)
          ? 'credit-card'
          : state.paymentMethod;

        // Filter out sensitive payment fields from formData
        const {
          cvv,
          card_cvv,
          month,
          expiration_month,
          year,
          expiration_year,
          'exp-month': expMonth,
          'exp-year': expYear,
          card_number,
          ...remainingFormData
        } = state.formData;

        // Remove empty string values from formData (no point persisting empty fields)
        const safeFormData = Object.fromEntries(
          Object.entries(remainingFormData).filter(([_, value]) => {
            // Keep non-empty strings, booleans, and numbers
            if (typeof value === 'string') return value.trim() !== '';
            if (typeof value === 'boolean' || typeof value === 'number')
              return true;
            return false;
          })
        );

        // Filter out empty billing address fields
        let billingAddress = state.billingAddress;
        if (billingAddress) {
          const filteredBilling = Object.fromEntries(
            Object.entries(billingAddress).filter(([_, value]) => {
              if (typeof value === 'string') return value.trim() !== '';
              return false;
            })
          );
          // Only persist if there's at least one non-empty field
          billingAddress =
            Object.keys(filteredBilling).length > 0
              ? (filteredBilling as any)
              : undefined;
        }

        return {
          step: state.step,
          formData: safeFormData, // Exclude CVV, expiration, card number, and empty values
          shippingMethod: state.shippingMethod,
          billingAddress, // Only non-empty billing fields
          sameAsShipping: state.sameAsShipping,
          paymentMethod, // Every method except the three express ones survives a reload
          vouchers: state.vouchers, // Persist so user-entered coupons survive refresh; bundle vouchers are deduped on re-apply
          // Explicitly exclude:
          // - errors (transient validation state)
          // - isProcessing (transient UI state)
          // - paymentToken (sensitive, should not persist)
          // - testMode (session-specific)
          // - CVV, card number, expiration (sensitive payment data)
          // - Empty string values (no benefit to persist)
        } as any;
      },
    }
  )
);