@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.
- package/README.md +337 -0
- package/dist/buy-x-get-y-data.d.ts +29 -0
- package/dist/buy-x-get-y-data.js +103 -0
- package/dist/buy-x-get-y-design.d.ts +2 -0
- package/dist/buy-x-get-y-design.js +38 -0
- package/dist/buy-x-get-y-styles.d.ts +2 -0
- package/dist/buy-x-get-y-styles.js +3 -0
- package/dist/buy-x-get-y.d.ts +9 -0
- package/dist/buy-x-get-y.js +69 -0
- package/dist/cart-goal-data.d.ts +48 -0
- package/dist/cart-goal-data.js +63 -0
- package/dist/cart-goal-server.d.ts +17 -0
- package/dist/cart-goal-server.js +73 -0
- package/dist/cart-goal.d.ts +13 -0
- package/dist/cart-goal.js +113 -0
- package/dist/collection.d.ts +10 -0
- package/dist/collection.js +91 -0
- package/dist/config.d.ts +4 -0
- package/dist/config.js +4 -0
- package/dist/countdown-data.d.ts +16 -0
- package/dist/countdown-data.js +46 -0
- package/dist/countdown-design.d.ts +17 -0
- package/dist/countdown-design.js +107 -0
- package/dist/countdown-styles.d.ts +1 -0
- package/dist/countdown-styles.js +2 -0
- package/dist/countdown.d.ts +6 -0
- package/dist/countdown.js +86 -0
- package/dist/customer-identity.d.ts +15 -0
- package/dist/customer-identity.js +32 -0
- package/dist/presentation.d.ts +20 -0
- package/dist/presentation.js +85 -0
- package/dist/preview-session.d.ts +2 -0
- package/dist/preview-session.js +21 -0
- package/dist/react.d.ts +31 -0
- package/dist/react.js +89 -0
- package/dist/server.d.ts +53 -0
- package/dist/server.js +284 -0
- package/dist/volume-table.d.ts +9 -0
- package/dist/volume-table.js +94 -0
- package/dist/volume.d.ts +27 -0
- package/dist/volume.js +82 -0
- 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,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
|
+
}
|