Skip to content

Instantly share code, notes, and snippets.

@marufsiddiqui
Last active January 5, 2025 18:10
Show Gist options
  • Save marufsiddiqui/98fd634a4ca83998316a605f07aa8f4a to your computer and use it in GitHub Desktop.
Save marufsiddiqui/98fd634a4ca83998316a605f07aa8f4a to your computer and use it in GitHub Desktop.
Order Return tech spec

Okay, this is a good breakdown of the order return flow. Let's create a data structure that captures this information on the client-side using TypeScript and useReducer with Context. This approach offers good state management and allows for persistence.

Data Structure (TypeScript)

interface ProductDetails {
  productId: string;
  salesLineId: string;
  product: {
    name: string;
    manufacturer: string;
    productUrl: string;
    titleImageId: string;
  };
  price: {
    amount: number;
    currency: string;
  };
  returnableQuantity: number | null; // You might want to calculate this
  returnableUntil: string | null;
  returnable: boolean;
  unreturnableReason: string | null;
  returnSubItems: ProductDetails[]; // Assuming sub-items follow the same structure
}

interface ReturnableProduct extends ProductDetails {
  isSelected: boolean;
  quantityToReturn: number;
  reason?: string;
  returnMethod?: 'POST' | 'DROP_OFF'; // Assuming these are the possible methods
  availableReturnMethods?: ('POST' | 'DROP_OFF')[]; // If available methods are dynamic
}

interface ReturnState {
  orderId: string; // Or salesReference
  outletId: string;
  originalProducts: ProductDetails[]; // Keep the original data
  returnableProducts: ReturnableProduct[]; // Products the user can return
  selectedProducts: ReturnableProduct[]; // Products the user has selected for return
  currentStep: 'PRODUCT_SELECTION' | 'REASON_SELECTION' | 'METHOD_SELECTION' | 'CONFIRMATION';
  returnReasons: string[]; // Predefined reasons
}

// Initial state
const initialReturnState: ReturnState = {
  orderId: '',
  outletId: '',
  originalProducts: [],
  returnableProducts: [],
  selectedProducts: [],
  currentStep: 'PRODUCT_SELECTION',
  returnReasons: ['Too small', 'Too large', 'Damaged', 'Not as described', 'Changed my mind'], // Example reasons
};

Explanation:

  • ProductDetails: This mirrors the structure of the products received from your API.
  • ReturnableProduct: This extends ProductDetails and adds the specific data we need for the return process:
    • isSelected: Whether the user has selected this product for return.
    • quantityToReturn: The number of items of this product the user wants to return.
    • reason: The reason selected for returning this product.
    • returnMethod: The chosen method for returning this product.
    • availableReturnMethods: If the available return methods are dynamic (not always 'POST' and 'DROP_OFF'), store them here.
  • ReturnState: This is the main state object managed by the reducer:
    • orderId: Identifies the order.
    • outletId: Identifies the outlet.
    • originalProducts: Stores the original product data fetched from the API. This is useful for reference.
    • returnableProducts: A processed list of products that are potentially returnable, enhanced with selection and quantity information.
    • selectedProducts: An array of ReturnableProduct objects that the user has explicitly chosen to return.
    • currentStep: Tracks the current stage of the return process.
    • returnReasons: An array of predefined reasons.

Reducer Actions (TypeScript)

type ReturnAction =
  | { type: 'SET_ORDER_OPTIONS'; payload: any } // Payload will be your API response
  | { type: 'TOGGLE_PRODUCT_SELECTION'; payload: { productId: string } }
  | { type: 'SET_QUANTITY_TO_RETURN'; payload: { productId: string; quantity: number } }
  | { type: 'SET_REASON'; payload: { productId: string; reason: string } }
  | { type: 'SET_RETURN_METHOD'; payload: { productId: string; method: 'POST' | 'DROP_OFF' } }
  | { type: 'NEXT_STEP' }
  | { type: 'PREVIOUS_STEP' };

