wineater-bot 4.20.0 → 4.22.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.20.0",
3
+ "version": "4.22.0",
4
4
  "description": "",
5
5
  "main": "dist/wineater-chatbot.mjs",
6
6
  "module": "src/index.js",
package/src/App.svelte CHANGED
@@ -14,7 +14,7 @@
14
14
  import WineTile from './components/WineTile.svelte';
15
15
  import Action from './components/ui/Action.svelte';
16
16
  import CookieConsent from './components/CookieConsent.svelte';
17
- import { shouldAskConsent, onConsentChange } from './services/profile/consent.svelte';
17
+ import { shouldAskConsent } from './services/profile/consent.svelte';
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';
@@ -171,6 +171,8 @@
171
171
  userActed = true;
172
172
 
173
173
  let query = '';
174
+ // id этого поиска: ответы и finally более старого поиска не трогают новый
175
+ let myQueryId: string | undefined;
174
176
  try {
175
177
  isLoading = true;
176
178
  clearError();
@@ -178,6 +180,7 @@
178
180
  query = searchQuery.trim() + ';';
179
181
  lastSearchedQuery = query;
180
182
  currentQueryId = newQueryId();
183
+ myQueryId = currentQueryId;
181
184
  try {
182
185
  trackWidgetEvent(WIDGET_EVENTS.SEARCH_PERFORMED, storeId, { query });
183
186
  } catch (error) {
@@ -185,9 +188,46 @@
185
188
  }
186
189
 
187
190
  // Запрос уходит сразу, карточки уезжают вверх, пока он в полёте.
191
+ // v2 идёт потоком: 4 вина приходят, как только их выбрал реранкер,
192
+ // причины («почему») — следом, в том же ответе. Они видны только в
193
+ // попапе, поэтому плитки показываем, не дожидаясь их.
194
+ let animationDone = false;
195
+ let shown = false;
196
+ let pendingEarly: RecommendedWine[] | null = null;
197
+
198
+ const showResults = (searchResults: RecommendedWine[]) => {
199
+ if (!searchResults || searchResults.length === 0) {
200
+ results = [];
201
+ translatedPopups = [];
202
+ errorMessage = getTranslation({ key: 'noResultsMessage', lang: appLanguage });
203
+ trackWidgetEvent(WIDGET_EVENTS.NO_RESULTS_FOUND, storeId, { query });
204
+ return;
205
+ }
206
+ results = searchResults;
207
+ isLoading = false;
208
+ if (shown) return;
209
+ shown = true;
210
+ // Пагинация доступна только для search/v2 — у instant/legacy-путей нет
211
+ // "more"-эндпоинта. Неполная выдача (< 4) означает, что кандидаты и так
212
+ // кончились: кнопка в этом случае только мигнула бы впустую.
213
+ hasMore = apiVersion === '2' && searchResults.length >= 4;
214
+ feedbackShown(searchResults, query);
215
+ trackWidgetEvent(WIDGET_EVENTS.SEARCH_RESULTS_DISPLAYED, storeId, {
216
+ results_count: searchResults.length,
217
+ results: searchResults,
218
+ query
219
+ });
220
+ };
221
+
222
+ const onWines = (early: RecommendedWine[]) => {
223
+ if (myQueryId !== currentQueryId) return; // устаревший поиск
224
+ if (!animationDone) pendingEarly = early;
225
+ else showResults(early);
226
+ };
227
+
228
+ const request = wineaterApi.searchStream(query, currentQueryId, onWines);
188
229
  // Пустой catch нужен, чтобы отказ запроса во время анимации не стал
189
230
  // unhandled rejection: ошибку обработает await ниже.
190
- const request = wineaterApi.getWineRecommendationsSearch(query, currentQueryId);
191
231
  request.catch(() => {});
192
232
 
193
233
  // шаг 1: плавно поднять карточки
@@ -198,29 +238,26 @@
198
238
  isAnimating = false;
199
239
  hasMore = false;
200
240
  seenIds = [];
201
-
202
- const searchResults = await request;
203
-
204
- if (!searchResults || searchResults.length === 0) {
205
- results = [];
206
- translatedPopups = [];
207
- errorMessage = getTranslation({ key: 'noResultsMessage', lang: appLanguage });
208
-
209
- trackWidgetEvent(WIDGET_EVENTS.NO_RESULTS_FOUND, storeId, { query });
241
+ animationDone = true;
242
+ if (pendingEarly) showResults(pendingEarly);
243
+
244
+ const finalResults = await request;
245
+ if (myQueryId !== currentQueryId) return;
246
+
247
+ // Тот же состав — дописываем причины в уже показанные плитки, не
248
+ // перерисовывая сетку под курсором; иначе (fallback, редкие расхождения) — заменяем.
249
+ const sameWines =
250
+ shown &&
251
+ finalResults.length === results.length &&
252
+ finalResults.every((w, i) => w.id === results[i]?.id);
253
+ if (sameWines) {
254
+ const byId = new Map(finalResults.map((w) => [w.id, w]));
255
+ results = results.map((w) => ({ ...w, reason: byId.get(w.id)?.reason ?? w.reason }));
210
256
  } else {
211
- results = searchResults;
212
- // Пагинация доступна только для search/v2 — у instant/legacy-путей нет
213
- // "more"-эндпоинта. Неполная выдача (< 4) означает, что кандидаты и так
214
- // кончились: кнопка в этом случае только мигнула бы впустую.
215
- hasMore = apiVersion === '2' && searchResults.length >= 4;
216
- feedbackShown(searchResults, query);
217
- trackWidgetEvent(WIDGET_EVENTS.SEARCH_RESULTS_DISPLAYED, storeId, {
218
- results_count: searchResults.length,
219
- results: searchResults,
220
- query
221
- });
257
+ showResults(finalResults);
222
258
  }
223
259
  } catch (error) {
260
+ if (myQueryId !== currentQueryId) return;
224
261
  results = [];
225
262
  console.error('[wineater] search error:', error);
226
263
  trackWidgetEvent(WIDGET_EVENTS.ERROR_OCCURRED, storeId, {
@@ -229,7 +266,7 @@
229
266
  });
230
267
  setSearchError();
231
268
  } finally {
232
- isLoading = false;
269
+ if (myQueryId === currentQueryId) isLoading = false;
233
270
  }
234
271
  }
