@lime-bundles/react 0.2.0 → 2.0.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 +126 -0
- package/dist/index.cjs +126 -8
- 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 +125 -4
- 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
|
@@ -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.
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
# Web component
|
|
2
|
+
|
|
3
|
+
`<lime-bundle>` is a standards-compliant custom element that renders Lime Bundles wherever you can drop an HTML tag into a product page template. **One tag, done.** It's the same widget your merchants see in the Lime Bundles admin preview, with every setting and colour choice honoured.
|
|
4
|
+
|
|
5
|
+
Two delivery paths, same widget:
|
|
6
|
+
|
|
7
|
+
- **HTML path**: paste a `<script>` tag and an HTML element. Works on any storefront that can include a `<script type="module">`. Zero build step.
|
|
8
|
+
- **React path**: install via npm, import in your client entry, use `<lime-bundle>` like any JSX element. Recommended for Hydrogen / Next.js / Vite because it avoids Content Security Policy issues with third-party script origins.
|
|
9
|
+
|
|
10
|
+
## <a id="html-storefronts"></a>HTML storefronts
|
|
11
|
+
|
|
12
|
+
Zero build step, zero npm install. Paste two lines into your product page template:
|
|
13
|
+
|
|
14
|
+
```html
|
|
15
|
+
<!-- Paste once in your product page template -->
|
|
16
|
+
<script type="module" src="https://unpkg.com/@lime-bundles/widget"></script>
|
|
17
|
+
<lime-bundle
|
|
18
|
+
shop-domain="my-shop.myshopify.com"
|
|
19
|
+
storefront-token="<YOUR_LIME_BUNDLES_TOKEN>"
|
|
20
|
+
></lime-bundle>
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Generate your token by visiting `/app/settings/headless` in your Lime Bundles admin and clicking **Generate token**. The token is a read-only public Storefront Access Token, safe to ship in HTML. If it leaks, click **Regenerate** on the same page.
|
|
24
|
+
|
|
25
|
+
The widget auto-detects the current product from the URL (`/products/<handle>`) and renders every active bundle configured for it. On **Add bundle**, the widget calls Shopify's tokenless Storefront Cart API and redirects to checkout with the discount applied. No cart code needed.
|
|
26
|
+
|
|
27
|
+
## <a id="react-storefronts"></a>React storefronts (Hydrogen, Next.js, Vite)
|
|
28
|
+
|
|
29
|
+
Same `<lime-bundle>` element, but installed via npm so it ships through your bundler instead of a CDN. Recommended for React storefronts because:
|
|
30
|
+
|
|
31
|
+
1. Hydrogen's default Content Security Policy blocks third-party script origins. Importing the package through your own bundle avoids the CSP exception.
|
|
32
|
+
2. You get a pinned version in `package.json` instead of the CDN's "latest."
|
|
33
|
+
3. Tree-shaking and nonce handling are automatic.
|
|
34
|
+
|
|
35
|
+
**1. Install:**
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npm install @lime-bundles/widget
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
**2. Import once in your client entry.** This registers `<lime-bundle>` as a global custom element:
|
|
42
|
+
|
|
43
|
+
```tsx
|
|
44
|
+
// Hydrogen → app/entry.client.tsx
|
|
45
|
+
// Next.js App Router → app/layout.tsx (client component)
|
|
46
|
+
// Vite → src/main.tsx
|
|
47
|
+
import "@lime-bundles/widget";
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
**3. Use anywhere in JSX** (product page template is the typical placement):
|
|
51
|
+
|
|
52
|
+
```tsx
|
|
53
|
+
export default function ProductBundles({ token }: { token: string }) {
|
|
54
|
+
return (
|
|
55
|
+
<lime-bundle
|
|
56
|
+
shop-domain="my-shop.myshopify.com"
|
|
57
|
+
storefront-token={token}
|
|
58
|
+
/>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Pull the token from your framework's environment variables:
|
|
64
|
+
|
|
65
|
+
- **Hydrogen**: `context.env.PUBLIC_LIME_BUNDLES_TOKEN` inside your loader, pass it as a prop.
|
|
66
|
+
- **Next.js**: `process.env.NEXT_PUBLIC_LIME_BUNDLES_TOKEN`.
|
|
67
|
+
- **Vite**: `import.meta.env.VITE_LIME_BUNDLES_TOKEN`.
|
|
68
|
+
|
|
69
|
+
### TypeScript
|
|
70
|
+
|
|
71
|
+
TypeScript doesn't know about `<lime-bundle>` by default. Add a small ambient declaration to silence the JSX error:
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
// app/types/lime-bundle.d.ts
|
|
75
|
+
declare namespace JSX {
|
|
76
|
+
interface IntrinsicElements {
|
|
77
|
+
"lime-bundle": React.DetailedHTMLProps<
|
|
78
|
+
React.HTMLAttributes<HTMLElement> & {
|
|
79
|
+
"shop-domain": string;
|
|
80
|
+
"storefront-token": string;
|
|
81
|
+
"bundle-gid"?: string;
|
|
82
|
+
"product-handle"?: string;
|
|
83
|
+
},
|
|
84
|
+
HTMLElement
|
|
85
|
+
>;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Providing the product handle explicitly
|
|
91
|
+
|
|
92
|
+
If your storefront's product URL doesn't follow `/products/<handle>` (for example, some storefronts use `/shop/<handle>` or put the handle in a query param), set `product-handle` explicitly:
|
|
93
|
+
|
|
94
|
+
```html
|
|
95
|
+
<lime-bundle
|
|
96
|
+
shop-domain="my-shop.myshopify.com"
|
|
97
|
+
storefront-token="<TOKEN>"
|
|
98
|
+
product-handle="cool-tshirt"
|
|
99
|
+
></lime-bundle>
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Taking over the cart (BYO)
|
|
103
|
+
|
|
104
|
+
If you have your own cart (Hydrogen `useCart`, custom cart drawer, etc.), listen for `lime-bundle:add-to-cart` and call `event.preventDefault()` to suppress the default redirect:
|
|
105
|
+
|
|
106
|
+
```html
|
|
107
|
+
<script>
|
|
108
|
+
document.querySelector("lime-bundle").addEventListener(
|
|
109
|
+
"lime-bundle:add-to-cart",
|
|
110
|
+
async (event) => {
|
|
111
|
+
event.preventDefault(); // skip the default "redirect to Shopify checkout" flow
|
|
112
|
+
await myCart.linesAdd(event.detail.lines);
|
|
113
|
+
},
|
|
114
|
+
);
|
|
115
|
+
</script>
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The event is always dispatched; only the default action is conditional.
|
|
119
|
+
|
|
120
|
+
## Attributes
|
|
121
|
+
|
|
122
|
+
| Attribute | Required | Purpose |
|
|
123
|
+
|---|:-:|---|
|
|
124
|
+
| `shop-domain` | ✓ | Your shop domain, e.g. `my-shop.myshopify.com`. |
|
|
125
|
+
| `storefront-token` | ✓ | Generated in `/app/settings/headless`. Read-only Storefront Access Token (not the admin API key). |
|
|
126
|
+
| `bundle-gid` | | Pin one specific bundle. When set, overrides auto-detect. |
|
|
127
|
+
| `product-handle` | | Render bundles for a specific product handle. Overrides URL detection. |
|
|
128
|
+
| `app-url` | | Lime Bundles app URL (sends impression / add-to-cart analytics). Omit to disable analytics. |
|
|
129
|
+
| `analytics` | | Set to `"false"` to suppress analytics even if `app-url` is set. |
|
|
130
|
+
| `locale` | | BCP-47 tag for the buyer's locale. Forwarded to Storefront API. |
|
|
131
|
+
|
|
132
|
+
**Product resolution cascade** (when `bundle-gid` is absent): explicit `product-handle` → `<meta name="shopify:product-handle">` → `/products/<handle>` URL segment → error.
|
|
133
|
+
|
|
134
|
+
Changing any attribute at runtime re-fetches and re-renders. Safe to drive from a framework's reactivity.
|
|
135
|
+
|
|
136
|
+
## Events
|
|
137
|
+
|
|
138
|
+
All events bubble and cross shadow-DOM boundaries (`composed: true`), so you can listen on any ancestor.
|
|
139
|
+
|
|
140
|
+
### `lime-bundle:add-to-cart`
|
|
141
|
+
|
|
142
|
+
Fired when the customer clicks the CTA. The `detail` object carries the cart payload:
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
type AddToCartDetail = {
|
|
146
|
+
lines: CartLineInput[];
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
type CartLineInput = {
|
|
150
|
+
merchandiseId: string; // variant GID
|
|
151
|
+
quantity: number;
|
|
152
|
+
attributes: Array<{ key: string; value: string }>;
|
|
153
|
+
};
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Every `CartLineInput.attributes` array includes `{ key: "_lime_bundle_gid", value: <bundle GID> }`. Preserve it on the way to Shopify cart mutation or purchase attribution breaks.
|
|
157
|
+
|
|
158
|
+
### `lime-bundle:loaded`
|
|
159
|
+
|
|
160
|
+
Fired once bundle data has been fetched and parsed successfully. Useful for hiding a placeholder or triggering analytics in non-SDK systems. The event fires even when zero bundles apply to the current product (product has no bundles attached); check `bundleCount` to tell the difference.
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
type LoadedDetail = {
|
|
164
|
+
bundleCount: number;
|
|
165
|
+
bundleTypes: Array<"fixed" | "volume" | "mix_match">;
|
|
166
|
+
// Legacy single-bundle fields; undefined when bundleCount === 0.
|
|
167
|
+
bundleType?: "fixed" | "volume" | "mix_match";
|
|
168
|
+
title?: string;
|
|
169
|
+
};
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
**Migrating from v1.** Earlier versions emitted only `bundleType` and `title`, scoped to the first bundle rendered. v2 adds `bundleCount` and `bundleTypes` so listeners can see every bundle on the page. The legacy fields are still populated when at least one bundle renders but are `undefined` when `bundleCount === 0`. Guard accordingly, or migrate to the new fields:
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
el.addEventListener("lime-bundle:loaded", (e) => {
|
|
176
|
+
const d = e.detail as LoadedDetail;
|
|
177
|
+
if (d.bundleCount === 0) return;
|
|
178
|
+
// d.bundleType / d.title are safe to read here.
|
|
179
|
+
});
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### `lime-bundle:error`
|
|
183
|
+
|
|
184
|
+
Fired if bundle fetch or parse fails. The widget renders its own inline fallback, but listen for this event if you want to hide the widget entirely or report to your own telemetry:
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
type ErrorDetail = {
|
|
188
|
+
message: string;
|
|
189
|
+
code: string; // e.g. "LOAD_ERROR"
|
|
190
|
+
};
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## Other framework integrations
|
|
194
|
+
|
|
195
|
+
Non-React stacks that follow the same npm-install-plus-import pattern.
|
|
196
|
+
|
|
197
|
+
### Astro
|
|
198
|
+
|
|
199
|
+
```astro
|
|
200
|
+
---
|
|
201
|
+
// any component file
|
|
202
|
+
---
|
|
203
|
+
<lime-bundle
|
|
204
|
+
shop-domain="my-shop.myshopify.com"
|
|
205
|
+
storefront-token={import.meta.env.PUBLIC_LIME_BUNDLES_TOKEN}
|
|
206
|
+
bundle-gid="gid://shopify/Metaobject/42"
|
|
207
|
+
></lime-bundle>
|
|
208
|
+
<script>
|
|
209
|
+
import "@lime-bundles/widget";
|
|
210
|
+
document.querySelector("lime-bundle")!.addEventListener("lime-bundle:add-to-cart", async (e: any) => {
|
|
211
|
+
await fetch("/api/cart-add", { method: "POST", body: JSON.stringify(e.detail.lines) });
|
|
212
|
+
});
|
|
213
|
+
</script>
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### Vue 3
|
|
217
|
+
|
|
218
|
+
```vue
|
|
219
|
+
<template>
|
|
220
|
+
<lime-bundle
|
|
221
|
+
shop-domain="my-shop.myshopify.com"
|
|
222
|
+
:storefront-token="token"
|
|
223
|
+
bundle-gid="gid://shopify/Metaobject/42"
|
|
224
|
+
@lime-bundle:add-to-cart="handleAdd"
|
|
225
|
+
/>
|
|
226
|
+
</template>
|
|
227
|
+
|
|
228
|
+
<script setup lang="ts">
|
|
229
|
+
import "@lime-bundles/widget";
|
|
230
|
+
const token = import.meta.env.VITE_LIME_BUNDLES_TOKEN;
|
|
231
|
+
async function handleAdd(e: CustomEvent) {
|
|
232
|
+
await fetch("/cart/add", { method: "POST", body: JSON.stringify(e.detail.lines) });
|
|
233
|
+
}
|
|
234
|
+
</script>
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Vue's custom-element handling needs `app.config.compilerOptions.isCustomElement = (tag) => tag === "lime-bundle"` if you hit warnings.
|
|
238
|
+
|
|
239
|
+
### Svelte
|
|
240
|
+
|
|
241
|
+
```svelte
|
|
242
|
+
<script>
|
|
243
|
+
import "@lime-bundles/widget";
|
|
244
|
+
function handle(e) {
|
|
245
|
+
fetch("/cart/add", { method: "POST", body: JSON.stringify(e.detail.lines) });
|
|
246
|
+
}
|
|
247
|
+
</script>
|
|
248
|
+
|
|
249
|
+
<lime-bundle
|
|
250
|
+
shop-domain="my-shop.myshopify.com"
|
|
251
|
+
storefront-token={import.meta.env.VITE_LIME_BUNDLES_TOKEN}
|
|
252
|
+
bundle-gid="gid://shopify/Metaobject/42"
|
|
253
|
+
on:lime-bundle:add-to-cart={handle}
|
|
254
|
+
></lime-bundle>
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
## Styling
|
|
258
|
+
|
|
259
|
+
`<lime-bundle>` renders inside a closed Shadow DOM. Merchant custom CSS set in `/app/settings/custom-css` is auto-fetched and injected into the shadow root on `connectedCallback`. To override the built-in look, set CSS custom properties on the host:
|
|
260
|
+
|
|
261
|
+
```html
|
|
262
|
+
<lime-bundle
|
|
263
|
+
shop-domain="..."
|
|
264
|
+
style="--lb-primary-color: #e91e63; --lb-radius: 16px;"
|
|
265
|
+
></lime-bundle>
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
Full variable list: [css-variables.md](./css-variables.md).
|
|
269
|
+
|
|
270
|
+
## What's automatic
|
|
271
|
+
|
|
272
|
+
Every part of the widget a merchant configures in the admin is wired up for you:
|
|
273
|
+
|
|
274
|
+
- **Widget styling.** Every `--lb-*` CSS variable the merchant set in the admin editor (header colours, save badge, savings bar, product list styling, popular tier badge, mix-match picker styling, etc.) is applied inside the shadow root on render. Same widget, same look.
|
|
275
|
+
- **Shop custom CSS.** The sanitized CSS at `shop.metafields["$app"].custom_css` is injected into the shadow root alongside the built-in stylesheets so rules like `.lb-bundle-widget { ... }` hit the widget.
|
|
276
|
+
- **Countdown timer.** When a bundle has `endsAt`, the widget ticks a live countdown every second and hides the bar once the offer expires.
|
|
277
|
+
- **Variant dropdowns on fixed bundles.** Products with multiple eligible variants (filtered by the merchant's `selectedVariantIds`) render a `<select>`; switching variants live-updates the row price and the bundle total.
|
|
278
|
+
- **Mix-match picker modal.** Click any empty slot to open the modal; search, quantity stepper, progress bar, and pricing update as selections change. Keyboard: Escape closes, Tab traps inside the modal.
|
|
279
|
+
- **Out-of-stock behaviour.** Honours `widgetConfig.outOfStockBehavior` (`"hide"` removes OOS products from the list; `"show_greyed_out"` renders them disabled). Fixed bundles hide the whole widget when required products are OOS; mix-match hides when the available count can't satisfy `minQuantity`.
|
|
280
|
+
- **A/B test assignment.** The widget bucketises the visitor via `getABTestAssignment` (cookie-persisted, consent-gated) and merges Variant B overrides (title, description, discount, volume tiers) when the visitor lands in B. Honours Shopify's `customerPrivacy` framework or the SDK's `setConsent(true)` helper.
|
|
281
|
+
- **Impression + add-to-cart analytics.** Fire on visibility + CTA click regardless of whether the merchant takes over the cart via `preventDefault`. Disable by setting `analytics="false"` or omitting `app-url`.
|
|
282
|
+
- **Purchase attribution.** The `orders/create` webhook ingests `bundle_purchased` events server-side from the `_lime_bundle_gid` cart attribute the widget adds automatically. No `checkout_completed` handler needed.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lime-bundles/react",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "React components and hooks for Lime Bundles
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "React components and hooks for the Lime Bundles Shopify app. Use on Hydrogen, Next.js, Vite, or any React-based headless storefront.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
7
7
|
"module": "./dist/index.js",
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
"./package.json": "./package.json"
|
|
17
17
|
},
|
|
18
18
|
"files": [
|
|
19
|
-
"dist"
|
|
19
|
+
"dist",
|
|
20
|
+
"docs"
|
|
20
21
|
],
|
|
21
22
|
"publishConfig": {
|
|
22
23
|
"access": "public"
|
|
@@ -51,7 +52,7 @@
|
|
|
51
52
|
"react-dom": ">=18.0.0"
|
|
52
53
|
},
|
|
53
54
|
"dependencies": {
|
|
54
|
-
"@lime-bundles/core": "^
|
|
55
|
+
"@lime-bundles/core": "^2.0.0"
|
|
55
56
|
},
|
|
56
57
|
"devDependencies": {
|
|
57
58
|
"@shopify/hydrogen-react": "^2026.4.1",
|