@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/dist/server.js ADDED
@@ -0,0 +1,284 @@
1
+ import { giftIcon } from './buy-x-get-y-styles.js';
2
+ import { normalizeBuyXGetY } from './buy-x-get-y-data.js';
3
+ export { createDiscountDepotCartGoalRoute } from './cart-goal-server.js';
4
+ import { normalizeCountdown } from './countdown-data.js';
5
+ import { customerPreviewHeaders, resolvePreviewCustomer } from './customer-identity.js';
6
+ import { normalizeVolumeTable } from './volume.js';
7
+ import { DISCOUNT_DEPOT_API_URL, DISCOUNT_DEPOT_BATCH_API_URL, } from './config.js';
8
+ /** All stores use the endpoint shipped in this package. */
9
+ export function createDiscountDepotRoute({ apiUrl = DISCOUNT_DEPOT_API_URL, batchApiUrl = DISCOUNT_DEPOT_BATCH_API_URL, } = {}) {
10
+ const headers = { 'Cache-Control': 'private, no-store' };
11
+ const loader = (args) => {
12
+ if (args?.request.method === 'GET' && new URL(args.request.url).searchParams.get('asset') === 'gift-icon') {
13
+ const bytes = Uint8Array.from(atob(giftIcon.split(',')[1]), (character) => character.charCodeAt(0));
14
+ return new Response(bytes, { headers: {
15
+ 'Content-Type': 'image/png',
16
+ 'Cache-Control': 'public, max-age=86400',
17
+ 'X-Content-Type-Options': 'nosniff',
18
+ } });
19
+ }
20
+ return Response.json({ error: 'Use POST with variantId or variantIds (maximum 50)' }, {
21
+ status: 405,
22
+ headers: { ...headers, Allow: 'POST' },
23
+ });
24
+ };
25
+ const action = async ({ request, context, }) => {
26
+ if (request.method !== 'POST')
27
+ return loader();
28
+ let variantId;
29
+ let body;
30
+ try {
31
+ body = await request.json();
32
+ variantId =
33
+ body && typeof body === 'object' && 'variantId' in body
34
+ ? body.variantId
35
+ : undefined;
36
+ }
37
+ catch {
38
+ return Response.json({ error: 'Invalid JSON' }, { status: 400, headers });
39
+ }
40
+ if (body && typeof body === 'object' && 'variantIds' in body) {
41
+ if ('variantId' in body) {
42
+ return Response.json({ error: 'Use variantId or variantIds, not both' }, { status: 400, headers });
43
+ }
44
+ const ids = body.variantIds;
45
+ if (!Array.isArray(ids) ||
46
+ ids.length === 0 ||
47
+ ids.length > 50 ||
48
+ !ids.every((id) => typeof id === 'string' &&
49
+ /^gid:\/\/shopify\/ProductVariant\/\d+$/.test(id))) {
50
+ return Response.json({ error: 'variantIds must contain 1–50 Shopify ProductVariant GIDs' }, { status: 400, headers });
51
+ }
52
+ if (!batchApiUrl)
53
+ return Response.json({ error: 'Discount Depot is not configured' }, { status: 503, headers });
54
+ const variantIds = [...new Set(ids)];
55
+ try {
56
+ const { nodes } = await context.storefront.query(VARIANTS_QUERY, {
57
+ variables: { ids: variantIds },
58
+ cache: context.storefront.CacheNone(),
59
+ });
60
+ const inputs = [];
61
+ for (const id of variantIds) {
62
+ const node = nodes.find((item) => item?.id === id);
63
+ if (node?.__typename === 'ProductVariant' &&
64
+ node.price &&
65
+ node.product) {
66
+ inputs.push({
67
+ productId: node.product.id,
68
+ variantId: id,
69
+ price: node.price.amount,
70
+ currencyCode: node.price.currencyCode,
71
+ country: context.storefront.i18n.country,
72
+ });
73
+ }
74
+ }
75
+ const results = inputs.length
76
+ ? await getDepotDiscounts(context.storefront.getShopifyDomain(), inputs, batchApiUrl, await resolvePreviewCustomer(context, request))
77
+ : [];
78
+ const byId = new Map(results.map((entry) => [entry.variantId, entry.discount]));
79
+ return Response.json({
80
+ discounts: variantIds.map((id) => ({
81
+ variantId: id,
82
+ discount: byId.get(id) ?? null,
83
+ })),
84
+ }, { headers });
85
+ }
86
+ catch {
87
+ return Response.json({ error: 'Discount service unavailable' }, { status: 502, headers });
88
+ }
89
+ }
90
+ if (typeof variantId !== 'string' ||
91
+ !/^gid:\/\/shopify\/ProductVariant\/\d+$/.test(variantId)) {
92
+ return Response.json({ error: 'A Shopify ProductVariant GID is required' }, { status: 400, headers });
93
+ }
94
+ if (!apiUrl) {
95
+ return Response.json({ error: 'Discount Depot is not configured' }, { status: 503, headers });
96
+ }
97
+ try {
98
+ const { node } = await context.storefront.query(VARIANT_QUERY, {
99
+ variables: { id: variantId },
100
+ cache: context.storefront.CacheNone(),
101
+ });
102
+ if (!node ||
103
+ node.__typename !== 'ProductVariant' ||
104
+ !node.price ||
105
+ !node.product) {
106
+ return Response.json({ error: 'Variant not found' }, { status: 404, headers });
107
+ }
108
+ const includeVolumeTable = !!body &&
109
+ typeof body === 'object' &&
110
+ 'includeVolumeTable' in body &&
111
+ body.includeVolumeTable === true;
112
+ const preview = await getDepotProductPreview(context.storefront.getShopifyDomain(), {
113
+ productId: node.product.id,
114
+ variantId: node.id,
115
+ price: node.price.amount,
116
+ currencyCode: node.price.currencyCode,
117
+ country: context.storefront.i18n.country,
118
+ }, apiUrl, includeVolumeTable, await resolvePreviewCustomer(context, request), !!body && typeof body === 'object' && 'includeBuyXGetY' in body && body.includeBuyXGetY === true);
119
+ return Response.json({
120
+ variantId,
121
+ discount: preview.discount,
122
+ ...(includeVolumeTable ? { volumeTable: preview.volumeTable } : {}),
123
+ ...(body && typeof body === 'object' && 'includeBuyXGetY' in body && body.includeBuyXGetY === true ? { buyXGetY: preview.buyXGetY } : {}),
124
+ }, { headers });
125
+ }
126
+ catch {
127
+ return Response.json({ error: 'Discount service unavailable' }, { status: 502, headers });
128
+ }
129
+ };
130
+ return { loader, action };
131
+ }
132
+ const VARIANT_QUERY = `
133
+ query DiscountDepotVariant($id: ID!, $country: CountryCode, $language: LanguageCode)
134
+ @inContext(country: $country, language: $language) {
135
+ node(id: $id) {
136
+ __typename
137
+ ... on ProductVariant { id product { id } price { amount currencyCode } }
138
+ }
139
+ }
140
+ `;
141
+ const VARIANTS_QUERY = `
142
+ query DiscountDepotVariants($ids: [ID!]!, $country: CountryCode, $language: LanguageCode)
143
+ @inContext(country: $country, language: $language) {
144
+ nodes(ids: $ids) {
145
+ __typename
146
+ ... on ProductVariant { id product { id } price { amount currencyCode } }
147
+ }
148
+ }
149
+ `;
150
+ /** Read-only price preview. Shopify remains responsible for cart/checkout totals. */
151
+ export async function getDepotDiscount(shop, input, apiUrl = DISCOUNT_DEPOT_API_URL) {
152
+ return (await getDepotProductPreview(shop, input, apiUrl)).discount;
153
+ }
154
+ export async function getDepotProductPreview(shop, input, apiUrl = DISCOUNT_DEPOT_API_URL, includeVolumeTable = false, credentials, includeBuyXGetY = false) {
155
+ if (!apiUrl)
156
+ return { discount: null, volumeTable: null, buyXGetY: [] };
157
+ const domain = new URL(`https://${shop.replace(/^https?:\/\//, '')}`).hostname;
158
+ const body = JSON.stringify({ ...input, shop: domain, discountType: 'AUTOMATIC',
159
+ ...(includeVolumeTable ? { includeVolumeTable: true } : {}),
160
+ ...(includeBuyXGetY ? { includeBuyXGetY: true } : {}) });
161
+ const response = await fetch(apiUrl, {
162
+ method: 'POST',
163
+ // Oxygen supports manual redirects; non-2xx responses are rejected below.
164
+ redirect: 'manual',
165
+ headers: customerPreviewHeaders(credentials),
166
+ signal: AbortSignal.timeout(10000),
167
+ body,
168
+ });
169
+ if (!response.ok)
170
+ throw new Error(`Discount Depot returned HTTP ${response.status}`);
171
+ const result = await response.json();
172
+ const volumeTable = includeVolumeTable &&
173
+ result &&
174
+ typeof result === 'object' &&
175
+ 'volumeTable' in result
176
+ ? normalizeVolumeTable(result.volumeTable, {
177
+ amount: input.price,
178
+ currencyCode: input.currencyCode,
179
+ })
180
+ : null;
181
+ let discount = null;
182
+ try {
183
+ discount = normalizeDiscount(result, input);
184
+ }
185
+ catch (error) {
186
+ // Independent previews: malformed product pricing must not hide valid tiers.
187
+ if (!includeVolumeTable && !includeBuyXGetY)
188
+ throw error;
189
+ }
190
+ const buyXGetY = includeBuyXGetY && result && typeof result === 'object' && 'buyXGetY' in result
191
+ ? normalizeBuyXGetY(result.buyXGetY, input.currencyCode) : [];
192
+ return { discount, volumeTable, buyXGetY };
193
+ }
194
+ function normalizeDiscount(result, input, batch = false) {
195
+ if (!result || typeof result !== 'object' || !('eligible' in result)) {
196
+ throw new Error('Invalid Discount Depot response');
197
+ }
198
+ if (result.eligible === false)
199
+ return null;
200
+ // Code-based offers must never appear as automatic discounts on the PDP.
201
+ if (('discountType' in result && result.discountType !== 'AUTOMATIC') ||
202
+ (!batch && !('discountType' in result)))
203
+ return null;
204
+ if (result.eligible !== true || !('displayPrice' in result)) {
205
+ throw new Error('Invalid Discount Depot eligibility');
206
+ }
207
+ const value = result.displayPrice;
208
+ if (!['string', 'number'].includes(typeof value) ||
209
+ String(value).trim() === '' ||
210
+ !Number.isFinite(Number(value)) ||
211
+ Number(value) < 0 ||
212
+ Number(value) > Number(input.price))
213
+ throw new Error('Invalid Discount Depot price');
214
+ if (!('currencyCode' in result) ||
215
+ result.currencyCode !== input.currencyCode) {
216
+ throw new Error('Discount Depot currency does not match Shopify');
217
+ }
218
+ return {
219
+ eligible: true,
220
+ originalPrice: input.price,
221
+ ...(!batch && 'countdown' in result
222
+ ? { countdown: normalizeCountdown(result.countdown) } : {}),
223
+ displayPrice: String(value),
224
+ currencyCode: input.currencyCode,
225
+ message: 'message' in result && typeof result.message === 'string'
226
+ ? result.message
227
+ : undefined,
228
+ ruleLabel: 'ruleLabel' in result && typeof result.ruleLabel === 'string'
229
+ ? result.ruleLabel
230
+ : undefined,
231
+ ruleLabelCss: 'ruleLabelCss' in result && typeof result.ruleLabelCss === 'string'
232
+ ? result.ruleLabelCss
233
+ : undefined,
234
+ priceColor: 'priceColor' in result && typeof result.priceColor === 'string'
235
+ ? result.priceColor
236
+ : undefined,
237
+ showOriginalPrice: 'showOriginalPrice' in result && result.showOriginalPrice === true
238
+ ? true
239
+ : false,
240
+ };
241
+ }
242
+ export async function getDepotDiscounts(shop, inputs, apiUrl = DISCOUNT_DEPOT_BATCH_API_URL, credentials) {
243
+ if (inputs.length < 1 || inputs.length > 50)
244
+ throw new Error('Batch requires 1–50 variants');
245
+ const domain = new URL(`https://${shop.replace(/^https?:\/\//, '')}`)
246
+ .hostname;
247
+ const requestBody = JSON.stringify({ requests: inputs.map(input => ({
248
+ ...input, shop: domain, discountType: 'AUTOMATIC',
249
+ })) });
250
+ const response = await fetch(apiUrl, {
251
+ method: 'POST',
252
+ // Oxygen supports manual redirects; non-2xx responses are rejected below.
253
+ redirect: 'manual',
254
+ headers: customerPreviewHeaders(credentials),
255
+ signal: AbortSignal.timeout(10000),
256
+ body: requestBody,
257
+ });
258
+ if (!response.ok)
259
+ throw new Error(`Discount Depot returned HTTP ${response.status}`);
260
+ const body = await response.json();
261
+ if (!body ||
262
+ typeof body !== 'object' ||
263
+ !('discounts' in body) ||
264
+ !Array.isArray(body.discounts)) {
265
+ throw new Error('Invalid Discount Depot batch response');
266
+ }
267
+ const entries = body.discounts;
268
+ return inputs.map((input) => {
269
+ const matches = entries.filter((entry) => entry &&
270
+ typeof entry === 'object' &&
271
+ 'variantId' in entry &&
272
+ entry.variantId === input.variantId);
273
+ let discount = null;
274
+ if (matches.length === 1) {
275
+ try {
276
+ discount = normalizeDiscount(matches[0].discount, input, true);
277
+ }
278
+ catch {
279
+ /* Invalid rows fall back individually. */
280
+ }
281
+ }
282
+ return { variantId: input.variantId, discount };
283
+ });
284
+ }
@@ -0,0 +1,9 @@
1
+ import { type PricedVariant } from './presentation.js';
2
+ export interface DiscountDepotVolumeTableProps {
3
+ table: unknown;
4
+ variant?: PricedVariant | null;
5
+ locale?: string;
6
+ className?: string;
7
+ }
8
+ /** No network calls: render the table returned with the PDP price preview. */
9
+ export declare function DiscountDepotVolumeTable({ table, variant, locale, className, }: DiscountDepotVolumeTableProps): import("react").JSX.Element | null;
@@ -0,0 +1,94 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { safePriceColor } from './presentation.js';
4
+ import { normalizeVolumeTable } from './volume.js';
5
+ import { DiscountDepotCountdown } from './countdown.js';
6
+ /** No network calls: render the table returned with the PDP price preview. */
7
+ export function DiscountDepotVolumeTable({ table, variant, locale = 'en-IN', className, }) {
8
+ const [hovered, setHovered] = useState(null);
9
+ const [copiedCode, setCopiedCode] = useState('');
10
+ const [copyError, setCopyError] = useState(false);
11
+ const data = variant ? normalizeVolumeTable(table, variant.price) : null;
12
+ if (!data)
13
+ return null;
14
+ const design = data.design ?? {};
15
+ const setting = (name, ...aliases) => {
16
+ for (const key of [`vd_${name}`, name, ...aliases]) {
17
+ if (design[key] !== undefined)
18
+ return design[key];
19
+ }
20
+ return undefined;
21
+ };
22
+ const label = (name, fallback) => {
23
+ const value = setting(name);
24
+ return typeof value === 'string' && value.trim() ? value : fallback;
25
+ };
26
+ const color = (name, fallback) => safePriceColor(String(setting(name) ?? '')) ?? fallback;
27
+ const size = (name, fallback, max) => {
28
+ const value = setting(name);
29
+ return value !== undefined &&
30
+ String(value).trim() !== '' &&
31
+ Number.isFinite(Number(value))
32
+ ? Math.max(0, Math.min(max, Number(value)))
33
+ : fallback;
34
+ };
35
+ const enabled = (value) => ![false, 'false', 0, '0'].includes(value);
36
+ const savings = enabled(setting('show_savings_column', 'show_saving_column'));
37
+ const prices = enabled(setting('show_prices_column', 'show_price_column'));
38
+ const borderless = [true, 'true', 1, '1'].includes(setting('borderless'));
39
+ const border = borderless
40
+ ? 'none'
41
+ : `1px solid ${color('border_color', '#cccccc')}`;
42
+ let money;
43
+ try {
44
+ money = new Intl.NumberFormat(locale, {
45
+ style: 'currency',
46
+ currency: data.currencyCode,
47
+ });
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ const format = (amount) => money.format(Number(amount));
53
+ const cell = {
54
+ padding: size('cell_padding', 14, 48),
55
+ textAlign: 'center',
56
+ borderBottom: border,
57
+ };
58
+ const codeBanner = data.discountType === 'CODE' && data.showDiscountCode && data.discountCode && enabled(setting('show_discount_code')) ? (_jsxs("div", { style: { display: 'flex', alignItems: 'baseline', flexWrap: 'wrap', gap: 6, marginBlock: 12, fontSize: 14, color: 'inherit' }, children: [_jsx("span", { children: "Use this code to get discount:" }), _jsx("span", { style: { overflowWrap: 'anywhere', fontWeight: 600 }, children: data.discountCode }), _jsx("button", { type: "button", "aria-label": `Copy discount code ${data.discountCode}`, onClick: async () => {
59
+ try {
60
+ await navigator.clipboard.writeText(data.discountCode);
61
+ setCopiedCode(data.discountCode);
62
+ setCopyError(false);
63
+ }
64
+ catch {
65
+ setCopyError(true);
66
+ }
67
+ }, title: copiedCode === data.discountCode ? 'Copied' : 'Copy code', style: { padding: 4, background: 'none', color: 'inherit', border: 0, display: 'inline-flex', alignSelf: 'center', cursor: 'pointer' }, children: _jsx("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.7", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", focusable: "false", children: copiedCode === data.discountCode ? _jsx("path", { d: "m5 12 4 4L19 6" }) : _jsxs(_Fragment, { children: [_jsx("rect", { x: "8", y: "8", width: "12", height: "12", rx: "2" }), _jsx("path", { d: "M16 8V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h3" })] }) }) }), copyError && _jsx("span", { role: "status", children: "Select and copy the code manually." })] })) : null;
68
+ return (_jsxs(_Fragment, { children: [_jsx("div", { className: className ?? 'discount-depot-volume-table', style: {
69
+ overflowX: 'auto',
70
+ border,
71
+ borderRadius: size('Roundness', 0, 48),
72
+ marginTop: 16,
73
+ }, children: _jsxs("table", { "aria-label": "Volume discounts", style: { width: '100%', borderCollapse: 'collapse', margin: 0 }, children: [_jsx("thead", { style: {
74
+ backgroundColor: color('header_background_color', '#000000'),
75
+ color: color('header_font_color', '#ffffff'),
76
+ }, children: _jsxs("tr", { children: [_jsx("th", { scope: "col", style: cell, children: data.condition === 'quantity'
77
+ ? label('quantity_label', 'Buy')
78
+ : label('spend_label', 'Spend') }), savings && (_jsx("th", { scope: "col", style: cell, children: label('savings_label', 'Get') })), prices && (_jsx("th", { scope: "col", style: cell, children: label('price_label', 'Price') }))] }) }), _jsx("tbody", { style: { color: color('body_font_color', '#555555') }, children: data.tiers.map((tier, index) => {
79
+ const threshold = (value) => data.condition === 'subtotal' ? format(value) : value;
80
+ return (_jsxs("tr", { onMouseEnter: () => setHovered(index), onMouseLeave: () => setHovered(null), style: {
81
+ backgroundColor: hovered === index
82
+ ? color('background_highlight', '#e5e5e5')
83
+ : color('body_background_color', '#ffffff'),
84
+ }, children: [_jsxs("th", { scope: "row", style: { ...cell, fontWeight: 400 }, children: [threshold(tier.minimum), tier.maximum === null
85
+ ? data.condition === 'quantity'
86
+ ? '+'
87
+ : ''
88
+ : Number(tier.maximum) === Number(tier.minimum)
89
+ ? ''
90
+ : ` – ${threshold(tier.maximum)}`] }), savings && (_jsx("td", { style: cell, children: tier.discountType === 'percentage'
91
+ ? `${tier.discountValue}% ${label('off_text_percentage', 'off')}`
92
+ : `${format(tier.discountValue)} ${label('off_text_fixed', 'off')}` })), prices && (_jsx("td", { style: cell, children: tier.unitPrice === null ? '—' : format(tier.unitPrice) }))] }, tier.minimum));
93
+ }) }), codeBanner && (_jsx("tfoot", { style: { backgroundColor: color('body_background_color', '#ffffff'), color: color('body_font_color', '#555555') }, children: _jsx("tr", { children: _jsx("td", { colSpan: 1 + Number(savings) + Number(prices), style: { padding: '0 12px', borderTop: border }, children: codeBanner }) }) }))] }) }), _jsx(DiscountDepotCountdown, { countdown: data.countdown, design: design })] }));
94
+ }
@@ -0,0 +1,27 @@
1
+ import { type DepotCountdown } from './countdown-data.js';
2
+ /** Public, evaluated preview data; never send raw database rules to the browser. */
3
+ export interface DepotVolumeTable {
4
+ eligible: true;
5
+ discountType: 'AUTOMATIC' | 'CODE';
6
+ discountCode?: string;
7
+ showDiscountCode?: boolean;
8
+ currencyCode: string;
9
+ originalPrice: string;
10
+ condition: 'quantity' | 'subtotal';
11
+ title?: string;
12
+ countdown?: DepotCountdown | null;
13
+ tiers: {
14
+ minimum: string;
15
+ maximum: string | null;
16
+ discountType: 'percentage' | 'fixed';
17
+ discountValue: string;
18
+ /** Evaluated per-unit price. Null when a subtotal offer has no fixed unit price. */
19
+ unitPrice: string | null;
20
+ title?: string;
21
+ }[];
22
+ design?: Record<string, string | number | boolean>;
23
+ }
24
+ export declare function normalizeVolumeTable(value: unknown, price: {
25
+ amount: string;
26
+ currencyCode: string;
27
+ }): DepotVolumeTable | null;
package/dist/volume.js ADDED
@@ -0,0 +1,82 @@
1
+ import { normalizeCountdown } from './countdown-data.js';
2
+ const decimal = (value) => typeof value === 'string' &&
3
+ /^\d+(?:\.\d+)?$/.test(value) &&
4
+ Number.isFinite(Number(value));
5
+ export function normalizeVolumeTable(value, price) {
6
+ if (!value || typeof value !== 'object')
7
+ return null;
8
+ const table = value;
9
+ if (table.eligible !== true ||
10
+ !['AUTOMATIC', 'CODE'].includes(table.discountType ?? '') ||
11
+ (table.discountType === 'CODE' && table.showDiscountCode !== false &&
12
+ (typeof table.discountCode !== 'string' || !table.discountCode.trim() || table.discountCode.length > 255 || /[\x00-\x1f\x7f]/.test(table.discountCode))) ||
13
+ table.currencyCode !== price.currencyCode ||
14
+ !decimal(table.originalPrice) ||
15
+ Number(table.originalPrice) !== Number(price.amount) ||
16
+ !['quantity', 'subtotal'].includes(table.condition ?? '') ||
17
+ !Array.isArray(table.tiers) ||
18
+ !table.tiers.length ||
19
+ table.tiers.length > 50)
20
+ return null;
21
+ const tiers = [];
22
+ for (const row of table.tiers) {
23
+ if (!row ||
24
+ !decimal(row.minimum) ||
25
+ Number(row.minimum) <= 0 ||
26
+ (table.condition === 'quantity' &&
27
+ !Number.isSafeInteger(Number(row.minimum))) ||
28
+ (row.maximum !== null &&
29
+ (!decimal(row.maximum) ||
30
+ Number(row.maximum) < Number(row.minimum) ||
31
+ (table.condition === 'quantity' &&
32
+ !Number.isSafeInteger(Number(row.maximum))))) ||
33
+ !['percentage', 'fixed'].includes(row.discountType) ||
34
+ !decimal(row.discountValue) ||
35
+ Number(row.discountValue) <= 0 ||
36
+ (row.discountType === 'percentage' && Number(row.discountValue) > 100) ||
37
+ (row.unitPrice !== null &&
38
+ (!decimal(row.unitPrice) ||
39
+ Number(row.unitPrice) > Number(price.amount))) ||
40
+ (table.condition === 'quantity' && row.unitPrice === null))
41
+ return null;
42
+ tiers.push({
43
+ minimum: row.minimum,
44
+ maximum: row.maximum,
45
+ discountType: row.discountType,
46
+ discountValue: row.discountValue,
47
+ unitPrice: row.unitPrice,
48
+ title: typeof row.title === 'string' ? row.title.slice(0, 200) : undefined,
49
+ });
50
+ }
51
+ tiers.sort((a, b) => Number(a.minimum) - Number(b.minimum));
52
+ for (let i = 1; i < tiers.length; i++) {
53
+ const previous = tiers[i - 1];
54
+ if (Number(previous.minimum) === Number(tiers[i].minimum) ||
55
+ (previous.maximum !== null &&
56
+ Number(previous.maximum) >= Number(tiers[i].minimum)))
57
+ return null;
58
+ }
59
+ // An open-ended minimum is a ladder threshold, not an exclusive range.
60
+ const design = {};
61
+ if (table.design && typeof table.design === 'object') {
62
+ for (const [key, entry] of Object.entries(table.design).slice(0, 100)) {
63
+ if (/^(?:vd_)?[a-zA-Z_]+$/.test(key) &&
64
+ ((typeof entry === 'string' && entry.length <= 300) ||
65
+ typeof entry === 'boolean' ||
66
+ (typeof entry === 'number' && Number.isFinite(entry))))
67
+ design[key] = entry;
68
+ }
69
+ }
70
+ return {
71
+ eligible: true,
72
+ discountType: table.discountType,
73
+ ...(table.discountType === 'CODE' ? { discountCode: table.showDiscountCode === false ? undefined : table.discountCode, showDiscountCode: table.showDiscountCode !== false } : {}),
74
+ currencyCode: price.currencyCode,
75
+ originalPrice: table.originalPrice,
76
+ condition: table.condition,
77
+ title: typeof table.title === 'string' ? table.title.slice(0, 200) : undefined,
78
+ tiers,
79
+ design,
80
+ countdown: normalizeCountdown(table.countdown),
81
+ };
82
+ }
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@discount-depot/hydrogen",
3
+ "version": "0.1.4",
4
+ "description": "Automatic discount previews for Shopify Hydrogen storefronts",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "files": [
8
+ "dist",
9
+ "README.md"
10
+ ],
11
+ "exports": {
12
+ "./react": {
13
+ "types": "./dist/react.d.ts",
14
+ "import": "./dist/react.js"
15
+ },
16
+ "./server": {
17
+ "types": "./dist/server.d.ts",
18
+ "import": "./dist/server.js"
19
+ }
20
+ },
21
+ "scripts": {
22
+ "build": "tsc -p tsconfig.json",
23
+ "prepack": "npm run build"
24
+ },
25
+ "peerDependencies": {
26
+ "@shopify/hydrogen": ">=2026.4.0 <2027",
27
+ "react": "^18.3.1 || ^19.0.0",
28
+ "react-router": "^7.16.0"
29
+ },
30
+ "devDependencies": {
31
+ "typescript": "^5.9.2",
32
+ "@types/react": "^18.3.28"
33
+ }
34
+ }