wineater-bot 4.9.7 → 4.11.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.9.7",
3
+ "version": "4.11.0",
4
4
  "description": "",
5
5
  "main": "dist/wineater-chatbot.mjs",
6
6
  "module": "src/index.js",
package/src/App.svelte CHANGED
@@ -2,7 +2,7 @@
2
2
  import { setCookie, setUniqueUserCookie, trackWidgetEvent } from './services/analytics/analytics';
3
3
  import Tracker from '@openreplay/tracker';
4
4
  import type { RecommendedWine, Wine, WineId } from './services/api/api';
5
- import { wineaterApi } from './services/api/api';
5
+ import { wineaterApi, apiVersion } from './services/api/api';
6
6
  import { onMount } from 'svelte';
7
7
  import { WIDGET_EVENTS } from './services/analytics/constants';
8
8
  import Header from './components/Header.svelte';
@@ -10,6 +10,7 @@
10
10
  import Search from './components/Search.svelte';
11
11
  import Loader from './components/Loader.svelte';
12
12
  import WineTile from './components/WineTile.svelte';
13
+ import Action from './components/ui/Action.svelte';
13
14
  import { appStore } from './services/store/app-store.svelte';
14
15
  import { getTranslation, translateTexts } from './services/i18n/i18n';
15
16
  import { generateInlineStyle } from './services/ui-config/utils';
@@ -30,6 +31,7 @@
30
31
 
31
32
  let initialized = $state(false);
32
33
  let isLoading = $state(false);
34
+ let isLoadingMore = $state(false);
33
35
  let results = $state<RecommendedWine[] | Wine[]>([]);
34
36
  let selectedWineId = $state<WineId>(null);
35
37
  let searchQuery = $state('');
@@ -37,6 +39,11 @@
37
39
  let isAnimating = $state(false);
38
40
  let hoveredWineId = $state(null);
39
41
  let translatedPopups = $state([]);
42
+ // Последний реально выполненный поиск (с добавленным ';') — searchQuery
43
+ // может измениться в поле ввода до следующего сабмита, а "ещё вариантов"
44
+ // должен повторять именно тот запрос, что дал текущие results.
45
+ let lastSearchedQuery = $state('');
46
+ let hasMore = $state(false);
40
47
  let appLanguage = $derived(appStore.getAppLanguage());
41
48
  let storeLanguage = $derived(appStore.getStoreLanguage());
42
49
  let apiKey = $derived(appStore.getApiKey());
@@ -130,8 +137,10 @@
130
137
  // шаг 2: очистить список и сбросить флаг
131
138
  results = [];
132
139
  isAnimating = false;
140
+ hasMore = false;
133
141
 
134
142
  query = searchQuery.trim() + ';';