const returnReducer = (state: ReturnState, action: ReturnAction): ReturnState => {
  switch (action.type) {
    case 'SET_ORDER_OPTIONS': {
      const { salesReference, outletId, products } = action.payload.data.returnOrderOptionsV2;
      const returnableProducts: ReturnableProduct[] = products.map(
        (product: ProductDetails) => ({
          ...product,
          isSelected: false,
          quantityToReturn: 0,
        })
      );
      return {
        ...state,
        orderId: salesReference,
        outletId,
        originalProducts: products,
        returnableProducts,
      };
    }
    case 'TOGGLE_PRODUCT_SELECTION': {
      const { productId } = action.payload;
      return {
        ...state,
        returnableProducts: state.returnableProducts.map((product) =>
          product.productId === productId ? { ...product, isSelected: !product.isSelected, quantityToReturn: product.isSelected ? 0 : 1 } : product
        ),
      };
    }
    case 'SET_QUANTITY_TO_RETURN': {
      const { productId, quantity } = action.payload;
      return {
        ...state,
        returnableProducts: state.returnableProducts.map((product) =>
          product.productId === productId ? { ...product, quantityToReturn: quantity } : product
        ),
      };
    }
    case 'SET_REASON': {
      const { productId, reason } = action.payload;
      return {
        ...state,
        selectedProducts: state.selectedProducts.map((product) =>
          product.productId === productId ? { ...product, reason } : product
        ),
      };
    }
    case 'SET_RETURN_METHOD': {
      const { productId, method } = action.payload;
      return {
        ...state,
        selectedProducts: state.selectedProducts.map((product) =>
          product.productId === productId ? { ...product, returnMethod: method } : product
        ),
      };
    }
    case 'NEXT_STEP': {
      if (state.currentStep === 'PRODUCT_SELECTION') {
        return { ...state, currentStep: 'REASON_SELECTION', selectedProducts: state.returnableProducts.filter(p => p.isSelected && p.quantityToReturn > 0) };
      } else if (state.currentStep === 'REASON_SELECTION') {
        return { ...state, currentStep: 'METHOD_SELECTION' };
      } else if (state.currentStep === 'METHOD_SELECTION') {
        return { ...state, currentStep: 'CONFIRMATION' };
      }
      return state;
    }
    case 'PREVIOUS_STEP': {
      if (state.currentStep === 'REASON_SELECTION') {
        return { ...state, currentStep: 'PRODUCT_SELECTION' };
      } else if (state.currentStep === 'METHOD_SELECTION') {
        return { ...state, currentStep: 'REASON_SELECTION' };
      } else if (state.currentStep === 'CONFIRMATION') {
        return { ...state, currentStep: 'METHOD_SELECTION' };
      }
      return state;
    }
    default:
      return state;
  }
};

Context Setup (TypeScript)

import React, { createContext, useContext, useReducer } from 'react';

interface ReturnContextProps {
  state: ReturnState;
  dispatch: React.Dispatch<ReturnAction>;
}

const ReturnContext = createContext<ReturnContextProps | undefined>(undefined);

export const ReturnProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  const [state, dispatch] = useReducer(returnReducer, initialReturnState);

  return (
    <ReturnContext.Provider value={{ state, dispatch }}>
      {children}
    </ReturnContext.Provider>
  );
};

export const useReturn = () => {
  const context = useContext(ReturnContext);
  if (!context) {
    throw new Error('useReturn must be used within a ReturnProvider');
  }
  return context;
};

Implementation in Components

1. Product Selection Screen:

import React from 'react';
import { useReturn } from './ReturnContext';

const ProductSelectionScreen: React.FC = () => {
  const { state, dispatch } = useReturn();

  const handleToggleSelect = (productId: string) => {
    dispatch({ type: 'TOGGLE_PRODUCT_SELECTION', payload: { productId } });
  };

  const handleQuantityChange = (productId: string, quantity: number) => {
    dispatch({ type: 'SET_QUANTITY_TO_RETURN', payload: { productId, quantity } });
  };

  const handleNext = () => {
    dispatch({ type: 'NEXT_STEP' });
  };

  const totalItemsToReturn = state.returnableProducts
    .filter(p => p.isSelected)
    .reduce((sum, p) => sum + p.quantityToReturn, 0);

  const isNextDisabled = totalItemsToReturn === 0;

  return (
    <div>
      <h2>Select Products to Return</h2>
      <ul>
        {state.returnableProducts.map((product) => (
          <li key={product.productId}>
            {product.product.name}, count: {product.returnSubItems.reduce((sum, item) => sum + item.quantity, 0)}
            <input
              type="checkbox"
              checked={product.isSelected}
              onChange={() => handleToggleSelect(product.productId)}
            />
            {product.isSelected && (
              <input
                type="number"
                value={product.quantityToReturn}
                min={1}
                max={product.returnSubItems.reduce((sum, item) => sum + item.quantity, 0)} // Assuming you want to limit by available quantity
                onChange={(e) => handleQuantityChange(product.productId, parseInt(e.target.value))}
              />
            )}
          </li>
        ))}
      </ul>
      <div>Total Items Returning: {totalItemsToReturn}</div>
      <button onClick={handleNext} disabled={isNextDisabled}>
        Next
      </button>
    </div>
  );
};

