wineater-bot 4.24.1 → 4.24.2

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 (57) hide show
  1. package/package.json +5 -1
  2. package/.claude/settings.local.json +0 -10
  3. package/.prettierignore +0 -9
  4. package/.prettierrc +0 -16
  5. package/CLAUDE.md +0 -115
  6. package/docs/CUSTOMIZATION.md +0 -133
  7. package/docs/SVELTE.md +0 -146
  8. package/e2e/demo.test.ts +0 -6
  9. package/eslint.config.js +0 -41
  10. package/index.html +0 -23
  11. package/playwright.config.ts +0 -9
  12. package/pnpm-workspace.yaml +0 -2
  13. package/src/App.svelte +0 -784
  14. package/src/App.svelte.test.ts +0 -13
  15. package/src/assets/biobio.png +0 -0
  16. package/src/components/CookieConsent.svelte +0 -73
  17. package/src/components/Footer.svelte +0 -122
  18. package/src/components/Header.svelte +0 -99
  19. package/src/components/Search.svelte +0 -177
  20. package/src/components/SkeletonTiles.svelte +0 -94
  21. package/src/components/WineBottle.svelte +0 -56
  22. package/src/components/WinePopup.svelte +0 -192
  23. package/src/components/WineTile.svelte +0 -458
  24. package/src/components/icons/Budget.svelte +0 -35
  25. package/src/components/icons/Diamond.svelte +0 -35
  26. package/src/components/icons/Fish.svelte +0 -56
  27. package/src/components/icons/Heart.svelte +0 -28
  28. package/src/components/icons/Meat.svelte +0 -32
  29. package/src/components/ui/Action.svelte +0 -83
  30. package/src/demo.spec.ts +0 -13
  31. package/src/index.js +0 -7
  32. package/src/services/analytics/analytics.ts +0 -142
  33. package/src/services/analytics/constants.ts +0 -18
  34. package/src/services/api/api.ts +0 -298
  35. package/src/services/api/deserialize.ts +0 -120
  36. package/src/services/bottle/bottle.spec.ts +0 -32
  37. package/src/services/bottle/bottle.ts +0 -78
  38. package/src/services/cache.ts +0 -28
  39. package/src/services/i18n/i18n.ts +0 -146
  40. package/src/services/i18n/translations.json +0 -658
  41. package/src/services/profile/consent.svelte.ts +0 -115
  42. package/src/services/profile/events.ts +0 -73
  43. package/src/services/profile/uid.ts +0 -87
  44. package/src/services/shopify/cart.ts +0 -105
  45. package/src/services/shopify/shopify.spec.ts +0 -66
  46. package/src/services/shopify/shopify.ts +0 -150
  47. package/src/services/store/app-store.svelte.ts +0 -172
  48. package/src/services/store/deserialize.ts +0 -25
  49. package/src/services/store/types.ts +0 -12
  50. package/src/services/ui-config/get-config.ts +0 -25
  51. package/src/services/ui-config/projects-config.ts +0 -156
  52. package/src/services/ui-config/types.ts +0 -15
  53. package/src/services/ui-config/utils.ts +0 -40
  54. package/tsconfig.json +0 -23
  55. package/vite.config.js +0 -84
  56. package/vite.config.js.timestamp-1772121492941-e00587396dc48.mjs +0 -71
  57. package/vitest-setup-client.ts +0 -2
