@discount-depot/hydrogen 0.1.4

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.
Files changed (42) hide show
  1. package/README.md +337 -0
  2. package/dist/buy-x-get-y-data.d.ts +29 -0
  3. package/dist/buy-x-get-y-data.js +103 -0
  4. package/dist/buy-x-get-y-design.d.ts +2 -0
  5. package/dist/buy-x-get-y-design.js +38 -0
  6. package/dist/buy-x-get-y-styles.d.ts +2 -0
  7. package/dist/buy-x-get-y-styles.js +3 -0
  8. package/dist/buy-x-get-y.d.ts +9 -0
  9. package/dist/buy-x-get-y.js +69 -0
  10. package/dist/cart-goal-data.d.ts +48 -0
  11. package/dist/cart-goal-data.js +63 -0
  12. package/dist/cart-goal-server.d.ts +17 -0
  13. package/dist/cart-goal-server.js +73 -0
  14. package/dist/cart-goal.d.ts +13 -0
  15. package/dist/cart-goal.js +113 -0
  16. package/dist/collection.d.ts +10 -0
  17. package/dist/collection.js +91 -0
  18. package/dist/config.d.ts +4 -0
  19. package/dist/config.js +4 -0
  20. package/dist/countdown-data.d.ts +16 -0
  21. package/dist/countdown-data.js +46 -0
  22. package/dist/countdown-design.d.ts +17 -0
  23. package/dist/countdown-design.js +107 -0
  24. package/dist/countdown-styles.d.ts +1 -0
  25. package/dist/countdown-styles.js +2 -0
  26. package/dist/countdown.d.ts +6 -0
  27. package/dist/countdown.js +86 -0
  28. package/dist/customer-identity.d.ts +15 -0
  29. package/dist/customer-identity.js +32 -0
  30. package/dist/presentation.d.ts +20 -0
  31. package/dist/presentation.js +85 -0
  32. package/dist/preview-session.d.ts +2 -0
  33. package/dist/preview-session.js +21 -0
  34. package/dist/react.d.ts +31 -0
  35. package/dist/react.js +89 -0
  36. package/dist/server.d.ts +53 -0
  37. package/dist/server.js +284 -0
  38. package/dist/volume-table.d.ts +9 -0
  39. package/dist/volume-table.js +94 -0
  40. package/dist/volume.d.ts +27 -0
  41. package/dist/volume.js +82 -0
  42. package/package.json +34 -0
