@lime-bundles/react 1.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -60
- package/dist/index.cjs +134 -12
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +24 -2
- package/dist/index.d.ts +24 -2
- package/dist/index.js +133 -8
- package/dist/index.js.map +1 -1
- package/docs/README.md +81 -0
- package/docs/css-variables.md +158 -0
- package/docs/hydrogen.md +185 -0
- package/docs/react-nextjs.md +379 -0
- package/docs/web-component.md +282 -0
- package/package.json +5 -4
package/docs/hydrogen.md
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# Hydrogen
|
|
2
|
+
|
|
3
|
+
> **Before you start:** most Hydrogen merchants don't need this guide. The [web component](./web-component.md) (`<lime-bundle>`) works inside any Hydrogen page and handles cart and checkout via Shopify's tokenless Storefront Cart API. Paste the snippet from `/app/settings/headless` into your product route and you're done. Reach for this guide only when you want React components (`<FixedBundle>`) that thread cart mutations through Hydrogen's `useCart` state.
|
|
4
|
+
|
|
5
|
+
SSR-first integration: fetch the bundle server-side in a route loader, hydrate a `<FixedBundle>` (or volume / mix-match) with Hydrogen's `useCart` for the add-to-cart callback.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @lime-bundles/react @lime-bundles/core
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
`@lime-bundles/core` is a transitive dependency. Installing it explicitly lets you call `fetchBundleData` from a loader without pulling React.
|
|
14
|
+
|
|
15
|
+
## Environment
|
|
16
|
+
|
|
17
|
+
Open `/app/settings/headless` in your Lime Bundles admin, click **Generate token**, then append the value to your Hydrogen project's `.env`:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# Existing values you already had: keep these as-is
|
|
21
|
+
PUBLIC_STORE_DOMAIN=my-shop.myshopify.com
|
|
22
|
+
PUBLIC_STOREFRONT_API_TOKEN=<your existing Shopify Storefront token>
|
|
23
|
+
|
|
24
|
+
# New: Lime Bundles' own Storefront token
|
|
25
|
+
PUBLIC_LIME_BUNDLES_TOKEN=<paste the generated token>
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The variable is intentionally namespaced so it doesn't collide with the `PUBLIC_STOREFRONT_API_TOKEN` that Shopify's Headless app issues for your primary storefront. Lime Bundles uses its own Storefront Access Token, scoped to bundle metaobjects and product listings only.
|
|
29
|
+
|
|
30
|
+
Lime Bundles creates the token for you via Shopify's Admin API. No custom-app configuration needed (Shopify deprecated that flow on 2026-01-01). The token carries the read-only scopes `unauthenticated_read_metaobjects` and `unauthenticated_read_product_listings` and nothing else. To rotate, click **Regenerate** on the same page. It revokes the current token and issues a new one atomically.
|
|
31
|
+
|
|
32
|
+
## Getting a bundle GID
|
|
33
|
+
|
|
34
|
+
Each bundle you create in the Lime Bundles admin has a Shopify Metaobject GID (`gid://shopify/Metaobject/<numericId>`). You have two ways to use it:
|
|
35
|
+
|
|
36
|
+
- **Pin one bundle**: hardcode the GID. Find it in the admin at `/app/bundles/<id>/analytics` (URL), or by inspecting a bundle's API response.
|
|
37
|
+
- **Render every bundle configured against a product**: skip the explicit GID and use `fetchBundlesForProduct({ productHandle })` instead. Hand it the product handle from your loader; it returns every active bundle the merchant attached to that product. See the [React guide](./react-nextjs.md#auto-fetch-every-bundle-for-a-product) for the client-side `useBundlesForProduct` hook.
|
|
38
|
+
|
|
39
|
+
Both patterns are shown below.
|
|
40
|
+
|
|
41
|
+
## Fetch a specific bundle in a loader
|
|
42
|
+
|
|
43
|
+
Hydrogen runs on Oxygen, where `process.env` is not available in the browser. Surface the values the route component needs through the loader. Oxygen injects environment variables as `context.env`:
|
|
44
|
+
|
|
45
|
+
```tsx
|
|
46
|
+
// app/routes/products.$handle.tsx
|
|
47
|
+
import { fetchBundleData } from "@lime-bundles/react";
|
|
48
|
+
import { useLoaderData } from "@shopify/remix-oxygen";
|
|
49
|
+
import type { LoaderFunctionArgs } from "@shopify/remix-oxygen";
|
|
50
|
+
|
|
51
|
+
export async function loader({ request, context }: LoaderFunctionArgs) {
|
|
52
|
+
const shopDomain = context.env.PUBLIC_STORE_DOMAIN;
|
|
53
|
+
const storefrontAccessToken = context.env.PUBLIC_LIME_BUNDLES_TOKEN;
|
|
54
|
+
const bundleGid = "gid://shopify/Metaobject/123456789";
|
|
55
|
+
|
|
56
|
+
const bundle = await fetchBundleData({
|
|
57
|
+
shopDomain,
|
|
58
|
+
storefrontAccessToken,
|
|
59
|
+
bundleGid,
|
|
60
|
+
// Required on SSR. Without it Shopify may return 430 Security Rejection
|
|
61
|
+
// for server-originated traffic on a private token. `x-forwarded-for` is
|
|
62
|
+
// set by Oxygen and most other hosts; take the first IP in the list.
|
|
63
|
+
buyerIp:
|
|
64
|
+
request.headers.get("x-forwarded-for")?.split(",")[0].trim() ?? undefined,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
return { bundle, shopDomain, storefrontAccessToken, bundleGid };
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`fetchBundleData` throws `StorefrontApiError` on HTTP / GraphQL failure and `BundleParseError` when the metaobject is missing, inactive, not yet scheduled, or expired. Hydrogen surfaces thrown loader errors via its standard `ErrorBoundary`.
|
|
72
|
+
|
|
73
|
+
## Fetch every bundle for a product (auto-detect)
|
|
74
|
+
|
|
75
|
+
If you'd rather not pin a GID, use `fetchBundlesForProduct` in the loader. Pass it the product handle from route params and it returns every active bundle the merchant attached to that product (inactive, draft, not-yet-started, and expired bundles are filtered out):
|
|
76
|
+
|
|
77
|
+
```tsx
|
|
78
|
+
// app/routes/products.$handle.tsx
|
|
79
|
+
import { fetchBundlesForProduct } from "@lime-bundles/react";
|
|
80
|
+
import type { LoaderFunctionArgs } from "@shopify/remix-oxygen";
|
|
81
|
+
|
|
82
|
+
export async function loader({ request, params, context }: LoaderFunctionArgs) {
|
|
83
|
+
const shopDomain = context.env.PUBLIC_STORE_DOMAIN;
|
|
84
|
+
const storefrontAccessToken = context.env.PUBLIC_LIME_BUNDLES_TOKEN;
|
|
85
|
+
|
|
86
|
+
const bundles = await fetchBundlesForProduct({
|
|
87
|
+
shopDomain,
|
|
88
|
+
storefrontAccessToken,
|
|
89
|
+
productHandle: params.handle!,
|
|
90
|
+
buyerIp:
|
|
91
|
+
request.headers.get("x-forwarded-for")?.split(",")[0].trim() ?? undefined,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
return { bundles, shopDomain, storefrontAccessToken };
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The route component then maps the list and renders the right component per `bundleType` (see the [React guide](./react-nextjs.md#auto-fetch-every-bundle-for-a-product)). Prefer this shape when the product has more than one bundle configured or when you don't want to redeploy each time a merchant swaps bundles.
|
|
99
|
+
|
|
100
|
+
For a purely client-side version (no loader), use the `useBundlesForProduct` hook in a component.
|
|
101
|
+
|
|
102
|
+
## Render and wire cart
|
|
103
|
+
|
|
104
|
+
```tsx
|
|
105
|
+
// Same file: route component
|
|
106
|
+
import { FixedBundle } from "@lime-bundles/react";
|
|
107
|
+
import { useCart } from "@shopify/hydrogen-react";
|
|
108
|
+
|
|
109
|
+
export default function ProductRoute() {
|
|
110
|
+
const { shopDomain, storefrontAccessToken, bundleGid } =
|
|
111
|
+
useLoaderData<typeof loader>();
|
|
112
|
+
const { linesAdd } = useCart();
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<FixedBundle
|
|
116
|
+
shopDomain={shopDomain}
|
|
117
|
+
storefrontAccessToken={storefrontAccessToken}
|
|
118
|
+
bundleGid={bundleGid}
|
|
119
|
+
onAddToCart={async (lines) => {
|
|
120
|
+
await linesAdd(lines);
|
|
121
|
+
}}
|
|
122
|
+
onError={(err) => console.error("Bundle error", err)}
|
|
123
|
+
/>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
The component fetches the bundle client-side via `useBundleData` on mount. Server-side prefetching in the loader warms Shopify's CDN cache so the client-side fetch returns instantly. If you want to avoid the duplicate fetch entirely, pass the `bundle` prop from the loader into a client component that renders directly without the Lime Bundles component. See [React guide](./react-nextjs.md#ssr-without-refetch) for that pattern.
|
|
129
|
+
|
|
130
|
+
## The cart contract
|
|
131
|
+
|
|
132
|
+
`onAddToCart` receives a Hydrogen-compatible `CartLineInput[]`:
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
type CartLineInput = {
|
|
136
|
+
merchandiseId: string; // variant GID
|
|
137
|
+
quantity: number;
|
|
138
|
+
attributes: Array<{ key: string; value: string }>;
|
|
139
|
+
};
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
The `attributes` array always includes `{ key: "_lime_bundle_gid", value: <bundle GID> }`. **Do not strip this.** The `orders/create` webhook relies on it for purchase attribution. Hydrogen's `cart.linesAdd` preserves custom attributes through checkout by default.
|
|
143
|
+
|
|
144
|
+
## Worked example: Volume bundle
|
|
145
|
+
|
|
146
|
+
```tsx
|
|
147
|
+
import { VolumeBundle } from "@lime-bundles/react";
|
|
148
|
+
|
|
149
|
+
<VolumeBundle
|
|
150
|
+
shopDomain={SHOP_DOMAIN}
|
|
151
|
+
storefrontAccessToken={TOKEN}
|
|
152
|
+
bundleGid="gid://shopify/Metaobject/42"
|
|
153
|
+
onAddToCart={async (lines) => {
|
|
154
|
+
await linesAdd(lines);
|
|
155
|
+
}}
|
|
156
|
+
/>
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
The component renders tier cards, auto-selects the configured default tier, and calls `onAddToCart` with the chosen tier's merchandise and quantity.
|
|
160
|
+
|
|
161
|
+
## Styling and merchant widget config
|
|
162
|
+
|
|
163
|
+
The React SDK does **not** automatically apply the widget styles the merchant set in the admin editor. That's deliberate. If you chose `@lime-bundles/react` on Hydrogen you likely want full control over the look so bundles match your storefront's design system.
|
|
164
|
+
|
|
165
|
+
Every `ParsedBundle` that `fetchBundleData` resolves to exposes the merchant's full config as `bundle.widgetConfig`. Read any field you want from it:
|
|
166
|
+
|
|
167
|
+
```tsx
|
|
168
|
+
<button style={{ background: bundle.widgetConfig.cta.primaryColor }}>
|
|
169
|
+
{bundle.widgetConfig.cta.ctaText}
|
|
170
|
+
</button>
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Want drop-in parity with the admin preview instead? Swap to the [web component](./web-component.md): it honours `widgetConfig` end-to-end without any wiring on your side.
|
|
174
|
+
|
|
175
|
+
Full field list in [`packages/core/src/bundle/types.ts`](../../packages/core/src/bundle/types.ts). Helpers `WIDGET_CONFIG_DEFAULTS`, `mergeWidgetConfig`, `flattenWidgetConfig`, and `applyWidgetConfigVars` are exported from `@lime-bundles/core` if you want to apply the CSS custom properties to your own DOM.
|
|
176
|
+
|
|
177
|
+
## What's NOT your job
|
|
178
|
+
|
|
179
|
+
- **Purchase attribution.** The Lime Bundles `orders/create` webhook scans every line item's `_lime_bundle_gid` attribute and records a `bundle_purchased` AnalyticsEvent. Don't add client-side `checkout_completed` tracking; it double-counts.
|
|
180
|
+
- **Custom CSS.** If merchants configure custom CSS in `/app/settings/custom-css`, `useBundleData` auto-fetches and injects a scoped `<style>` tag. No action needed.
|
|
181
|
+
|
|
182
|
+
## Next steps
|
|
183
|
+
|
|
184
|
+
- [CSS variables reference](./css-variables.md): brand-match the widget to your theme.
|
|
185
|
+
- [Web component guide](./web-component.md): fallback for sections of the site that aren't React-controlled.
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
# React / Next.js / Vite
|
|
2
|
+
|
|
3
|
+
> **Before you start:** you may not need this guide. The [web component](./web-component.md) (`<lime-bundle>`) works inside any React app. Paste it into your JSX like any other custom element, handle cart and checkout via Shopify's tokenless Storefront Cart API, zero configuration. Use this guide only when you want React components (`<FixedBundle>`, `<VolumeBundle>`, `<MixMatchBundle>`) with type-safe props and cart integration through your existing React state.
|
|
4
|
+
|
|
5
|
+
`@lime-bundles/react` works anywhere React does. This guide covers Next.js App Router, Pages Router, and Vite / CRA. For Remix on Oxygen, see the [Hydrogen guide](./hydrogen.md). Hydrogen is Remix under the hood, and the loader pattern there applies unchanged. BYO-cart: Lime Bundles renders and tracks analytics; you wire up your own cart mutation.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @lime-bundles/react @lime-bundles/core
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
React ≥18 and react-dom ≥18 are peer dependencies.
|
|
14
|
+
|
|
15
|
+
## Environment
|
|
16
|
+
|
|
17
|
+
Open `/app/settings/headless` in your Lime Bundles admin, click **Generate token**, then append the value to your project. Don't overwrite the Shopify Storefront token your app already uses:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# .env.local (Next.js) or .env (Vite)
|
|
21
|
+
NEXT_PUBLIC_SHOPIFY_STORE_DOMAIN=my-shop.myshopify.com
|
|
22
|
+
NEXT_PUBLIC_LIME_BUNDLES_TOKEN=<paste the generated token>
|
|
23
|
+
|
|
24
|
+
# Vite: same name with the VITE_ prefix instead
|
|
25
|
+
# VITE_LIME_BUNDLES_TOKEN=<paste the generated token>
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The variable is intentionally namespaced to `LIME_BUNDLES_` so it doesn't collide with the Storefront API token Shopify's own Headless app issues for your primary storefront. Lime Bundles uses its own Storefront Access Token, scoped to bundle metaobjects and product listings only.
|
|
29
|
+
|
|
30
|
+
The `NEXT_PUBLIC_` prefix (or `VITE_` for Vite) exposes the value to client bundles. Lime Bundles creates the token for you via Shopify's Admin API. No custom-app setup needed (Shopify deprecated that flow on 2026-01-01). The token is a read-only public Storefront Access Token with `unauthenticated_read_metaobjects` and `unauthenticated_read_product_listings` scopes, safe to ship in client code. To rotate, click **Regenerate** in the admin. It atomically revokes the old token and issues a new one.
|
|
31
|
+
|
|
32
|
+
## Pick a rendering mode
|
|
33
|
+
|
|
34
|
+
Before you drop a component into a route, decide which bundles you want to show.
|
|
35
|
+
|
|
36
|
+
- **All bundles on this product** (the standard Shopify behaviour). Use [`useBundlesForProduct({ productHandle })`](#auto-fetch-every-bundle-for-a-product). Hand it a product handle from your router (`params.handle`, `params.slug`, etc.) and render whatever comes back.
|
|
37
|
+
- **One specific bundle, pinned to this page.** Hardcode the bundle's Shopify Metaobject GID (you can copy it from the URL of the bundle's analytics page in the admin) and pass it to `<FixedBundle>` / `<VolumeBundle>` / `<MixMatchBundle>` via `bundleGid`.
|
|
38
|
+
|
|
39
|
+
Most product-page integrations want the first pattern. Landing-pages / curated placements want the second.
|
|
40
|
+
|
|
41
|
+
## Next.js App Router (client-only, pin one bundle)
|
|
42
|
+
|
|
43
|
+
```tsx
|
|
44
|
+
// app/bundles/[id]/page.tsx
|
|
45
|
+
"use client";
|
|
46
|
+
|
|
47
|
+
import { FixedBundle } from "@lime-bundles/react";
|
|
48
|
+
|
|
49
|
+
export default function BundlePage({ params }: { params: { id: string } }) {
|
|
50
|
+
return (
|
|
51
|
+
<FixedBundle
|
|
52
|
+
shopDomain={process.env.NEXT_PUBLIC_SHOPIFY_STORE_DOMAIN!}
|
|
53
|
+
storefrontAccessToken={process.env.NEXT_PUBLIC_LIME_BUNDLES_TOKEN!}
|
|
54
|
+
bundleGid={`gid://shopify/Metaobject/${params.id}`}
|
|
55
|
+
onAddToCart={async (lines) => {
|
|
56
|
+
// Wire your cart here. Storefront Cart API, a Route Handler, etc.
|
|
57
|
+
await fetch("/api/cart/add", {
|
|
58
|
+
method: "POST",
|
|
59
|
+
body: JSON.stringify(lines),
|
|
60
|
+
});
|
|
61
|
+
}}
|
|
62
|
+
onError={(err) => console.error(err)}
|
|
63
|
+
/>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The component fetches the bundle on mount via `useBundleData`, manages loading / error state internally, and calls `onAddToCart` when the merchant clicks the CTA.
|
|
69
|
+
|
|
70
|
+
## Next.js App Router (server-fetch + client-render)
|
|
71
|
+
|
|
72
|
+
Fetch the bundle in a Server Component, render it in a Client Component. This avoids the client-side network round-trip on first paint.
|
|
73
|
+
|
|
74
|
+
```tsx
|
|
75
|
+
// app/bundles/[id]/page.tsx (server component, no "use client")
|
|
76
|
+
import { fetchBundleData } from "@lime-bundles/react";
|
|
77
|
+
import { BundleClient } from "./BundleClient";
|
|
78
|
+
import { headers } from "next/headers";
|
|
79
|
+
|
|
80
|
+
export default async function BundlePage({ params }: { params: { id: string } }) {
|
|
81
|
+
const h = await headers();
|
|
82
|
+
const bundle = await fetchBundleData({
|
|
83
|
+
shopDomain: process.env.NEXT_PUBLIC_SHOPIFY_STORE_DOMAIN!,
|
|
84
|
+
storefrontAccessToken: process.env.NEXT_PUBLIC_LIME_BUNDLES_TOKEN!,
|
|
85
|
+
bundleGid: `gid://shopify/Metaobject/${params.id}`,
|
|
86
|
+
buyerIp: h.get("x-forwarded-for")?.split(",")[0] ?? undefined,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return <BundleClient bundle={bundle} />;
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
```tsx
|
|
94
|
+
// app/bundles/[id]/BundleClient.tsx
|
|
95
|
+
"use client";
|
|
96
|
+
import type { ParsedBundle } from "@lime-bundles/react";
|
|
97
|
+
|
|
98
|
+
export function BundleClient({ bundle }: { bundle: ParsedBundle }) {
|
|
99
|
+
// Render directly from the prop. No fetch needed.
|
|
100
|
+
// Wire up your own UI here, or pass to <FixedBundle> if you still
|
|
101
|
+
// want the components to double-check liveness via useBundleData.
|
|
102
|
+
return <pre>{JSON.stringify(bundle, null, 2)}</pre>;
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### SSR without refetch
|
|
107
|
+
|
|
108
|
+
If you want server-rendered HTML *and* the Lime Bundles React components, pass the server-fetched bundle through React Query / SWR / a context, and have the inner component skip its own `useBundleData` call. A lightweight pattern: fork `<FixedBundle>` into your own component and copy the rendering logic (~80 lines). The SDK exports the bundle data type so this is straightforward.
|
|
109
|
+
|
|
110
|
+
## Next.js Pages Router
|
|
111
|
+
|
|
112
|
+
Use `getServerSideProps`:
|
|
113
|
+
|
|
114
|
+
```tsx
|
|
115
|
+
import { fetchBundleData, FixedBundle, type ParsedBundle } from "@lime-bundles/react";
|
|
116
|
+
import type { GetServerSideProps } from "next";
|
|
117
|
+
|
|
118
|
+
export const getServerSideProps: GetServerSideProps = async ({ req, params }) => {
|
|
119
|
+
const bundle = await fetchBundleData({
|
|
120
|
+
shopDomain: process.env.NEXT_PUBLIC_SHOPIFY_STORE_DOMAIN!,
|
|
121
|
+
storefrontAccessToken: process.env.NEXT_PUBLIC_LIME_BUNDLES_TOKEN!,
|
|
122
|
+
bundleGid: `gid://shopify/Metaobject/${params!.id}`,
|
|
123
|
+
buyerIp: (req.headers["x-forwarded-for"] as string)?.split(",")[0],
|
|
124
|
+
});
|
|
125
|
+
return { props: { bundle } };
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export default function Page({ bundle }: { bundle: ParsedBundle }) {
|
|
129
|
+
return (
|
|
130
|
+
<FixedBundle
|
|
131
|
+
shopDomain={process.env.NEXT_PUBLIC_SHOPIFY_STORE_DOMAIN!}
|
|
132
|
+
storefrontAccessToken={process.env.NEXT_PUBLIC_LIME_BUNDLES_TOKEN!}
|
|
133
|
+
bundleGid={bundle.id}
|
|
134
|
+
onAddToCart={async (lines) => {
|
|
135
|
+
await fetch("/api/cart/add", { method: "POST", body: JSON.stringify(lines) });
|
|
136
|
+
}}
|
|
137
|
+
/>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Vite / CRA
|
|
143
|
+
|
|
144
|
+
```tsx
|
|
145
|
+
import { FixedBundle } from "@lime-bundles/react";
|
|
146
|
+
|
|
147
|
+
export function App() {
|
|
148
|
+
return (
|
|
149
|
+
<FixedBundle
|
|
150
|
+
shopDomain={import.meta.env.VITE_SHOPIFY_STORE_DOMAIN}
|
|
151
|
+
storefrontAccessToken={import.meta.env.VITE_LIME_BUNDLES_TOKEN}
|
|
152
|
+
bundleGid="gid://shopify/Metaobject/123"
|
|
153
|
+
onAddToCart={async (lines) => {
|
|
154
|
+
await fetch("/cart/add", { method: "POST", body: JSON.stringify(lines) });
|
|
155
|
+
}}
|
|
156
|
+
/>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Styling and merchant widget config
|
|
162
|
+
|
|
163
|
+
The React SDK does **not** automatically apply the widget styles the merchant set in the admin editor. That's deliberate. If you chose `@lime-bundles/react`, you almost certainly want full control over the look so bundles match your storefront's design system.
|
|
164
|
+
|
|
165
|
+
Every `ParsedBundle` (the object `fetchBundleData` resolves to and `useBundleData` hands you) exposes the merchant's full config as `bundle.widgetConfig`. Read any field you want:
|
|
166
|
+
|
|
167
|
+
```tsx
|
|
168
|
+
function BundleCTA({ bundle }: { bundle: ParsedBundle }) {
|
|
169
|
+
return (
|
|
170
|
+
<button style={{ background: bundle.widgetConfig.cta.primaryColor }}>
|
|
171
|
+
{bundle.widgetConfig.cta.ctaText}
|
|
172
|
+
</button>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Want drop-in parity with the admin preview (same header, save badge, product list, etc.) without writing markup? Swap to the [web component](./web-component.md) instead; it honours `widgetConfig` end-to-end.
|
|
178
|
+
|
|
179
|
+
The shape of `widgetConfig` is stable at v2.0.0. See [`packages/core/src/bundle/types.ts`](../../packages/core/src/bundle/types.ts) for the full field list. Helpers `WIDGET_CONFIG_DEFAULTS`, `mergeWidgetConfig`, `flattenWidgetConfig`, and `applyWidgetConfigVars` are exported from `@lime-bundles/core` if you want to apply the CSS custom properties onto your own DOM instead of reading fields one at a time.
|
|
180
|
+
|
|
181
|
+
## Auto-fetch every bundle for a product
|
|
182
|
+
|
|
183
|
+
The web component's default mode is "show every active bundle configured against the current product." The React SDK equivalent is `useBundlesForProduct`:
|
|
184
|
+
|
|
185
|
+
```tsx
|
|
186
|
+
import {
|
|
187
|
+
useBundlesForProduct,
|
|
188
|
+
FixedBundle,
|
|
189
|
+
VolumeBundle,
|
|
190
|
+
MixMatchBundle,
|
|
191
|
+
type CartLineInput,
|
|
192
|
+
} from "@lime-bundles/react";
|
|
193
|
+
|
|
194
|
+
const SHOP_DOMAIN = process.env.NEXT_PUBLIC_SHOPIFY_STORE_DOMAIN!;
|
|
195
|
+
const TOKEN = process.env.NEXT_PUBLIC_LIME_BUNDLES_TOKEN!;
|
|
196
|
+
|
|
197
|
+
function ProductBundles({ handle }: { handle: string }) {
|
|
198
|
+
const result = useBundlesForProduct({
|
|
199
|
+
shopDomain: SHOP_DOMAIN,
|
|
200
|
+
storefrontAccessToken: TOKEN,
|
|
201
|
+
productHandle: handle,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
if (result.status === "loading") return null; // or your own skeleton
|
|
205
|
+
if (result.status === "error") return null;
|
|
206
|
+
if (result.bundles.length === 0) return null;
|
|
207
|
+
|
|
208
|
+
const cartProps = {
|
|
209
|
+
shopDomain: SHOP_DOMAIN,
|
|
210
|
+
storefrontAccessToken: TOKEN,
|
|
211
|
+
onAddToCart: async (lines: CartLineInput[]) => {
|
|
212
|
+
await fetch("/api/cart/add", {
|
|
213
|
+
method: "POST",
|
|
214
|
+
headers: { "Content-Type": "application/json" },
|
|
215
|
+
body: JSON.stringify({ lines }),
|
|
216
|
+
});
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
return (
|
|
221
|
+
<>
|
|
222
|
+
{result.bundles.map((bundle) => {
|
|
223
|
+
switch (bundle.bundleType) {
|
|
224
|
+
case "fixed":
|
|
225
|
+
return <FixedBundle key={bundle.id} bundleGid={bundle.id} {...cartProps} />;
|
|
226
|
+
case "volume":
|
|
227
|
+
return <VolumeBundle key={bundle.id} bundleGid={bundle.id} {...cartProps} />;
|
|
228
|
+
case "mix_match":
|
|
229
|
+
return <MixMatchBundle key={bundle.id} bundleGid={bundle.id} {...cartProps} />;
|
|
230
|
+
}
|
|
231
|
+
})}
|
|
232
|
+
</>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Inactive, draft, not-yet-started, and expired bundles are filtered out by the hook; consumers don't need to guard.
|
|
238
|
+
|
|
239
|
+
For SSR / Hydrogen loaders, use `fetchBundlesForProduct` (the async helper `useBundlesForProduct` wraps) and pass the list as a prop to a client component.
|
|
240
|
+
|
|
241
|
+
## Build your own UI (DIY path)
|
|
242
|
+
|
|
243
|
+
If the built-in `<FixedBundle>` / `<VolumeBundle>` / `<MixMatchBundle>` components aren't the right fit (you want a radically different layout, or you want to compose bundles into an existing design system), `@lime-bundles/react` re-exports every primitive the first-party widget uses. You never have to reverse-engineer our totals or CSS variables.
|
|
244
|
+
|
|
245
|
+
**Data + parsing:**
|
|
246
|
+
|
|
247
|
+
```tsx
|
|
248
|
+
import {
|
|
249
|
+
createStorefrontClient,
|
|
250
|
+
fetchBundlesForProduct,
|
|
251
|
+
parseMetaobjectBundle,
|
|
252
|
+
BUNDLE_METAOBJECT_QUERY,
|
|
253
|
+
BUNDLES_FOR_PRODUCT_QUERY,
|
|
254
|
+
} from "@lime-bundles/react";
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
**Pricing math (matches Shopify Discount Function per-unit floor rounding):**
|
|
258
|
+
|
|
259
|
+
```tsx
|
|
260
|
+
import {
|
|
261
|
+
parseCents,
|
|
262
|
+
formatCents,
|
|
263
|
+
percentageDiscountUnit,
|
|
264
|
+
computeFixedPricing,
|
|
265
|
+
computeBundleSaleCents,
|
|
266
|
+
calculateTierSavings,
|
|
267
|
+
getActiveTier,
|
|
268
|
+
} from "@lime-bundles/react";
|
|
269
|
+
|
|
270
|
+
const pricing = computeFixedPricing(bundle);
|
|
271
|
+
// { rows, totalCents, saleCents, savingsCents, headerBadge, currency }
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
**Merchant widget config → CSS variables:**
|
|
275
|
+
|
|
276
|
+
```tsx
|
|
277
|
+
import {
|
|
278
|
+
WIDGET_CONFIG_DEFAULTS,
|
|
279
|
+
mergeWidgetConfig,
|
|
280
|
+
applyWidgetConfigVars,
|
|
281
|
+
CSS_VAR_MAP,
|
|
282
|
+
} from "@lime-bundles/react";
|
|
283
|
+
|
|
284
|
+
useEffect(() => {
|
|
285
|
+
if (wrapperRef.current) {
|
|
286
|
+
applyWidgetConfigVars(wrapperRef.current, bundle.widgetConfig);
|
|
287
|
+
}
|
|
288
|
+
}, [bundle.widgetConfig]);
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
**Image URL transforms (Shopify CDN 152×152 thumbnails):**
|
|
292
|
+
|
|
293
|
+
```tsx
|
|
294
|
+
import { transformImageUrl } from "@lime-bundles/react";
|
|
295
|
+
|
|
296
|
+
<img
|
|
297
|
+
src={transformImageUrl(product.featuredImage.url, { width: 152, height: 152, crop: "center" })}
|
|
298
|
+
width={152}
|
|
299
|
+
height={152}
|
|
300
|
+
loading="lazy"
|
|
301
|
+
alt={product.title}
|
|
302
|
+
/>
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
**Countdown timer:**
|
|
306
|
+
|
|
307
|
+
```tsx
|
|
308
|
+
import { formatCountdown } from "@lime-bundles/react";
|
|
309
|
+
|
|
310
|
+
const [msLeft, setMsLeft] = useState(Date.parse(bundle.endsAt!) - Date.now());
|
|
311
|
+
useEffect(() => {
|
|
312
|
+
const id = setInterval(() => setMsLeft(Date.parse(bundle.endsAt!) - Date.now()), 1000);
|
|
313
|
+
return () => clearInterval(id);
|
|
314
|
+
}, [bundle.endsAt]);
|
|
315
|
+
|
|
316
|
+
return <span>{formatCountdown(msLeft)}</span>;
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
**A/B variant resolution:**
|
|
320
|
+
|
|
321
|
+
```tsx
|
|
322
|
+
import { getABTestAssignment, applyABVariantB } from "@lime-bundles/react";
|
|
323
|
+
|
|
324
|
+
// appUrl is your Lime Bundles app URL (same value your theme app block uses).
|
|
325
|
+
// shopDomain is the merchant's *.myshopify.com domain.
|
|
326
|
+
const appUrl = process.env.NEXT_PUBLIC_LIME_BUNDLES_APP_URL!;
|
|
327
|
+
const shopDomain = process.env.NEXT_PUBLIC_SHOPIFY_STORE_DOMAIN!;
|
|
328
|
+
|
|
329
|
+
const assignment = await getABTestAssignment(
|
|
330
|
+
appUrl,
|
|
331
|
+
shopDomain,
|
|
332
|
+
bundle.abTestId!,
|
|
333
|
+
bundle.id,
|
|
334
|
+
);
|
|
335
|
+
const bundleForRender = assignment?.variant === "B" ? applyABVariantB(bundle) : bundle;
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
**Analytics + consent:**
|
|
339
|
+
|
|
340
|
+
```tsx
|
|
341
|
+
import {
|
|
342
|
+
reportImpression,
|
|
343
|
+
reportAddToCart,
|
|
344
|
+
observeImpression,
|
|
345
|
+
setConsent,
|
|
346
|
+
hasConsent,
|
|
347
|
+
} from "@lime-bundles/react";
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
**Custom CSS injection:**
|
|
351
|
+
|
|
352
|
+
```tsx
|
|
353
|
+
import { injectCustomCss, sanitizeCustomCss } from "@lime-bundles/react";
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
**Mix-match quantity validation:**
|
|
357
|
+
|
|
358
|
+
```tsx
|
|
359
|
+
import { validateQuantity } from "@lime-bundles/react";
|
|
360
|
+
|
|
361
|
+
const { valid, message } = validateQuantity(selectedCount, bundle.minQuantity, bundle.maxQuantity);
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
Anything the web component does, a developer importing from `@lime-bundles/react` can do too. The built-in components are one way to arrange these primitives; ship your own the moment you need a different one.
|
|
365
|
+
|
|
366
|
+
## Cart mutation patterns
|
|
367
|
+
|
|
368
|
+
You choose how `onAddToCart` talks to Shopify. Three common patterns:
|
|
369
|
+
|
|
370
|
+
1. **Storefront Cart API directly from the client.** Call `cartLinesAdd` via `createStorefrontClient` (from `@lime-bundles/core`). Public token, safe in the browser.
|
|
371
|
+
2. **Proxy via your own API route.** POST to `/api/cart/add` (Next.js Route Handler) which calls Storefront Cart API server-side. Lets you validate inventory, tack on UTM params, or enforce per-shop rate limits before forwarding.
|
|
372
|
+
3. **Existing cart context.** If you already use Hydrogen-React's `CartProvider`, call `useCart().linesAdd` inside the callback. See [Hydrogen guide](./hydrogen.md).
|
|
373
|
+
|
|
374
|
+
In all three, preserve the `attributes` array on each `CartLineInput` verbatim. `_lime_bundle_gid` and `_lime_bundle_type` drive purchase attribution on the Lime Bundles `orders/create` webhook.
|
|
375
|
+
|
|
376
|
+
## Next steps
|
|
377
|
+
|
|
378
|
+
- [CSS variables reference](./css-variables.md): brand-match the widget.
|
|
379
|
+
- [Web component guide](./web-component.md): fallback for non-React sections.
|