export default ProductSelectionScreen;

2. Reason Selection Screen:

import React from 'react';
import { useReturn } from './ReturnContext';

const ReasonSelectionScreen: React.FC = () => {
  const { state, dispatch } = useReturn();

  const handleReasonChange = (productId: string, reason: string) => {
    dispatch({ type: 'SET_REASON', payload: { productId, reason } });
  };

  const handleNext = () => {
    dispatch({ type: 'NEXT_STEP' });
  };

  const allReasonsSelected = state.selectedProducts.every(p => p.reason);
  const isNextDisabled = !allReasonsSelected;

  return (
    <div>
      <h2>Select Reasons for Return</h2>
      {state.selectedProducts.map((product) => (
        <div key={product.productId}>
          <p>{product.product.name}</p>
          <select
            value={product.reason}
            onChange={(e) => handleReasonChange(product.productId, e.target.value)}
          >
            <option value="">Select a reason</option>
            {state.returnReasons.map((reason) => (
              <option key={reason} value={reason}>
                {reason}
              </option>
            ))}
          </select>
        </div>
      ))}
      <button onClick={handleNext} disabled={isNextDisabled}>
        Next
      </button>
    </div>
  );
};

export default ReasonSelectionScreen;

3. Method Selection Screen:

import React from 'react';
import { useReturn } from './ReturnContext';

const MethodSelectionScreen: React.FC = () => {
  const { state, dispatch } = useReturn();
  const availableMethods: ('POST' | 'DROP_OFF')[] = ['POST', 'DROP_OFF']; // Example, can be dynamic

  const handleMethodChange = (productId: string, method: 'POST' | 'DROP_OFF') => {
    dispatch({ type: 'SET_RETURN_METHOD', payload: { productId, method } });
  };

  const handleNext = () => {
    // API call logic here using state.selectedProducts
    console.log('Submitting Return Request:', state.selectedProducts);
    dispatch({ type: 'NEXT_STEP' }); // Move to confirmation on success
  };

  const allMethodsSelected = state.selectedProducts.every(p => p.returnMethod);
  const isNextDisabled = !allMethodsSelected;

  return (
    <div>
      <h2>Select Return Methods</h2>
      {state.selectedProducts.map((product) => (
        <div key={product.productId}>
          <p>{product.product.name}</p>
          {availableMethods.map((method) => (
            <label key={method}>
              <input
                type="radio"
                name={`return-method-${product.productId}`}
                value={method}
                checked={product.returnMethod === method}
                onChange={() => handleMethodChange(product.productId, method)}
              />
              {method}
            </label>
          ))}
        </div>
      ))}
      <button onClick={handleNext} disabled={isNextDisabled}>
        Next
      </button>
    </div>
  );
};

export default MethodSelectionScreen;

4. Confirmation Screen (Example):

import React from 'react';
import { useReturn } from './ReturnContext';

const ConfirmationScreen: React.FC = () => {
  const { state } = useReturn();

  const groupedByMethod = state.selectedProducts.reduce((acc, product) => {
    const method = product.returnMethod || 'Unknown';
    acc[method] = acc[method] || [];
    acc[method].push(product);
    return acc;
  }, {} as Record<string, ReturnableProduct[]>);

  return (
    <div>
      <h2>Return Confirmation</h2>
      {Object.entries(groupedByMethod).map(([method, products]) => (
        <div key={method}>
          <h3>Return via {method}</h3>
          <ul>
            {products.map((product) => (
              <li key={product.productId}>
                {product.product.name} - Quantity: {product.quantityToReturn} - Reason: {product.reason}
              </li>
            ))}
          </ul>
        </div>
      ))}
    </div>
  );
};

export default ConfirmationScreen;