package/README.md ADDED
@@ -0,0 +1,337 @@
1
+ # Discount Depot for Hydrogen
2
+
3
+ Reusable automatic-discount price previews for Shopify Hydrogen stores.
4
+ The package has separate React and server entry points. One shared API URL ships
5
+ inside the package; no Discount Depot tokens or merchant environment settings are required. Tested with Hydrogen 2026.4.5, React 18.3.1,
6
+ React Router 7.16.0 and Vite 8. Other headless frameworks require their own
7
+ server adapter; this package is not a universal Shopify theme app extension.
8
+
9
+ ## Merchant setup
10
+
11
+ 1. Install the package supplied by Discount Depot. During development, install
12
+ the provided tarball: `npm install ./discount-depot-hydrogen-0.1.3.tgz`.
13
+ After publication, the command will be `npm install @discount-depot/hydrogen`.
14
+ This repository does not publish it automatically.
15
+
16
+ 2. Add `app/routes/($locale).api.discount-depot.ts`:
17
+
18
+ ```ts
19
+ import {createDiscountDepotRoute} from '@discount-depot/hydrogen/server';
20
+
21
+ const discountDepot = createDiscountDepotRoute();
22
+ export const action = discountDepot.action;
23
+ export const loader = discountDepot.loader;
24
+ ```
25
+
26
+ A store without optional locale routing can name it `api.discount-depot.ts`.
27
+ Keep `/api/discount-depot` as the public path. For a different path, pass
28
+ `endpoint` to the component. Route registration follows the host's route setup.
29
+
30
+ 3. Wrap the existing PDP price block:
31
+
32
+ ```tsx
33
+ import {DiscountDepotPrice} from '@discount-depot/hydrogen/react';
34
+
35
+ <DiscountDepotPrice variant={selectedVariant}>
36
+ <ProductPrice
37
+ price={selectedVariant?.price}
38
+ compareAtPrice={selectedVariant?.compareAtPrice}
39
+ />
40
+ </DiscountDepotPrice>;
41
+ ```
42
+
43
+ `variant` needs only `id` and `price: {amount, currencyCode}`. No product
44
+ loader edits, product IDs, hardcoded shop name or discount logic are needed.
45
+ Existing price markup remains the fallback. Optional props: `locale` for
46
+ number formatting, `className` for the discount block, and `endpoint` for
47
+ a custom resource route. Restart the development server after installation.
48
+
49
+ ## Behavior
50
+
51
+ ### Collection and catalog batches
52
+
53
+ Use `DiscountDepotCollection` from the React entry point for a grid. Pass only
54
+ the **current connection page's variants**, not the accumulated Load more list.
55
+ Its render function receives a map keyed by variant ID. Pass each mapped result
56
+ to `DiscountDepotPricePreview`, which is presentational and never fetches:
57
+
58
+ ```tsx
59
+ <DiscountDepotCollection
60
+ variants={pageProducts.map((p) => p.selectedOrFirstAvailableVariant)}
61
+ >
62
+ {(discounts) =>
63
+ visibleProducts.map((product) => (
64
+ <DiscountDepotPricePreview
65
+ key={product.id}
66
+ variant={product.selectedOrFirstAvailableVariant}
67
+ discount={discounts.get(
68
+ product.selectedOrFirstAvailableVariant?.id ?? '',
69
+ )}
70
+ >
71
+ <ExistingProductPrice product={product} />
72
+ </DiscountDepotPricePreview>
73
+ ))
74
+ }
75
+ </DiscountDepotCollection>
76
+ ```
77
+
78
+ This repository's `DiscountDepotProductGrid` connects this to Hydrogen Pagination
79
+ and `ProductItem`. Include `selectedOrFirstAvailableVariant { id price { amount
80
+ currencyCode } }` in grid queries. Configure page sizes at 50 or fewer; the
81
+ component batches at most 50 unique variants and leaves extra cards at regular
82
+ prices. It retains results for accumulated cards, requests each new page once,
83
+ and separates results by collection/market/filter context. An optional `scopeKey`
84
+ can define custom filter scope for other pagination implementations.
85
+
86
+ The same local route accepts `{"variantIds":["gid://shopify/ProductVariant/..."]}`.
87
+ It resolves IDs with one `nodes(ids: $ids)` Storefront query and sends one POST to
88
+ the shared `/api/headless/discounts` backend with `{"requests":[...]}`. Each request
89
+ row has the same shop/product/variant/price/currency/country/automatic fields as
90
+ the single-product endpoint. The response is `{"discounts":[{"variantId":"...",
91
+ "discount":{...}}]}`. Null, missing, duplicate or invalid result rows fall back
92
+ independently. The batch contract may omit `discountType` inside a discount;
93
+ an explicitly non-automatic type is rejected. The original amount always comes
94
+ from Shopify. A batch with more than 50 IDs, an empty batch or malformed IDs
95
+ returns HTTP 400.
96
+
97
+ Both price renderers prefer `ruleLabel` over `message`, allow only selected CSS
98
+ properties and simple safe values in `ruleLabelCss`, use a validated `priceColor`,
99
+ and show the original amount only for `showOriginalPrice === true`. There is no
100
+ HTML injection or CSS stylesheet injection. Existing `DiscountDepotPrice`
101
+ continues to use the single-variant route for PDPs.
102
+
103
+ The first server render and loading state show the existing Shopify price.
104
+ After hydration, the widget requests its same-origin resource route. That
105
+ route reads Shopify's actual price, product ID and market on the server and
106
+ calls the common Discount Depot endpoint with the domain returned by Hydrogen's
107
+ `storefront.getShopifyDomain()`. Browser-provided prices and shops are ignored. The widget cancels stale requests and refetches when
108
+ the variant, price, currency or locale route changes. Missing configuration,
109
+ ineligible offers and outages preserve the existing price. There is no shared
110
+ cross-store price cache or mutable global store configuration.
111
+
112
+ Automatic discounts only are displayed. Cart/checkout amounts remain computed
113
+ by Shopify: the app must create the matching Shopify automatic discount.
114
+ This package never changes merchandise prices or applies coupon codes.
115
+
116
+ ## Backend contract
117
+
118
+ `POST /api/headless/discount`, JSON, without an Authorization header:
119
+
120
+ ```json
121
+ {
122
+ "shop": "merchant.myshopify.com",
123
+ "productId": "gid://shopify/Product/123",
124
+ "variantId": "gid://shopify/ProductVariant/456",
125
+ "price": "1000.00",
126
+ "currencyCode": "INR",
127
+ "country": "IN",
128
+ "discountType": "AUTOMATIC"
129
+ }
130
+ ```
131
+
132
+ Normalize `shop` and use it to select that installed store's active public
133
+ rules. Scope every lookup by shop and verify that products/variants belong to
134
+ it. Return no discount for unknown, uninstalled or disabled shops. If a store
135
+ uses a custom domain, resolve it to the corresponding installed shop on your
136
+ backend. Anonymous previews need no token; authenticated previews use the Shopify customer access token described below. The `shop`
137
+ is a lookup key rather than proof of shop ownership. Return only public
138
+ pricing/message fields, not private rule definitions or management data.
139
+
140
+ Evaluate only active automatic discounts targeting this product/variant and
141
+ market. Return no unit-price discount if quantity/cart/customer/BXGY eligibility
142
+ cannot be established by this request. Use Shopify-compatible selection and
143
+ combination rules. Price amounts are in major currency units (rupees, not paise).
144
+
145
+ ```json
146
+ {
147
+ "eligible": true,
148
+ "discountType": "AUTOMATIC",
149
+ "displayPrice": "900.00",
150
+ "currencyCode": "INR",
151
+ "message": "10% automatic discount"
152
+ }
153
+ ```
154
+
155
+ No eligible discount: `{"eligible": false}`. No `data` wrapper. Non-automatic
156
+ responses, mismatched currencies and invalid prices are rejected. The backend
157
+ must calculate in the requested currency. The original amount comes from Shopify.
158
+ Upstream calls time out after ten seconds; the widget stops waiting after fifteen.
159
+
160
+ ## Shared URL: set once in the package
161
+
162
+ Set the full working endpoint in `src/config.ts`:
163
+
164
+ ```ts
165
+ export const DISCOUNT_DEPOT_API_URL =
166
+ 'https://custompricing.axtrics.com/api/headless/discount';
167
+ ```
168
+
169
+ The shared staging endpoint is configured in this release. Build and pack after
170
+ changing it. Backend availability and discount responses must be verified before
171
+ live rollout. All stores on that package version use the same URL.
172
+ A changed URL requires rebuilding/distributing an updated package; use a stable
173
+ domain so merchants do not need updates when your backend deployment changes.
174
+ There are no Discount Depot API URL/token/shop env variables to configure.
175
+
176
+ ## App-owner rollout
177
+
178
+ Host one stable `/api/headless/discount` endpoint. In merchant onboarding,
179
+ provide the package install command, resource route and price-wrapper snippets.
180
+ The server receives `shop` automatically and looks up rules scoped to that
181
+ shop. The backend still needs to implement the public automatic-discount lookup
182
+ and matching Shopify automatic discounts for actual cart/checkout amounts.
183
+ This repository does not contain the app backend.
184
+
185
+ Build: `npm run build`. Create an installable package: `npm pack`.
186
+
187
+ ## PDP volume discount table
188
+
189
+ ## Cart Goal banner
190
+
191
+ Exported components: `DiscountDepotCartGoal` (fetching) and
192
+ `DiscountDepotCartGoalPreview` (presentation). Add a local route at
193
+ `($locale).api.discount-depot.cart-goal.ts` using
194
+ `createDiscountDepotCartGoalRoute()` from the server export. It reads the
195
+ session cart with `context.cart.get({numCartLines:250})` and calls the shared
196
+ `/api/headless/cart-goal` endpoint. It never forwards the cart secret.
197
+
198
+ ```tsx
199
+ <DiscountDepotCartGoal cart={originalCart} pending={optimisticCart?.isOptimistic === true} />
200
+ ```
201
+
202
+ Use the settled cart and mark pending optimistic mutations. Empty/incomplete
203
+ carts, pending updates, invalid/currency-mismatched offers and failed requests
204
+ hide the banner. Matching cart fingerprints prevent stale offers. Labels and
205
+ safe cg_* style fields match the Cart Offer app block. Completed goals hide
206
+ unless the backend returns a higher next tier. This is a promotional preview;
207
+ Shopify remains responsible for totals and applying the actual discount.
208
+
209
+ The storefront's `/cart` page is wired. Backend contract and implementation
210
+ prompt: `guides/discount-depot-cart-goal-backend-prompt.md`.
211
+
212
+ ## Volume table integration
213
+
214
+ ## Buy X Get Y
215
+
216
+ Enable `showBuyXGetY` on `DiscountDepotPrice` for PDP previews and on
217
+ `DiscountDepotCartGoal` for cart offers. Both are enabled in this storefront.
218
+ Existing single-product/cart requests carry `includeBuyXGetY:true`; no per-card
219
+ requests are made. The backend must return the optional `buyXGetY` array.
220
+
221
+ The reusable `DiscountDepotBuyXGetY` component accepts `offers`, `currencyCode`,
222
+ optional `locale` and `allowCartActions` (default false). It displays conditions,
223
+ reward products/prices, safe saved design and countdowns. In the cart, unlocked
224
+ and available rewards with a positive validated `addQuantity` have a manual
225
+ Add reward button using the localized Hydrogen cart route. Applied/sold-out
226
+ rewards cannot be added. No automatic gift insertion/removal is implemented.
227
+ Shopify's discount function determines actual cart/checkout savings.
228
+
229
+ Backend implementation contract: `guides/discount-depot-bxgy-backend-prompt.md`.
230
+
231
+ ## Volume table usage
232
+
233
+ Product discounts support countdowns independently: the single-preview backend
234
+ returns an optional top-level `countdown` for its selected eligible product rule.
235
+ The local adapter validates it into `discount.countdown`; `DiscountDepotPrice`
236
+ renders it below the price even without `showVolumeTable`. It uses the same
237
+ absolute-window rules as the volume timer. No extra network request is made.
238
+
239
+ The table also renders an optional `volumeTable.countdown` below it. Its fields
240
+ are `enabled: true`, `eligible: true`, ISO `startsAt`/`endsAt` with timezone, and
241
+ optional plain-text `title`/`label`. It stays hidden outside that absolute window,
242
+ updates every second, and never starts a fresh duration on refresh or variant
243
+ change. Expiry hides only the timer. Missing/invalid metadata keeps it hidden.
244
+ The backend must resolve the checkbox, eligibility, timezone and stable deadline
245
+ from the saved rule; see `guides/discount-depot-countdown-backend-prompt.md` in
246
+ the storefront repository. `DiscountDepotCountdown` is also exported for reuse.
247
+
248
+ Put effective saved countdown customization in `countdown.design` for both
249
+ product and volume timers. It supports the app's classic/banner/coupon themes
250
+ (coupon uses sticky-bottom placement), dhms/hms/ms formats, unit labels, colors,
251
+ safe linear gradients, font size, roundness, padding and timer padding. Use
252
+ `vd_countdown_*` keys, unprefixed `countdown_*` or admin camelCase names. Existing
253
+ volume table design is a fallback. Admin text renders as plain text; only known
254
+ settings are used. Missing settings use matching theme defaults. The backend
255
+ must send the saved design to reproduce merchant customization.
256
+
257
+ Enable the table in the existing price wrapper:
258
+
259
+ ```tsx
260
+ <DiscountDepotPrice variant={selectedVariant} showVolumeTable>
261
+ <ProductPrice price={selectedVariant?.price} />
262
+ </DiscountDepotPrice>
263
+ ```
264
+
265
+ This makes one combined request per selected variant, with
266
+ `{variantId, includeVolumeTable: true}`. The adapter adds trusted Shopify context
267
+ and forwards the flag to the existing single-preview backend endpoint. It does
268
+ not change collection batch calls. Old backends that omit `volumeTable` keep
269
+ working; the table stays hidden until the backend supplies eligible tiers.
270
+ The table can display even when the unconditional product discount is null.
271
+ Variant/market changes clear old data and ignore late responses.
272
+
273
+ For custom layouts, the exported presentational component needs no network call:
274
+
275
+ ```tsx
276
+ import {DiscountDepotVolumeTable} from '@discount-depot/hydrogen/react';
277
+
278
+ <DiscountDepotVolumeTable variant={selectedVariant} table={preview.volumeTable} />
279
+ ```
280
+
281
+ The `/server` export also provides `getDepotProductPreview(shop, input, apiUrl, true)`
282
+ for server integrations. Both exports provide the `DepotVolumeTable` TypeScript
283
+ type. A table has `eligible: true`, `discountType: 'AUTOMATIC'`, matching
284
+ `currencyCode` and `originalPrice`, `condition: 'quantity' | 'subtotal'`, and
285
+ 1–50 tiers with decimal-string `minimum`, nullable `maximum`,
286
+ `discountType: 'percentage' | 'fixed'`, `discountValue`, and nullable `unitPrice`.
287
+ Quantity tiers require a unit price; subtotal tiers may leave it null.
288
+
289
+ Saved `vd_*` design fields support the app block's labels, colors, border,
290
+ roundness, padding, hover highlight and savings/price column visibility.
291
+ Unprefixed legacy field names are also supported. Labels render as text, colors
292
+ are validated, and numeric spacing is bounded; raw HTML/CSS is never injected.
293
+ Invalid or ineligible tables render nothing and leave normal pricing usable.
294
+ This component previews future volume tiers; Shopify calculates checkout totals.
295
+
296
+ The storefront repository contains the full backend implementation prompt and
297
+ JSON example in `guides/discount-depot-volume-backend-prompt.md`.
298
+
299
+ ## Customer eligibility with Shopify login
300
+
301
+ Version 0.1.2 uses Hydrogen's existing Customer Account login, with no Discount
302
+ Depot signing key or per-store key setup. Keep the normal Shopify customer login
303
+ configured and expose `context.customerAccount` in the resource route context.
304
+ The app must be installed and have customer permissions for tag/segment checks.
305
+
306
+ For logged-in customers the route calls `getAccessToken()` on that server client
307
+ and forwards the Shopify credential to Discount Depot in a server-only header.
308
+ The backend discovers that shop's Customer Account API and verifies `customer { id }`
309
+ with Shopify before evaluating customer IDs, tags, exclusions or live segments.
310
+ PDP product prices, volume ladder rules and collection batches all use this flow.
311
+ Browser JSON and incoming customer headers cannot override the session.
312
+
313
+ The package returns no credentials or customer data to the browser. Both servers
314
+ use private/no-store responses. Upstream redirects are rejected. Keep the shared
315
+ backend on HTTPS and redact customer-token headers in infrastructure logs.
316
+
317
+ Guest sessions request anonymous/guest offers. A failed session or missing
318
+ Customer Account client keeps public offers but hides restricted offers.
319
+ Returning from login/logout invalidates personalized previews. Shopify remains
320
+ responsible for eligibility and totals at checkout; usage/cart-dependent offers
321
+ are not established by this customer login integration.
322
+
323
+ No `DISCOUNT_DEPOT_CUSTOMER_SIGNING_KEY` is needed. Update both backend and package
324
+ from the earlier signed-proof implementation. This supports Shopify Customer
325
+ Account sessions; legacy/custom login requires an adapter. Tests use Hydrogen
326
+ 2026.4.5, so compatibility with every Hydrogen release is not claimed.
327
+
328
+ For custom server loaders, pass credentials resolved by your own trusted Shopify
329
+ session as the optional final parameter of `getDepotProductPreview` or
330
+ `getDepotDiscounts`: `{state: 'authenticated', accessToken, origin}`. Never accept
331
+ this object from browser input. Prefer `createDiscountDepotRoute()` to get the
332
+ automatic session integration.
333
+
334
+ Version 0.1.3 fixes Oxygen runtime compatibility by using manual redirects and
335
+ rejecting non-success responses. Customer credentials are never forwarded across
336
+ redirects. The regression suite includes real workerd fetch tests for guest and
337
+ logged-in PDP/batch requests.
@@ -0,0 +1,29 @@
1
+ import { type DepotCountdown } from './countdown-data.js';
2
+ export interface DepotBuyXGetYOffer {
3
+ id: string;
4
+ eligible: true;
5
+ discountType: 'AUTOMATIC';
6
+ currencyCode: string;
7
+ title: string;
8
+ /** Fully resolved, plain-text condition; never claim an unearned gift is free. */
9
+ message: string;
10
+ status: 'potential' | 'unlocked' | 'applied';
11
+ /** Store-wide rewards use the compact message card, as in the app block. */
12
+ showGiftProducts: boolean;
13
+ gifts: {
14
+ variantId: string;
15
+ handle: string;
16
+ title: string;
17
+ image?: {
18
+ url: string;
19
+ altText: string;
20
+ } | null;
21
+ available: boolean;
22
+ originalPrice: string;
23
+ displayPrice: string;
24
+ addQuantity: number;
25
+ }[];
26
+ design: Record<string, string | number>;
27
+ countdown: DepotCountdown | null;
28
+ }
29
+ export declare function normalizeBuyXGetY(value: unknown, currency: string, allowCartActions?: boolean): DepotBuyXGetYOffer[];
@@ -0,0 +1,103 @@
1
+ import { normalizeCountdown } from './countdown-data.js';
2
+ export function normalizeBuyXGetY(value, currency, allowCartActions = false) {
3
+ if (!Array.isArray(value) || value.length > 10)
4
+ return [];
5
+ const money = (amount) => typeof amount === 'string' &&
6
+ /^\d+(?:\.\d+)?$/.test(amount) &&
7
+ Number.isFinite(Number(amount));
8
+ const text = (entry) => typeof entry === 'string' && entry.trim().length > 0 && entry.length <= 500;
9
+ const seen = new Set();
10
+ const offers = [];
11
+ for (const raw of value) {
12
+ if (!raw || typeof raw !== 'object')
13
+ continue;
14
+ const offer = raw;
15
+ if (!text(offer.id) ||
16
+ seen.has(offer.id) ||
17
+ offer.eligible !== true ||
18
+ offer.discountType !== 'AUTOMATIC' ||
19
+ offer.currencyCode !== currency ||
20
+ !text(offer.title) ||
21
+ !text(offer.message) ||
22
+ !['potential', 'unlocked', 'applied'].includes(offer.status ?? '') ||
23
+ !Array.isArray(offer.gifts) ||
24
+ offer.gifts.length > 20)
25
+ continue;
26
+ const gifts = [];
27
+ let invalid = false;
28
+ const ids = new Set();
29
+ for (const gift of offer.gifts) {
30
+ if (!gift ||
31
+ !/^gid:\/\/shopify\/ProductVariant\/\d+$/.test(gift.variantId) ||
32
+ ids.has(gift.variantId) ||
33
+ typeof gift.handle !== 'string' ||
34
+ !/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,254}$/.test(gift.handle) ||
35
+ !text(gift.title) ||
36
+ typeof gift.available !== 'boolean' ||
37
+ !money(gift.originalPrice) ||
38
+ !money(gift.displayPrice) ||
39
+ Number(gift.displayPrice) > Number(gift.originalPrice) ||
40
+ !Number.isSafeInteger(gift.addQuantity) ||
41
+ gift.addQuantity < 0 ||
42
+ gift.addQuantity > 100) {
43
+ invalid = true;
44
+ break;
45
+ }
46
+ ids.add(gift.variantId);
47
+ gifts.push({
48
+ variantId: gift.variantId,
49
+ handle: gift.handle,
50
+ title: gift.title,
51
+ image: normalizeGiftImage(gift.image),
52
+ available: gift.available,
53
+ originalPrice: gift.originalPrice,
54
+ displayPrice: gift.displayPrice,
55
+ addQuantity: allowCartActions && offer.status === 'unlocked' && gift.available
56
+ ? gift.addQuantity
57
+ : 0,
58
+ });
59
+ }
60
+ if (invalid)
61
+ continue;
62
+ const design = {};
63
+ if (offer.design && typeof offer.design === 'object') {
64
+ for (const [key, entry] of Object.entries(offer.design).slice(0, 50)) {
65
+ if (/^[a-z_]+$/.test(key) &&
66
+ ((typeof entry === 'string' && entry.length <= 300) ||
67
+ (typeof entry === 'number' && Number.isFinite(entry))))
68
+ design[key] = entry;
69
+ }
70
+ }
71
+ seen.add(offer.id);
72
+ offers.push({
73
+ id: offer.id,
74
+ eligible: true,
75
+ discountType: 'AUTOMATIC',
76
+ currencyCode: currency,
77
+ title: offer.title,
78
+ message: offer.message,
79
+ status: offer.status,
80
+ showGiftProducts: offer.showGiftProducts !== false,
81
+ gifts,
82
+ design,
83
+ countdown: normalizeCountdown(offer.countdown),
84
+ });
85
+ }
86
+ return offers;
87
+ }
88
+ function normalizeGiftImage(value) {
89
+ if (!value || typeof value !== 'object')
90
+ return null;
91
+ const image = value;
92
+ if (typeof image.url !== 'string' || image.url.length > 2048)
93
+ return null;
94
+ try {
95
+ const url = new URL(image.url);
96
+ if (url.protocol !== 'https:' || url.username || url.password)
97
+ return null;
98
+ return { url: url.href, altText: typeof image.altText === 'string' ? image.altText.slice(0, 500) : '' };
99
+ }
100
+ catch {
101
+ return null;
102
+ }
103
+ }
@@ -0,0 +1,2 @@
1
+ import type { CSSProperties } from 'react';
2
+ export declare function buyXGetYDesign(design: Record<string, string | number>): CSSProperties;
@@ -0,0 +1,38 @@
1
+ import { safePriceColor } from './presentation.js';
2
+ // Same setting names as bxgy-inline-styles.liquid. Only validated scalar
3
+ // values reach CSS; backend-supplied stylesheets and HTML are never rendered.
4
+ export function buyXGetYDesign(design) {
5
+ const colors = {
6
+ 'card-background': ['bxgy_card_background_color', '#ffffff'],
7
+ 'card-border-color': ['bxgy_card_border_color', '#e4e8ef', 'border_color'],
8
+ 'header-background': ['bxgy_header_background_color', '#0f2027'],
9
+ 'header-border-color': ['bxgy_header_border_color', '#203a43'],
10
+ 'icon-background': ['bxgy_icon_background_color', 'rgba(255,255,255,0.15)'],
11
+ 'icon-border-color': ['bxgy_icon_border_color', 'rgba(255,255,255,0.25)'],
12
+ 'heading-text-color': ['bxgy_heading_text_color', '#ffffff'],
13
+ 'body-text-color': ['bxgy_body_text_color', '#374151', 'text_color'],
14
+ 'link-color': ['bxgy_link_color', '#2563eb'],
15
+ 'item-background': ['bxgy_item_background_color', '#f8fafc'],
16
+ 'item-border-color': ['bxgy_item_border_color', '#e9ecf2'],
17
+ 'price-color': ['bxgy_price_color', '#16a34a'],
18
+ 'button-background': ['bxgy_button_background_color', '#16a34a', 'button_background_color'],
19
+ 'button-border-color': ['bxgy_button_border_color', '#15803d'],
20
+ 'button-text-color': ['bxgy_button_text_color', '#ffffff', 'button_text_color'],
21
+ 'warning-background': ['bxgy_warning_background_color', '#f0f9ff'],
22
+ 'warning-border-color': ['bxgy_warning_border_color', '#bae6fd'],
23
+ 'warning-text-color': ['bxgy_warning_text_color', '#0369a1'],
24
+ };
25
+ const style = {};
26
+ for (const [variable, [key, fallback, alias]] of Object.entries(colors)) {
27
+ style[`--bxgy-${variable}`] = safePriceColor(String(design[key] ?? (alias ? design[alias] : '') ?? '')) ?? fallback;
28
+ }
29
+ for (const [variable, key, fallback, alias] of [
30
+ ['card-border-radius', 'bxgy_card_border_radius', 16, 'border_radius'],
31
+ ['button-border-radius', 'bxgy_button_border_radius', 10, ''],
32
+ ]) {
33
+ const raw = design[key] ?? design[alias];
34
+ const value = raw !== undefined && raw !== '' ? Number(raw) : fallback;
35
+ style[`--bxgy-${variable}`] = `${Number.isFinite(value) ? Math.max(0, Math.min(200, value)) : fallback}px`;
36
+ }
37
+ return style;
38
+ }