wineater-bot 4.23.2 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wineater-bot",
3
- "version": "4.23.2",
3
+ "version": "4.24.0",
4
4
  "description": "",
5
5
  "main": "dist/wineater-chatbot.mjs",
6
6
  "module": "src/index.js",
package/src/App.svelte CHANGED
@@ -18,6 +18,17 @@
18
18
  import { appStore } from './services/store/app-store.svelte';
19
19
  import { getTranslation, translateTexts } from './services/i18n/i18n';
20
20
  import { generateInlineStyle, preconnectFontHosts } from './services/ui-config/utils';
21
+ import {
22
+ QuotaExceededError,
23
+ shopifyContext,
24
+ storefrontSessionId,
25
+ variantIdOf
26
+ } from './services/shopify/shopify';
27
+ import { addToCart } from './services/shopify/cart';
28
+
29
+ // Shopify storefront (app embed): add to cart instead of a link out, no Wineater link,
30
+ // no cookies of our own, no web fonts; the shop's searches are limited by its plan.
31
+ const shopify = shopifyContext();
21
32
 
22
33
  function initCookies() {
23
34
  // chatbotToken — токен магазина, не данные посетителя. uniqueUserID
@@ -27,8 +38,10 @@
27
38
  }
28
39
 
29
40
  const DEMO_TOKEN = 'HeUw6IXuhvz6tRuXdPEGEbj3SGQ3Q2YtEc1Z8jd41Kg9LtyPb4Epu0to31SYIadG';
30
- initCookies();
31
- preconnectFontHosts();
41
+ if (!shopify) {
42
+ initCookies();
43
+ preconnectFontHosts();
44
+ }
32
45
 
33
46
  const INSTANT_CACHE_MAX_AGE_MS = 6 * 60 * 60 * 1000;
34
47
  // A personal start grid follows the taste profile, which changes with every
@@ -68,6 +81,10 @@
68
81
  // Один id на поиск: уходит в тело search/v2 и в каждое событие обратной
69
82
  // связи (shown/click/...), чтобы бэкенд связал клики с конкретной выдачей.
70
83
  let currentQueryId: string | null = null;
84
+ // Shopify: wines put into the cart from this widget (the button shows "Added")
85
+ let addedIds = $state<string[]>([]);
86
+ // Shopify: the shop used up its monthly searches — instant picks + a soft line instead of an error
87
+ let sommelierResting = $state(false);
71
88
 
72
89
  // Профиль вкуса: что показали и в каком порядке. Пустая выдача не событие.
73
90
  function feedbackShown(wines: (Wine | RecommendedWine)[], text: string) {
@@ -85,6 +102,7 @@
85
102
  let storeId = $derived(appStore.getStoreId());
86
103
  let isStoreInitialized = $derived(appStore.isInitialized());
87
104
  let uiConfig = $derived(appStore.getUiConfig());
105
+ let searchAvailable = $derived(appStore.isSearchAvailable());
88
106
 
89
107
  function setSearchError() {
90
108
  errorMessage = getTranslation({ key: 'errorMessage', lang: appLanguage });
@@ -169,6 +187,21 @@
169
187
  }
170
188
  }
171
189
 
190
+ // Shopify: searches used up — show the start picks again with a soft line, never an error
191
+ async function showRestingPicks() {
192
+ sommelierResting = true;
193
+ appStore.setSearchAvailable(false);
194
+ try {
195
+ const { wines } = await wineaterApi.getInstantRecommendations();
196
+ results = wines;
197
+ translatedPopups = [];
198
+ hasMore = false;
199
+ } catch (error) {
200
+ console.error('[wineater] instant recommendations error:', error);
201
+ results = [];
202
+ }
203
+ }
204
+
172
205
  // Простая функция-анимация: поднимаем карточки и ждём