143
+ lastSearchedQuery = query;
135
144
  try {
136
145
  trackWidgetEvent(WIDGET_EVENTS.SEARCH_PERFORMED, storeId, { query });
137
146
  } catch (error) {
@@ -148,6 +157,10 @@
148
157
  trackWidgetEvent(WIDGET_EVENTS.NO_RESULTS_FOUND, storeId, { query });
149
158
  } else {
150
159
  results = searchResults;
160
+ // Пагинация доступна только для search/v2 — у instant/legacy-путей нет
161
+ // "more"-эндпоинта. Неполная выдача (< 4) означает, что кандидаты и так
162
+ // кончились: кнопка в этом случае только мигнула бы впустую.
163
+ hasMore = apiVersion === '2' && searchResults.length >= 4;
151
164
  trackWidgetEvent(WIDGET_EVENTS.SEARCH_RESULTS_DISPLAYED, storeId, {
152
165
  results_count: searchResults.length,
153
166
  results: searchResults,
@@ -167,6 +180,64 @@
167
180
  }
168
181
  }
169
182
 
183
+ async function handleShowMore() {
184
+ if (isLoadingMore || !hasMore) return;
185
+ try {
186
+ isLoadingMore = true;
187
+ const excludeIds = results.map((w) => w.id);
188
+ const { recommendations, hasMore: more } = await wineaterApi.getMoreWineRecommendations(
189
+ lastSearchedQuery,
190
+ excludeIds
191
+ );
192
+ trackWidgetEvent(WIDGET_EVENTS.SHOW_MORE_CLICKED, storeId, {
193
+ query: lastSearchedQuery,
194
+ results_count: recommendations.length
195
+ });
196
+ results = [...results, ...recommendations];
197
+ hasMore = more;
198
+ } catch (error) {
199
+ console.error('[wineater] show more error:', error);
200
+ hasMore = false;
201
+ } finally {
202
+ isLoadingMore = false;
203
+ }
204
+ }
205
+
206
+ async function handleSimilarClick(wine: Wine | RecommendedWine) {
207
+ try {
208
+ isLoading = true;
209
+ clearError();
210
+ trackWidgetEvent(WIDGET_EVENTS.SIMILAR_WINES_CLICKED, storeId, {
211
+ wine_id: wine.id,
212
+ wine_title: wine.title
213
+ });
214
+
215
+ await animateClearAll(220);
216
+ results = [];
217
+ isAnimating = false;
218
+ hasMore = false;
219
+ selectedWineId = null;
220
+
221
+ const similarWines = await wineaterApi.getSimilarWines(wine.id);
222
+ searchQuery = wine.title;
223
+ lastSearchedQuery = '';
224
+
225
+ if (!similarWines || similarWines.length === 0) {
226
+ results = [];
227
+ translatedPopups = [];
228
+ errorMessage = getTranslation({ key: 'noResultsMessage', lang: appLanguage });
229
+ } else {
230
+ results = similarWines;
231
+ }
232
+ } catch (error) {
233
+ results = [];
234
+ console.error('[wineater] similar wines error:', error);
235
+ setSearchError();
236
+ } finally {
237
+ isLoading = false;
238
+ }
239
+ }
240
+
170
241
  $effect(async () => {
171
242
  // whenever results change, recompute translatedPopups asynchronously
172
243
  if (!results || results.length === 0) {
@@ -200,6 +271,15 @@
200
271
  });
201
272
  }
202
273
 
274
+ function handleBuyLinkClick(wine: Wine | RecommendedWine, index: number) {
275
+ trackWidgetEvent(WIDGET_EVENTS.BUY_BUTTON_CLICKED, storeId, {
276
+ wine_id: wine.id,
277
+ wine_title: wine.title,
278
+ wine_url: wine.url,
279
+ is_mobile: window.innerWidth <= 768
280
+ });
281
+ }
282
+
203
283
  function closePopup() {
204
284
  const wine = results.find((w) => w.id === selectedWineId);
205
285
  trackWidgetEvent(WIDGET_EVENTS.WINE_DETAILS_CLOSED, storeId, {
@@ -314,13 +394,22 @@
314
394
  {isAnimating}
315
395
  translatedPopup={translatedPopups[index]}
316
396
  onBuyClick={handleBuyBtnClick}
397
+ onBuyLinkClick={handleBuyLinkClick}
317
398
  onPopupClose={closePopup}
399
+ onSimilarClick={handleSimilarClick}
318
400
  {onTileEnter}
319
401
  {onTileLeave}
320
402
  {onTileClick}
321
403
  />
322
404
  {/each}
323
405
  </div>
406
+ {#if hasMore}
407
+ <div class="wr-App-showMore">
408
+ <Action handleClick={handleShowMore} variant="outlined">
409
+ {getTranslation({ key: 'showMore', lang: appLanguage })}
410
+ </Action>
411
+ </div>
412
+ {/if}
324
413
  {/if}
325
414
  </div>
326
415
 
@@ -392,6 +481,12 @@
392
481
  margin-bottom: 0;
393
482
  }
394
483
 
484
+ .wr-App-showMore {
485
+ display: flex;
486
+ justify-content: center;
487
+ padding: 16px 0 24px;
488
+ }
489
+
395
490
  /* ===================== 12) Error Message ===================== */
396
491
  .wr-App-error {
397
492
  text-align: center;
@@ -17,7 +17,9 @@
17
17
  onTileLeave,
18
18
  onTileClick,
19
19
  onBuyClick,
20
+ onBuyLinkClick,
20
21
  onPopupClose,
22
+ onSimilarClick,
21
23
  isAnimating,
22
24
  translatedPopup
23
25
  }: {
@@ -29,7 +31,9 @@
29
31
  onTileLeave: (wine: Wine | RecommendedWine) => void;
30
32
  onTileClick: (event: MouseEvent, wine: Wine | RecommendedWine) => void;
31
33
  onBuyClick: (wine: Wine | RecommendedWine, index: number) => void;
34
+ onBuyLinkClick: (wine: Wine | RecommendedWine, index: number) => void;
32
35
  onPopupClose: () => void;
36
+ onSimilarClick?: (wine: Wine | RecommendedWine) => void;
33
37
  isAnimating: boolean;
34
38
  translatedPopup: Partial<RecommendedWine> | null;
35
39
  } = $props();
@@ -99,6 +103,12 @@
99
103
  </div>
100
104
  {/if}
101
105
 
106
+ {#if wine.byTheGlass}
107
+ <div class="wr-WineTile-glass">
108
+ {getTranslation({ key: 'byTheGlass', lang })}{wine.glassPrice ? ` — ${formatPrice(wine.glassPrice)}` : ''}
109
+ </div>
110
+ {/if}
111
+
102
112
  <h3 class="wr-WineTile-title">{wine.producer ? wine.producer : ''} {wine.title}</h3>
103
113
  </header>
104
114
 
@@ -114,6 +124,7 @@
114
124
  {#if wine.url}
115
125
  <Action
116
126
  href={wine.url}
127
+ handleClick={() => onBuyLinkClick(wine, index)}
117
128
  className="wr-WineTile-action wr-WineTile-action--link js-WineTileAction"
118
129
  target="_blank"
119
130
  variant="outlined"
@@ -129,6 +140,15 @@
129
140
  {getTranslation({ key: 'moreInfo', lang })}
130
141
  </Action>
131
142
  {/if}
143
+ {#if onSimilarClick}
144
+ <Action
145
+ handleClick={() => onSimilarClick(wine)}
146
+ variant="text"
147
+ className="wr-WineTile-action wr-WineTile-action--similar js-WineTileAction"
148
+ >
149
+ {getTranslation({ key: 'similarWines', lang })}
150
+ </Action>
151
+ {/if}
132
152
  </div>
133
153
  </footer>
134
154
 
@@ -252,8 +272,26 @@
252
272
  color: #333;
253
273
  }
254
274
 
275
+ .wr-WineTile-glass {
276
+ font-size: 12px;
277
+ font-weight: 500;
278
+ color: var(--wr-brand-color, #7b2beb);
279
+ margin-bottom: 8px;
280
+ }
281
+
255
282
  .wr-WineTile-actions {
256
283
  margin: 16px auto 0;
284
+ display: flex;
285
+ flex-direction: column;
286
+ align-items: center;
287
+ gap: 8px;
288
+ }
289
+
290
+ .wr-WineTile-action--similar {
291
+ font-size: 13px;
292
+ color: var(--wr-brand-color, #7b2beb);
293
+ text-decoration: underline;
294
+ padding: 0;
257
295
  }
258
296
 
259
297
  /* Hover popup inside tile */
@@ -25,7 +25,7 @@
25
25
  </script>
26
26
 
27
27
  {#if href}
28
- <a class={calculatedClassName} {href} {id} {target} rel="noopener noreferrer">
28
+ <a class={calculatedClassName} {href} {id} {target} rel="noopener noreferrer" onclick={handleClick}>
29
29
  <slot></slot>
30
30
  </a>
31
31
  {:else}
@@ -9,6 +9,8 @@ export const WIDGET_EVENTS = {
9
9
  WINE_DETAILS_VIEWED: 'wine_details_viewed',
10
10
  WINE_DETAILS_CLOSED: 'wine_details_closed',
11
11
  BUY_BUTTON_CLICKED: 'buy_button_clicked',
12
+ SHOW_MORE_CLICKED: 'show_more_clicked',
13
+ SIMILAR_WINES_CLICKED: 'similar_wines_clicked',
12
14
  FILTER_APPLIED: 'filter_applied',
13
15
  LANGUAGE_CHANGED: 'language_changed',
14
16
  ERROR_OCCURRED: 'error_occurred',
@@ -1,4 +1,4 @@
1
- import { deserializeInstantRecommendation, deserializeRecommendedWine } from "./deserialize";
1
+ import { deserializeInstantRecommendation, deserializeRecommendedWine, deserializeMoreRecommendations } from "./deserialize";
2
2
 
3
3
  export type WineId = string;
4
4
  export type WinePrice = number;
@@ -25,6 +25,8 @@ export type Wine = {
25
25
  itemId: string | null;
26
26
  isPromote: boolean | null;
27
27
  stock: number | null;
28
+ byTheGlass: boolean;
29
+ glassPrice: number | null;
28
30
  };
29
31
 
30
32
  export type RecommendedWine = Omit<Wine, "shortDescription" | "producer" | "pairingRecommendation" | "itemId"> & {
@@ -96,6 +98,52 @@ class WineaterApi {
96
98
  }
97
99
  }
98
100
 
101
+ async getMoreWineRecommendations(text: string, excludeIds: string[]): Promise<{ recommendations: RecommendedWine[]; hasMore: boolean }> {
102
+ try {
103
+ const response = await fetch(`${this.#baseUrl}dish/recommendations/search/v2/more`, {
104
+ method: 'POST',
105
+ headers: {
106
+ 'Content-Type': 'application/json',
107
+ 'Authorization': `Bearer ${this.#token}`
108
+ },
109
+ body: JSON.stringify({ text, exclude_ids: excludeIds })
110
+ });
111
+
112
+ const data = await response.json();
113
+
114
+ if (!response.ok) {
115
+ throw new Error(`API call failed with status ${response.status}: ${data.message || 'No additional error information'}`);
116
+ }
117
+
118
+ return deserializeMoreRecommendations(data);
119
+ } catch (error) {
120
+ throw new Error(`Error in getMoreWineRecommendations: ${error.message}`);
121
+ }
122
+ }
123
+
124
+ async getSimilarWines(id: WineId): Promise<RecommendedWine[]> {
125
+ try {
126
+ const response = await fetch(`${this.#baseUrl}dish/recommendations/similar`, {
127
+ method: 'POST',
128
+ headers: {
129
+ 'Content-Type': 'application/json',
130
+ 'Authorization': `Bearer ${this.#token}`
131
+ },
132
+ body: JSON.stringify({ id })
133
+ });
134
+
135
+ const data = await response.json();
136
+
137
+ if (!response.ok) {
138
+ throw new Error(`API call failed with status ${response.status}: ${data.message || 'No additional error information'}`);
139
+ }
140
+
141
+ return data.filter(Boolean).map(deserializeRecommendedWine);
142
+ } catch (error) {
143
+ throw new Error(`Error in getSimilarWines: ${error.message}`);
144
+ }
145
+ }
146
+
99
147
  // TODO: define store type
100
148
  async getStoreConfig(): Promise<any> {
101
149
  try {
@@ -117,5 +165,5 @@ class WineaterApi {
117
165
 
118
166
  const DEMO_TOKEN = 'HeUw6IXuhvz6tRuXdPEGEbj3SGQ3Q2YtEc1Z8jd41Kg9LtyPb4Epu0to31SYIadG';
119
167
  const token = window?.wineaterData?.CLIENT_TOKEN || DEMO_TOKEN;
120
- const apiVersion = String(window?.wineaterData?.apiVersion || '1');
168
+ export const apiVersion = String(window?.wineaterData?.apiVersion || '1');
121
169
  export const wineaterApi = new WineaterApi(token, apiVersion)
@@ -23,6 +23,8 @@ type RawInstantRecommendation = {
23
23
  item_id?: string;
24
24
  is_promote?: boolean;
25
25
  stock?: string;
26
+ by_the_glass?: boolean;
27
+ glass_price?: string | number;
26
28
  }
27
29
 
28
30
  export function deserializeInstantRecommendation(raw: RawInstantRecommendation): Wine {
@@ -49,6 +51,8 @@ export function deserializeInstantRecommendation(raw: RawInstantRecommendation):
49
51
  itemId: raw.item_id || null,
50
52
  isPromote: raw.is_promote ? Boolean(raw.is_promote) : null,
51
53
  stock: raw.stock ? Number(raw.stock) : null,
54
+ byTheGlass: Boolean(raw.by_the_glass),
55
+ glassPrice: raw.glass_price != null ? Number(raw.glass_price) : null,
52
56
  }
53
57
  }
54
58
 
@@ -74,6 +78,8 @@ type RawRecommendedWine = {
74
78
  distance: string;
75
79
  reason: string;
76
80
  type: string;
81
+ by_the_glass?: boolean;
82
+ glass_price?: string | number;
77
83
  }
78
84
  export function deserializeRecommendedWine(raw: RawRecommendedWine): RecommendedWine {
79
85
  return {
@@ -98,5 +104,17 @@ export function deserializeRecommendedWine(raw: RawRecommendedWine): Recommended
98
104
  distance: Number(raw.distance),
99
105
  reason: String(raw.reason),
100
106
  type: String(raw.type),
107
+ byTheGlass: Boolean(raw.by_the_glass),
108
+ glassPrice: raw.glass_price != null ? Number(raw.glass_price) : null,
101
109
  }
110
+ }
111
+
112
+ export function deserializeMoreRecommendations(raw: {
113
+ has_more: boolean;
114
+ recommendations: RawRecommendedWine[];
115
+ }): { recommendations: RecommendedWine[]; hasMore: boolean } {
116
+ return {
117
+ recommendations: (raw.recommendations || []).filter(Boolean).map(deserializeRecommendedWine),
118
+ hasMore: Boolean(raw.has_more),
119
+ };
102
120
  }
@@ -17,6 +17,9 @@
17
17
  "type": "Type",
18
18
  "reason": "Suggestion",
19
19
  "moreInfo": "Plus d'infos",
20
+ "showMore": "Plus de vins",
21
+ "similarWines": "Vins similaires",
22
+ "byTheGlass": "Au verre",
20
23
  "winePairingIntro": "Pour ce vin, voici quelques suggestions de plats :",
21
24
  "askForDinner": "Que voulez-vous manger ce soir?",
22
25
  "clearChatButton": "Effacer le chat",
@@ -80,6 +83,9 @@
80
83
  "type": "Tipo",
81
84
  "reason": "Sugerencia",
82
85
  "moreInfo": "Más info",
86
+ "showMore": "Ver más",
87
+ "similarWines": "Vinos similares",
88
+ "byTheGlass": "Por copa",
83
89
  "winePairingIntro": "Para este vino, aquí tienes algunas sugerencias de platos:",
84
90
  "askForDinner": "¿Qué te gustaría cenar?",
85
91
  "clearChatButton": "Limpiar el chat",
@@ -143,6 +149,9 @@
143
149
  "type": "Type",
144
150
  "reason": "Suggestion",
145
151
  "moreInfo": "More info",
152
+ "showMore": "Show more",
153
+ "similarWines": "Similar wines",
154
+ "byTheGlass": "By the glass",
146
155
  "winePairingIntro": "For this wine, here are some suggested dishes:",
147
156
  "askForDinner": "What would you like to have for dinner?",
148
157
  "clearChatButton": "Clear Chat",
@@ -206,6 +215,9 @@
206
215
  "type": "Tip",
207
216
  "reason": "Preporuka",
208
217
  "moreInfo": "Više informacija",
218
+ "showMore": "Prikaži još",
219
+ "similarWines": "Slična vina",
220
+ "byTheGlass": "Na čašu",
209
221
  "winePairingIntro": "Za ovo vino, preporučuju se sledeća jela:",
210
222
  "askForDinner": "Šta biste želeli za večeru?",
211
223
  "clearChatButton": "Obriši razgovor",
@@ -269,6 +281,9 @@
269
281
  "type": "Typ",
270
282
  "reason": "Anregung",
271
283
  "moreInfo": "Mehr Info",
284
+ "showMore": "Mehr anzeigen",
285
+ "similarWines": "Ähnliche Weine",
286
+ "byTheGlass": "Glasweise",
272
287
  "winePairingIntro": "Für diesen Wein gibt es folgende Essensvorschläge:",
273
288
  "askForDinner": "Was möchten Sie zum Abendessen essen?",
274
289
  "clearChatButton": "Chat löschen",
@@ -331,6 +346,9 @@
331
346
  "type": "Тип",
332
347
  "reason": "Рекомендація",
333
348
  "moreInfo": "Докладніше",
349
+ "showMore": "Показати ще",
350
+ "similarWines": "Схожі вина",
351
+ "byTheGlass": "На келих",
334
352
  "winePairingIntro": "До цього вина підходять такі страви:",
335
353
  "askForDinner": "Що ви хочете на вечерю?",
336
354
  "clearChatButton": "Очистити чат",
@@ -394,6 +412,9 @@
394
412
  "type": "Тип",
395
413
  "reason": "Рекомендация",
396
414
  "moreInfo": "Подробнее",
415
+ "showMore": "Ещё варианты",
416
+ "similarWines": "Похожие вина",
417
+ "byTheGlass": "По бокалам",
397
418
  "winePairingIntro": "К этому вину подходят следующие блюда:",
398
419
  "askForDinner": "Что бы вы хотели на ужин?",
399
420
  "clearChatButton": "Очистить чат",
@@ -457,6 +478,9 @@
457
478
  "type": "Түрі",
458
479
  "reason": "Ұсыныс",
459
480
  "moreInfo": "Толығырақ",
481
+ "showMore": "Тағы көрсету",
482
+ "similarWines": "Ұқсас шараптар",
483
+ "byTheGlass": "Бокалмен",
460
484
  "winePairingIntro": "Бұл шарапқа келесі тағамдар сәйкес келеді:",
461
485
  "askForDinner": "Кешкі асқа не қалайсыз?",
462
486
  "clearChatButton": "Чатты тазалау",
@@ -520,6 +544,9 @@
520
544
  "type": "Tipo",
521
545
  "reason": "Sugestão",
522
546
  "moreInfo": "Mais informações",
547
+ "showMore": "Mostrar mais",
548
+ "similarWines": "Vinhos semelhantes",
549
+ "byTheGlass": "Por taça",
523
550
  "winePairingIntro": "Para este vinho, aqui estão algumas sugestões de pratos:",
524
551
  "askForDinner": "O que você gostaria de jantar?",
525
552
  "clearChatButton": "Limpar chat",