@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
@@ -0,0 +1,69 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { CartForm } from '@shopify/hydrogen';
3
+ import { Link, useLocation } from 'react-router';
4
+ import { normalizeBuyXGetY } from './buy-x-get-y-data.js';
5
+ import { buyXGetYDesign } from './buy-x-get-y-design.js';
6
+ import { buyXGetYStyles } from './buy-x-get-y-styles.js';
7
+ import { DiscountDepotCountdown } from './countdown.js';
8
+ /** Presentational offers; data comes from the existing PDP/cart request. */
9
+ export function DiscountDepotBuyXGetY({ offers, currencyCode, locale = 'en-IN', allowCartActions = false, allowProductActions = false, }) {
10
+ const { pathname } = useLocation();
11
+ const prefix = pathname.match(/^\/[a-z]{2}-[a-z]{2}(?=\/|$)/i)?.[0] ?? '';
12
+ const data = normalizeBuyXGetY(offers, currencyCode, allowCartActions);
13
+ if (!data.length)
14
+ return null;
15
+ let money;
16
+ try {
17
+ money = new Intl.NumberFormat(locale, {
18
+ style: 'currency',
19
+ currency: currencyCode,
20
+ });
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ return (_jsxs("div", { className: "discount-depot-buy-x-get-y", children: [_jsx("style", { children: buyXGetYStyles }), data.map((offer) => {
26
+ return (_jsx("section", { className: "dd-bxgy", "aria-label": offer.title, style: buyXGetYDesign(offer.design), children: _jsxs("div", { className: "box-wrapper axt-bxgy-card-background", children: [_jsxs("div", { className: "box-header", children: [_jsx("div", { className: "axt-offer-icon", "aria-hidden": "true", children: _jsx("img", { className: "axtSpecialOfferImg", src: `${prefix}/api/discount-depot?asset=gift-icon`, alt: "", width: 28, height: 28 }) }), _jsxs("div", { className: "axt-offer-copy", children: [_jsx("p", { className: "axtSpecialOfferMsg", children: offer.title }), _jsx("div", { className: "axt-unlocked-badge", children: offer.status === 'applied' ? 'Reward already in cart' : 'Unlocked with qualifying items' })] })] }), _jsxs("div", { className: "axt-main-box", children: [_jsx("p", { children: offer.message }), offer.showGiftProducts && offer.gifts.length > 0 && (_jsx("ul", { "aria-label": "Gift products", tabIndex: 0, style: {
27
+ listStyle: 'none',
28
+ padding: 0,
29
+ margin: '12px 0 0',
30
+ display: 'grid',
31
+ gridTemplateColumns: 'minmax(0, 1fr)',
32
+ gap: 10,
33
+ maxHeight: 'min(50vh, 360px)',
34
+ overflowY: 'auto',
35
+ overscrollBehaviorY: 'contain',
36
+ scrollbarGutter: 'stable',
37
+ }, children: offer.gifts.map((gift) => (_jsxs("li", { style: {
38
+ padding: 10,
39
+ display: 'flex',
40
+ alignItems: 'center',
41
+ justifyContent: 'space-between',
42
+ gap: 10,
43
+ background: 'var(--bxgy-item-background)',
44
+ border: '1px solid var(--bxgy-item-border-color)',
45
+ borderRadius: 8,
46
+ }, children: [gift.image ? _jsx("img", { src: gift.image.url, alt: gift.image.altText || gift.title, loading: "lazy", width: 40, height: 40, onError: (event) => { event.currentTarget.style.visibility = 'hidden'; }, style: { objectFit: 'contain', flexShrink: 0, borderRadius: 4 } }, gift.image.url) : _jsx("span", { "aria-label": "Product image unavailable", style: { width: 40, height: 40, flexShrink: 0, borderRadius: 4, background: 'var(--bxgy-item-border-color)' } }), _jsxs("div", { style: { minWidth: 0, flex: 1 }, children: [_jsx(Link, { to: `${prefix}/products/${gift.handle}`, title: gift.title, style: { textDecoration: 'none', display: 'block', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontSize: 12, fontWeight: 600, color: 'var(--bxgy-body-text-color)' }, children: gift.title }), _jsxs("p", { style: { margin: 0, fontSize: 12, fontWeight: 700 }, children: [_jsx("strong", { style: { color: 'var(--bxgy-price-color)' }, children: Number(gift.displayPrice) === 0
47
+ ? 'FREE'
48
+ : money.format(Number(gift.displayPrice)) }), Number(gift.originalPrice) > Number(gift.displayPrice) && (_jsx("del", { style: { marginLeft: 4, fontSize: 11, opacity: 0.6, color: 'var(--bxgy-body-text-color)' }, children: money.format(Number(gift.originalPrice)) }))] })] }), _jsx("div", { style: { flexShrink: 0, maxWidth: '45%' }, children: !gift.available ? (_jsx("p", { children: "Sold out" })) : (allowCartActions && gift.addQuantity > 0) || (allowProductActions && offer.status !== 'applied') ? (_jsx(CartForm, { route: `${prefix}/cart`, action: CartForm.ACTIONS.LinesAdd, inputs: {
49
+ lines: [
50
+ {
51
+ merchandiseId: gift.variantId,
52
+ quantity: allowCartActions && gift.addQuantity > 0 ? gift.addQuantity : 1,
53
+ },
54
+ ],
55
+ }, children: (fetcher) => (_jsxs(_Fragment, { children: [_jsx("button", { type: "submit", "aria-label": `Add ${gift.title} to cart`, disabled: fetcher.state !== 'idle', style: {
56
+ padding: '6px 12px',
57
+ fontSize: 11,
58
+ fontWeight: 600,
59
+ whiteSpace: 'nowrap',
60
+ cursor: fetcher.state === 'idle' ? 'pointer' : 'wait',
61
+ backgroundColor: fetcher.state === 'idle' && fetcher.data?.cart && !fetcher.data?.errors?.length ? 'var(--bxgy-warning-background)' : 'var(--bxgy-button-background)',
62
+ color: fetcher.state === 'idle' && fetcher.data?.cart && !fetcher.data?.errors?.length ? 'var(--bxgy-warning-text-color)' : 'var(--bxgy-button-text-color)',
63
+ border: `1px solid ${fetcher.state === 'idle' && fetcher.data?.cart && !fetcher.data?.errors?.length ? 'var(--bxgy-warning-border-color)' : 'var(--bxgy-button-border-color)'}`,
64
+ borderRadius: 'var(--bxgy-button-border-radius)',
65
+ }, children: fetcher.state === 'idle'
66
+ ? 'Add to cart'
67
+ : 'Adding…' }), fetcher.data?.errors?.length ? (_jsx("p", { role: "alert", children: "Unable to add this reward. Please try again." })) : null, fetcher.state === 'idle' && fetcher.data?.cart && !fetcher.data?.errors?.length ? (_jsx("p", { role: "status", children: "Added to cart" })) : null] })) })) : offer.status === 'applied' ? (_jsx("p", { children: "Reward already in cart" })) : null })] }, gift.variantId))) })), allowProductActions && offer.showGiftProducts && offer.gifts.length > 0 && (_jsx("p", { style: { marginTop: 8, fontSize: 12 }, children: "Offer price applies when the cart meets the offer conditions." })), _jsx(DiscountDepotCountdown, { countdown: offer.countdown })] })] }) }, offer.id));
68
+ })] }));
69
+ }
@@ -0,0 +1,48 @@
1
+ import { type DepotCountdown } from './countdown-data.js';
2
+ export interface GoalCart {
3
+ updatedAt?: string;
4
+ totalQuantity: number;
5
+ cost: {
6
+ subtotalAmount: {
7
+ amount: string;
8
+ currencyCode: string;
9
+ };
10
+ };
11
+ lines: {
12
+ nodes: {
13
+ id: string;
14
+ quantity: number;
15
+ cost: {
16
+ totalAmount: {
17
+ amount: string;
18
+ currencyCode: string;
19
+ };
20
+ };
21
+ merchandise: {
22
+ id: string;
23
+ product: {
24
+ id: string;
25
+ };
26
+ price: {
27
+ amount: string;
28
+ currencyCode: string;
29
+ };
30
+ };
31
+ }[];
32
+ };
33
+ }
34
+ export declare function cartGoalKey(cart: GoalCart): string;
35
+ export interface DepotCartGoal {
36
+ eligible: true;
37
+ discountType: 'AUTOMATIC';
38
+ currencyCode: string;
39
+ /** Backend-selected next goal, in major currency units. */
40
+ targetAmount: string;
41
+ qualifyingAmount: string;
42
+ remainingAmount: string;
43
+ rewardType: 'percentage' | 'fixed';
44
+ rewardValue: string;
45
+ design: Record<string, string | number | boolean>;
46
+ countdown: DepotCountdown | null;
47
+ }
48
+ export declare function normalizeCartGoal(value: unknown, currency: string): DepotCartGoal | null;
@@ -0,0 +1,63 @@
1
+ import { normalizeCountdown } from './countdown-data.js';
2
+ export function cartGoalKey(cart) {
3
+ return JSON.stringify([
4
+ cart.updatedAt,
5
+ cart.totalQuantity,
6
+ cart.cost.subtotalAmount,
7
+ cart.lines.nodes
8
+ .map((line) => [
9
+ line.id,
10
+ line.quantity,
11
+ line.merchandise.id,
12
+ line.cost.totalAmount.amount,
13
+ ])
14
+ .sort((a, b) => String(a[0]).localeCompare(String(b[0]))),
15
+ ]);
16
+ }
17
+ export function normalizeCartGoal(value, currency) {
18
+ if (!value || typeof value !== 'object')
19
+ return null;
20
+ const goal = value;
21
+ const amount = (v) => typeof v === 'string' &&
22
+ /^\d+(?:\.\d+)?$/.test(v) &&
23
+ Number.isFinite(Number(v));
24
+ if (goal.eligible !== true ||
25
+ goal.discountType !== 'AUTOMATIC' ||
26
+ goal.currencyCode !== currency ||
27
+ !amount(goal.targetAmount) ||
28
+ Number(goal.targetAmount) <= 0 ||
29
+ !amount(goal.qualifyingAmount) ||
30
+ !amount(goal.remainingAmount) ||
31
+ Number(goal.remainingAmount) <= 0 ||
32
+ Number(goal.qualifyingAmount) >= Number(goal.targetAmount) ||
33
+ Math.abs(Number(goal.targetAmount) -
34
+ Number(goal.qualifyingAmount) -
35
+ Number(goal.remainingAmount)) > 0.000001 ||
36
+ !amount(goal.rewardValue) ||
37
+ Number(goal.rewardValue) <= 0 ||
38
+ !['percentage', 'fixed'].includes(goal.rewardType ?? '') ||
39
+ (goal.rewardType === 'percentage' && Number(goal.rewardValue) > 100))
40
+ return null;
41
+ const design = {};
42
+ if (goal.design && typeof goal.design === 'object') {
43
+ for (const [key, entry] of Object.entries(goal.design).slice(0, 100)) {
44
+ if (/^(?:cg_)?[a-z_]+$/.test(key) &&
45
+ ((typeof entry === 'string' && entry.length <= 300) ||
46
+ typeof entry === 'boolean' ||
47
+ (typeof entry === 'number' && Number.isFinite(entry))))
48
+ design[key] = entry;
49
+ }
50
+ }
51
+ return {
52
+ eligible: true,
53
+ discountType: 'AUTOMATIC',
54
+ currencyCode: currency,
55
+ targetAmount: goal.targetAmount,
56
+ qualifyingAmount: goal.qualifyingAmount,
57
+ remainingAmount: goal.remainingAmount,
58
+ rewardType: goal.rewardType,
59
+ rewardValue: goal.rewardValue,
60
+ design,
61
+ countdown: normalizeCountdown(goal.countdown),
62
+ };
63
+ }
@@ -0,0 +1,17 @@
1
+ import type { DiscountContext } from './server.js';
2
+ import { type GoalCart } from './cart-goal-data.js';
3
+ export declare function createDiscountDepotCartGoalRoute({ apiUrl, }?: {
4
+ apiUrl?: string | undefined;
5
+ }): {
6
+ loader: () => Response;
7
+ action: ({ request, context, }: {
8
+ request: Request;
9
+ context: DiscountContext & {
10
+ cart: {
11
+ get: (options?: {
12
+ numCartLines: number;
13
+ }) => Promise<GoalCart | null>;
14
+ };
15
+ };
16
+ }) => Promise<Response>;
17
+ };
@@ -0,0 +1,73 @@
1
+ import { normalizeBuyXGetY } from './buy-x-get-y-data.js';
2
+ import { resolvePreviewCustomer, customerPreviewHeaders, } from './customer-identity.js';
3
+ import { cartGoalKey, normalizeCartGoal, } from './cart-goal-data.js';
4
+ import { DISCOUNT_DEPOT_CART_GOAL_API_URL } from './config.js';
5
+ export function createDiscountDepotCartGoalRoute({ apiUrl = DISCOUNT_DEPOT_CART_GOAL_API_URL, } = {}) {
6
+ const headers = { 'Cache-Control': 'private, no-store' };
7
+ const loader = () => Response.json({ error: 'Use POST' }, { status: 405, headers: { ...headers, Allow: 'POST' } });
8
+ const action = async ({ request, context, }) => {
9
+ if (request.method !== 'POST')
10
+ return loader();
11
+ try {
12
+ const body = await request.json();
13
+ const includeBuyXGetY = body?.includeBuyXGetY === true;
14
+ // Never accept browser-supplied shop, prices, cart IDs or customer identity.
15
+ const cart = await context.cart.get({ numCartLines: 250 });
16
+ if (!cart || !cart.totalQuantity || !cart.lines.nodes.length)
17
+ return Response.json({ cartKey: null, cartGoal: null }, { headers });
18
+ const key = cartGoalKey(cart);
19
+ const currencyCode = cart.cost.subtotalAmount.currencyCode;
20
+ const lines = cart.lines.nodes;
21
+ if (lines.length > 250 ||
22
+ lines.reduce((sum, line) => sum + line.quantity, 0) !==
23
+ cart.totalQuantity ||
24
+ lines.some((line) => !Number.isSafeInteger(line.quantity) ||
25
+ line.quantity <= 0 ||
26
+ line.cost.totalAmount.currencyCode !== currencyCode ||
27
+ line.merchandise.price.currencyCode !== currencyCode)) {
28
+ return Response.json({ cartKey: key, cartGoal: null }, { headers });
29
+ }
30
+ const credentials = await resolvePreviewCustomer(context, request);
31
+ const response = await fetch(apiUrl, {
32
+ method: 'POST',
33
+ redirect: 'manual',
34
+ headers: customerPreviewHeaders(credentials),
35
+ body: JSON.stringify({
36
+ shop: new URL(`https://${context.storefront.getShopifyDomain().replace(/^https?:\/\//, '')}`).hostname,
37
+ country: context.storefront.i18n.country,
38
+ currencyCode,
39
+ discountType: 'AUTOMATIC',
40
+ ...(includeBuyXGetY ? { includeBuyXGetY: true } : {}),
41
+ cart: {
42
+ subtotalAmount: cart.cost.subtotalAmount.amount,
43
+ totalQuantity: cart.totalQuantity,
44
+ lines: lines.map((line) => ({
45
+ productId: line.merchandise.product.id,
46
+ variantId: line.merchandise.id,
47
+ quantity: line.quantity,
48
+ unitPrice: line.merchandise.price.amount,
49
+ lineTotal: line.cost.totalAmount.amount,
50
+ })),
51
+ },
52
+ }),
53
+ signal: AbortSignal.timeout(8000),
54
+ });
55
+ if (!response.ok)
56
+ throw new Error('Cart goal unavailable');
57
+ const result = (await response.json());
58
+ return Response.json({
59
+ cartKey: key,
60
+ cartGoal: normalizeCartGoal(result?.cartGoal, currencyCode),
61
+ ...(includeBuyXGetY
62
+ ? {
63
+ buyXGetY: normalizeBuyXGetY(result?.buyXGetY, currencyCode, true),
64
+ }
65
+ : {}),
66
+ }, { headers });
67
+ }
68
+ catch {
69
+ return Response.json({ error: 'Cart goal unavailable', cartGoal: null }, { status: 502, headers });
70
+ }
71
+ };
72
+ return { loader, action };
73
+ }
@@ -0,0 +1,13 @@
1
+ import { type GoalCart } from './cart-goal-data.js';
2
+ export declare function DiscountDepotCartGoal({ cart, pending, endpoint, locale, showBuyXGetY, }: {
3
+ cart?: GoalCart | null;
4
+ pending?: boolean;
5
+ endpoint?: string;
6
+ locale?: string;
7
+ showBuyXGetY?: boolean;
8
+ }): import("react").JSX.Element | null;
9
+ export declare function DiscountDepotCartGoalPreview({ goal, currencyCode, locale, }: {
10
+ goal: unknown;
11
+ currencyCode: string;
12
+ locale?: string;
13
+ }): import("react").JSX.Element | null;
@@ -0,0 +1,113 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
3
+ import { useLocation } from 'react-router';
4
+ import { cartGoalKey, normalizeCartGoal, } from './cart-goal-data.js';
5
+ import { safePriceColor } from './presentation.js';
6
+ import { DiscountDepotCountdown } from './countdown.js';
7
+ import { usePreviewSessionRevision } from './preview-session.js';
8
+ import { DiscountDepotBuyXGetY } from './buy-x-get-y.js';
9
+ import { normalizeBuyXGetY, } from './buy-x-get-y-data.js';
10
+ export function DiscountDepotCartGoal({ cart, pending = false, endpoint, locale = 'en-IN', showBuyXGetY = false, }) {
11
+ const { pathname } = useLocation();
12
+ const prefix = pathname.match(/^\/[a-z]{2}-[a-z]{2}(?=\/|$)/i)?.[0] ?? '';
13
+ const url = endpoint ?? `${prefix}/api/discount-depot/cart-goal`;
14
+ const revision = usePreviewSessionRevision();
15
+ const snapshot = cart?.totalQuantity && cart.lines.nodes.length ? cartGoalKey(cart) : null;
16
+ const currency = cart?.cost.subtotalAmount.currencyCode;
17
+ const key = JSON.stringify([snapshot, url, revision, showBuyXGetY]);
18
+ const [result, setResult] = useState(null);
19
+ useEffect(() => {
20
+ setResult(null);
21
+ if (pending || !snapshot || !currency)
22
+ return;
23
+ let active = true;
24
+ const controller = new AbortController();
25
+ const timeout = setTimeout(() => controller.abort(), 12000);
26
+ void (async () => {
27
+ try {
28
+ const response = await fetch(url, {
29
+ method: 'POST',
30
+ headers: { 'Content-Type': 'application/json' },
31
+ body: JSON.stringify(showBuyXGetY ? { includeBuyXGetY: true } : {}),
32
+ signal: controller.signal,
33
+ });
34
+ if (!response.ok)
35
+ return;
36
+ const data = await response.json();
37
+ if (active && data?.cartKey === snapshot)
38
+ setResult({
39
+ key,
40
+ goal: normalizeCartGoal(data.cartGoal, currency),
41
+ buyXGetY: showBuyXGetY
42
+ ? normalizeBuyXGetY(data.buyXGetY, currency, true)
43
+ : [],
44
+ });
45
+ }
46
+ catch {
47
+ /* Cart/checkout stays usable when previews are unavailable. */
48
+ }
49
+ finally {
50
+ clearTimeout(timeout);
51
+ }
52
+ })();
53
+ return () => {
54
+ active = false;
55
+ controller.abort();
56
+ clearTimeout(timeout);
57
+ };
58
+ }, [key, snapshot, currency, url, pending, showBuyXGetY]);
59
+ if (pending || result?.key !== key)
60
+ return null;
61
+ return (_jsxs(_Fragment, { children: [_jsx(DiscountDepotCartGoalPreview, { goal: result.goal, currencyCode: currency, locale: locale }), showBuyXGetY && (_jsx(DiscountDepotBuyXGetY, { offers: result.buyXGetY, currencyCode: currency, locale: locale, allowCartActions: true }))] }));
62
+ }
63
+ export function DiscountDepotCartGoalPreview({ goal, currencyCode, locale = 'en-IN', }) {
64
+ const data = normalizeCartGoal(goal, currencyCode);
65
+ if (!data)
66
+ return null;
67
+ const setting = (key) => data.design[`cg_${key}`] ?? data.design[key];
68
+ const text = (key, fallback) => typeof setting(key) === 'string' ? String(setting(key)) : fallback;
69
+ const number = (key, fallback, max) => {
70
+ const value = setting(key);
71
+ return (typeof value === 'number' || typeof value === 'string') &&
72
+ String(value).trim() !== '' &&
73
+ Number.isFinite(Number(value))
74
+ ? Math.max(0, Math.min(max, Number(value)))
75
+ : fallback;
76
+ };
77
+ const color = (key, fallback) => safePriceColor(text(key, fallback)) ?? fallback;
78
+ const flag = (key) => [true, 'true', 1, '1'].includes(setting(key));
79
+ let format;
80
+ try {
81
+ format = new Intl.NumberFormat(locale, {
82
+ style: 'currency',
83
+ currency: currencyCode,
84
+ });
85
+ }
86
+ catch {
87
+ return null;
88
+ }
89
+ const money = (value) => format.format(Number(value));
90
+ const align = text('order_text_align', 'center');
91
+ const borderStyle = text('style', 'solid');
92
+ const font = text('order_font_family', 'serif');
93
+ const style = {
94
+ backgroundColor: color('background_color', '#ffffff'),
95
+ color: color('order_text_color', '#000000'),
96
+ borderRadius: number('order_border_radius', 0, 100),
97
+ padding: number('order_padding', 0, 100),
98
+ fontSize: number('order_text_size', 16, 72),
99
+ fontFamily: flag('inherit_font')
100
+ ? 'inherit'
101
+ : /^[a-zA-Z\s,'"-]{1,100}$/.test(font)
102
+ ? font
103
+ : 'inherit',
104
+ textAlign: ['left', 'center', 'right'].includes(align)
105
+ ? align
106
+ : 'center',
107
+ border: flag('borderless')
108
+ ? 'none'
109
+ : `${number('order_border', 2, 10)}px ${['solid', 'dashed', 'dotted', 'double', 'none'].includes(borderStyle) ? borderStyle : 'solid'} ${color('border_color', '#000000')}`,
110
+ marginBottom: 16,
111
+ };
112
+ return (_jsxs(_Fragment, { children: [_jsxs("section", { className: "discount-depot-cart-goal", "aria-label": "Cart offer", "aria-live": "polite", style: style, children: [text('spend_label', 'Spend'), " ", money(data.remainingAmount), ' ', text('more_and', 'more and'), ' ', data.rewardType === 'percentage' ? (_jsxs(_Fragment, { children: [text('percent_saved', 'save'), " ", data.rewardValue, text('percent_cap', '%!')] })) : (_jsxs(_Fragment, { children: [text('fixed_saved', 'get'), " ", money(data.rewardValue), ' ', text('fixed_cap', 'off!')] }))] }), _jsx(DiscountDepotCountdown, { countdown: data.countdown })] }));
113
+ }
@@ -0,0 +1,10 @@
1
+ import { type ReactNode } from 'react';
2
+ import type { DepotDiscount } from './server.js';
3
+ import { type PricedVariant } from './presentation.js';
4
+ /** Supply ONLY the new connection page, even when the grid accumulates old pages. */
5
+ export declare function DiscountDepotCollection({ variants, children, endpoint, scopeKey, }: {
6
+ variants: (PricedVariant | null | undefined)[];
7
+ children: (discounts: ReadonlyMap<string, DepotDiscount>) => ReactNode;
8
+ endpoint?: string;
9
+ scopeKey?: string;
10
+ }): import("react").JSX.Element;
@@ -0,0 +1,91 @@
1
+ import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
2
+ import { usePreviewSessionRevision } from './preview-session.js';
3
+ import { useEffect, useRef, useState } from 'react';
4
+ import { useLocation } from 'react-router';
5
+ import { isValidDiscount } from './presentation.js';
6
+ /** Supply ONLY the new connection page, even when the grid accumulates old pages. */
7
+ export function DiscountDepotCollection({ variants, children, endpoint, scopeKey, }) {
8
+ const { pathname, search } = useLocation();
9
+ const params = new URLSearchParams(search);
10
+ // Hydrogen's accumulated pagination uses these query parameters.
11
+ for (const name of [...params.keys()]) {
12
+ if (/^(?:cursor|direction)$|_(?:cursor|direction)$/.test(name))
13
+ params.delete(name);
14
+ }
15
+ params.sort();
16
+ const sessionRevision = usePreviewSessionRevision();
17
+ const scope = JSON.stringify([scopeKey ?? `${pathname}?${params}`, sessionRevision]);
18
+ const prefix = pathname.match(/^\/[a-z]{2}-[a-z]{2}(?=\/|$)/i)?.[0] ?? '';
19
+ const url = endpoint ?? `${prefix}/api/discount-depot`;
20
+ const unique = new Map();
21
+ for (const variant of variants)
22
+ if (variant && !unique.has(variant.id) && unique.size < 50)
23
+ unique.set(variant.id, variant);
24
+ const signature = JSON.stringify([...unique.values()]
25
+ .sort((a, b) => a.id.localeCompare(b.id))
26
+ .map(({ id, price }) => ({ id, price })));
27
+ const requestKey = JSON.stringify([scope, url, signature]);
28
+ const requested = useRef(new Set());
29
+ const mounted = useRef(false);
30
+ const [results, setResults] = useState(() => new Map());
31
+ useEffect(() => {
32
+ mounted.current = true;
33
+ return () => {
34
+ mounted.current = false;
35
+ };
36
+ }, []);
37
+ useEffect(() => {
38
+ if (requested.current.has(requestKey))
39
+ return;
40
+ const page = JSON.parse(signature);
41
+ if (!page.length)
42
+ return;
43
+ requested.current.add(requestKey);
44
+ // A request belongs to its page and may finish after Load more. Retain it
45
+ // for the earlier cards; scope keys prevent collection/market contamination.
46
+ void (async () => {
47
+ try {
48
+ const response = await fetch(url, {
49
+ method: 'POST',
50
+ headers: { 'Content-Type': 'application/json' },
51
+ body: JSON.stringify({ variantIds: page.map((variant) => variant.id) }),
52
+ signal: AbortSignal.timeout(15000),
53
+ });
54
+ if (!response.ok)
55
+ return;
56
+ const data = await response.json();
57
+ if (!data ||
58
+ typeof data !== 'object' ||
59
+ !('discounts' in data) ||
60
+ !Array.isArray(data.discounts))
61
+ return;
62
+ const rows = data.discounts;
63
+ const accepted = new Map();
64
+ for (const variant of page) {
65
+ const matches = rows.filter((row) => row &&
66
+ typeof row === 'object' &&
67
+ 'variantId' in row &&
68
+ row.variantId === variant.id);
69
+ if (matches.length === 1 &&
70
+ isValidDiscount(matches[0].discount, variant))
71
+ accepted.set(variant.id, matches[0].discount);
72
+ }
73
+ if (mounted.current)
74
+ setResults((previous) => {
75
+ const next = new Map(previous);
76
+ const discounts = new Map(previous.get(scope));
77
+ for (const variant of page)
78
+ discounts.delete(variant.id);
79
+ for (const [id, discount] of accepted)
80
+ discounts.set(id, discount);
81
+ next.set(scope, discounts);
82
+ return next;
83
+ });
84
+ }
85
+ catch {
86
+ /* Keep Shopify prices for failed pages. */
87
+ }
88
+ })();
89
+ }, [requestKey, signature, scope, url]);
90
+ return _jsx(_Fragment, { children: children(results.get(scope) ?? new Map()) });
91
+ }
@@ -0,0 +1,4 @@
1
+ /** Shared Discount Depot endpoint for every Hydrogen store using this package. */
2
+ export declare const DISCOUNT_DEPOT_API_URL = "https://custompricing.axtrics.com/api/headless/discount";
3
+ export declare const DISCOUNT_DEPOT_BATCH_API_URL = "https://custompricing.axtrics.com/api/headless/discounts";
4
+ export declare const DISCOUNT_DEPOT_CART_GOAL_API_URL = "https://custompricing.axtrics.com/api/headless/cart-goal";
package/dist/config.js ADDED
@@ -0,0 +1,4 @@
1
+ /** Shared Discount Depot endpoint for every Hydrogen store using this package. */
2
+ export const DISCOUNT_DEPOT_API_URL = 'https://custompricing.axtrics.com/api/headless/discount';
3
+ export const DISCOUNT_DEPOT_BATCH_API_URL = 'https://custompricing.axtrics.com/api/headless/discounts';
4
+ export const DISCOUNT_DEPOT_CART_GOAL_API_URL = 'https://custompricing.axtrics.com/api/headless/cart-goal';
@@ -0,0 +1,16 @@
1
+ export interface DepotCountdown {
2
+ enabled: true;
3
+ eligible: true;
4
+ /** Absolute UTC/offset timestamps, evaluated from the saved rule by the backend. */
5
+ startsAt: string;
6
+ endsAt: string;
7
+ expiryAction?: 'restart';
8
+ restartDurationMs?: number;
9
+ ruleEndsAt?: string | null;
10
+ title?: string;
11
+ label?: string;
12
+ design?: Record<string, string | number>;
13
+ }
14
+ export declare function normalizeCountdown(value: unknown): DepotCountdown | null;
15
+ /** Advance saved cycles, never reset the deadline to the page-load time. */
16
+ export declare function countdownDeadline(data: DepotCountdown, now: number): number;
@@ -0,0 +1,46 @@
1
+ export function normalizeCountdown(value) {
2
+ if (!value || typeof value !== 'object')
3
+ return null;
4
+ const data = value;
5
+ const timestamp = (entry) => typeof entry === 'string' &&
6
+ /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?(?:Z|[+-]\d\d:\d\d)$/.test(entry) &&
7
+ Number.isFinite(Date.parse(entry));
8
+ if (data.enabled !== true ||
9
+ data.eligible !== true ||
10
+ !timestamp(data.startsAt) ||
11
+ !timestamp(data.endsAt) ||
12
+ Date.parse(data.endsAt) <= Date.parse(data.startsAt))
13
+ return null;
14
+ const design = {};
15
+ if (data.design && typeof data.design === 'object') {
16
+ for (const [key, value] of Object.entries(data.design).slice(0, 100)) {
17
+ if (/^(?:(?:vd_|cd_)?countdown_[a-z_]+|countdown[A-Z][a-zA-Z]+)$/.test(key) &&
18
+ ((typeof value === 'string' && value.length <= 500) ||
19
+ (typeof value === 'number' && Number.isFinite(value))))
20
+ design[key] = value;
21
+ }
22
+ }
23
+ return {
24
+ enabled: true,
25
+ eligible: true,
26
+ startsAt: data.startsAt,
27
+ endsAt: data.endsAt,
28
+ ...(data.expiryAction === 'restart' && Number.isSafeInteger(data.restartDurationMs) && Number(data.restartDurationMs) > 0 && (data.ruleEndsAt == null || timestamp(data.ruleEndsAt)) ? {
29
+ expiryAction: 'restart',
30
+ restartDurationMs: data.restartDurationMs,
31
+ ruleEndsAt: data.ruleEndsAt ?? null,
32
+ } : {}),
33
+ title: typeof data.title === 'string' ? data.title.slice(0, 200) : undefined,
34
+ label: typeof data.label === 'string' ? data.label.slice(0, 200) : undefined,
35
+ ...(Object.keys(design).length ? { design } : {}),
36
+ };
37
+ }
38
+ /** Advance saved cycles, never reset the deadline to the page-load time. */
39
+ export function countdownDeadline(data, now) {
40
+ let end = Date.parse(data.endsAt);
41
+ const duration = data.restartDurationMs ?? 0;
42
+ if (data.expiryAction === 'restart' && duration > 0 && now >= end) {
43
+ end += (Math.floor((now - end) / duration) + 1) * duration;
44
+ }
45
+ return data.ruleEndsAt ? Math.min(end, Date.parse(data.ruleEndsAt)) : end;
46
+ }
@@ -0,0 +1,17 @@
1
+ export declare function safeCountdownBackground(value: string): string | undefined;
2
+ export declare function resolveCountdownDesign(design: Record<string, string | number | boolean>): {
3
+ theme: string;
4
+ format: string;
5
+ title: string;
6
+ label: string;
7
+ labels: string[];
8
+ bg: string;
9
+ border: string;
10
+ color: string;
11
+ timeBg: string;
12
+ timeColor: string;
13
+ size: number;
14
+ radius: number;
15
+ pad: number;
16
+ timePad: number;
17
+ };