173
206
  function animateClearAll(duration = 220): Promise<void> {
174
207
  if (!results || results.length === 0) return Promise.resolve();
@@ -224,7 +257,8 @@
224
257
  // Пагинация доступна только для search/v2 — у instant/legacy-путей нет
225
258
  // "more"-эндпоинта. Неполная выдача (< 4) означает, что кандидаты и так
226
259
  // кончились: кнопка в этом случае только мигнула бы впустую.
227
- hasMore = apiVersion === '2' && searchResults.length >= 4;
260
+ // в Shopify нет "more": каждый запрос считается в лимит плана
261
+ hasMore = apiVersion === '2' && !shopify && searchResults.length >= 4;
228
262
  feedbackShown(searchResults, query);
229
263
  trackWidgetEvent(WIDGET_EVENTS.SEARCH_RESULTS_DISPLAYED, storeId, {
230
264
  results_count: searchResults.length,
@@ -272,6 +306,10 @@
272
306
  }
273
307
  } catch (error) {
274
308
  if (myQueryId !== currentQueryId) return;
309
+ if (error instanceof QuotaExceededError) {
310
+ await showRestingPicks();
311
+ return;
312
+ }
275
313
  results = [];
276
314
  console.error('[wineater] search error:', error);
277
315
  trackWidgetEvent(WIDGET_EVENTS.ERROR_OCCURRED, storeId, {
@@ -419,6 +457,27 @@
419
457
  });
420
458
  }
421
459
 
460
+ // Shopify: the BUY button puts the bottle into the shop's cart (on click only)
461
+ async function handleAddToCart(wine: Wine | RecommendedWine, index: number) {
462
+ const variantId = variantIdOf(wine as { url?: string | null; itemId?: string | null });
463
+ if (!shopify || !variantId) return;
464
+ trackFeedback({ type: 'add_to_cart', query_id: currentQueryId ?? undefined, sku_ids: [wine.id], position: index });
465
+ trackWidgetEvent(WIDGET_EVENTS.BUY_BUTTON_CLICKED, storeId, {
466
+ action: 'add_to_cart',
467
+ wine_id: wine.id,
468
+ wine_title: wine.title,
469
+ wine_url: wine.url,
470
+ variant_id: variantId,
471
+ query: lastSearchedQuery || undefined,
472
+ is_mobile: window.innerWidth <= 768
473
+ });
474
+ const result = await addToCart(variantId, { root: shopify.root, sessionId: storefrontSessionId() });
475
+ if (!result.ok) return; // the cart page is opening
476
+ addedIds = [...addedIds, wine.id];
477
+ // the embed closes its panel so the theme's cart drawer is visible
478
+ window.dispatchEvent(new CustomEvent('wineater:added-to-cart', { detail: { variantId, via: result.via } }));
479
+ }
480
+
422
481
  function closePopup() {
423
482
  const wine = results.find((w) => w.id === selectedWineId);
424
483
  trackWidgetEvent(WIDGET_EVENTS.WINE_DETAILS_CLOSED, storeId, {
@@ -465,7 +524,9 @@
465
524
  }
466
525
 
467
526
  function handleClickOutside(event: MouseEvent) {
468
- const target = event.target as HTMLElement;
527
+ // inside a shadow root (Shopify embed) document listeners see the host as the target
528
+ const target = (event.composedPath?.()[0] ?? event.target) as HTMLElement;
529
+ if (!target?.closest) return;
469
530
  const isInsidePopup = target.closest('.js-WinePopup') || target.closest('.js-WineTileAction');
470
531
 
471
532
  if (!isInsidePopup) {
@@ -474,12 +535,16 @@
474
535
  }
475
536
 
476
537
  // Set CSS variable from config reactively
538
+ // Shopify: variables go on the shadow host, not on the theme's <html>
539
+ const cssVarTarget: HTMLElement =
540
+ ((window?.wineaterData?.mountTarget?.getRootNode?.() as ShadowRoot | undefined)?.host as HTMLElement) ||
541
+ document.documentElement;
477
542
  $effect(() => {
478
543
  if (uiConfig?.brandColor) {
479
- document.documentElement.style.setProperty('--wr-brand-color', uiConfig.brandColor);
544
+ cssVarTarget.style.setProperty('--wr-brand-color', uiConfig.brandColor);
480
545
  }
481
546
  if (uiConfig?.wineaterColor) {
482
- document.documentElement.style.setProperty('--wr-wineater-color', uiConfig.wineaterColor);
547
+ cssVarTarget.style.setProperty('--wr-wineater-color', uiConfig.wineaterColor);
483
548
  }
484
549
  });
485
550
 
@@ -504,6 +569,7 @@
504
569
  }
505
570
 
506
571
  initialized = true;
572
+ if (shopify && !searchAvailable) sommelierResting = true;
507
573
  // без согласия (EU-магазин) событие уходит обезличенно — не повторяем
508
574
  // его после «Принять», иначе инициализация посчиталась бы дважды
509
575
  trackWidgetEvent(WIDGET_EVENTS.WIDGET_INITIALIZED, storeId);
@@ -518,7 +584,7 @@
518
584
  {#if isStoreInitialized}
519
585
  <!-- wr-Custom is used only for custom styling -->
520
586
  <!-- id is used to increase specificity -->
521
- <div id="wr-Custom" class="wr-App">
587
+ <div id="wr-Custom" class="wr-App" class:wr-App--embedded={!!shopify}>
522
588
  {#if uiConfig.logoUrl}
523
589
  <div class="wr-App-picture">
524
590
  <img class="wr-App-logo" src={uiConfig.logoUrl} alt="" />
@@ -529,6 +595,11 @@
529
595
  <Search bind:searchQuery onSubmit={handleSearch} {initialized} />
530
596
 
531
597
  <div class="wr-App-content">
598
+ {#if sommelierResting && !errorMessage}
599
+ <p class="wr-App-resting" role="status">
600
+ {getTranslation({ key: 'sommelierResting', lang: appLanguage })}
601
+ </p>
602
+ {/if}
532
603
  {#if errorMessage}
533
604
  <div class="wr-App-error">
534
605
  <p>{errorMessage}</p>
@@ -545,8 +616,10 @@
545
616
  translatedPopup={translatedPopups[index]}
546
617
  onBuyClick={handleBuyBtnClick}
547
618
  onBuyLinkClick={handleBuyLinkClick}
619
+ onAddToCart={shopify ? handleAddToCart : undefined}
620
+ addedToCart={addedIds.includes(wine.id)}
548
621
  onPopupClose={closePopup}
549
- onSimilarClick={handleSimilarClick}
622
+ onSimilarClick={shopify ? undefined : handleSimilarClick}
550
623
  {onTileEnter}
551
624
  {onTileLeave}
552
625
  {onTileClick}
@@ -569,11 +642,13 @@
569
642
  <CookieConsent lang={appLanguage} />
570
643
  {/if}
571
644
 
572
- <Footer {countryCode} />
645
+ {#if !shopify}
646
+ <Footer {countryCode} />
647
+ {/if}
573
648
  </div>
574
649
  {:else if !configFailed}
575
650
  <!-- Shell with the same footprint as the widget while the store config loads -->
576
- <div class="wr-App wr-App--loading" aria-busy="true">
651
+ <div class="wr-App wr-App--loading" class:wr-App--embedded={!!shopify} aria-busy="true">
577
652
  <div class="wr-App-loadingTop"></div>
578
653
  <SkeletonTiles />
579
654
  </div>
@@ -616,6 +691,28 @@
616
691
  position: relative;
617
692
  }
618
693
 
694
+ /* Shopify: the embed's panel is the frame — fill it, no card of our own */
695
+ .wr-App.wr-App--embedded {
696
+ min-width: 0;
697
+ max-width: none;
698
+ width: 100%;
699
+ margin: 0;
700
+ box-shadow: none;
701
+ border-radius: 0;
702
+ min-height: 0;
703
+ padding-bottom: 12px;
704
+ }
705
+
706
+ .wr-App-resting {
707
+ margin: 0 0 8px;
708
+ padding: 10px 14px;
709
+ border-radius: 10px;
710
+ background: #f6f7fd;
711
+ color: #444;
712
+ font-size: 14px;
713
+ text-align: center;
714
+ }
715
+
619
716
  .wr-App-loadingTop {
620
717
  /* roughly the height of Header + Search, so tiles do not jump once they render */
621
718
  height: 150px;
@@ -669,6 +766,11 @@
669
766
  min-width: 100vw;
670
767
  }
671
768
 
769
+ .wr-App.wr-App--embedded {
770
+ min-width: 0;
771
+ padding: 0 12px 12px;
772
+ }
773
+
672
774
  .wr-App-placeholder {
673
775
  display: flex;
674
776
  gap: 8px;
@@ -7,6 +7,7 @@
7
7
  import Action from './ui/Action.svelte';
8
8
  import WineBottle from './WineBottle.svelte';
9
9
  import { hasImage } from '../services/bottle/bottle';
10
+ import { variantIdOf } from '../services/shopify/shopify';
10
11
 
11
12
  // Desktop grid has 4 columns: the first row is above the fold
12
13
  const FIRST_ROW_SIZE = 4;
@@ -26,6 +27,8 @@
26
27
  onTileClick,
27
28
  onBuyClick,
28
29
  onBuyLinkClick,
30
+ onAddToCart,
31
+ addedToCart = false,
29
32
  onPopupClose,
30
33
  onSimilarClick,
31
34
  isAnimating,
@@ -40,6 +43,9 @@
40
43
  onTileClick: (event: MouseEvent, wine: Wine | RecommendedWine) => void;
41
44
  onBuyClick: (wine: Wine | RecommendedWine, index: number) => void;
42
45
  onBuyLinkClick: (wine: Wine | RecommendedWine, index: number) => void;
46
+ /** Shopify mode: BUY puts the bottle into the shop's cart */
47
+ onAddToCart?: (wine: Wine | RecommendedWine, index: number) => void;
48
+ addedToCart?: boolean;
43
49
  onPopupClose: () => void;
44
50
  onSimilarClick?: (wine: Wine | RecommendedWine) => void;
45
51
  isAnimating: boolean;
@@ -78,6 +84,18 @@
78
84
  .filter(Boolean);
79
85
  }
80
86
  let isMobile = $state(false);
87
+ let canAddToCart = $derived(!!onAddToCart && !!variantIdOf(wine as { url?: string | null; itemId?: string | null }));
88
+ let adding = $state(false);
89
+
90
+ async function addToCartClick() {
91
+ if (adding || addedToCart) return;
92
+ adding = true;
93
+ try {
94
+ await onAddToCart?.(wine, index);
95
+ } finally {
96
+ adding = false;
97
+ }
98
+ }
81
99
 
82
100
  onMount(() => {
83
101
  const checkIsMobile = () => {
@@ -129,7 +147,13 @@
129
147
  </div>
130
148
  {/if}
131
149
 
132
- <h3 class="wr-WineTile-title">{wine.title}</h3>
150
+ {#if canAddToCart && wine.url}
151
+ <h3 class="wr-WineTile-title">
152
+ <a class="wr-WineTile-titleLink js-WineTileAction" href={wine.url}>{wine.title}</a>
153
+ </h3>
154
+ {:else}
155
+ <h3 class="wr-WineTile-title">{wine.title}</h3>
156
+ {/if}
133
157
  </header>
134
158
 
135
159
  {#if joinParams(wine, ['type', 'country', 'region', 'grape'])}
@@ -141,7 +165,15 @@
141
165
  <footer class="wr-WineTile-footer">
142
166
  <!-- We need this wrapper for top margin -->
143
167
  <div class="wr-WineTile-actions">
144
- {#if wine.url}
168
+ {#if canAddToCart}
169
+ <Action
170
+ handleClick={addToCartClick}
171
+ variant={addedToCart ? 'outlined' : 'primary'}
172
+ className="wr-WineTile-action wr-WineTile-action--cart js-WineTileAction"
173
+ >
174
+ {getTranslation({ key: addedToCart ? 'addedToCart' : 'addToCart', lang })}
175
+ </Action>
176
+ {:else if wine.url}
145
177
  <Action
146
178
  href={wine.url}
147
179
  handleClick={() => onBuyLinkClick(wine, index)}
@@ -275,6 +307,15 @@
275
307
  color: #000;
276
308
  }
277
309
 
310
+ .wr-WineTile-titleLink {
311
+ color: inherit;
312
+ text-decoration: none;
313
+ }
314
+
315
+ .wr-WineTile-titleLink:hover {
316
+ text-decoration: underline;
317
+ }
318
+
278
319
  .wr-WineTile-params {
279
320
  font-size: 14px;
280
321
  color: #666;
package/src/index.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import SvelteApp from './App.svelte'
2
2
  import { mount } from 'svelte';
3
3
 
4
- const app = mount(SvelteApp, { target: document.getElementById("wineater-widget-conteiner") });
4
+ // Shopify app embed passes its own target (inside a shadow root); everyone else uses the page div
5
+ const target = window.wineaterData?.mountTarget || document.getElementById("wineater-widget-conteiner");
6
+ const app = mount(SvelteApp, { target });
5
7
  export default app
@@ -1,4 +1,5 @@
1
1
  import { trackingAllowed } from '../profile/consent.svelte';
2
+ import { shopifyContext } from '../shopify/shopify';
2
3
 
3
4
  function generateUUID(): string {
4
5
  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
@@ -41,7 +42,14 @@ export function setUniqueUserCookie(): string {
41
42
  }
42
43
 
43
44
  export function trackWidgetEvent(event_type: string, store_id, data = {}, meta = {}) {
44
- const endpoint = 'https://admin-api.wineater.com/api/widget-events';
45
+ // Тот же маршрут бэкенда, что и на admin-api.wineater.com (wineater-api выключен, домен admin-api
46
+ // смотрит на бэкенд ради уже опубликованных версий — его не снимать, пока они живы).
47
+ // Shopify storefront: the backend finds the store by `shop` (store_id from the client is ignored)
48
+ const shop = shopifyContext()?.shop;
49
+ const endpoint = shop
50
+ ? 'https://api.wineater.com/shopify/storefront/events'
51
+ : 'https://api.wineater.com/api/widget-events';
52
+ const target = shop ? { shop } : {};
45
53
  const event_time = new Date().toISOString();
46
54
  const region = navigator.language.split('-')[1] || null;
47
55
  const language = navigator.language.split('-')[0] || 'en';
@@ -52,6 +60,7 @@ export function trackWidgetEvent(event_type: string, store_id, data = {}, meta =
52
60
  // можно опознать). Грубый класс устройства оставляем для статистики.
53
61
  if (!trackingAllowed()) {
54
62
  const anonymousPayload = {
63
+ ...target,
55
64
  event_type,
56
65
  store_id,
57
66
  event_time,
@@ -94,6 +103,7 @@ export function trackWidgetEvent(event_type: string, store_id, data = {}, meta =
94
103
  }
95
104
 
96
105
  const payload = {
106
+ ...target,
97
107
  event_type,
98
108
  store_id,
99
109
  session_id,
@@ -113,7 +123,9 @@ export function trackWidgetEvent(event_type: string, store_id, data = {}, meta =
113
123
  headers: {
114
124
  'Content-Type': 'application/json'
115
125
  },
116
- body: JSON.stringify(payload)
126
+ body: JSON.stringify(payload),
127
+ // Shopify: add to cart may fall back to the cart page right after this event
128
+ keepalive: !!shop
117
129
  })
118
130
  .then(response => {
119
131
  if (!response.ok) {
@@ -1,6 +1,7 @@
1
1
  import { deserializeInstantRecommendation, deserializeRecommendedWine, deserializeMoreRecommendations } from "./deserialize";
2
2
  import { getUid } from "../profile/uid";
3
3
  import { initFeedback, type FeedbackEvent } from "../profile/events";
4
+ import { QuotaExceededError, quotaErrorOf, shopifyContext, storefrontSessionId } from "../shopify/shopify";
4
5
 
5
6
  export type WineId = string;
6
7
  export type WinePrice = number;
@@ -39,6 +40,17 @@ export type RecommendedWine = Omit<Wine, "shortDescription" | "producer" | "pair
39
40
 
40
41
  const UID_HEADER = 'X-Wineater-Uid';
41
42
 
43
+ /** HTTP error with the status and parsed body (the message keeps the old format). */
44
+ export class ApiError extends Error {
45
+ status: number;
46
+ data: any;
47
+ constructor(status: number, data: any) {
48
+ super(`API call failed with status ${status}: ${data?.message || 'No additional error information'}`);
49
+ this.status = status;
50
+ this.data = data;
51
+ }
52
+ }
53
+
42
54
  class WineaterApi {
43
55
  #baseUrl = 'https://api.wineater.com/';
44
56
  #token: string;
@@ -47,6 +59,8 @@ class WineaterApi {
47
59
  // preflight rejected by a backend that does not allow it yet): we then
48
60
  // retry without the header and stop sending it for this page.
49
61
  #uidHeaderUnsupported = false;
62
+ // Shopify mode only: answers in the storefront language (set by appStore)
63
+ #language: string | null = null;
50
64
 
51
65
  constructor(token: string, apiVersion: string = '1') {
52
66
  this.#token = token;
@@ -93,9 +107,14 @@ class WineaterApi {
93
107
  }
94
108
 
95
109
  const text = await response.text();
96
- const data = text ? JSON.parse(text) : null;
110
+ let data = null;
111
+ try {
112
+ data = text ? JSON.parse(text) : null;
113
+ } catch {
114
+ if (response.ok) throw new Error('API returned invalid JSON');
115
+ }
97
116
  if (!response.ok) {
98
- throw new Error(`API call failed with status ${response.status}: ${data?.message || 'No additional error information'}`);
117
+ throw new ApiError(response.status, data);
99
118
  }
100
119
  return { data: data as T, headers: response.headers };
101
120
  }
@@ -105,10 +124,13 @@ class WineaterApi {
105
124
  // its instant cache by it, so a recomputed profile is picked up next visit.
106
125
  async getInstantRecommendations(): Promise<{ wines: Wine[]; profileHash: string | null }> {
107
126
  try {
108
- const endpoint = this.#apiVersion === '2'
109
- ? 'dish/recommendations/instant/v2'
110
- : 'dish/recommendations/instant';
111
- const { data, headers } = await this.#send<any[]>(endpoint, {});
127
+ const shop = shopifyContext()?.shop;
128
+ const endpoint = shop
129
+ ? 'shopify/storefront/instant'
130
+ : this.#apiVersion === '2'
131
+ ? 'dish/recommendations/instant/v2'
132
+ : 'dish/recommendations/instant';
133
+ const { data, headers } = await this.#send<any[]>(endpoint, shop ? this.#shopBody({}) : {});
112
134
  return {
113
135
  wines: data.filter(Boolean).map(deserializeInstantRecommendation),
114
136
  profileHash: headers.get('X-Wineater-Profile') || null,
@@ -121,6 +143,7 @@ class WineaterApi {
121
143
  // `queryId` is generated by the widget per search and echoed in feedback
122
144
  // events, so the backend can tie clicks to the exact result set.
123
145
  async getWineRecommendationsSearch(text: string, queryId?: string): Promise<RecommendedWine[]> {
146
+ if (shopifyContext()) return this.#shopifySearch(text, queryId);
124
147
  try {
125
148
  const endpoint = this.#apiVersion === '2'
126
149
  ? 'dish/recommendations/search/v2'
@@ -145,7 +168,8 @@ class WineaterApi {
145
168
  onWines: (wines: RecommendedWine[]) => void,
146
169
  ): Promise<RecommendedWine[]> {
147
170
  const fallback = () => this.getWineRecommendationsSearch(text, queryId);
148
- if (this.#apiVersion !== '2') return fallback();
171
+ // the Shopify storefront search counts toward the plan and has no stream
172
+ if (this.#apiVersion !== '2' || shopifyContext()) return fallback();
149
173
 
150
174
  let response: Response;
151
175
  try {
@@ -218,6 +242,34 @@ class WineaterApi {
218
242
  await this.#request('events', batch, { keepalive: true });
219
243
  }
220
244
 
245
+ setLanguage(lang: string | null) {
246
+ this.#language = lang;
247
+ }
248
+
249
+ #shopBody<T extends object>(body: T): T & { shop: string; language?: string } {
250
+ const shop = shopifyContext()!.shop;
251
+ return this.#language ? { ...body, shop, language: this.#language } : { ...body, shop };
252
+ }
253
+
254
+ // Shopify storefront search: the backend counts it toward the shop's plan and answers
255
+ // 429 quota_exceeded once the monthly searches are used up.
256
+ async #shopifySearch(text: string, queryId?: string): Promise<RecommendedWine[]> {
257
+ try {
258
+ const body = this.#shopBody({ text, session_id: storefrontSessionId(), ...(queryId ? { query_id: queryId } : {}) });
259
+ const data = await this.#request<any[]>('shopify/storefront/search', body);
260
+ return (data ?? []).filter(Boolean).map(deserializeRecommendedWine);
261
+ } catch (error) {
262
+ if (error instanceof ApiError && quotaErrorOf(error.status, error.data)) throw new QuotaExceededError();
263
+ throw new Error(`Error in getWineRecommendations: ${error.message}`);
264
+ }
265
+ }
266
+
267
+ /** Public config of a Shopify shop when the embed had no app-data metafield. */
268
+ async getShopifyConfig(): Promise<any> {
269
+ const shop = shopifyContext()!.shop;
270
+ return this.#request<any>(`shopify/storefront/config?shop=${encodeURIComponent(shop)}`, undefined, { method: 'GET' });
271
+ }
272
+
221
273
  // TODO: define store type
222
274
  async getStoreConfig(): Promise<any> {
223
275
  try {
@@ -67,7 +67,10 @@
67
67
  "consentText": "L'assistant vin enregistre un identifiant anonyme sur cet appareil pour apprendre vos goûts et améliorer ses recommandations. Vous pouvez l'utiliser sans accepter.",
68
68
  "consentAccept": "Accepter",
69
69
  "consentDecline": "Refuser",
70
- "consentPrivacy": "Politique de confidentialité"
70
+ "consentPrivacy": "Politique de confidentialité",
71
+ "addToCart": "Ajouter au panier",
72
+ "addedToCart": "Ajouté",
73
+ "sommelierResting": "Notre sommelier se repose. Découvrez notre sélection ci-dessous."
71
74
  },
72
75
  "es": {
73
76
  "lang": "es",
@@ -137,7 +140,10 @@
137
140
  "consentText": "El asistente de vinos guarda un identificador anónimo en este dispositivo para conocer tus gustos y mejorar las recomendaciones. Puedes usarlo sin aceptar.",
138
141
  "consentAccept": "Aceptar",
139
142
  "consentDecline": "Rechazar",
140
- "consentPrivacy": "Política de privacidad"
143
+ "consentPrivacy": "Política de privacidad",
144
+ "addToCart": "Añadir al carrito",
145
+ "addedToCart": "Añadido",
146
+ "sommelierResting": "Nuestro sumiller está descansando. Descubre nuestra selección."
141
147
  },
142
148
  "en": {
143
149
  "lang": "en",
@@ -207,7 +213,10 @@
207
213
  "consentText": "The wine assistant stores an anonymous identifier on this device to learn your taste and improve recommendations. You can use it without accepting.",
208
214
  "consentAccept": "Accept",
209
215
  "consentDecline": "Decline",
210
- "consentPrivacy": "Privacy policy"
216
+ "consentPrivacy": "Privacy policy",
217
+ "addToCart": "Add to cart",
218
+ "addedToCart": "Added",
219
+ "sommelierResting": "Our sommelier is resting. Browse our picks below."
211
220
  },
212
221
  "sr": {
213
222
  "lang": "sr",
@@ -277,7 +286,10 @@
277
286
  "consentText": "Vinski asistent čuva anonimni identifikator na ovom uređaju kako bi upoznao vaš ukus i poboljšao preporuke. Možete ga koristiti i bez prihvatanja.",
278
287
  "consentAccept": "Prihvati",
279
288
  "consentDecline": "Odbij",
280
- "consentPrivacy": "Politika privatnosti"
289
+ "consentPrivacy": "Politika privatnosti",
290
+ "addToCart": "Dodaj u korpu",
291
+ "addedToCart": "Dodato",
292
+ "sommelierResting": "Naš somelijer se odmara. Pogledajte naš izbor ispod."
281
293
  },
282
294
  "de": {
283
295
  "lang": "de",
@@ -346,7 +358,10 @@
346
358
  "consentText": "Der Weinassistent speichert eine anonyme Kennung auf diesem Gerät, um Ihren Geschmack kennenzulernen und Empfehlungen zu verbessern. Sie können ihn auch ohne Zustimmung nutzen.",
347
359
  "consentAccept": "Akzeptieren",
348
360
  "consentDecline": "Ablehnen",
349
- "consentPrivacy": "Datenschutzerklärung"
361
+ "consentPrivacy": "Datenschutzerklärung",
362
+ "addToCart": "In den Warenkorb",
363
+ "addedToCart": "Hinzugefügt",
364
+ "sommelierResting": "Unser Sommelier macht gerade Pause. Entdecken Sie unsere Auswahl."
350
365
  },
351
366
  "ukr": {
352
367
  "lang": "ukr",
@@ -416,7 +431,10 @@
416
431
  "consentText": "Винний помічник зберігає на цьому пристрої анонімний ідентифікатор, щоб враховувати ваш смак і покращувати рекомендації. Користуватися можна й без згоди.",
417
432
  "consentAccept": "Прийняти",
418
433
  "consentDecline": "Відхилити",
419
- "consentPrivacy": "Політика конфіденційності"
434
+ "consentPrivacy": "Політика конфіденційності",
435
+ "addToCart": "Додати в кошик",
436
+ "addedToCart": "Додано",
437
+ "sommelierResting": "Наш сомельє відпочиває. Перегляньте нашу добірку нижче."
420
438
  },
421
439
  "ru": {
422
440
  "lang": "ru",
@@ -486,7 +504,10 @@
486
504
  "consentText": "Винный помощник сохраняет на этом устройстве анонимный идентификатор, чтобы учитывать ваш вкус и улучшать рекомендации. Пользоваться можно и без согласия.",
487
505
  "consentAccept": "Принять",
488
506
  "consentDecline": "Отклонить",
489
- "consentPrivacy": "Политика конфиденциальности"
507
+ "consentPrivacy": "Политика конфиденциальности",
508
+ "addToCart": "В корзину",
509
+ "addedToCart": "Добавлено",
510
+ "sommelierResting": "Наш сомелье отдыхает. Посмотрите нашу подборку ниже."
490
511
  },
491
512
  "kk": {
492
513
  "lang": "kk",
@@ -556,7 +577,10 @@
556
577
  "consentText": "Шарап көмекшісі сіздің талғамыңызды ескеріп, ұсыныстарды жақсарту үшін осы құрылғыда анонимді идентификатор сақтайды. Келісімсіз де пайдалана аласыз.",
557
578
  "consentAccept": "Қабылдау",
558
579
  "consentDecline": "Бас тарту",
559
- "consentPrivacy": "Құпиялылық саясаты"
580
+ "consentPrivacy": "Құпиялылық саясаты",
581
+ "addToCart": "Себетке қосу",
582
+ "addedToCart": "Қосылды",
583
+ "sommelierResting": "Біздің сомелье демалып жатыр. Төмендегі таңдауымызды қараңыз."
560
584
  },
561
585
  "pt": {
562
586
  "lang": "pt",
@@ -626,6 +650,9 @@
626
650
  "consentText": "O assistente de vinhos guarda um identificador anónimo neste dispositivo para conhecer o seu gosto e melhorar as recomendações. Pode usá-lo sem aceitar.",
627
651
  "consentAccept": "Aceitar",
628
652
  "consentDecline": "Recusar",
629
- "consentPrivacy": "Política de privacidade"
653
+ "consentPrivacy": "Política de privacidade",
654
+ "addToCart": "Adicionar ao carrinho",
655
+ "addedToCart": "Adicionado",
656
+ "sommelierResting": "Nosso sommelier está descansando. Veja nossas sugestões abaixo."
630
657
  }
631
658
  }
@@ -37,6 +37,15 @@ let choice = $state<ConsentChoice | null>(readChoice());
37
37
  /** null — store config not loaded yet (unknown whether consent is required). */
38
38
  let storeRequires = $state<boolean | null>(null);
39
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
+
40
49
  /** Called by appStore once the store config is applied. */
41
50
  export function setStoreConsentPolicy(requiresConsent: boolean): void {
42
51
  storeRequires = requiresConsent;
@@ -54,6 +63,7 @@ export function consentChoice(): ConsentChoice | null {
54
63
 
55
64
  /** Show the banner: consent needed and the visitor has not chosen yet. */
56
65
  export function shouldAskConsent(): boolean {
66
+ if (externalGate) return false;
57
67
  return storeRequires !== null && consentRequired() && choice === null;
58
68
  }
59
69
 
@@ -64,6 +74,7 @@ export function shouldAskConsent(): boolean {
64
74
  */
65
75
  export function trackingAllowed(): boolean {
66
76
  if ((window as any)?.wineaterData?.tracking === false) return false;
77
+ if (externalGate) return externalGate();
67
78
  if (storeRequires === null) return false;
68
79
  if (!consentRequired()) return true;
69
80
  return choice === 'granted';