235
272
 
@@ -453,18 +490,13 @@
453
490
  }
454
491
 
455
492
  initialized = true;
456
- // без согласия (EU-магазин) trackWidgetEvent — no-op; после «Принять»
457
- // событие инициализации отправляется один раз, см. onConsentChange ниже
493
+ // без согласия (EU-магазин) событие уходит обезличенно — не повторяем
494
+ // его после «Принять», иначе инициализация посчиталась бы дважды
458
495
  trackWidgetEvent(WIDGET_EVENTS.WIDGET_INITIALIZED, storeId);
459
496
  })();
460
497
 
461
- const offConsent = onConsentChange((c) => {
462
- if (c === 'granted') trackWidgetEvent(WIDGET_EVENTS.WIDGET_INITIALIZED, storeId);
463
- });
464
-
465
498
  return () => {
466
499
  document.removeEventListener('click', handleClickOutside);
467
- offConsent();
468
500
  };
469
501
  });
470
502
  </script>
@@ -41,10 +41,35 @@ export function setUniqueUserCookie(): string {
41
41
  }
42
42
 
43
43
  export function trackWidgetEvent(event_type: string, store_id, data = {}, meta = {}) {
44
- // Аналитика идентифицирует посетителя (uniqueUserID, user agent, экран) —
45
- // в магазинах с requires_cookie_consent только после «Принять».
46
- if (!trackingAllowed()) return;
47
44
  const endpoint = 'https://admin-api.wineater.com/api/widget-events';
45
+ const event_time = new Date().toISOString();
46
+ const region = navigator.language.split('-')[1] || null;
47
+ const language = navigator.language.split('-')[0] || 'en';
48
+
49
+ // Без согласия (EU-магазин, отказ или ещё не выбрал) событие всё равно
50
+ // уходит — на нём строится монетизация, — но обезличенно: без user_id,
51
+ // session_id, cookie и без user agent / точного экрана (по ним посетителя
52
+ // можно опознать). Грубый класс устройства оставляем для статистики.
53
+ if (!trackingAllowed()) {
54
+ const anonymousPayload = {
55
+ event_type,
56
+ store_id,
57
+ event_time,
58
+ data,
59
+ meta: { ...meta, anonymous: true },
60
+ device_info: { device: window.innerWidth < 768 ? 'mobile' : 'desktop' },
61
+ region,
62
+ language,
63
+ };
64
+ return fetch(endpoint, {
65
+ method: 'POST',
66
+ headers: { 'Content-Type': 'application/json' },
67
+ body: JSON.stringify(anonymousPayload),
68
+ keepalive: true,
69
+ }).catch((error) => {
70
+ console.debug('[wineater] anonymous event failed:', error?.message);
71
+ });
72
+ }
48
73
 
49
74
  // Ensure we have a unique user ID
50
75
  let user_id = getCookie('uniqueUserID');
@@ -61,9 +86,6 @@ export function trackWidgetEvent(event_type: string, store_id, data = {}, meta =
61
86
  platform: navigator.platform
62
87
  };
63
88
 
64
- // Get current timestamp
65
- const event_time = new Date().toISOString();
66
-
67
89
  // Generate session ID if not exists
68
90
  let session_id = getCookie('sessionID');
69
91
  if (!session_id) {
@@ -82,8 +104,8 @@ export function trackWidgetEvent(event_type: string, store_id, data = {}, meta =
82
104
  user_id
83
105
  },
84
106
  device_info,
85
- region: navigator.language.split('-')[1] || null,
86
- language: navigator.language.split('-')[0] || 'en'
107
+ region,
108
+ language
87
109
  };
88
110
 
89
111
  return fetch(endpoint, {
@@ -120,6 +120,69 @@ class WineaterApi {
120
120
  }
121
121
  }
122
122
 
123
+ /**
124
+ * v2 search over NDJSON (`search/v2/stream`): `onWines` fires as soon as the
125
+ * reranker has picked the 4 wines (~1 s before the reasons are written), with
126
+ * `reason` empty; the promise resolves with the final wines including reasons.
127
+ * Any transport problem (no stream support, HTTP error, old backend without
128
+ * the endpoint) falls back to the plain JSON request — same result, later.
129
+ */
130
+ async searchStream(
131
+ text: string,
132
+ queryId: string | undefined,
133
+ onWines: (wines: RecommendedWine[]) => void,
134
+ ): Promise<RecommendedWine[]> {
135
+ const fallback = () => this.getWineRecommendationsSearch(text, queryId);
136
+ if (this.#apiVersion !== '2') return fallback();
137
+
138
+ let response: Response;
139
+ try {
140
+ response = await fetch(`${this.#baseUrl}dish/recommendations/search/v2/stream`, {
141
+ method: 'POST',
142
+ headers: this.#headers(),
143
+ body: JSON.stringify(queryId ? { text, query_id: queryId } : { text }),
144
+ });
145
+ } catch {
146
+ // network / CORS (incl. the uid header) — the JSON path knows how to recover
147
+ return fallback();
148
+ }
149
+ if (!response.ok || !response.body) return fallback();
150
+
151
+ const reader = response.body.getReader();
152
+ const decoder = new TextDecoder();
153
+ let buffer = '';
154
+ let early: RecommendedWine[] | null = null;
155
+ const toWines = (recs: any[]) => (recs ?? []).filter(Boolean).map(deserializeRecommendedWine);
156
+
157
+ try {
158
+ for (;;) {
159
+ const { done, value } = await reader.read();
160
+ if (value) buffer += decoder.decode(value, { stream: true });
161
+ const lines = buffer.split('\n');
162
+ buffer = done ? '' : lines.pop() ?? '';
163
+ for (const line of done ? lines.filter(Boolean) : lines) {
164
+ if (!line.trim()) continue;
165
+ const event = JSON.parse(line);
166
+ if (event.type === 'wines') {
167
+ // reasons are not written yet — show none rather than a placeholder
168
+ early = toWines(event.recommendations).map((w) => ({ ...w, reason: '' }));
169
+ onWines(early);
170
+ } else if (event.type === 'final') {
171
+ return toWines(event.recommendations);
172
+ } else if (event.type === 'error') {
173
+ if (early) return early;
174
+ return fallback();
175
+ }
176
+ }
177
+ if (done) break;
178
+ }
179
+ } catch (error) {
180
+ if (early) return early;
181
+ return fallback();
182
+ }
183
+ return early ?? fallback();
184
+ }
185
+
123
186
  async getMoreWineRecommendations(text: string, excludeIds: string[]): Promise<{ recommendations: RecommendedWine[]; hasMore: boolean }> {
124
187
  try {
125
188
  const data = await this.#request<any>('dish/recommendations/search/v2/more', { text, exclude_ids: excludeIds });
@@ -4,10 +4,11 @@
4
4
  // arrives with the store config (true for EU shops). A host page can force it
5
5
  // with `window.wineaterData.requireConsent = true`.
6
6
  //
7
- // Until the visitor accepts, the widget creates no identifiers and sends no
8
- // tracking: no taste-profile uid (localStorage + cookie), no `uniqueUserID`
9
- // cookie, no /events, no admin-api analytics. Search works as usual, just not
10
- // personalised. Declining also wipes identifiers created earlier.
7
+ // Until the visitor accepts, the widget creates no identifiers: no
8
+ // taste-profile uid (localStorage + cookie), no `uniqueUserID`/`sessionID`
9
+ // cookies, no /events. admin-api analytics is still sent, but anonymised
10
+ // (no ids, no user agent/screen) — see trackWidgetEvent. Search works as
11
+ // usual, just not personalised. Declining also wipes identifiers created earlier.
11
12
  //
12
13
  // The choice itself is kept in localStorage — strictly necessary, so it is
13
14
  // allowed without consent.
@@ -20,7 +21,7 @@
20
21
  export type ConsentChoice = 'granted' | 'denied';
21
22
 
22
23
  const CONSENT_KEY = 'wineater_consent';
23
- const IDENTIFIER_KEYS = ['wineater_uid', 'uniqueUserID'];
24
+ const IDENTIFIER_KEYS = ['wineater_uid', 'uniqueUserID', 'sessionID'];
24
25
 
25
26
  function readChoice(): ConsentChoice | null {
26
27
  try {