wineater-bot 4.9.8 → 4.12.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.8",
3
+ "version": "4.12.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,14 @@
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);
47
+ // Всё, что уже показывали по текущему запросу — «Try again» заменяет выдачу,
48
+ // и без накопления повторный клик вернул бы те же вина.
49
+ let seenIds = $state<string[]>([]);
40
50
  let appLanguage = $derived(appStore.getAppLanguage());
41
51
  let storeLanguage = $derived(appStore.getStoreLanguage());
42
52
  let apiKey = $derived(appStore.getApiKey());
@@ -130,8 +140,11 @@
130
140
  // шаг 2: очистить список и сбросить флаг
131
141
  results = [];
132
142
  isAnimating = false;
143
+ hasMore = false;
144
+ seenIds = [];
133
145
 
134
146
  query = searchQuery.trim() + ';';
147
+ lastSearchedQuery = query;
135
148
  try {
136
149
  trackWidgetEvent(WIDGET_EVENTS.SEARCH_PERFORMED, storeId, { query });
137
150
  } catch (error) {
@@ -148,6 +161,10 @@
148
161
  trackWidgetEvent(WIDGET_EVENTS.NO_RESULTS_FOUND, storeId, { query });
149
162
  } else {
150
163
  results = searchResults;
164
+ // Пагинация доступна только для search/v2 — у instant/legacy-путей нет
165
+ // "more"-эндпоинта. Неполная выдача (< 4) означает, что кандидаты и так
166
+ // кончились: кнопка в этом случае только мигнула бы впустую.
167
+ hasMore = apiVersion === '2' && searchResults.length >= 4;
151
168
  trackWidgetEvent(WIDGET_EVENTS.SEARCH_RESULTS_DISPLAYED, storeId, {
152
169
  results_count: searchResults.length,
153
170
  results: searchResults,
@@ -167,6 +184,68 @@
167
184
  }
168
185
  }
169
186
 
187
+ async function handleShowMore() {
188
+ if (isLoadingMore || !hasMore) return;
189
+ try {
190
+ isLoadingMore = true;
191
+ // Карточки заменяются, а не дописываются, поэтому исключать надо всё
192
+ // показанное за сессию — иначе следующий клик вернёт те же вина.
193
+ seenIds = [...new Set([...seenIds, ...results.map((w) => w.id)])];
194
+ const { recommendations, hasMore: more } = await wineaterApi.getMoreWineRecommendations(
195
+ lastSearchedQuery,
196
+ seenIds
197
+ );
198
+ trackWidgetEvent(WIDGET_EVENTS.SHOW_MORE_CLICKED, storeId, {
199
+ query: lastSearchedQuery,
200
+ results_count: recommendations.length
201
+ });
202
+ if (recommendations.length > 0) {
203
+ results = recommendations;
204
+ }
205
+ hasMore = more;
206
+ } catch (error) {
207
+ console.error('[wineater] show more error:', error);
208
+ hasMore = false;
209
+ } finally {
210
+ isLoadingMore = false;
211
+ }
212
+ }
213
+
214
+ async function handleSimilarClick(wine: Wine | RecommendedWine) {
215
+ try {
216
+ isLoading = true;
217
+ clearError();
218
+ trackWidgetEvent(WIDGET_EVENTS.SIMILAR_WINES_CLICKED, storeId, {
219
+ wine_id: wine.id,
220
+ wine_title: wine.title
221
+ });
222
+
223
+ await animateClearAll(220);
224
+ results = [];
225
+ isAnimating = false;
226
+ hasMore = false;
227
+ selectedWineId = null;
228
+
229
+ const similarWines = await wineaterApi.getSimilarWines(wine.id);
230
+ searchQuery = wine.title;
231
+ lastSearchedQuery = '';
232
+
233
+ if (!similarWines || similarWines.length === 0) {
234
+ results = [];
235
+ translatedPopups = [];
236
+ errorMessage = getTranslation({ key: 'noResultsMessage', lang: appLanguage });
237
+ } else {
238
+ results = similarWines;
239
+ }
240
+ } catch (error) {
241
+ results = [];
242
+ console.error('[wineater] similar wines error:', error);
243
+ setSearchError();
244
+ } finally {
245
+ isLoading = false;
246
+ }
247
+ }
248
+
170
249
  $effect(async () => {
171
250
  // whenever results change, recompute translatedPopups asynchronously
172
251
  if (!results || results.length === 0) {
@@ -325,12 +404,20 @@
325
404
  onBuyClick={handleBuyBtnClick}
326
405
  onBuyLinkClick={handleBuyLinkClick}
327
406
  onPopupClose={closePopup}
407
+ onSimilarClick={handleSimilarClick}
328
408
  {onTileEnter}
329
409
  {onTileLeave}
330
410
  {onTileClick}
331
411
  />
332
412
  {/each}
333
413
  </div>
414
+ {#if hasMore}
415
+ <div class="wr-App-showMore">
416
+ <Action handleClick={handleShowMore} variant="outlined">
417
+ {getTranslation({ key: 'tryAgain', lang: appLanguage })}
418
+ </Action>
419
+ </div>
420
+ {/if}
334
421
  {/if}
335
422
  </div>
336
423
 
@@ -402,6 +489,12 @@
402
489
  margin-bottom: 0;
403
490
  }
404
491
 
492
+ .wr-App-showMore {
493
+ display: flex;
494
+ justify-content: center;
495
+ padding: 16px 0 24px;
496
+ }
497
+
405
498
  /* ===================== 12) Error Message ===================== */
406
499
  .wr-App-error {
407
500
  text-align: center;
@@ -19,6 +19,7 @@
19
19
  onBuyClick,
20
20
  onBuyLinkClick,
21
21
  onPopupClose,
22
+ onSimilarClick,
22
23
  isAnimating,
23
24
  translatedPopup
24
25
  }: {
@@ -32,6 +33,7 @@
32
33
  onBuyClick: (wine: Wine | RecommendedWine, index: number) => void;
33
34
  onBuyLinkClick: (wine: Wine | RecommendedWine, index: number) => void;
34
35
  onPopupClose: () => void;
36
+ onSimilarClick?: (wine: Wine | RecommendedWine) => void;
35
37
  isAnimating: boolean;
36
38
  translatedPopup: Partial<RecommendedWine> | null;
37
39
  } = $props();
@@ -101,6 +103,12 @@
101
103
  </div>
102
104
  {/if}
103
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
+
104
112
  <h3 class="wr-WineTile-title">{wine.producer ? wine.producer : ''} {wine.title}</h3>
105
113
  </header>
106
114
 
@@ -132,6 +140,15 @@
132
140
  {getTranslation({ key: 'moreInfo', lang })}
133
141
  </Action>
134
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}
135
152
  </div>
136
153
  </footer>
137
154
 
@@ -255,8 +272,51 @@
255
272
  color: #333;
256
273
  }
257
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
+
258
282
  .wr-WineTile-actions {
259
283
  margin: 16px auto 0;
284
+ display: flex;
285
+ flex-direction: column;
286
+ align-items: center;
287
+ gap: 8px;
288
+ }
289
+
290
+ /* :global обязателен — класс уезжает внутрь <Action>, а скоуп Svelte
291
+ действует только на разметку этого компонента, иначе правило отбрасывается
292
+ как неиспользуемое. Цвет тот же, что у Buy; подчёркивание выезжает слева. */
293
+ :global(.wr-WineTile-action--similar) {
294
+ font-size: 14px;
295
+ font-weight: 500;
296
+ color: var(--wr-brand-color, #7b2beb);
297
+ text-decoration: none;
298
+ background: transparent;
299
+ border: none;
300
+ padding: 0;
301
+ position: relative;
302
+ }
303
+
304
+ :global(.wr-WineTile-action--similar)::after {
305
+ content: '';
306
+ position: absolute;
307
+ left: 0;
308
+ bottom: -2px;
309
+ width: 100%;
310
+ height: 1px;
311
+ background-color: currentColor;
312
+ transform: scaleX(0);
313
+ transform-origin: left;
314
+ transition: transform 180ms cubic-bezier(0.22, 0.61, 0.36, 1);
315
+ }
316
+
317
+ :global(.wr-WineTile-action--similar:hover)::after,
318
+ :global(.wr-WineTile-action--similar:focus-visible)::after {
319
+ transform: scaleX(1);
260
320
  }
261
321
 
262
322
  /* Hover popup inside tile */
@@ -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
+ "tryAgain": "Réessayer",
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
+ "tryAgain": "Probar otra vez",
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
+ "tryAgain": "Try again",
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
+ "tryAgain": "Pokušaj ponovo",
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
+ "tryAgain": "Nochmal versuchen",
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
+ "tryAgain": "Спробувати ще",
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
+ "tryAgain": "Попробовать ещё",
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
+ "tryAgain": "Қайта көру",
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
+ "tryAgain": "Tentar de novo",
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",