@asteby/storefront-react 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,573 @@
1
+ 'use strict';
2
+
3
+ var react = require('react');
4
+ var storefront = require('@asteby/storefront');
5
+ var jsxRuntime = require('react/jsx-runtime');
6
+
7
+ // src/promos/promo-popup.tsx
8
+ function useModalDialog({ open, onClose, initialFocus }) {
9
+ const ref = react.useRef(null);
10
+ const opener = react.useRef(null);
11
+ const onCloseRef = react.useRef(onClose);
12
+ onCloseRef.current = onClose;
13
+ react.useEffect(() => {
14
+ const el = ref.current;
15
+ if (!el || !open) return;
16
+ opener.current = typeof document !== "undefined" ? document.activeElement : null;
17
+ const hasModal = typeof el.showModal === "function";
18
+ if (!el.open) {
19
+ if (hasModal) {
20
+ try {
21
+ el.showModal();
22
+ } catch {
23
+ el.setAttribute("open", "");
24
+ }
25
+ } else el.setAttribute("open", "");
26
+ }
27
+ const target = initialFocus?.current ?? el.querySelector("[data-sf-autofocus]") ?? el;
28
+ if (!target.hasAttribute("tabindex") && target === el) el.setAttribute("tabindex", "-1");
29
+ target.focus({ preventScroll: true });
30
+ const onCancel = (e) => {
31
+ e.preventDefault();
32
+ onCloseRef.current("esc");
33
+ };
34
+ const onKey = (e) => {
35
+ if (e.key === "Escape") {
36
+ e.preventDefault();
37
+ onCloseRef.current("esc");
38
+ }
39
+ };
40
+ el.addEventListener("cancel", onCancel);
41
+ if (!hasModal) el.addEventListener("keydown", onKey);
42
+ return () => {
43
+ el.removeEventListener("cancel", onCancel);
44
+ el.removeEventListener("keydown", onKey);
45
+ if (el.open) {
46
+ if (typeof el.close === "function") {
47
+ try {
48
+ el.close();
49
+ } catch {
50
+ el.removeAttribute("open");
51
+ }
52
+ } else el.removeAttribute("open");
53
+ }
54
+ const back = opener.current;
55
+ if (back instanceof HTMLElement && back.isConnected) back.focus({ preventScroll: true });
56
+ };
57
+ }, [open]);
58
+ const onBackdropClick = react.useCallback((e) => {
59
+ if (e.target === e.currentTarget) onCloseRef.current("backdrop");
60
+ }, []);
61
+ return { ref, onBackdropClick };
62
+ }
63
+ function prefersReducedMotion() {
64
+ try {
65
+ return typeof window !== "undefined" && !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
70
+ var StorefrontContext = react.createContext(null);
71
+ function useOptionalStorefront() {
72
+ return react.useContext(StorefrontContext);
73
+ }
74
+
75
+ // src/locale.ts
76
+ function useResolvedLocale(locale) {
77
+ const sf = useOptionalStorefront();
78
+ return locale ?? sf?.locale ?? "es-MX";
79
+ }
80
+
81
+ // src/promos/promo-logic.ts
82
+ var DEFAULT_PROMO_EXCLUDED_PATHS = [
83
+ "/carrito*",
84
+ "/cart*",
85
+ "/pagar*",
86
+ "/checkout*",
87
+ "/gracias*",
88
+ "/cuenta*",
89
+ "/account*",
90
+ "/login*",
91
+ "/iniciar-sesion*",
92
+ "/registro*",
93
+ "/terminos*",
94
+ "/privacidad*",
95
+ "/legal*",
96
+ "/preview*",
97
+ "/editor*"
98
+ ];
99
+ function stripMarketPrefix(pathname) {
100
+ return pathname.replace(/^\/[a-z]{2}(?:-[a-z]{2})?(?=\/|$)/i, "") || "/";
101
+ }
102
+ function matches(path, pattern) {
103
+ if (pattern instanceof RegExp) return pattern.test(path);
104
+ const p = pattern.toLowerCase();
105
+ if (p.endsWith("*")) {
106
+ const prefix = p.slice(0, -1);
107
+ return path === prefix.replace(/\/$/, "") || path.startsWith(prefix);
108
+ }
109
+ return path === p || path === p.replace(/\/$/, "");
110
+ }
111
+ function isExcludedPath(pathname, patterns = DEFAULT_PROMO_EXCLUDED_PATHS) {
112
+ const path = (pathname || "/").split(/[?#]/)[0].toLowerCase().replace(/\/+$/, "") || "/";
113
+ const bare = stripMarketPrefix(path);
114
+ return patterns.some((p) => matches(path, p) || matches(bare, p));
115
+ }
116
+ var CRAWLER = /bot|crawl|spider|slurp|bingpreview|facebookexternalhit|embedly|quora link|outbrain|pinterest|vkshare|w3c_validator|lighthouse|headless|prerender|google-structured-data|gtmetrix|pingdom/i;
117
+ function isCrawler(userAgent, webdriver) {
118
+ return webdriver === true || CRAWLER.test(userAgent ?? "");
119
+ }
120
+ var PROMO_COOKIE = "sf_promo";
121
+ function memorySeenStore() {
122
+ const data = /* @__PURE__ */ new Map();
123
+ return { data, get: (n) => data.get(n) ?? null, set: (n, v) => void data.set(n, v) };
124
+ }
125
+ function browserSeenStore() {
126
+ const fallback = memorySeenStore();
127
+ return {
128
+ get(name) {
129
+ try {
130
+ const fromSession = sessionStorage.getItem(name);
131
+ if (fromSession) return fromSession;
132
+ } catch {
133
+ }
134
+ try {
135
+ const m = document.cookie.split("; ").find((c) => c.startsWith(`${name}=`));
136
+ if (m) return decodeURIComponent(m.slice(name.length + 1));
137
+ } catch {
138
+ }
139
+ return fallback.get(name);
140
+ },
141
+ set(name, value, { days, session }) {
142
+ fallback.set(name, value, {});
143
+ if (session) {
144
+ try {
145
+ sessionStorage.setItem(name, value);
146
+ } catch {
147
+ }
148
+ return;
149
+ }
150
+ try {
151
+ const maxAge = Math.round((days ?? 7) * 86400);
152
+ document.cookie = `${name}=${encodeURIComponent(value)}; Max-Age=${maxAge}; Path=/; SameSite=Lax${location.protocol === "https:" ? "; Secure" : ""}`;
153
+ } catch {
154
+ }
155
+ }
156
+ };
157
+ }
158
+ function serializeSeen(r) {
159
+ return `${r.id}:${r.version}:${r.at}`;
160
+ }
161
+ function parseSeen(raw) {
162
+ if (!raw) return null;
163
+ const m = /^(.+):(\d+):(\d+)$/.exec(raw);
164
+ if (!m) return null;
165
+ return { id: m[1], version: Number(m[2]), at: Number(m[3]) };
166
+ }
167
+ function frequencyAllows({ id, version, frequency, days = 7, seen, now }) {
168
+ if (!seen || seen.id !== id || seen.version !== version) return true;
169
+ if (frequency === "days") return now - seen.at >= days * 864e5;
170
+ return false;
171
+ }
172
+ function withinWindow({ starts_at, ends_at }, now) {
173
+ const s = starts_at ? Date.parse(starts_at) : NaN;
174
+ const e = ends_at ? Date.parse(ends_at) : NaN;
175
+ if (Number.isFinite(s) && now < s) return false;
176
+ if (Number.isFinite(e) && now > e) return false;
177
+ return true;
178
+ }
179
+ var DEFAULT_PROMO_TRIGGER = { delayMs: 5e3, pageviews: 2, scrollPercent: 40 };
180
+ function resolveTrigger(t) {
181
+ return { ...DEFAULT_PROMO_TRIGGER, ...t ?? {} };
182
+ }
183
+ function scrollDepthPercent(scrollY, viewportHeight, docHeight) {
184
+ const scrollable = docHeight - viewportHeight;
185
+ if (!(scrollable > 0)) return 0;
186
+ return Math.min(100, Math.max(0, scrollY / scrollable * 100));
187
+ }
188
+ function safeInternalPath(href) {
189
+ if (!href || !href.startsWith("/") || href.startsWith("//") || /[\\\u0000-\u001f]/.test(href)) return null;
190
+ return href;
191
+ }
192
+ var PAGEVIEW_KEY = "sf_promo_pv";
193
+ function PromoPopup(props) {
194
+ const { announcement, messages, onEvent, className } = props;
195
+ const locale = useResolvedLocale(props.locale);
196
+ const [open, setOpen] = react.useState(false);
197
+ const shown = react.useRef(null);
198
+ const titleId = react.useId();
199
+ const m = react.useMemo(() => storefront.promotionMessages(locale, messages), [locale, messages]);
200
+ const storeRef = react.useRef(props.store ?? null);
201
+ const live = react.useRef(props);
202
+ live.current = props;
203
+ const pathname = props.pathname ?? (typeof window !== "undefined" ? window.location.pathname : "/");
204
+ const excluded = isExcludedPath(pathname, props.excludePaths);
205
+ const annKey = announcement ? `${announcement.id}:${announcement.version}` : null;
206
+ const views = react.useRef(0);
207
+ const lastCounted = react.useRef(null);
208
+ react.useEffect(() => {
209
+ if (lastCounted.current === pathname) return;
210
+ lastCounted.current = pathname;
211
+ try {
212
+ views.current = Number(sessionStorage.getItem(PAGEVIEW_KEY) ?? "0") + 1;
213
+ sessionStorage.setItem(PAGEVIEW_KEY, String(views.current));
214
+ } catch {
215
+ views.current += 1;
216
+ }
217
+ }, [pathname]);
218
+ react.useEffect(() => {
219
+ const announcement2 = live.current.announcement;
220
+ if (!announcement2 || excluded) {
221
+ setOpen(false);
222
+ return;
223
+ }
224
+ const now = () => live.current.now ? live.current.now() : Date.now();
225
+ const ua = live.current.userAgent ?? (typeof navigator !== "undefined" ? navigator.userAgent : "");
226
+ const driver = live.current.webdriver ?? (typeof navigator !== "undefined" ? navigator.webdriver : false);
227
+ if (isCrawler(ua, driver)) return;
228
+ if (!withinWindow(announcement2, now())) return;
229
+ const frequency = live.current.frequency ?? announcement2.frequency ?? "days";
230
+ const store = storeRef.current ??= typeof window !== "undefined" ? browserSeenStore() : memorySeenStore();
231
+ const cookieName = PROMO_COOKIE;
232
+ const allowed = () => frequencyAllows({
233
+ id: announcement2.id,
234
+ version: announcement2.version,
235
+ frequency,
236
+ days: live.current.frequencyDays ?? announcement2.frequency_days ?? 7,
237
+ seen: parseSeen(store.get(cookieName)),
238
+ now: now()
239
+ });
240
+ if (!allowed()) return;
241
+ if (shown.current === annKey) return;
242
+ const trigger = resolveTrigger({ ...announcement2.trigger ? {
243
+ ...announcement2.trigger.delay_ms !== void 0 ? { delayMs: announcement2.trigger.delay_ms } : {},
244
+ ...announcement2.trigger.pageviews !== void 0 ? { pageviews: announcement2.trigger.pageviews } : {},
245
+ ...announcement2.trigger.scroll_percent !== void 0 ? { scrollPercent: announcement2.trigger.scroll_percent } : {}
246
+ } : {}, ...live.current.trigger ?? {} });
247
+ let done = false;
248
+ const cleanups = [];
249
+ const fire = (via) => {
250
+ if (done || !allowed()) return;
251
+ done = true;
252
+ cleanups.forEach((c) => c());
253
+ shown.current = annKey;
254
+ store.set(cookieName, serializeSeen({ id: announcement2.id, version: announcement2.version, at: now() }), frequency === "session" ? { session: true } : { days: frequency === "campaign" ? 365 : live.current.frequencyDays ?? announcement2.frequency_days ?? 7 });
255
+ setOpen(true);
256
+ live.current.onEvent?.({ type: "view", id: announcement2.id, version: announcement2.version, via });
257
+ };
258
+ if (trigger.pageviews !== false && views.current >= trigger.pageviews) {
259
+ const t = setTimeout(() => fire("pageviews"), 0);
260
+ cleanups.push(() => clearTimeout(t));
261
+ }
262
+ if (trigger.delayMs !== false) {
263
+ const t = setTimeout(() => fire("delay"), trigger.delayMs);
264
+ cleanups.push(() => clearTimeout(t));
265
+ }
266
+ if (trigger.scrollPercent !== false && typeof window !== "undefined") {
267
+ const target = trigger.scrollPercent;
268
+ const onScroll = () => {
269
+ const depth = scrollDepthPercent(window.scrollY, window.innerHeight, document.documentElement.scrollHeight);
270
+ if (depth >= target) fire("scroll");
271
+ };
272
+ window.addEventListener("scroll", onScroll, { passive: true });
273
+ cleanups.push(() => window.removeEventListener("scroll", onScroll));
274
+ }
275
+ return () => {
276
+ done = true;
277
+ cleanups.forEach((c) => c());
278
+ };
279
+ }, [annKey, excluded]);
280
+ const close = (how) => {
281
+ setOpen(false);
282
+ if (announcement && how !== "action") onEvent?.({ type: "close", id: announcement.id, how });
283
+ };
284
+ const { ref, onBackdropClick } = useModalDialog({ open, onClose: close });
285
+ if (!announcement || !open || excluded) return null;
286
+ const cta = safeInternalPath(announcement.cta_url);
287
+ const ctaLabel = announcement.cta_label;
288
+ const limited = !!announcement.ends_at;
289
+ let validUntil = null;
290
+ if (announcement.ends_at) {
291
+ try {
292
+ validUntil = storefront.formatAvailabilityMessage(m["popup.valid_until"], {
293
+ date: new Intl.DateTimeFormat(locale, { dateStyle: "long" }).format(new Date(announcement.ends_at))
294
+ });
295
+ } catch {
296
+ validUntil = null;
297
+ }
298
+ }
299
+ const onCta = () => {
300
+ onEvent?.({ type: "cta", id: announcement.id });
301
+ setOpen(false);
302
+ };
303
+ return /* @__PURE__ */ jsxRuntime.jsx("dialog", { ref, className, "aria-labelledby": titleId, "data-sf-promo-popup": true, onClick: onBackdropClick, children: /* @__PURE__ */ jsxRuntime.jsxs("div", { "data-sf-promo-body": true, children: [
304
+ /* @__PURE__ */ jsxRuntime.jsx(
305
+ "button",
306
+ {
307
+ type: "button",
308
+ onClick: () => close("x"),
309
+ "aria-label": m["popup.close"],
310
+ "data-sf-promo-close": true,
311
+ style: { minWidth: 44, minHeight: 44 },
312
+ children: /* @__PURE__ */ jsxRuntime.jsx("span", { "aria-hidden": "true", children: "\xD7" })
313
+ }
314
+ ),
315
+ limited && /* @__PURE__ */ jsxRuntime.jsx("p", { "data-sf-promo-eyebrow": true, children: m["popup.limited_time"] }),
316
+ /* @__PURE__ */ jsxRuntime.jsx("h2", { id: titleId, tabIndex: -1, "data-sf-autofocus": true, children: announcement.headline }),
317
+ announcement.subheadline && /* @__PURE__ */ jsxRuntime.jsx("p", { children: announcement.subheadline }),
318
+ announcement.image && /* @__PURE__ */ jsxRuntime.jsx("img", { src: announcement.image.url, alt: announcement.image.alt, loading: "lazy", decoding: "async" }),
319
+ (announcement.tiers.length > 0 || announcement.free_shipping_threshold) && /* @__PURE__ */ jsxRuntime.jsxs("ul", { "data-sf-promo-tiers": true, children: [
320
+ announcement.tiers.map((t) => /* @__PURE__ */ jsxRuntime.jsx("li", { children: storefront.formatAvailabilityMessage(m["popup.discount_tier"], {
321
+ reward: storefront.rewardLabel(t.reward, locale, messages),
322
+ threshold: storefront.formatMoney(t.threshold, locale, { withoutTrailingZeros: true })
323
+ }) }, `${t.threshold.amount}-${storefront.rewardLabel(t.reward, locale, messages)}`)),
324
+ announcement.free_shipping_threshold && /* @__PURE__ */ jsxRuntime.jsx("li", { children: Number.parseFloat(announcement.free_shipping_threshold.amount) === 0 ? m["popup.free_shipping_always"] : storefront.formatAvailabilityMessage(m["popup.free_shipping_min"], {
325
+ threshold: storefront.formatMoney(announcement.free_shipping_threshold, locale, { withoutTrailingZeros: true })
326
+ }) })
327
+ ] }),
328
+ cta && ctaLabel && (props.renderCta ? props.renderCta({ href: cta, label: ctaLabel, onClick: onCta }) : /* @__PURE__ */ jsxRuntime.jsx("a", { href: cta, onClick: onCta, "data-sf-promo-cta": true, style: { minHeight: 44, display: "inline-flex", alignItems: "center" }, children: ctaLabel })),
329
+ /* @__PURE__ */ jsxRuntime.jsxs("p", { "data-sf-promo-legal": true, children: [
330
+ announcement.legal_text,
331
+ validUntil && /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
332
+ " ",
333
+ validUntil,
334
+ "."
335
+ ] })
336
+ ] })
337
+ ] }) });
338
+ }
339
+ function PromoProgressBar({ text, percent, label, showBar = true, milestones, state, kind, className }) {
340
+ const id = react.useId();
341
+ const [reduced, setReduced] = react.useState(false);
342
+ react.useEffect(() => setReduced(prefersReducedMotion()), []);
343
+ const fill = { width: `${percent}%`, transition: reduced ? "none" : "width 600ms ease" };
344
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className, "data-sf-promo-bar": kind, "data-state": state, children: [
345
+ /* @__PURE__ */ jsxRuntime.jsx("p", { id: `${id}-t`, "aria-live": "polite", "data-sf-promo-bar-text": true, children: text }),
346
+ showBar && /* @__PURE__ */ jsxRuntime.jsx("div", { role: "progressbar", "aria-label": label, "aria-describedby": `${id}-t`, "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": percent, "aria-valuetext": text, "data-sf-promo-track": true, children: /* @__PURE__ */ jsxRuntime.jsx("div", { style: fill, "data-sf-promo-fill": true }) }),
347
+ showBar && milestones && milestones.length > 0 && /* @__PURE__ */ jsxRuntime.jsx("ul", { "data-sf-promo-milestones": true, children: milestones.map((t) => /* @__PURE__ */ jsxRuntime.jsxs("li", { "data-reached": t.reached, style: { left: `${t.position}%` }, children: [
348
+ /* @__PURE__ */ jsxRuntime.jsx("span", { children: t.label }),
349
+ " ",
350
+ /* @__PURE__ */ jsxRuntime.jsx("span", { children: t.threshold }),
351
+ t.reached && /* @__PURE__ */ jsxRuntime.jsx("span", { "data-sf-sr-only": true, style: { position: "absolute", width: 1, height: 1, overflow: "hidden", clip: "rect(0 0 0 0)" }, children: " \u2713" })
352
+ ] }, t.id)) })
353
+ ] });
354
+ }
355
+ function CartProgressBars({ promotions, locale: localeProp, messages, showMilestones = true, className }) {
356
+ const locale = useResolvedLocale(localeProp);
357
+ const view = react.useMemo(() => storefront.promoProgressView(promotions, locale, messages), [promotions, locale, messages]);
358
+ const m = storefront.promotionMessages(locale, messages);
359
+ if (!view.discount && !view.shipping) return null;
360
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className, "data-sf-promo-bars": true, children: [
361
+ view.discount && /* @__PURE__ */ jsxRuntime.jsx(
362
+ PromoProgressBar,
363
+ {
364
+ kind: "discount",
365
+ state: view.discount.state,
366
+ text: view.discount.text,
367
+ percent: view.discount.percent,
368
+ label: m["bar.discount"],
369
+ ...showMilestones ? { milestones: view.discount.tiers } : {}
370
+ }
371
+ ),
372
+ view.shipping && /* @__PURE__ */ jsxRuntime.jsx(PromoProgressBar, { kind: "shipping", state: view.shipping.state, text: view.shipping.text, percent: view.shipping.percent, label: m["bar.shipping"], showBar: view.shipping.showBar })
373
+ ] });
374
+ }
375
+ function CrossSellModal(props) {
376
+ const { open, promotions, recommendations, totalSaved, onAdd, onContinue, onSkip, messages, renderImage, className } = props;
377
+ const locale = useResolvedLocale(props.locale);
378
+ const m = storefront.promotionMessages(locale, messages);
379
+ const titleId = react.useId();
380
+ const descId = react.useId();
381
+ const view = react.useMemo(() => storefront.promoProgressView(promotions, locale, messages).discount, [promotions, locale, messages]);
382
+ const { ref, onBackdropClick } = useModalDialog({ open, onClose: (r) => onSkip(r === "esc" ? "esc" : r === "x" ? "x" : "backdrop") });
383
+ const nextTier = promotions?.next_tier ?? null;
384
+ const max = !!promotions && !nextTier && !!promotions.applied;
385
+ if (!open) return null;
386
+ const f = storefront.formatAvailabilityMessage;
387
+ return /* @__PURE__ */ jsxRuntime.jsx("dialog", { ref, className, "aria-labelledby": titleId, "aria-describedby": descId, onClick: onBackdropClick, "data-sf-crosssell": true, children: /* @__PURE__ */ jsxRuntime.jsxs("div", { "data-sf-crosssell-body": true, children: [
388
+ /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", onClick: () => onSkip("x"), "aria-label": m["popup.close"], "data-sf-crosssell-close": true, style: { minWidth: 44, minHeight: 44 }, children: /* @__PURE__ */ jsxRuntime.jsx("span", { "aria-hidden": "true", children: "\xD7" }) }),
389
+ /* @__PURE__ */ jsxRuntime.jsx("p", { "data-sf-crosssell-eyebrow": true, children: m["upsell.subtitle"] }),
390
+ /* @__PURE__ */ jsxRuntime.jsx("h2", { id: titleId, tabIndex: -1, "data-sf-autofocus": true, children: m["upsell.title"] }),
391
+ /* @__PURE__ */ jsxRuntime.jsxs("section", { id: descId, "data-sf-crosssell-level": true, "aria-live": "polite", children: [
392
+ nextTier ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
393
+ /* @__PURE__ */ jsxRuntime.jsx("p", { "data-sf-crosssell-next": true, children: m["upsell.next_level"] }),
394
+ /* @__PURE__ */ jsxRuntime.jsx("p", { children: /* @__PURE__ */ jsxRuntime.jsx("strong", { children: f(m["upsell.you_miss"], { remaining: storefront.formatMoney(nextTier.remaining, locale, { withoutTrailingZeros: true }) }) }) }),
395
+ /* @__PURE__ */ jsxRuntime.jsx("p", { children: f(m["upsell.unlocks_reward"], { reward: storefront.rewardLabel(nextTier.reward, locale, messages) }) })
396
+ ] }) : max && promotions?.applied ? /* @__PURE__ */ jsxRuntime.jsx("p", { "data-sf-crosssell-best": true, children: f(m["upsell.best"], { reward: storefront.rewardLabel(promotions.applied.reward, locale, messages) }) }) : null,
397
+ promotions?.applied && nextTier && /* @__PURE__ */ jsxRuntime.jsx("p", { "data-sf-crosssell-unlocked": true, children: f(m["upsell.unlocked"], { reward: storefront.rewardLabel(promotions.applied.reward, locale, messages) }) })
398
+ ] }),
399
+ /* @__PURE__ */ jsxRuntime.jsxs("dl", { "data-sf-crosssell-summary": true, children: [
400
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { children: [
401
+ /* @__PURE__ */ jsxRuntime.jsx("dt", { children: m["upsell.total_saved"] }),
402
+ /* @__PURE__ */ jsxRuntime.jsx("dd", { children: storefront.formatMoney(totalSaved, locale, { withoutTrailingZeros: true }) })
403
+ ] }),
404
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { children: [
405
+ /* @__PURE__ */ jsxRuntime.jsx("dt", { children: m["upsell.current_benefit"] }),
406
+ /* @__PURE__ */ jsxRuntime.jsx("dd", { children: storefront.rewardLabel(promotions?.applied?.reward ?? { kind: "percent", percent: "0" }, locale, messages) })
407
+ ] }),
408
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { children: [
409
+ /* @__PURE__ */ jsxRuntime.jsx("dt", { children: m["upsell.status"] }),
410
+ /* @__PURE__ */ jsxRuntime.jsx("dd", { children: promotions?.applied ? m["upsell.status_active"] : m["upsell.status_pending"] })
411
+ ] })
412
+ ] }),
413
+ view && /* @__PURE__ */ jsxRuntime.jsx(PromoProgressBar, { kind: "discount", state: view.state, text: view.text, percent: view.percent, label: m["bar.discount"], milestones: view.tiers }),
414
+ nextTier && /* @__PURE__ */ jsxRuntime.jsx("p", { "data-sf-crosssell-next-reward": true, children: f(m["upsell.next_reward"], {
415
+ reward: storefront.rewardLabel(nextTier.reward, locale, messages),
416
+ threshold: storefront.formatMoney(nextTier.threshold, locale, { withoutTrailingZeros: true })
417
+ }) }),
418
+ !max && recommendations.length > 0 && /* @__PURE__ */ jsxRuntime.jsxs("section", { "aria-label": m["upsell.suggestions"], children: [
419
+ /* @__PURE__ */ jsxRuntime.jsx("h3", { children: m["upsell.suggestions"] }),
420
+ /* @__PURE__ */ jsxRuntime.jsx("ul", { "data-sf-crosssell-cards": true, children: recommendations.map((rec) => /* @__PURE__ */ jsxRuntime.jsx(RecommendationCard, { rec, onAdd, locale, messages, ...renderImage ? { renderImage } : {} }, rec.product.id)) })
421
+ ] }),
422
+ /* @__PURE__ */ jsxRuntime.jsx("p", { "data-sf-crosssell-footer": true, children: m["upsell.footer"] }),
423
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { "data-sf-crosssell-actions": true, children: [
424
+ /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", onClick: () => onSkip("skip"), style: { minHeight: 44 }, "data-sf-crosssell-skip": true, children: m["upsell.skip"] }),
425
+ /* @__PURE__ */ jsxRuntime.jsxs("button", { type: "button", onClick: onContinue, style: { minHeight: 44 }, "data-sf-crosssell-continue": true, children: [
426
+ m["upsell.continue"],
427
+ " \u2192"
428
+ ] })
429
+ ] })
430
+ ] }) });
431
+ }
432
+ function RecommendationCard({
433
+ rec,
434
+ onAdd,
435
+ locale,
436
+ messages,
437
+ renderImage
438
+ }) {
439
+ const m = storefront.promotionMessages(locale, messages);
440
+ const selectId = react.useId();
441
+ const [variantId, setVariantId] = react.useState(rec.variants.some((v) => v.id === rec.default_variant_id) ? rec.default_variant_id : rec.variants[0]?.id ?? "");
442
+ const [busy, setBusy] = react.useState(false);
443
+ const [error, setError] = react.useState(null);
444
+ const inFlight = react.useRef(false);
445
+ const variant = rec.variants.find((v) => v.id === variantId) ?? rec.variants[0];
446
+ const image = rec.product.featured_image;
447
+ if (variant && variant.id !== variantId) setVariantId(variant.id);
448
+ if (!variant) return null;
449
+ async function add() {
450
+ if (inFlight.current || !variant) return;
451
+ inFlight.current = true;
452
+ setBusy(true);
453
+ setError(null);
454
+ try {
455
+ const r = await onAdd({ productId: rec.product.id, variantId: variant.id, recommendation: rec });
456
+ if (r && (r.ok === false || r.error)) setError(r.error === "sold_out" ? m["upsell.sold_out"] : m["upsell.error"]);
457
+ } catch {
458
+ setError(m["upsell.error"]);
459
+ } finally {
460
+ inFlight.current = false;
461
+ setBusy(false);
462
+ }
463
+ }
464
+ return /* @__PURE__ */ jsxRuntime.jsxs("li", { "data-sf-crosssell-card": true, children: [
465
+ renderImage ? renderImage(rec) : image ? /* @__PURE__ */ jsxRuntime.jsx("img", { src: image.url, alt: image.alt ?? rec.product.title, width: 160, loading: "lazy", decoding: "async" }) : null,
466
+ rec.unlocks && /* @__PURE__ */ jsxRuntime.jsx("p", { "data-sf-crosssell-unlocks": true, children: storefront.formatAvailabilityMessage(m["upsell.unlocks_badge"], { reward: storefront.rewardLabel(rec.unlocks.reward, locale, messages) }) }),
467
+ /* @__PURE__ */ jsxRuntime.jsx("p", { "data-sf-crosssell-name": true, children: rec.product.title }),
468
+ /* @__PURE__ */ jsxRuntime.jsx("p", { "data-sf-crosssell-price": true, children: storefront.formatMoney(variant.price, locale, { withoutTrailingZeros: true }) }),
469
+ rec.variants.length > 1 && /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
470
+ /* @__PURE__ */ jsxRuntime.jsx("label", { htmlFor: selectId, children: m["upsell.flavor"] }),
471
+ /* @__PURE__ */ jsxRuntime.jsx("select", { id: selectId, value: variant.id, onChange: (e) => setVariantId(e.target.value), style: { minHeight: 44 }, children: rec.variants.map((v) => /* @__PURE__ */ jsxRuntime.jsx("option", { value: v.id, children: v.title }, v.id)) })
472
+ ] }),
473
+ /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", onClick: () => void add(), "aria-busy": busy, "aria-disabled": busy, style: { minHeight: 44 }, "data-sf-crosssell-add": true, children: busy ? m["upsell.adding"] : m["upsell.add"] }),
474
+ error && /* @__PURE__ */ jsxRuntime.jsx("p", { role: "alert", "data-sf-crosssell-error": true, children: error })
475
+ ] });
476
+ }
477
+ function OrderNote({ value, onSave, debounceMs = 800, form, locale: localeProp, messages, className, maxLength = storefront.NOTE_MAX_LENGTH }) {
478
+ const locale = useResolvedLocale(localeProp);
479
+ const m = storefront.promotionMessages(locale, messages);
480
+ const id = react.useId();
481
+ const [text, setText] = react.useState(value ?? "");
482
+ const [status, setStatus] = react.useState("idle");
483
+ const [hydrated, setHydrated] = react.useState(false);
484
+ const saved = react.useRef(storefront.normalizeNote(value));
485
+ const timer = react.useRef(null);
486
+ const seq = react.useRef(0);
487
+ const latest = react.useRef({ text, onSave });
488
+ latest.current = { text, onSave };
489
+ react.useEffect(() => setHydrated(true), []);
490
+ react.useEffect(() => {
491
+ if (status === "dirty" || status === "saving") return;
492
+ const incoming = value ?? "";
493
+ if (storefront.normalizeNote(incoming) !== storefront.normalizeNote(latest.current.text)) setText(incoming);
494
+ saved.current = storefront.normalizeNote(value);
495
+ }, [value]);
496
+ react.useEffect(() => () => void (timer.current && clearTimeout(timer.current)), []);
497
+ async function flush() {
498
+ if (timer.current) clearTimeout(timer.current);
499
+ timer.current = null;
500
+ const next = storefront.normalizeNote(latest.current.text);
501
+ if (next === saved.current) {
502
+ setStatus((s) => s === "dirty" ? "idle" : s);
503
+ return;
504
+ }
505
+ const mine = ++seq.current;
506
+ setStatus("saving");
507
+ try {
508
+ await latest.current.onSave(next);
509
+ if (mine !== seq.current) return;
510
+ saved.current = next;
511
+ setStatus("saved");
512
+ } catch {
513
+ if (mine === seq.current) setStatus("error");
514
+ }
515
+ }
516
+ function onChange(v) {
517
+ setText(v.slice(0, maxLength));
518
+ setStatus("dirty");
519
+ if (debounceMs !== false) {
520
+ if (timer.current) clearTimeout(timer.current);
521
+ timer.current = setTimeout(() => void flush(), debounceMs);
522
+ }
523
+ }
524
+ const statusText = status === "saving" ? m["note.saving"] : status === "saved" ? m["note.saved"] : status === "error" ? m["note.error"] : "";
525
+ const field = /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
526
+ /* @__PURE__ */ jsxRuntime.jsx("label", { htmlFor: id, children: m["note.label"] }),
527
+ /* @__PURE__ */ jsxRuntime.jsx(
528
+ "textarea",
529
+ {
530
+ id,
531
+ name: "note",
532
+ value: text,
533
+ maxLength,
534
+ placeholder: m["note.placeholder"],
535
+ onChange: (e) => onChange(e.target.value),
536
+ onBlur: () => void flush(),
537
+ "aria-describedby": `${id}-s`,
538
+ rows: 3
539
+ }
540
+ ),
541
+ /* @__PURE__ */ jsxRuntime.jsx("p", { id: `${id}-c`, "data-sf-note-counter": true, children: storefront.formatAvailabilityMessage(m["note.counter"], { n: text.length, max: maxLength }) }),
542
+ /* @__PURE__ */ jsxRuntime.jsx("p", { id: `${id}-s`, role: status === "error" ? "alert" : "status", "aria-live": "polite", "data-sf-note-status": status, children: statusText })
543
+ ] });
544
+ if (!form) return /* @__PURE__ */ jsxRuntime.jsx("div", { className, "data-sf-order-note": true, children: field });
545
+ return /* @__PURE__ */ jsxRuntime.jsxs("form", { method: form.method ?? "post", ...form.action ? { action: form.action } : {}, className, "data-sf-order-note": true, onSubmit: (e) => hydrated && e.preventDefault(), children: [
546
+ /* @__PURE__ */ jsxRuntime.jsx("input", { type: "hidden", name: form.intentName ?? "intent", value: form.intent ?? "note" }),
547
+ field,
548
+ !hydrated && /* @__PURE__ */ jsxRuntime.jsx("button", { type: "submit", children: m["note.save"] })
549
+ ] });
550
+ }
551
+
552
+ exports.CartProgressBars = CartProgressBars;
553
+ exports.CrossSellModal = CrossSellModal;
554
+ exports.DEFAULT_PROMO_EXCLUDED_PATHS = DEFAULT_PROMO_EXCLUDED_PATHS;
555
+ exports.DEFAULT_PROMO_TRIGGER = DEFAULT_PROMO_TRIGGER;
556
+ exports.OrderNote = OrderNote;
557
+ exports.PROMO_COOKIE = PROMO_COOKIE;
558
+ exports.PromoPopup = PromoPopup;
559
+ exports.PromoProgressBar = PromoProgressBar;
560
+ exports.browserSeenStore = browserSeenStore;
561
+ exports.frequencyAllows = frequencyAllows;
562
+ exports.isCrawler = isCrawler;
563
+ exports.isExcludedPath = isExcludedPath;
564
+ exports.memorySeenStore = memorySeenStore;
565
+ exports.parseSeen = parseSeen;
566
+ exports.resolveTrigger = resolveTrigger;
567
+ exports.safeInternalPath = safeInternalPath;
568
+ exports.scrollDepthPercent = scrollDepthPercent;
569
+ exports.serializeSeen = serializeSeen;
570
+ exports.stripMarketPrefix = stripMarketPrefix;
571
+ exports.withinWindow = withinWindow;
572
+ //# sourceMappingURL=promotions.cjs.map
573
+ //# sourceMappingURL=promotions.cjs.map