Routing (using react-router-dom)

import React from 'react';
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
import { ReturnProvider } from './ReturnContext';
import ProductSelectionScreen from './ProductSelectionScreen';
import ReasonSelectionScreen from './ReasonSelectionScreen';
import MethodSelectionScreen from './MethodSelectionScreen';
import ConfirmationScreen from './ConfirmationScreen';

const App: React.FC = () => {
  return (
    <ReturnProvider>
      <Router>
        <Routes>
          <Route path="/return/products" element={<ProductSelectionScreen />} />
          <Route path="/return/reasons" element={<ReasonSelectionScreen />} />
          <Route path="/return/methods" element={<MethodSelectionScreen />} />
          <Route path="/return/confirm" element={<ConfirmationScreen />} />
        </Routes>
      </Router>
    </ReturnProvider>
  );
};

export default App;

Special Scenarios:

  1. Single product, 1 item:

    • In your product selection screen, if there's only one product with a returnable quantity of 1, you can automatically select it and navigate to the reason selection screen.
  2. Only one return method available:

    • In your method selection screen, if only one return method is available for all selected products, you can automatically select that method for each product and skip the method selection screen, navigating directly to the confirmation. You'll need to adjust your reducer and component logic to handle this.

Persistence:

To persist the state across page refreshes, you can use localStorage:

// In your ReturnProvider:

import { useEffect } from 'react';

export const ReturnProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  const [state, dispatch] = useReducer(returnReducer, initialReturnState, () => {
    const localState = localStorage.getItem('returnState');
    return localState ? JSON.parse(localState) : initialReturnState;
  });

  useEffect(() => {
    localStorage.setItem('returnState', JSON.stringify(state));
  }, [state]);

  // ... rest of the provider code
};

Important Considerations:

  • API Integration: You'll need to dispatch an action (e.g., 'SET_ORDER_OPTIONS') with the data from your API call to initialize the state.
  • Error Handling and Loading States: Implement loading indicators and error handling for API calls.
  • Form Validation: Add more robust form validation, especially on the quantity and reason selection screens.
  • Dynamic Return Methods: If the returnMethods in your API response are not always just 'POST' and 'DROP_OFF', you'll need to adjust the ReturnableProduct interface and the method selection logic accordingly.
  • Navigation: Use useNavigate from react-router-dom to programmatically move between routes based on the user's actions.

This comprehensive data structure and state management approach should provide a solid foundation for implementing your order return feature. Remember to adapt the code to your specific API responses and UI requirements.

// Reactive Variables (e.g., in a `state` or `apollo` folder)
import { makeVar } from '@apollo/client';
import { OrderReturnDomain, ReturnableProductDomain } from '../domain';
import { OrderReturnOptionsFE } from '../models';

const initialReturnState: OrderReturnDomain | null = null;
export const currentReturnStateVar = makeVar<OrderReturnDomain | null>(initialReturnState);

// Function to initialize the state from API data
export const initializeReturnState = (apiData: any) => {
  const returnOptions: OrderReturnOptionsFE = {
    orderId: apiData.data.returnOrderOptionsV2.salesReference,
    outletId: apiData.data.returnOrderOptionsV2.outletId,
    products: apiData.data.returnOrderOptionsV2.products.map((item: any) => ({
      productId: item.productId,
      salesLineId: item.salesLineId,
      productName: item.product.name,
      manufacturer: item.product.manufacturer,
      availableQuantity: item.returnSubItems.reduce((sum: number, subItem: any) => sum + subItem.quantity, 0),
      availableReturnMethods: ['POST', 'DROP_OFF'], // Example
    })),
  };

  const domainProducts = returnOptions.products.map(
    (productFE) => new ReturnableProductDomain(productFE)
  );
  const domainState = new OrderReturnDomain(
    returnOptions.orderId,
    returnOptions.outletId,
    domainProducts
  );

  // **Special Scenario 1: Single Product, 1 Item**
  if (domainState.returnableProducts.length === 1 && domainState.returnableProducts[0].availableQuantity === 1) {
    const singleProduct = domainState.returnableProducts[0];
    singleProduct.select(); // Automatically select
    domainState.currentStep = 'REASON_SELECTION'; // Advance to reason selection
  }

  // **Special Scenario 2: Single Return Method**
  const allProductsSingleMethod = domainState.returnableProducts.every(product => product.availableReturnMethods.length === 1);
  if (domainState.getSelectedProducts().length > 0 && domainState.currentStep === 'METHOD_SELECTION' && allProductsSingleMethod) {
    const singleMethod = domainState.returnableProducts[0].availableReturnMethods[0];
    domainState.getSelectedProducts().forEach(product => product.setReturnMethod(singleMethod));
    domainState.currentStep = 'CONFIRMATION'; // Skip method selection
  }

  currentReturnStateVar(domainState);
};

