You can use the Headless API with Shopify Hydrogen to implement product rewards which are added directly to the cart for free.

Unlike other kinds of reward, free products require updates to your cart, which is done using the Storefront API, so they require a few more steps to implement.

Our reference implementation includes a full implementation of free product rewards, including them in the Cart sidebar. Even if you use our reference implementation, we recommend reading the rest of this guide to understand the steps involved.

Prerequisites

Ensure you’re using Shopify Functions for free products

Some older LoyaltyLion accounts may be using Shopify Scripts to implement free products. You must migrate to Shopify Functions in order to use free product rewards with the Headless API.

If you’re still using Shopify Scripts (or you’re not sure), get in touch and we can help you migrate over to Shopify Functions.

Add a Buyer Identity to the Cart

Free product rewards only work if the cart has a buyer identity, to associate the current logged in customer with the current cart.

If you’re using Hydrogen, you can do this using the provided helpers. In our reference implementation, we do this in the root loader inside loadCriticalData.

export async function loader(args: LoaderFunctionArgs) {
  const { context } = args;

  if (await context.customerAccount.isLoggedIn()) {
    const buyer = await context.customerAccount.getBuyer();
    await context.cart.updateBuyerIdentity({
      customerAccessToken: buyer.customerAccessToken,
    });
  }
}

Implementation

Initialize a customer session with your current cart

Use the Initialize Session API to initialize a customer session and pass along your current cart.

We use your cart to generate the response, for example any product rewards which have requirements like a minimum cart total will indicate they are not available to redeem in the response based on the provided cart.

In our reference implementation, we do this using a deferred API call in the root loader. This pending data is then passed down to the Cart component and awaited in a <Suspense> wrapper.

return (
  <Suspense fallback={<p>Loading rewards...</p>}>
    <Await resolve={loyaltylion}>
      {(loyaltylion) => {
        if (!loyaltylion) {
          return null;
        }
      }}
    </Await>
  </Suspense>
);

Check if the customer is enrolled

Confirm that the customer is enrolled by checking that customer.state == "enrolled". Customers in any other state are not eligible to redeem rewards.

Render free product rewards available to the customer

Search for any applicable rewards in the customer.available_rewards array. Free product rewards will have kind == "product_cart".

Next, iterate each reward and render it along with an action button to redeem the reward. You’ll need to check each reward’s context.can_redeem.state to determine if the customer can redeem the reward, and, if they can’t, the reason why, which is useful to display to the customer.

const fetcher = useFetcher();
const isLoading = fetcher.state !== 'idle';

const canRedeem = reward.context.can_redeem.state === 'redeemable';

const redeemButtonText = (() => {
  switch (reward.context.can_redeem.state) {
    case 'redeemable':
      return `Redeem for ${reward.variant.cost_text}`;
    case 'claim_limit_reached':
      return 'Limit reached';
    case 'insufficient_points':
      return `${reward.context.can_redeem.additional_points_required} more points needed`;
    case 'cart_requirements_not_met':
      return 'Cart requirements not met';
    case 'max_redemptions_for_cart_reached':
      return 'Already redeemed';
    default:
      return 'Unavailable';
  }
})();

return (
  <div key={reward.id}>
    <div>
      {reward.properties.product.image_url ? (
        <img src={reward.properties.product.image_url} alt="" width="64" />
      ) : (
        <div className='image-placeholder' />
      )}
    </div>
    <div>
      <h4>{reward.variant.title}</h4>
      <div>
        <fetcher.Form
          method="post"
          action={`/loyaltylion/redeem-free-product/${reward.id}`}
        >
          <input
            type="hidden"
            name="variant_id"
            value={reward.properties.product.variants[0].id}
          />
          <button type="submit" disabled={!canRedeem || isLoading}>
            {isLoading ? 'Loading...' : redeemButtonText}
          </button>
        </fetcher.Form>
      </div>
    </div>
  </div>
);

Handle the redemption of a free product reward

When the customer clicks the redeem button, you’ll need to submit an action. Inside your server-side action handler,

  • use the Redeem Free Product endpoint to redeem the reward
  • if successful, the response will contain an active_cart_redemption.cart_line object, which includes the necessary information you need to add to the cart using the Storefront API

In our reference implementation, we do this in the loyaltylion.redeem-free-product.$id.tsx route.

Once the action is successful, Hydrogen/Remix should automatically re-run the relevant loaders, which will update both the cart and LoyaltyLion data, triggering an automatic UI update.

export async function action({params, request, context}: ActionFunctionArgs) {
  if (!(await context.customerAccount.isLoggedIn())) {
    return null;
  }

  const {data} = await context.customerAccount.query(`
    query { customer { id } }
  `);

  if (!data.customer || !params.id) {
    return null;
  }

  const rewardId = parseInt(params.id);
  const variantId = (await request.formData()).get('variant_id') as string;
  const cartId = context.cart.getCartId();

  if (!cartId) {
    throw new Error('Cannot redeem free product reward without a cart');
  }

  const res = await createLoyaltyLionClient(context).rewards.redeemProductCart({
    cart_id: cartId,
    customer_merchant_id: data.customer.id,
    reward_id: rewardId,
    variant_id: variantId,
  });

  if (!res.data) {
    console.error('Failed to redeem product reward', {
      data: res.data,
      error: res.error,
    });
    throw new Error('Request failed');
  }

  const cartLine = res.data.active_cart_redemption.cart_line;

  await context.cart.addLines([
    {
      merchandiseId: cartLine.merchandise_id,
      quantity: cartLine.quantity,
      attributes: cartLine.attributes,
    },
  ]);
}

Handle requested cart actions

When you call Initialize Session and provide your current cart, will check the cart for any expired or invalid product redemptions and automatically cancel them and refund the points.

If this happens, we’ll return a list of requested_cart_actions in the response.

These actions indicate the changes, if any, that need to made to the cart. Currently this is limited to removing lines from the cart, because we’ve cancelled their associated redemption. This might be because the redemption expired, or because its requirements are no longer met by the cart (e.g. the cart total is too low).

In our reference implementation, we stash these requested line IDs to remove on the LoyaltyLionData object, pass them down to the Cart component, and then use a hook which will remove the lines from the cart by submitting an action.