diff --git a/app/components/CartLineItem.module.css b/app/components/CartLineItem.module.css
new file mode 100644
index 0000000..47440c9
--- /dev/null
+++ b/app/components/CartLineItem.module.css
@@ -0,0 +1,11 @@
+.freeRewardBadge {
+ display: inline-flex;
+ align-items: center;
+ border-radius: 0.375rem;
+ background-color: #ecfdf5;
+ padding: 0.25rem 0.5rem;
+ font-size: 0.75rem;
+ font-weight: 500;
+ color: #047857;
+ box-shadow: inset 0 0 0 1px rgba(5, 150, 105, 0.2);
+}
diff --git a/app/components/CartLineItem.tsx b/app/components/CartLineItem.tsx
index 80e34be..cf906d6 100644
--- a/app/components/CartLineItem.tsx
+++ b/app/components/CartLineItem.tsx
@@ -6,6 +6,7 @@ import {Link} from 'react-router';
import {ProductPrice} from './ProductPrice';
import {useAside} from './Aside';
import type {CartApiQueryFragment} from 'storefrontapi.generated';
+import styles from './CartLineItem.module.css';
type CartLine = OptimisticCartLine;
@@ -25,6 +26,10 @@ export function CartLineItem({
const lineItemUrl = useVariantUrl(product.handle, selectedOptions);
const {close} = useAside();
+ const isFreeReward = (line?.attributes ?? []).some(
+ (x) => x.key === '__lion_sfp_id',
+ );
+
return (
{image && (
@@ -52,7 +57,11 @@ export function CartLineItem({
{product.title}
-
+ {isFreeReward ? (
+ FREE REWARD
+ ) : (
+
+ )}
{selectedOptions.map((option) => (
@@ -62,7 +71,7 @@ export function CartLineItem({
))}
-
+
);
@@ -73,12 +82,26 @@ export function CartLineItem({
* These controls are disabled when the line item is new, and the server
* hasn't yet responded that it was successfully added to the cart.
*/
-function CartLineQuantity({line}: {line: CartLine}) {
+function CartLineQuantity({
+ line,
+ isFreeReward,
+}: {
+ line: CartLine;
+ isFreeReward: boolean;
+}) {
if (!line || typeof line?.quantity === 'undefined') return null;
const {id: lineId, quantity, isOptimistic} = line;
const prevQuantity = Number(Math.max(0, quantity - 1).toFixed(0));
const nextQuantity = Number((quantity + 1).toFixed(0));
+ if (isFreeReward) {
+ return (
+
+
+
+ );
+ }
+
return (
Quantity: {quantity}
diff --git a/app/components/CartMain.tsx b/app/components/CartMain.tsx
index 1117b68..bad680a 100644
--- a/app/components/CartMain.tsx
+++ b/app/components/CartMain.tsx
@@ -4,22 +4,31 @@ import type {CartApiQueryFragment} from 'storefrontapi.generated';
import {useAside} from '~/components/Aside';
import {CartLineItem} from '~/components/CartLineItem';
import {CartSummary} from './CartSummary';
+import {LoyaltylionData} from '~/lib/loyaltylion/fetchLoyaltylionData';
+import {useLoyaltylionCartUpdates} from '~/lib/loyaltylion/useLoyaltylionCartUpdates';
+import {AvailableProductRewards} from './loyaltylion/AvailableProductRewards';
export type CartLayout = 'page' | 'aside';
export type CartMainProps = {
cart: CartApiQueryFragment | null;
layout: CartLayout;
+ loyaltylion: Promise;
};
/**
* The main cart component that displays the cart items and summary.
* It is used by both the /cart route and the cart aside dialog.
*/
-export function CartMain({layout, cart: originalCart}: CartMainProps) {
+export function CartMain({
+ layout,
+ cart: originalCart,
+ loyaltylion,
+}: CartMainProps) {
// The useOptimisticCart hook applies pending actions to the cart
// so the user immediately sees feedback when they modify the cart.
const cart = useOptimisticCart(originalCart);
+ useLoyaltylionCartUpdates(loyaltylion);
const linesCount = Boolean(cart?.lines?.nodes?.length || 0);
const withDiscount =
@@ -41,6 +50,7 @@ export function CartMain({layout, cart: originalCart}: CartMainProps) {
{cartHasItems && }
+
);
}
diff --git a/app/components/Header.tsx b/app/components/Header.tsx
index 45b620b..3df9f15 100644
--- a/app/components/Header.tsx
+++ b/app/components/Header.tsx
@@ -7,12 +7,14 @@ import {
} from '@shopify/hydrogen';
import type {HeaderQuery, CartApiQueryFragment} from 'storefrontapi.generated';
import {useAside} from '~/components/Aside';
+import {LoyaltylionData} from '~/lib/loyaltylion/fetchLoyaltylionData';
interface HeaderProps {
header: HeaderQuery;
cart: Promise;
isLoggedIn: Promise;
publicStoreDomain: string;
+ loyaltylion: Promise;
}
type Viewport = 'desktop' | 'mobile';
@@ -22,6 +24,7 @@ export function Header({
isLoggedIn,
cart,
publicStoreDomain,
+ loyaltylion,
}: HeaderProps) {
const {shop, menu} = header;
return (
@@ -35,7 +38,11 @@ export function Header({
primaryDomainUrl={header.shop.primaryDomain.url}
publicStoreDomain={publicStoreDomain}
/>
-
+
);
}
@@ -98,10 +105,39 @@ export function HeaderMenu({
function HeaderCtas({
isLoggedIn,
cart,
-}: Pick) {
+ loyaltylion,
+}: Pick) {
return (
+
+
+
+ {(loyaltylion) => {
+ if (!loyaltylion) {
+ return null;
+ }
+
+ const {customer} = loyaltylion;
+
+ return (
+ <>
+ {customer && customer.state === 'enrolled' && (
+
+ {Intl.NumberFormat().format(customer.points_approved)}{' '}
+ points
+
+ )}
+ >
+ );
+ }}
+
+
+
diff --git a/app/components/PageLayout.tsx b/app/components/PageLayout.tsx
index ed7843b..f1c8ce5 100644
--- a/app/components/PageLayout.tsx
+++ b/app/components/PageLayout.tsx
@@ -14,6 +14,7 @@ import {
SearchFormPredictive,
} from '~/components/SearchFormPredictive';
import {SearchResultsPredictive} from '~/components/SearchResultsPredictive';
+import {LoyaltylionData} from '~/lib/loyaltylion/fetchLoyaltylionData';
interface PageLayoutProps {
cart: Promise;
@@ -21,6 +22,7 @@ interface PageLayoutProps {
header: HeaderQuery;
isLoggedIn: Promise;
publicStoreDomain: string;
+ loyaltylion: Promise;
children?: React.ReactNode;
}
@@ -30,11 +32,12 @@ export function PageLayout({
footer,
header,
isLoggedIn,
+ loyaltylion,
publicStoreDomain,
}: PageLayoutProps) {
return (
-
+
{header && (
@@ -43,6 +46,7 @@ export function PageLayout({
cart={cart}
isLoggedIn={isLoggedIn}
publicStoreDomain={publicStoreDomain}
+ loyaltylion={loyaltylion}
/>
)}
{children}
@@ -55,13 +59,18 @@ export function PageLayout({
);
}
-function CartAside({cart}: {cart: PageLayoutProps['cart']}) {
+function CartAside({
+ cart,
+ loyaltylion,
+}: Pick) {
return (
Loading cart ...}>
{(cart) => {
- return ;
+ return (
+
+ );
}}
diff --git a/app/components/loyaltylion/AvailableProductRewards.module.css b/app/components/loyaltylion/AvailableProductRewards.module.css
new file mode 100644
index 0000000..1485f10
--- /dev/null
+++ b/app/components/loyaltylion/AvailableProductRewards.module.css
@@ -0,0 +1,17 @@
+.heading {
+ border-top: 1px solid #e0e0e0;
+ padding-top: 10px;
+}
+
+.header {
+ margin: 0 0 10px 0;
+ padding: 0;
+}
+
+.productRewards {
+ margin-bottom: 10px;
+}
+
+.productRewardContainer {
+ margin-bottom: 10px;
+}
diff --git a/app/components/loyaltylion/AvailableProductRewards.tsx b/app/components/loyaltylion/AvailableProductRewards.tsx
new file mode 100644
index 0000000..3a8ad26
--- /dev/null
+++ b/app/components/loyaltylion/AvailableProductRewards.tsx
@@ -0,0 +1,52 @@
+import {Suspense} from 'react';
+import {Await} from 'react-router';
+import {LoyaltylionData} from '~/lib/loyaltylion/fetchLoyaltylionData';
+import styles from './AvailableProductRewards.module.css';
+import {ProductReward} from './ProductReward';
+
+export type AvailableProductRewardsProps = {
+ loyaltylion: Promise;
+};
+
+export function AvailableProductRewards({
+ loyaltylion,
+}: AvailableProductRewardsProps) {
+ return (
+ Loading rewards...}>
+
+ {(loyaltylion) => {
+ if (!loyaltylion) {
+ return null;
+ }
+
+ const {customer} = loyaltylion;
+
+ if (!customer || customer.state !== 'enrolled') {
+ return null;
+ }
+
+ const freeProductRewards = customer.available_rewards.filter(
+ (reward) => reward.kind === 'product_cart',
+ );
+
+ if (freeProductRewards.length === 0) {
+ return null;
+ }
+
+ return (
+ <>
+
+
Redeem rewards
+
+
+ {freeProductRewards.map((reward) => (
+
+ ))}
+
+ >
+ );
+ }}
+
+
+ );
+}
diff --git a/app/components/loyaltylion/ProductReward.module.css b/app/components/loyaltylion/ProductReward.module.css
new file mode 100644
index 0000000..1910b61
--- /dev/null
+++ b/app/components/loyaltylion/ProductReward.module.css
@@ -0,0 +1,37 @@
+.container {
+ display: grid;
+ grid-template-areas:
+ 'image content'
+ 'image content';
+ column-gap: 10px;
+ grid-template-columns: 1fr 10fr;
+}
+
+.image {
+ grid-area: image;
+}
+
+.content {
+ grid-area: content;
+ display: flex;
+ flex-direction: column;
+}
+
+.title {
+}
+
+.action {
+}
+
+.imagePlaceholder {
+ width: 64px;
+ height: 64px;
+ background-color: #f0f0f0;
+}
+
+.button {
+}
+
+.button:disabled {
+ opacity: 0.5;
+}
diff --git a/app/components/loyaltylion/ProductReward.tsx b/app/components/loyaltylion/ProductReward.tsx
new file mode 100644
index 0000000..3475eb2
--- /dev/null
+++ b/app/components/loyaltylion/ProductReward.tsx
@@ -0,0 +1,65 @@
+import {CustomerAvailableRewardProductCart} from '@loyaltylion/headless-api-client';
+import {useFetcher} from 'react-router';
+import styles from './ProductReward.module.css';
+
+export function ProductReward({
+ reward,
+}: {
+ reward: CustomerAvailableRewardProductCart;
+}) {
+ 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 (
+
+
+ {reward.properties.product.image_url ? (
+
+ ) : (
+
+ )}
+
+
+
{reward.variant.title}
+
+
+
+
+ {isLoading ? 'Loading...' : redeemButtonText}
+
+
+
+
+
+ );
+}
diff --git a/app/components/loyaltylion/RewardsPage.module.css b/app/components/loyaltylion/RewardsPage.module.css
new file mode 100644
index 0000000..d428347
--- /dev/null
+++ b/app/components/loyaltylion/RewardsPage.module.css
@@ -0,0 +1,34 @@
+.grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+ gap: 1rem;
+ padding: 2rem;
+}
+
+.card {
+ background-color: white;
+ border-radius: 0.5rem;
+ box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
+ overflow: hidden;
+ border-top: 1px solid #e5e7eb;
+ border-bottom: 1px solid #e5e7eb;
+}
+
+.cardContainer > div + div {
+ border-top: 1px solid #e5e7eb;
+}
+
+.cardHeader {
+ padding: 1.25rem 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+
+ strong {
+ font-size: 1.1rem;
+ }
+}
+
+.cardBody {
+ padding: 1.25rem 1rem;
+}
diff --git a/app/components/loyaltylion/RewardsPage.tsx b/app/components/loyaltylion/RewardsPage.tsx
new file mode 100644
index 0000000..0e55e96
--- /dev/null
+++ b/app/components/loyaltylion/RewardsPage.tsx
@@ -0,0 +1,96 @@
+import {LoyaltylionData} from '~/lib/loyaltylion/fetchLoyaltylionData';
+import styles from './RewardsPage.module.css';
+
+export type RewardsPageProps = {
+ loyaltylion: LoyaltylionData;
+};
+
+export function RewardsPage({loyaltylion}: RewardsPageProps) {
+ const {configuration: config, customer} = loyaltylion;
+
+ // if an enrolled customer is present, you should render their
+ // available rewards: this will only show rewards available to them
+ // based on their current tier, and also includes additional context
+ // indicating if they're able to redeem it (has enough points, limit
+ // reached, etc)
+ const rewards =
+ customer?.state === 'enrolled'
+ ? customer.available_rewards
+ : config.rewards;
+
+ // as above, but for rules
+ const rules =
+ customer?.state === 'enrolled' ? customer.available_rules : config.rules;
+
+ return (
+ <>
+
+ {config.program.name}
+
+
+
+
Rewards
+
+ {rewards.map((reward) => {
+ // if we have an enrolled customer, the reward will have a
+ // single variant, which is the active variant based on
+ // their current tier
+ if ('variant' in reward) {
+ return (
+
+
+ {reward.variant.title}
+ {reward.variant.cost_text}
+
+
+ );
+ }
+
+ // if no enrolled customer, it will instead have all the
+ // variants - one per tier. you can choose to render any
+ // or all variants
+ const variant = reward.variants[0];
+
+ return (
+
+
+ {variant.title}
+ {variant.cost_text}
+
+
+ );
+ })}
+
+
+
+
+
Rules
+
+ {rules.map((rule) => {
+ if ('variant' in rule) {
+ return (
+
+
+ {rule.variant.title}
+ {rule.variant.result_short_text}
+
+
+ );
+ }
+
+ const variant = rule.variants[0];
+
+ return (
+
+
+ {variant.title}
+ {variant.result_short_text}
+
+
+ );
+ })}
+
+
+ >
+ );
+}
diff --git a/app/lib/loyaltylion/client.ts b/app/lib/loyaltylion/client.ts
new file mode 100644
index 0000000..8cfa9b1
--- /dev/null
+++ b/app/lib/loyaltylion/client.ts
@@ -0,0 +1,11 @@
+import {AppLoadContext} from 'react-router';
+import {createHeadlessApiClient} from '@loyaltylion/headless-api-client';
+
+export async function createLoyaltyLionClient(context: AppLoadContext) {
+ return createHeadlessApiClient({
+ siteId: parseInt(context.env.LOYALTYLION_SITE_ID),
+ apiKey: context.env.LOYALTYLION_API_KEY,
+ baseUrl: 'https://api.loyaltylion.dev',
+ channel: 'web',
+ });
+}
diff --git a/app/lib/loyaltylion/fetchLoyaltylionData.ts b/app/lib/loyaltylion/fetchLoyaltylionData.ts
new file mode 100644
index 0000000..3a8b2a9
--- /dev/null
+++ b/app/lib/loyaltylion/fetchLoyaltylionData.ts
@@ -0,0 +1,121 @@
+import {AppLoadContext} from 'react-router';
+import {createLoyaltyLionClient} from './client';
+import {
+ CustomersInitializeSessionResponseBody,
+ SiteConfiguration,
+} from '@loyaltylion/headless-api-client';
+
+export type LoyaltylionData = {
+ configuration: SiteConfiguration;
+ customer: CustomersInitializeSessionResponseBody['customer'] | null;
+ cartUpdates: {
+ lineIdsToRemove: string[];
+ } | null;
+};
+
+/**
+ * Fetch LoyaltyLion configuration and (if someone is signed in), customer data
+ */
+export async function fetchLoyaltylionData(
+ context: AppLoadContext,
+): Promise {
+ const lionApi = createLoyaltyLionClient(context);
+
+ if (!(await context.customerAccount.isLoggedIn())) {
+ const res = await lionApi.configuration.getConfiguration();
+
+ if (!res.data || res.error) {
+ console.error('Failed to get LoyaltyLion configuration', {
+ data: res.data,
+ error: res.error,
+ });
+
+ return null;
+ }
+
+ return {
+ configuration: res.data,
+ customer: null,
+ cartUpdates: null,
+ };
+ }
+
+ const {data} = await context.customerAccount.query(`
+ query { customer { id, emailAddress { emailAddress } } }
+ `);
+
+ if (!data.customer) {
+ return null;
+ }
+
+ const cart = await context.cart.get();
+
+ const res = await lionApi.customers.initializeSession({
+ customer: {
+ id: data.customer.id,
+ email: data.customer.emailAddress.emailAddress,
+ },
+ cart: !cart
+ ? {kind: 'empty'}
+ : {
+ kind: 'shopify',
+ id: cart.id,
+ cost: {
+ total_amount: {
+ amount: cart.cost.totalAmount.amount,
+ currency_code: cart.cost.totalAmount.currencyCode,
+ },
+ },
+ lines: cart.lines.nodes.map((line) => ({
+ id: line.id,
+ quantity: line.quantity,
+ merchandise: {
+ id: line.merchandise.id,
+ },
+ cost: {
+ total_amount: {
+ amount: line.cost.totalAmount.amount,
+ currency_code: line.cost.totalAmount.currencyCode,
+ },
+ amount_per_quantity: {
+ amount: line.cost.amountPerQuantity.amount,
+ currency_code: line.cost.amountPerQuantity.currencyCode,
+ },
+ },
+ attributes: line.attributes.map((attribute) => ({
+ key: attribute.key,
+ value: attribute.value ?? null,
+ })),
+ })),
+ },
+ });
+
+ if (!res.data || res.error) {
+ console.error('Failed to initialize LoyaltyLion customer', {
+ data: res.data,
+ error: res.error,
+ });
+
+ return null;
+ }
+
+ const lineIdsToRemove = res.data.requested_cart_actions
+ .filter((x) => x.kind === 'remove_cart_line')
+ .map((x) => x.cart_line_to_remove.id);
+
+ if (lineIdsToRemove.length > 0) {
+ return {
+ customer: res.data.customer,
+ configuration: res.data.configuration,
+ cartUpdates: {
+ lineIdsToRemove,
+ },
+ };
+ }
+
+ return {
+ customer: res.data.customer,
+ configuration: res.data.configuration,
+ cartUpdates: null,
+ };
+}
diff --git a/app/lib/loyaltylion/useLoyaltylionCartUpdates.ts b/app/lib/loyaltylion/useLoyaltylionCartUpdates.ts
new file mode 100644
index 0000000..35b3635
--- /dev/null
+++ b/app/lib/loyaltylion/useLoyaltylionCartUpdates.ts
@@ -0,0 +1,44 @@
+import {useFetcher} from 'react-router';
+import {LoyaltylionData} from './fetchLoyaltylionData';
+import {CartForm} from '@shopify/hydrogen';
+import {useEffect, useState} from 'react';
+
+/**
+ * Hook to apply any pending cart updates from LoyaltyLion Customer Init Data
+ */
+export function useLoyaltylionCartUpdates(
+ loyaltylion: Promise,
+) {
+ const {submit} = useFetcher({
+ key: [CartForm.ACTIONS.LinesUpdate, 'loyaltylion_cart_updates'].join('-'),
+ });
+ const [lineIdsToRemove, setLineIdsToRemove] = useState(null);
+
+ // wait for loyaltylion data to resolve and then check check if there are any
+ // pending cart updates
+ useEffect(() => {
+ loyaltylion.then((loyaltylion) => {
+ if (loyaltylion?.cartUpdates) {
+ setLineIdsToRemove(loyaltylion.cartUpdates.lineIdsToRemove);
+ }
+ });
+ }, [loyaltylion]);
+
+ useEffect(() => {
+ if (lineIdsToRemove) {
+ submit(
+ {
+ [CartForm.INPUT_NAME]: JSON.stringify({
+ action: CartForm.ACTIONS.LinesRemove,
+ inputs: {
+ lineIds: lineIdsToRemove,
+ },
+ }),
+ },
+ {method: 'POST', action: '/cart'},
+ );
+ }
+ }, [lineIdsToRemove, submit]);
+
+ return lineIdsToRemove;
+}
diff --git a/app/root.tsx b/app/root.tsx
index 353cb78..88cd3e8 100644
--- a/app/root.tsx
+++ b/app/root.tsx
@@ -16,6 +16,7 @@ import {FOOTER_QUERY, HEADER_QUERY} from '~/lib/fragments';
import resetStyles from '~/styles/reset.css?url';
import appStyles from '~/styles/app.css?url';
import {PageLayout} from './components/PageLayout';
+import {fetchLoyaltylionData} from './lib/loyaltylion/fetchLoyaltylionData';
export type RootLoader = typeof loader;
@@ -100,6 +101,13 @@ export async function loader(args: LoaderFunctionArgs) {
async function loadCriticalData({context}: LoaderFunctionArgs) {
const {storefront} = context;
+ if (await context.customerAccount.isLoggedIn()) {
+ const buyer = await context.customerAccount.getBuyer();
+ await context.cart.updateBuyerIdentity({
+ customerAccessToken: buyer.customerAccessToken,
+ });
+ }
+
const [header] = await Promise.all([
storefront.query(HEADER_QUERY, {
cache: storefront.CacheLong(),
@@ -137,6 +145,7 @@ function loadDeferredData({context}: LoaderFunctionArgs) {
return {
cart: cart.get(),
isLoggedIn: customerAccount.isLoggedIn(),
+ loyaltylion: fetchLoyaltylionData(context),
footer,
};
}
diff --git a/app/routes/cart.tsx b/app/routes/cart.tsx
index cb7357e..a923076 100644
--- a/app/routes/cart.tsx
+++ b/app/routes/cart.tsx
@@ -1,4 +1,8 @@
-import {type MetaFunction, useLoaderData} from 'react-router';
+import {
+ type MetaFunction,
+ useLoaderData,
+ useRouteLoaderData,
+} from 'react-router';
import type {CartQueryDataReturn} from '@shopify/hydrogen';
import {CartForm} from '@shopify/hydrogen';
import {
@@ -8,6 +12,7 @@ import {
type HeadersFunction,
} from '@shopify/remix-oxygen';
import {CartMain} from '~/components/CartMain';
+import {RootLoader} from '~/root';
export const meta: MetaFunction = () => {
return [{title: `Hydrogen | Cart`}];
@@ -107,11 +112,14 @@ export async function loader({context}: LoaderFunctionArgs) {
export default function Cart() {
const cart = useLoaderData();
+ const loyaltylion =
+ useRouteLoaderData('root')?.loyaltylion ??
+ Promise.resolve(null);
return (
Cart
-
+
);
}
diff --git a/app/routes/loyaltylion.redeem-free-product.$id.tsx b/app/routes/loyaltylion.redeem-free-product.$id.tsx
new file mode 100644
index 0000000..4a1a46c
--- /dev/null
+++ b/app/routes/loyaltylion.redeem-free-product.$id.tsx
@@ -0,0 +1,49 @@
+import {ActionFunctionArgs} from 'react-router';
+import {createLoyaltyLionClient} from '~/lib/loyaltylion/client';
+
+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,
+ },
+ ]);
+}
diff --git a/app/routes/rewards.tsx b/app/routes/rewards.tsx
new file mode 100644
index 0000000..a8b55b5
--- /dev/null
+++ b/app/routes/rewards.tsx
@@ -0,0 +1,30 @@
+import {Suspense} from 'react';
+import {Await, MetaFunction, useRouteLoaderData} from 'react-router';
+import {RewardsPage} from '~/components/loyaltylion/RewardsPage';
+import {RootLoader} from '~/root';
+
+export const meta: MetaFunction = ({data}) => {
+ return [{title: `Hydrogen | Rewards`}];
+};
+
+export default function Page() {
+ const loyaltylion =
+ useRouteLoaderData('root')?.loyaltylion ??
+ Promise.resolve(null);
+
+ return (
+
+
+
+ {(loyaltylion) => {
+ if (!loyaltylion) {
+ return null;
+ }
+
+ return ;
+ }}
+
+
+
+ );
+}
diff --git a/app/styles/loyaltylion.css.ts b/app/styles/loyaltylion.css.ts
new file mode 100644
index 0000000..83b8dfa
--- /dev/null
+++ b/app/styles/loyaltylion.css.ts
@@ -0,0 +1,5 @@
+import {style} from '@vanilla-extract/css';
+
+export const container = style({
+ padding: 10,
+});
diff --git a/env.d.ts b/env.d.ts
index be52daa..9934406 100644
--- a/env.d.ts
+++ b/env.d.ts
@@ -20,6 +20,18 @@ declare global {
interface Env extends HydrogenEnv {
// declare additional Env parameter use in the fetch handler and Remix loader context here
+
+ /**
+ * Fill this in with your LoyaltyLion site ID. Can be found in the LL admin
+ * URL: https://app.loyaltylion.com/sites/{site_id}
+ */
+ LOYALTYLION_SITE_ID: string;
+
+ /**
+ * Fill this in with your LoyaltyLion API key:
+ * https://developers.loyaltylion.com/api-reference/authentication/api-keys
+ */
+ LOYALTYLION_API_KEY: string;
}
}
diff --git a/package-lock.json b/package-lock.json
index 0314af4..8080f58 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,7 @@
"name": "hydrogen-reference-store",
"version": "2025.5.1",
"dependencies": {
+ "@loyaltylion/headless-api-client": "^0.0.1",
"@shopify/hydrogen": "2025.5.0",
"@shopify/remix-oxygen": "^3.0.0",
"@vanilla-extract/css": "^1.17.1",
@@ -2777,6 +2778,16 @@
"@lit-labs/ssr-dom-shim": "^1.2.0"
}
},
+ "node_modules/@loyaltylion/headless-api-client": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/@loyaltylion/headless-api-client/-/headless-api-client-0.0.1.tgz",
+ "integrity": "sha512-RA0JYBa3C27KxKJ81tC35OBf4C1W66YJ4sqtl7TNHMLcS6XjphkF08U+oV7BHGghb4Dq8BlmwYiOmHXREZfkLw==",
+ "license": "MIT",
+ "dependencies": {
+ "openapi-fetch": "^0.14.0",
+ "tslib": "^2.8.1"
+ }
+ },
"node_modules/@miniflare/cache": {
"version": "2.14.4",
"resolved": "https://registry.npmjs.org/@miniflare/cache/-/cache-2.14.4.tgz",
@@ -10101,6 +10112,21 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/openapi-fetch": {
+ "version": "0.14.0",
+ "resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.14.0.tgz",
+ "integrity": "sha512-PshIdm1NgdLvb05zp8LqRQMNSKzIlPkyMxYFxwyHR+UlKD4t2nUjkDhNxeRbhRSEd3x5EUNh2w5sJYwkhOH4fg==",
+ "license": "MIT",
+ "dependencies": {
+ "openapi-typescript-helpers": "^0.0.15"
+ }
+ },
+ "node_modules/openapi-typescript-helpers": {
+ "version": "0.0.15",
+ "resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.0.15.tgz",
+ "integrity": "sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==",
+ "license": "MIT"
+ },
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
diff --git a/package.json b/package.json
index bae81b2..eb748cf 100644
--- a/package.json
+++ b/package.json
@@ -14,6 +14,7 @@
},
"prettier": "@shopify/prettier-config",
"dependencies": {
+ "@loyaltylion/headless-api-client": "^0.0.1",
"@shopify/hydrogen": "2025.5.0",
"@shopify/remix-oxygen": "^3.0.0",
"@vanilla-extract/css": "^1.17.1",