wineater-bot 4.9.8 → 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.8",
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) {
@@ -325,12 +396,20 @@
325
396
  onBuyClick={handleBuyBtnClick}
326
397
  onBuyLinkClick={handleBuyLinkClick}
327
398
  onPopupClose={closePopup}
399
+ onSimilarClick={handleSimilarClick}
328
400
  {onTileEnter}
329
401
  {onTileLeave}
330
402
  {onTileClick}
331
403
  />
332
404
  {/each}
333
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}
334
413
  {/if}
335
414
  </div>
336
415
 
@@ -402,6 +481,12 @@
402
481
  margin-bottom: 0;
403
482
  }
404
483
 
484
+ .wr-App-showMore {
485
+ display: flex;
486
+ justify-content: center;
487
+ padding: 16px 0 24px;
488
+ }
489
+
405
490
  /* ===================== 12) Error Message ===================== */
406
491
  .wr-App-error {
407
492
  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,26 @@
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
+ .wr-WineTile-action--similar {
291
+ font-size: 13px;
292
+ color: var(--wr-brand-color, #7b2beb);
293
+ text-decoration: underline;
294
+ padding: 0;
260
295
  }
261
296
 
262
297
  /* 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
+ "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",