@@ -1,115 +0,0 @@
1
- // Cookie / tracking consent (GDPR, ePrivacy).
2
- //
3
- // The store decides whether consent is required: `Stores.requires_cookie_consent`
4
- // arrives with the store config (true for EU shops). A host page can force it
5
- // with `window.wineaterData.requireConsent = true`.
6
- //
7
- // Until the visitor accepts, the widget creates no identifiers: no
8
- // taste-profile uid (localStorage + cookie), no `uniqueUserID`/`sessionID`
9
- // cookies, no /events. admin-api analytics is still sent, but anonymised
10
- // (no ids, no user agent/screen) — see trackWidgetEvent. Search works as
11
- // usual, just not personalised. Declining also wipes identifiers created earlier.
12
- //
13
- // The choice itself is kept in localStorage — strictly necessary, so it is
14
- // allowed without consent.
15
- //
16
- // Leaf module on purpose: no imports. appStore pushes the store flag here via
17
- // setStoreConsentPolicy(); importing appStore from here created a cycle
18
- // (app-store → api → uid → consent → app-store) that crashed the bundle with a
19
- // TDZ error on load.
20
-
21
- export type ConsentChoice = 'granted' | 'denied';
22
-
23
- const CONSENT_KEY = 'wineater_consent';
24
- const IDENTIFIER_KEYS = ['wineater_uid', 'uniqueUserID', 'sessionID'];
25
-
26
- function readChoice(): ConsentChoice | null {
27
- try {
28
- const v = window.localStorage.getItem(CONSENT_KEY);
29
- return v === 'granted' || v === 'denied' ? v : null;
30
- } catch {
31
- return null;
32
- }
33
- }
34
-
35
- let choice = $state<ConsentChoice | null>(readChoice());
36
-
37
- /** null — store config not loaded yet (unknown whether consent is required). */
38
- let storeRequires = $state<boolean | null>(null);
39
-
40
- // Shopify storefronts have their own cookie banner (Customer Privacy API): there the host decides
41
- // and we never show ours.
42
- let externalGate: (() => boolean) | null = null;
43
-
44
- /** Hand the consent decision to the host platform (Shopify mode). */
45
- export function setConsentGate(gate: () => boolean): void {
46
- externalGate = gate;
47
- }
48
-
49
- /** Called by appStore once the store config is applied. */
50
- export function setStoreConsentPolicy(requiresConsent: boolean): void {
51
- storeRequires = requiresConsent;
52
- }
53
-
54
- /** Store (or host page) requires an explicit opt-in before any tracking. */
55
- export function consentRequired(): boolean {
56
- if ((window as any)?.wineaterData?.requireConsent === true) return true;
57
- return storeRequires === true;
58
- }
59
-
60
- export function consentChoice(): ConsentChoice | null {
61
- return choice;
62
- }
63
-
64
- /** Show the banner: consent needed and the visitor has not chosen yet. */
65
- export function shouldAskConsent(): boolean {
66
- if (externalGate) return false;
67
- return storeRequires !== null && consentRequired() && choice === null;
68
- }
69
-
70
- /**
71
- * Single gate for everything that identifies or tracks the visitor.
72
- * Before the store config is loaded we do not know whether consent is
73
- * required, so nothing is allowed yet — fail closed.
74
- */
75
- export function trackingAllowed(): boolean {
76
- if ((window as any)?.wineaterData?.tracking === false) return false;
77
- if (externalGate) return externalGate();
78
- if (storeRequires === null) return false;
79
- if (!consentRequired()) return true;
80
- return choice === 'granted';
81
- }
82
-
83
- function wipeIdentifiers(): void {
84
- for (const key of IDENTIFIER_KEYS) {
85
- try {
86
- window.localStorage.removeItem(key);
87
- } catch {
88
- /* blocked */
89
- }
90
- try {
91
- document.cookie = `${key}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
92
- } catch {
93
- /* blocked */
94
- }
95
- }
96
- }
97
-
98
- const listeners = new Set<(c: ConsentChoice) => void>();
99
-
100
- /** React to a choice (e.g. drop the in-memory uid after "decline"). */
101
- export function onConsentChange(fn: (c: ConsentChoice) => void): () => void {
102
- listeners.add(fn);
103
- return () => listeners.delete(fn);
104
- }
105
-
106
- export function setConsent(next: ConsentChoice): void {
107
- choice = next;
108
- try {
109
- window.localStorage.setItem(CONSENT_KEY, next);
110
- } catch {
111
- /* blocked — choice lives for this page only */
112
- }
113
- if (next === 'denied') wipeIdentifiers();
114
- for (const fn of listeners) fn(next);
115
- }
@@ -1,73 +0,0 @@
1
- // Implicit-feedback events for the taste profile → POST /events on the main
2
- // backend (not admin-api: that service is being retired). Events are buffered
3
- // and flushed every FLUSH_MS or on page hide, in one request, with
4
- // `keepalive` so the last batch survives navigation to the shop's product page.
5
- //
6
- // `search` is deliberately NOT sent from here: the backend records it itself
7
- // (it has the parsed query). We only send what the backend cannot see.
8
-
9
- import { getUid, trackingEnabled } from './uid';
10
-
11
- export type FeedbackEventType = 'shown' | 'click' | 'more' | 'similar' | 'add_to_cart' | 'buy_click';
12
-
13
- export type FeedbackEvent = {
14
- type: FeedbackEventType;
15
- query_id?: string;
16
- sku_ids?: string[];
17
- text?: string;
18
- position?: number;
19
- meta?: Record<string, unknown>;
20
- };
21
-
22
- const FLUSH_MS = 2000;
23
- const MAX_BATCH = 100;
24
-
25
- type Sender = (batch: { uid: string; events: FeedbackEvent[] }) => Promise<void>;
26
-
27
- let sender: Sender | null = null;
28
- let queue: FeedbackEvent[] = [];
29
- let timer: ReturnType<typeof setTimeout> | null = null;
30
- let listenersBound = false;
31
-
32
- export function initFeedback(send: Sender): void {
33
- sender = send;
34
- if (listenersBound || typeof document === 'undefined') return;
35
- listenersBound = true;
36
- document.addEventListener('visibilitychange', () => {
37
- if (document.visibilityState === 'hidden') void flushFeedback();
38
- });
39
- window.addEventListener('pagehide', () => void flushFeedback());
40
- }
41
-
42
- export function trackFeedback(event: FeedbackEvent): void {
43
- if (!trackingEnabled() || !sender) return;
44
- if (!event.sku_ids?.length && event.type !== 'more') return;
45
- queue.push(event);
46
- if (queue.length >= MAX_BATCH) {
47
- void flushFeedback();
48
- return;
49
- }
50
- if (!timer) timer = setTimeout(() => void flushFeedback(), FLUSH_MS);
51
- }
52
-
53
- export async function flushFeedback(): Promise<void> {
54
- if (timer) {
55
- clearTimeout(timer);
56
- timer = null;
57
- }
58
- if (!sender || queue.length === 0) return;
59
- const uid = getUid();
60
- if (!uid) {
61
- queue = [];
62
- return;
63
- }
64
- const events = queue.splice(0, MAX_BATCH);
65
- try {
66
- await sender({ uid, events });
67
- } catch (error) {
68
- // Feedback is best-effort: never surface it to the visitor, never retry
69
- // (a retry storm on a broken endpoint would hurt more than lost events).
70
- console.debug('[wineater] feedback flush failed:', (error as Error)?.message);
71
- }
72
- if (queue.length > 0) void flushFeedback();
73
- }
@@ -1,87 +0,0 @@
1
- // Anonymous visitor id for the taste profile (backend: docs/PROFILE.md).
2
- // UUID v4, persisted in localStorage with a cookie fallback; both can be
3
- // unavailable (private mode, blocked storage), in which case the id lives for
4
- // the page only and the backend simply sees a one-off visitor.
5
-
6
- import { trackingAllowed, onConsentChange } from './consent.svelte';
7
-
8
- const STORAGE_KEY = 'wineater_uid';
9
- const COOKIE_DAYS = 365;
10
-
11
- let memoryUid: string | null = null;
12
-
13
- function generateUuidV4(): string {
14
- const c = globalThis.crypto as Crypto | undefined;
15
- if (c?.randomUUID) return c.randomUUID();
16
- const bytes = new Uint8Array(16);
17
- if (c?.getRandomValues) c.getRandomValues(bytes);
18
- else for (let i = 0; i < 16; i++) bytes[i] = (Math.random() * 256) | 0;
19
- bytes[6] = (bytes[6] & 0x0f) | 0x40;
20
- bytes[8] = (bytes[8] & 0x3f) | 0x80;
21
- const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
22
- return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
23
- }
24
-
25
- const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
26
-
27
- function readCookie(name: string): string | null {
28
- try {
29
- const prefix = name + '=';
30
- for (const part of document.cookie.split(';')) {
31
- const c = part.trim();
32
- if (c.startsWith(prefix)) return decodeURIComponent(c.slice(prefix.length));
33
- }
34
- } catch {
35
- /* no document / blocked */
36
- }
37
- return null;
38
- }
39
-
40
- function writeCookie(name: string, value: string): void {
41
- try {
42
- const expires = new Date(Date.now() + COOKIE_DAYS * 864e5).toUTCString();
43
- document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/; SameSite=Lax; Secure`;
44
- } catch {
45
- /* blocked */
46
- }
47
- }
48
-
49
- /** tracking !== false и, если магазин требует, получено согласие (consent.svelte.ts). */
50
- export function trackingEnabled(): boolean {
51
- return trackingAllowed();
52
- }
53
-
54
- // "Отклонить" стирает id из storage — и из памяти, чтобы текущая страница
55
- // перестала слать заголовок сразу, а не после перезагрузки.
56
- onConsentChange((c) => {
57
- if (c === 'denied') memoryUid = null;
58
- });
59
-
60
- /** Stable anonymous id; same value for the whole page lifetime even if storage is blocked. */
61
- export function getUid(): string | null {
62
- if (!trackingEnabled()) return null;
63
- if (memoryUid) return memoryUid;
64
-
65
- let uid: string | null = null;
66
- try {
67
- uid = window.localStorage.getItem(STORAGE_KEY);
68
- } catch {
69
- /* blocked */
70
- }
71
- if (!uid || !UUID_RE.test(uid)) uid = readCookie(STORAGE_KEY);
72
- if (!uid || !UUID_RE.test(uid)) uid = generateUuidV4();
73
-
74
- try {
75
- window.localStorage.setItem(STORAGE_KEY, uid);
76
- } catch {
77
- /* quota / blocked */
78
- }
79
- if (readCookie(STORAGE_KEY) !== uid) writeCookie(STORAGE_KEY, uid);
80
-
81
- memoryUid = uid;
82
- return uid;
83
- }
84
-
85
- export function newQueryId(): string {
86
- return generateUuidV4();
87
- }
@@ -1,105 +0,0 @@
1
- // "Add to cart" on a Shopify storefront. Only ever runs on the shopper's click.
2
- // Order (phase 0 check on Horizon and Debut):
3
- // 1. Shopify.actions.updateCart + openCart — every Liquid storefront since 2026-06; the theme
4
- // refreshes its own cart UI. Checked at click time: the module loads at the end of the page.
5
- // 2. AJAX /cart/add.js with `sections` — older themes; Dawn-style cart drawer/notification
6
- // re-render from the returned sections, other themes get the usual cart events.
7
- // 3. Cart permalink /cart/<variant>:1?storefront=true — keeps the shopper on the cart page.
8
- // The cart attribute `_wineater_session` (hidden at checkout: leading underscore) ties the order
9
- // to the widget session for later attribution. It goes through /cart/update.js, which changes only
10
- // the keys it is given (updateCart's `attributes` would replace every other app's attributes).
11
-
12
- export type AddToCartResult = { ok: true; via: 'actions' | 'ajax' } | { ok: false; via: 'permalink' };
13
-
14
- const SESSION_ATTRIBUTE = '_wineater_session';
15
-
16
- function tagCart(root: string, sessionId: string): void {
17
- fetch(`${root}cart/update.js`, {
18
- method: 'POST',
19
- headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
20
- body: JSON.stringify({ attributes: { [SESSION_ATTRIBUTE]: sessionId } }),
21
- keepalive: true
22
- }).catch(() => {});
23
- }
24
-
25
- function sectionIds(): string[] {
26
- // Dawn and its descendants render these sections after an add
27
- const ids = ['cart-drawer', 'cart-icon-bubble', 'cart-notification-product', 'cart-notification-button', 'cart-live-region-text'];
28
- return ids.filter((id) => id === 'cart-drawer' || id === 'cart-icon-bubble' || document.getElementById(id));
29
- }
30
-
31
- function refreshThemeCart(res: any): void {
32
- const drawer = document.querySelector('cart-drawer') as any;
33
- if (drawer && typeof drawer.renderContents === 'function') {
34
- drawer.classList.remove('is-empty');
35
- drawer.renderContents(res);
36
- return;
37
- }
38
- const notification = document.querySelector('cart-notification') as any;
39
- if (notification && typeof notification.renderContents === 'function') {
40
- notification.renderContents(res);
41
- return;
42
- }
43
- const bubble = document.getElementById('cart-icon-bubble');
44
- if (bubble && res?.sections?.['cart-icon-bubble']) {
45
- const doc = new DOMParser().parseFromString(res.sections['cart-icon-bubble'], 'text/html');
46
- bubble.innerHTML = (doc.querySelector('.shopify-section') || doc.body).innerHTML;
47
- }
48
- // themes that listen for their own refresh events
49
- document.dispatchEvent(new CustomEvent('cart:refresh', { bubbles: true }));
50
- document.documentElement.dispatchEvent(new CustomEvent('cart:refresh', { bubbles: true }));
51
- }
52
-
53
- async function viaActions(variantId: string): Promise<boolean> {
54
- const actions = (window as any).Shopify?.actions;
55
- if (!actions || typeof actions.updateCart !== 'function') return false;
56
- // resolves with userErrors instead of throwing (sold out, quantity rules…)
57
- const res = await actions.updateCart({
58
- lines: [{ merchandiseId: `gid://shopify/ProductVariant/${variantId}`, quantity: 1 }]
59
- });
60
- if (res?.userErrors?.length) throw new Error(res.userErrors[0]?.message || 'cart rejected the line');
61
- if (typeof actions.openCart === 'function') {
62
- try {
63
- await actions.openCart();
64
- } catch {
65
- /* the item is in the cart; the drawer just did not open */
66
- }
67
- }
68
- return true;
69
- }
70
-
71
- async function viaAjax(variantId: string, root: string): Promise<boolean> {
72
- const res = await fetch(`${root}cart/add.js`, {
73
- method: 'POST',
74
- headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
75
- body: JSON.stringify({
76
- items: [{ id: Number(variantId), quantity: 1 }],
77
- sections: sectionIds(),
78
- sections_url: window.location.pathname
79
- })
80
- });
81
- if (!res.ok) return false;
82
- refreshThemeCart(await res.json().catch(() => null));
83
- return true;
84
- }
85
-
86
- export async function addToCart(variantId: string, opts: { root: string; sessionId: string }): Promise<AddToCartResult> {
87
- try {
88
- if (await viaActions(variantId)) {
89
- tagCart(opts.root, opts.sessionId);
90
- return { ok: true, via: 'actions' };
91
- }
92
- } catch (error) {
93
- console.debug('[wineater] cart actions failed, trying ajax:', (error as Error)?.message);
94
- }
95
- try {
96
- if (await viaAjax(variantId, opts.root)) {
97
- tagCart(opts.root, opts.sessionId);
98
- return { ok: true, via: 'ajax' };
99
- }
100
- } catch (error) {
101
- console.debug('[wineater] cart ajax failed, using the cart link:', (error as Error)?.message);
102
- }
103
- window.location.href = `${opts.root}cart/${variantId}:1?storefront=true&attributes[${SESSION_ATTRIBUTE}]=${encodeURIComponent(opts.sessionId)}`;
104
- return { ok: false, via: 'permalink' };
105
- }
@@ -1,66 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { parseShopifyContext, pickLanguage, quotaErrorOf, variantIdOf } from './shopify';
3
-
4
- describe('parseShopifyContext', () => {
5
- it('needs a myshopify domain', () => {
6
- expect(parseShopifyContext({ shop: 'demo.myshopify.com' })?.shop).toBe('demo.myshopify.com');
7
- expect(parseShopifyContext({ shop: 'DEMO.myshopify.com ' })?.shop).toBe('demo.myshopify.com');
8
- expect(parseShopifyContext({ shop: 'demo.com' })).toBeNull();
9
- expect(parseShopifyContext({ shop: '' })).toBeNull();
10
- expect(parseShopifyContext(undefined)).toBeNull();
11
- expect(parseShopifyContext(null)).toBeNull();
12
- });
13
- it('keeps the storefront root and falls back to /', () => {
14
- expect(parseShopifyContext({ shop: 'a.myshopify.com', root: '/fr/' })?.root).toBe('/fr/');
15
- expect(parseShopifyContext({ shop: 'a.myshopify.com' })?.root).toBe('/');
16
- expect(parseShopifyContext({ shop: 'a.myshopify.com', root: 'fr' })?.root).toBe('/');
17
- });
18
- it('accepts only a hex accent color', () => {
19
- expect(parseShopifyContext({ shop: 'a.myshopify.com', color: '#7b2beb' })?.color).toBe('#7b2beb');
20
- expect(parseShopifyContext({ shop: 'a.myshopify.com', color: 'red;}body{display:none' })?.color).toBeNull();
21
- });
22
- });
23
-
24
- describe('variantIdOf', () => {
25
- it('reads the variant from the product url the backend builds', () => {
26
- expect(variantIdOf({ url: 'https://s.myshopify.com/products/x?variant=68290776793339', itemId: null })).toBe(
27
- '68290776793339'
28
- );
29
- expect(variantIdOf({ url: '/products/x?foo=1&variant=42', itemId: null })).toBe('42');
30
- });
31
- it('falls back to item_id (instant cards)', () => {
32
- expect(variantIdOf({ url: 'https://s.myshopify.com/products/x', itemId: '777' })).toBe('777');
33
- expect(variantIdOf({ url: null, itemId: '777' })).toBe('777');
34
- });
35
- it('accepts only numeric ids', () => {
36
- expect(variantIdOf({ url: '/products/x?variant=abc', itemId: 'sku-1' })).toBeNull();
37
- expect(variantIdOf({ url: null, itemId: null })).toBeNull();
38
- expect(variantIdOf({ url: 'not a url ?variant=12', itemId: null })).toBe('12');
39
- });
40
- });
41
-
42
- describe('pickLanguage', () => {
43
- const supported = ['en', 'es', 'fr', 'de', 'pt', 'ru'];
44
- it('prefers the storefront locale when the widget speaks it', () => {
45
- expect(pickLanguage('fr', 'en', supported)).toBe('fr');
46
- expect(pickLanguage('pt-BR', 'en', supported)).toBe('pt');
47
- expect(pickLanguage('ES', 'en', supported)).toBe('es');
48
- });
49
- it('falls back to the language set in the app, then English', () => {
50
- expect(pickLanguage('it', 'es', supported)).toBe('es');
51
- expect(pickLanguage(undefined, 'de', supported)).toBe('de');
52
- expect(pickLanguage('it', 'it', supported)).toBe('en');
53
- expect(pickLanguage(null, null, supported)).toBe('en');
54
- });
55
- });
56
-
57
- describe('quotaErrorOf', () => {
58
- it('recognises the plan limit answer', () => {
59
- expect(quotaErrorOf(429, { code: 'quota_exceeded', limit: 500, used: 500 })).toBe(true);
60
- });
61
- it('does not confuse it with rate limiting or other errors', () => {
62
- expect(quotaErrorOf(429, { code: 'rate_limited' })).toBe(false);
63
- expect(quotaErrorOf(500, { code: 'quota_exceeded' })).toBe(false);
64
- expect(quotaErrorOf(429, null)).toBe(false);
65
- });
66
- });
@@ -1,150 +0,0 @@
1
- // Shopify mode: the widget runs on a Shopify storefront, opened from the Wineater app embed.
2
- // The embed loader puts the shop context into `window.wineaterData.shopify` before it loads this
3
- // bundle. In this mode the widget talks to /shopify/storefront/* (the shop is the key, the
4
- // backend checks Origin), puts wines into the Shopify cart instead of linking out, and follows
5
- // the shop's own consent banner (Customer Privacy API) instead of showing ours.
6
- //
7
- // Leaf module: no imports from the store or the API, so both can use it.
8
-
9
- export type ShopifyConfig = {
10
- token?: string | null;
11
- lang?: string | null;
12
- currency?: string | null;
13
- widget_type?: string | null;
14
- title?: string | null;
15
- hide_without_image?: boolean;
16
- search_available?: boolean;
17
- };
18
-
19
- export type ShopifyContext = {
20
- /** permanent myshopify domain */
21
- shop: string;
22
- /** storefront locale (request.locale.iso_code) */
23
- locale: string | null;
24
- /** cart currency */
25
- currency: string | null;
26
- /** Shopify.routes.root: '/' or a market/locale prefix like '/fr/' */
27
- root: string;
28
- /** public widget config (app-data metafield or /shopify/storefront/config) */
29
- config: ShopifyConfig | null;
30
- /** accent color from the theme editor setting */
31
- color: string | null;
32
- };
33
-
34
- const MYSHOPIFY = /^[a-z0-9][a-z0-9-]*\.myshopify\.com$/;
35
-
36
- export function parseShopifyContext(raw: unknown): ShopifyContext | null {
37
- if (!raw || typeof raw !== 'object') return null;
38
- const r = raw as Record<string, unknown>;
39
- const shop = String(r.shop ?? '')
40
- .trim()
41
- .toLowerCase();
42
- if (!MYSHOPIFY.test(shop)) return null;
43
- const root = typeof r.root === 'string' && r.root.startsWith('/') ? r.root : '/';
44
- return {
45
- shop,
46
- locale: typeof r.locale === 'string' && r.locale ? r.locale : null,
47
- currency: typeof r.currency === 'string' && r.currency ? r.currency : null,
48
- root: root.endsWith('/') ? root : `${root}/`,
49
- config: r.config && typeof r.config === 'object' ? (r.config as ShopifyConfig) : null,
50
- color: typeof r.color === 'string' && /^#[0-9a-f]{3,8}$/i.test(r.color) ? r.color : null
51
- };
52
- }
53
-
54
- let cached: ShopifyContext | null | undefined;
55
-
56
- /** Shop context set by the embed loader; null on every non-Shopify page. */
57
- export function shopifyContext(): ShopifyContext | null {
58
- if (cached === undefined) {
59
- cached = typeof window === 'undefined' ? null : parseShopifyContext((window as any).wineaterData?.shopify);
60
- }
61
- return cached;
62
- }
63
-
64
- export const isShopifyMode = (): boolean => shopifyContext() !== null;
65
-
66
- /**
67
- * Shopify variant id of a card. Search cards carry the product url the backend builds from the
68
- * variant (`…/products/<handle>?variant=<id>`); instant cards also carry `item_id` = variant id.
69
- */
70
- export function variantIdOf(wine: { url?: string | null; itemId?: string | null }): string | null {
71
- const fromUrl = /[?&]variant=(\d+)/.exec(wine.url ?? '')?.[1];
72
- if (fromUrl) return fromUrl;
73
- const item = String(wine.itemId ?? '').trim();
74
- return /^\d+$/.test(item) ? item : null;
75
- }
76
-
77
- /** Storefront locale if the widget speaks it, else the language set in the app, else English. */
78
- export function pickLanguage(
79
- locale: string | null | undefined,
80
- configLang: string | null | undefined,
81
- supported: string[]
82
- ): string {
83
- const base = (l: string | null | undefined) => (l ?? '').toLowerCase().split('-')[0];
84
- for (const l of [base(locale), base(configLang)]) if (l && supported.includes(l)) return l;
85
- return 'en';
86
- }
87
-
88
- /** 429 from /shopify/storefront/search because the shop used up its monthly searches. */
89
- export function quotaErrorOf(status: number, body: unknown): boolean {
90
- return status === 429 && (body as { code?: unknown } | null)?.code === 'quota_exceeded';
91
- }
92
-
93
- export class QuotaExceededError extends Error {
94
- constructor() {
95
- super('quota_exceeded');
96
- this.name = 'QuotaExceededError';
97
- }
98
- }
99
-
100
- /**
101
- * Per-tab session for the storefront search: the backend needs it to tell a repeated question
102
- * from a new one (repeats within 10 minutes do not count toward the plan). Not a visitor id:
103
- * it lives in sessionStorage (or memory) and dies with the tab.
104
- */
105
- let memorySession: string | null = null;
106
- export function storefrontSessionId(): string {
107
- if (memorySession) return memorySession;
108
- let id: string | null = null;
109
- try {
110
- id = window.sessionStorage.getItem('wineater_sf_session');
111
- } catch {
112
- /* blocked */
113
- }
114
- if (!id) {
115
- id = globalThis.crypto?.randomUUID?.() ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
116
- try {
117
- window.sessionStorage.setItem('wineater_sf_session', id);
118
- } catch {
119
- /* blocked: the id lives for this page only */
120
- }
121
- }
122
- memorySession = id;
123
- return id;
124
- }
125
-
126
- /**
127
- * Shopify Customer Privacy API: the shop's own cookie banner decides whether we may identify the
128
- * visitor. Until the API answers (or if the shop never loads it) we treat tracking as not allowed:
129
- * analytics still go out, anonymised, as for every other shop.
130
- */
131
- export function shopifyAnalyticsAllowed(): boolean {
132
- try {
133
- const cp = (window as any).Shopify?.customerPrivacy;
134
- if (cp && typeof cp.analyticsProcessingAllowed === 'function') return cp.analyticsProcessingAllowed() === true;
135
- } catch {
136
- /* ignore */
137
- }
138
- return false;
139
- }
140
-
141
- /** Ask Shopify to load the Customer Privacy API (no-op if the theme already did). */
142
- export function loadShopifyPrivacyApi(): void {
143
- try {
144
- const s = (window as any).Shopify;
145
- if (s?.customerPrivacy || typeof s?.loadFeatures !== 'function') return;
146
- s.loadFeatures([{ name: 'consent-tracking-api', version: '0.1' }], () => {});
147
- } catch {
148
- /* ignore */
149
- }
150
- }