// ... other reactive variable functions (selectProduct, unselectProduct, etc.) ...

export const goToNextStep = () => {
  const currentState = currentReturnStateVar();
  if (currentState) {
    if (currentState.currentStep === 'PRODUCT_SELECTION' && currentState.getSelectedProducts().length > 0) {
      currentState.currentStep = 'REASON_SELECTION';

      // **Alternative for Special Scenario 2: Single Return Method (at navigation)**
      const allProductsSingleMethod = currentState.getSelectedProducts().every(product => product.availableReturnMethods.length === 1);
      if (allProductsSingleMethod) {
        const singleMethod = currentState.getSelectedProducts()[0].availableReturnMethods[0];
        currentState.getSelectedProducts().forEach(product => product.setReturnMethod(singleMethod));
        currentState.currentStep = 'CONFIRMATION';
      }

    } else if (currentState.currentStep === 'REASON_SELECTION' && currentState.getSelectedProducts().every(p => p.reason)) {
      currentState.currentStep = 'METHOD_SELECTION';
    } else if (currentState.currentStep === 'METHOD_SELECTION' && currentState.getSelectedProducts().every(p => p.returnMethod)) {
      currentState.currentStep = 'CONFIRMATION';
    }
    currentReturnStateVar({ ...currentState });
  }
};

// ... other reactive variable functions ...

Explanation:

1. Single Product, 1 Item:

  • Location: Inside the initializeReturnState function, right after creating the domainState.
  • Logic:
    • We check if the returnableProducts array has exactly one element AND if the availableQuantity of that product is 1.
    • If both conditions are true, we:
      • Automatically select() the single product.
      • Advance the currentStep of the domainState to 'REASON_SELECTION'.
  • Why here? This logic makes sense during the initial setup because it's a condition based on the initial data. If the criteria are met at the beginning, we can directly guide the user to the appropriate step.

2. Single Return Method:

  • Location (Option 1 - During Initialization): Inside the initializeReturnState function, similar to the previous scenario.
  • Logic (Option 1):
    • We check if all returnableProducts have only one available return method (product.availableReturnMethods.length === 1).
    • AND if there are selected products (domainState.getSelectedProducts().length > 0) and the current step is 'METHOD_SELECTION'.
    • If these are met, we get the single available method and set it for all selected products.
    • Then, we advance the currentStep to 'CONFIRMATION'.
  • Location (Option 2 - During Navigation): Inside the goToNextStep function, specifically when transitioning from 'REASON_SELECTION' to 'METHOD_SELECTION'.
  • Logic (Option 2):
    • Before setting the currentStep to 'METHOD_SELECTION', we check if all selected products have only one available return method.
    • If so, we get the single method and set it for all selected products.
    • Then, we directly set the currentStep to 'CONFIRMATION', effectively skipping the method selection screen.

Choosing the Location for Single Return Method Logic:

  • Initialization (initializeReturnState):

    • Pros: The check happens once at the beginning. If the condition is met, the user will never see the method selection screen.
    • Cons: Assumes the available return methods are static or known at initialization. If they depend on other factors that are determined later, this might not be the best place.
  • Navigation (goToNextStep):

    • Pros: More dynamic. You can check the available methods right before rendering the method selection screen. This allows for scenarios where available methods might change based on previous selections or other factors.
    • Cons: The user might briefly see the method selection screen before being automatically advanced if the condition is met during navigation.

Recommendation:

For the "Single Return Method" scenario, the goToNextStep approach is generally more robust because it accounts for situations where the available return methods might be dynamic. However, if your availableReturnMethods are always determined upfront from the API data, handling it during initializeReturnState is also a valid and potentially simpler approach.

Remember to adapt the logic based on how your availableReturnMethods are determined (whether they come directly from the API or are calculated based on other factors).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment