import { useAppDispatch } from '@components/common/context/app.js'; import { _ } from '@evershop/evershop/lib/locale/translate/_'; import { CustomerAddressGraphql } from '@evershop/evershop/types/customerAddress'; import { produce } from 'immer'; import React, { createContext, useReducer, useContext, ReactNode, useCallback, useMemo, useEffect } from 'react'; type ExtendedCustomerAddress = CustomerAddressGraphql & { addressId: string | number; isDefault?: boolean; updateApi?: string; deleteApi?: string; }; export interface OrderItem { orderItemId: string; uuid: string; productId: string; qty: number; productSku: string; productName: string; productUrl: string; thumbnail?: string; productWeight: { value: number; unit: string; }; variantOptions?: { attributeCode: string; attributeName: string; attributeId: number; optionId: number; optionText: string; }[]; productPrice: { value: number; text: string; }; productPriceInclTax: { value: number; text: string; }; finalPrice: { value: number; text: string; }; finalPriceInclTax: { value: number; text: string; }; taxPercent: number; taxAmount: { value: number; text: string; }; taxAmountBeforeDiscount: { value: number; text: string; }; discountAmount: { value: number; text: string; }; lineTotal: { value: number; text: string; }; subTotal: { value: number; text: string; }; lineTotalWithDiscount: { value: number; text: string; }; lineTotalWithDiscountInclTax: { value: number; text: string; }; lineTotalInclTax: { value: number; text: string; }; total: { value: number; text: string; }; variantGroupId?: number; } export interface Order { orderId: number; uuid: string; orderNumber: string; currency: string; customerId?: number; customerGroupId?: number; customerEmail?: string; customerFullName?: string; coupon?: string; shippingMethod?: string; shippingMethodName?: string; paymentMethod?: string; paymentMethodName?: string; shippingNote?: string; status: { name: string; code: string; badge: string; }; shipmentStatus?: { name: string; code: string; badge: string; }; paymentStatus?: { name: string; code: string; badge: string; }; items: OrderItem[]; totalQty: number; totalWeight: { value: number; unit: string; }; taxAmount: { value: number; text: string; }; totalTaxAmount: { value: number; text: string; }; taxAmountBeforeDiscount: { value: number; text: string; }; discountAmount: { value: number; text: string; }; shippingFeeExclTax: { value: number; text: string; }; shippingFeeInclTax: { value: number; text: string; }; shippingTaxAmount: { value: number; text: string; }; subTotal: { value: number; text: string; }; subTotalInclTax: { value: number; text: string; }; subTotalWithDiscount: { value: number; text: string; }; subTotalWithDiscountInclTax: { value: number; text: string; }; grandTotal: { value: number; text: string; }; billingAddress?: CustomerAddressGraphql; shippingAddress?: CustomerAddressGraphql; createdAt: { value: string; text: string; }; updatedAt: { value: string; text: string; }; } interface Customer { uuid: string; email: string; fullName: string; groupId: string; addresses: ExtendedCustomerAddress[]; orders: Order[]; addAddressApi: string; createdAt: { value: string; text: string; }; [key: string]: unknown; } interface CustomerState { customer: Customer | undefined; // undefined for guest users isLoading: boolean; } type CustomerAction = | { type: 'SET_LOADING'; payload: boolean } | { type: 'SET_CUSTOMER'; payload: Customer | undefined } | { type: 'LOGOUT' }; const initialState: CustomerState = { customer: undefined, isLoading: false }; const customerReducer = ( state: CustomerState, action: CustomerAction ): CustomerState => { return produce(state, (draft) => { switch (action.type) { case 'SET_LOADING': draft.isLoading = action.payload; break; case 'SET_CUSTOMER': draft.customer = action.payload; draft.isLoading = false; break; case 'LOGOUT': draft.customer = undefined; draft.isLoading = false; break; } }); }; interface CustomerContextValue extends CustomerState {} interface CustomerDispatchContextValue { login: ( data: { email: string; password: string; [key: string]: unknown; }, redirectUrl: string ) => Promise; register: ( data: { full_name: string; email: string; password: string; [key: string]: unknown; }, loginIfSuccess: boolean, redirectUrl: string ) => Promise; logout: () => Promise; setCustomer: (customer: Customer | undefined) => void; addAddress: ( addressData: Omit ) => Promise; updateAddress: ( addressId: string | number, addressData: Partial ) => Promise; deleteAddress: (addressId: string | number) => Promise; } const CustomerContext = createContext( undefined ); const CustomerDispatchContext = createContext< CustomerDispatchContextValue | undefined >(undefined); interface CustomerProviderProps { children: ReactNode; loginAPI: string; logoutAPI: string; registerAPI: string; initialCustomer?: Customer; } const retry = async ( fn: () => Promise, retries = 3, delay = 1000 ): Promise => { try { return await fn(); } catch (error) { if (retries > 0) { await new Promise((resolve) => setTimeout(resolve, delay)); return retry(fn, retries - 1, delay * 2); } throw error; } }; export function CustomerProvider({ children, loginAPI, registerAPI, logoutAPI, initialCustomer }: CustomerProviderProps) { const [state, dispatch] = useReducer(customerReducer, { ...initialState, customer: initialCustomer }); const appDispatch = useAppDispatch(); // Effect to update customer when initialCustomer prop changes useEffect(() => { // Compare by JSON string to handle object changes properly const currentCustomerStr = JSON.stringify(state.customer); const initialCustomerStr = JSON.stringify(initialCustomer); if (initialCustomerStr !== currentCustomerStr) { dispatch({ type: 'SET_CUSTOMER', payload: initialCustomer }); } }, [initialCustomer]); // Helper function to get current URL with isAjax=true const getCurrentAjaxUrl = useCallback(() => { const currentUrl = new URL(window.location.href); currentUrl.searchParams.set('ajax', 'true'); return currentUrl.toString(); }, []); // Login function const login = useCallback( async ( data: { email: string; password: string; [key: string]: unknown; }, redirectUrl: string ): Promise => { dispatch({ type: 'SET_LOADING', payload: true }); try { const response = await retry(() => fetch(loginAPI, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }) ); const json = await response.json(); if (!response.ok) { throw new Error(json.error?.message || _('Login failed')); } // Trigger page data refresh which will update customer via useEffect await appDispatch.fetchPageData(getCurrentAjaxUrl()); if (redirectUrl) { window.location.href = redirectUrl; } return true; } catch (error) { dispatch({ type: 'SET_LOADING', payload: false }); throw error; } }, [loginAPI, appDispatch, getCurrentAjaxUrl] ); const register = useCallback( async ( data: { full_name: string; email: string; password: string; [key: string]: unknown; }, loginIfSuccess: boolean, redirectUrl: string ): Promise => { if (state.customer) { throw new Error(_('You are already logged in')); } dispatch({ type: 'SET_LOADING', payload: true }); try { const response = await retry(() => fetch(registerAPI, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }) ); const json = await response.json(); if (!response.ok) { throw new Error(json.error?.message || _('Registration failed')); } // Trigger page data refresh which will update customer via useEffect await appDispatch.fetchPageData(getCurrentAjaxUrl()); if (loginIfSuccess) { // Auto login after successful registration await login( { email: data.email, password: data.password }, redirectUrl ); } return true; } catch (error) { dispatch({ type: 'SET_LOADING', payload: false }); throw error; } }, [registerAPI, appDispatch, getCurrentAjaxUrl, login] ); // Logout function const logout = useCallback(async (): Promise => { dispatch({ type: 'SET_LOADING', payload: true }); try { await retry(() => fetch(logoutAPI, { method: 'POST', headers: { 'Content-Type': 'application/json' } }) ); // After successful logout, clear customer data locally dispatch({ type: 'LOGOUT' }); } catch (error) { // Even if logout API fails, clear local customer data dispatch({ type: 'LOGOUT' }); throw error; } finally { dispatch({ type: 'SET_LOADING', payload: false }); } }, [logoutAPI]); // Set customer directly (for external updates) const setCustomer = useCallback((customer: Customer | undefined) => { dispatch({ type: 'SET_CUSTOMER', payload: customer }); }, []); // Add address function const addAddress = useCallback( async ( addressData: Omit ): Promise => { if (!state.customer?.addAddressApi) { throw new Error(_('Add address API not available')); } dispatch({ type: 'SET_LOADING', payload: true }); const response = await retry(() => fetch(state.customer!.addAddressApi!, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(addressData) }) ); const json = await response.json(); if (!response.ok) { throw new Error(json.error?.message || _('Failed to add address')); } if (json.error) { throw new Error(json.error.message || _('Failed to add address')); } // Sync with server to get fresh customer data including the new address await appDispatch.fetchPageData(getCurrentAjaxUrl()); // Return the address from the API response for immediate use const newAddress = json.data; if (!newAddress) { throw new Error(_('No address data received')); } return newAddress; }, [state.customer, appDispatch, getCurrentAjaxUrl] ); // Update address function const updateAddress = useCallback( async ( addressId: string | number, addressData: Partial ): Promise => { const address = state.customer?.addresses?.find( (addr) => addr.addressId === addressId ); if (!address?.updateApi) { throw new Error(_('Update address API not available')); } dispatch({ type: 'SET_LOADING', payload: true }); const response = await retry(() => fetch(address.updateApi!, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(addressData) }) ); const json = await response.json(); if (!response.ok) { throw new Error(json.error?.message || _('Failed to update address')); } if (json.error) { throw new Error(json.error.message || _('Failed to update address')); } // Sync with server to get fresh customer data including the updated address await appDispatch.fetchPageData(getCurrentAjaxUrl()); // Return the address from the API response for immediate use const updatedAddress = json.data; if (!updatedAddress) { throw new Error(_('No address data received')); } return updatedAddress; }, [state.customer, appDispatch, getCurrentAjaxUrl] ); // Delete address function const deleteAddress = useCallback( async (addressId: string | number): Promise => { const address = state.customer?.addresses?.find( (addr) => addr.addressId === addressId ); if (!address?.deleteApi) { throw new Error(_('Delete address API not available')); } dispatch({ type: 'SET_LOADING', payload: true }); const response = await retry(() => fetch(address.deleteApi!, { method: 'DELETE', headers: { 'Content-Type': 'application/json' } }) ); const json = await response.json(); if (!response.ok) { throw new Error(json.error?.message || _('Failed to delete address')); } if (json.error) { throw new Error(json.error.message || _('Failed to delete address')); } await appDispatch.fetchPageData(getCurrentAjaxUrl()); }, [state.customer, appDispatch, getCurrentAjaxUrl] ); const contextValue = useMemo( (): CustomerContextValue => ({ ...state }), [state] ); const dispatchMethods = useMemo( (): CustomerDispatchContextValue => ({ login, register, logout, setCustomer, addAddress, updateAddress, deleteAddress }), [login, logout, setCustomer, addAddress, updateAddress, deleteAddress] ); return ( {children} ); } export const useCustomer = (): CustomerContextValue => { const context = useContext(CustomerContext); if (context === undefined) { throw new Error('useCustomer must be used within a CustomerProvider'); } return context; }; export const useCustomerDispatch = (): CustomerDispatchContextValue => { const context = useContext(CustomerDispatchContext); if (context === undefined) { throw new Error( 'useCustomerDispatch must be used within a CustomerProvider' ); } return context; }; export type { Customer, CustomerState, ExtendedCustomerAddress };