wineater-bot 4.23.3 → 4.24.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,105 @@
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
+ }
@@ -0,0 +1,66 @@
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
+ });
@@ -0,0 +1,150 @@
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
+ }
@@ -5,15 +5,25 @@ import type { StoreConfig } from "./types";
5
5
  import { deserializeStoreConfig } from "./deserialize";
6
6
  import type { ProjectUiConfig } from "../ui-config/types";
7
7
  import { getProjectUiConfig } from "../ui-config/get-config";
8
- import { setStoreConsentPolicy } from "../profile/consent.svelte";
8
+ import { setConsentGate, setStoreConsentPolicy } from "../profile/consent.svelte";
9
+ import translationsJson from "../i18n/translations.json";
10
+ import { loadShopifyPrivacyApi, pickLanguage, shopifyAnalyticsAllowed, shopifyContext, type ShopifyContext } from "../shopify/shopify";
9
11
 
10
12
  export type AppState = {
11
13
  storeConfig: StoreConfig | null;
12
14
  currentLanguage: LanguageCode;
13
15
  isConfigLoaded: boolean;
14
16
  uiConfig: ProjectUiConfig;
17
+ /** Shopify mode: false once the shop has used up its monthly searches */
18
+ searchAvailable: boolean;
15
19
  };
16
20
 
21
+ // Shopify mode: the theme's own font and the accent color from the theme editor, no web fonts
22
+ function shopifyUiConfig(sf: ShopifyContext): ProjectUiConfig {
23
+ const color = sf.color || '#7B2BEB';
24
+ return { ...getProjectUiConfig(''), brandColor: color, wineaterColor: color, customCss: '' };
25
+ }
26
+
17
27
  const STORE_CONFIG_MAX_AGE_MS = 24 * 60 * 60 * 1000;
18
28
 
19
29
  let state = $state<AppState>({
@@ -21,10 +31,13 @@ let state = $state<AppState>({
21
31
  currentLanguage: 'en',
22
32
  isConfigLoaded: false,
23
33
  uiConfig: null,
34
+ searchAvailable: true,
24
35
  });
25
36
 
26
37
  export const appStore = {
27
38
  getLanguageFromURL(): LanguageCode | null {
39
+ // a Shopify storefront owns its URL and its locale
40
+ if (shopifyContext()) return null;
28
41
  const params = new URLSearchParams(window.location.search);
29
42
  const lang = params.get('lang');
30
43
  return lang as LanguageCode | null;
@@ -46,6 +59,10 @@ export const appStore = {
46
59
  setStoreLanguage(lang: LanguageCode) {
47
60
  if (state.storeConfig && state.storeConfig.available_languages.includes(lang)) {
48
61
  state.currentLanguage = lang;
62
+ if (shopifyContext()) {
63
+ wineaterApi.setLanguage(lang);
64
+ return;
65
+ }
49
66
  const params = new URLSearchParams(window.location.search);
50
67
  params.set('lang', lang);
51
68
  const newUrl = `${window.location.pathname}?${params.toString()}`;
@@ -73,7 +90,12 @@ export const appStore = {
73
90
  // fallback to store default language
74
91
  this.setStoreLanguage(state.storeConfig.lang as LanguageCode);
75
92
  }
76
- this.setUiConfig(getProjectUiConfig(state.storeConfig.id));
93
+ const sf = shopifyContext();
94
+ this.setUiConfig(
95
+ sf
96
+ ? { ...shopifyUiConfig(sf), titleText: state.storeConfig.title || null }
97
+ : getProjectUiConfig(state.storeConfig.id)
98
+ );
77
99
  setStoreConsentPolicy(state.storeConfig.requires_cookie_consent);
78
100
  state.isConfigLoaded = true;
79
101
  },
@@ -81,6 +103,8 @@ export const appStore = {
81
103
  // paint without waiting for the network) and refreshed in the background
82
104
  // for the next visit. Without a cache we wait for the network as before.
83
105
  async initializeStore() {
106
+ const sf = shopifyContext();
107
+ if (sf) return this.initializeShopify(sf);
84
108
  // :v2 — конфиг получил requires_cookie_consent; старый кэш (24 ч) его не знает
85
109
  const cacheKey = `${cacheScope}:store-config:v2`;
86
110
  const fetchAndCache = async () => {
@@ -105,6 +129,37 @@ export const appStore = {
105
129
  state.isConfigLoaded = false;
106
130
  }
107
131
  },
132
+ // Shopify mode: the embed passes the public config (app-data metafield), so the first paint
133
+ // needs no request; without it we ask /shopify/storefront/config once.
134
+ async initializeShopify(sf: ShopifyContext) {
135
+ setConsentGate(shopifyAnalyticsAllowed);
136
+ loadShopifyPrivacyApi();
137
+ try {
138
+ const cfg = sf.config?.lang ? sf.config : await wineaterApi.getShopifyConfig();
139
+ const supported = Object.keys(translationsJson);
140
+ const lang = pickLanguage(sf.locale, cfg?.lang, supported);
141
+ this.applyStoreConfig({
142
+ id: `shopify:${sf.shop}`,
143
+ type: cfg?.widget_type ?? 'store',
144
+ lang,
145
+ available_languages: [lang],
146
+ open_ai_key: null,
147
+ currency: sf.currency || cfg?.currency || 'USD',
148
+ title: cfg?.title ?? '',
149
+ requires_cookie_consent: true,
150
+ });
151
+ state.searchAvailable = cfg?.search_available !== false;
152
+ } catch (error) {
153
+ console.error('Failed to initialize store:', error);
154
+ state.isConfigLoaded = false;
155
+ }
156
+ },
157
+ isSearchAvailable(): boolean {
158
+ return state.searchAvailable;
159
+ },
160
+ setSearchAvailable(available: boolean) {
161
+ state.searchAvailable = available;
162
+ },
108
163
  isInitialized(): boolean {
109
164
  return state.isConfigLoaded;
110
165
  },