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
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
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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-магазин)
|
|
457
|
-
//
|
|
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
|
|
86
|
-
language
|
|
107
|
+
region,
|
|
108
|
+
language
|
|
87
109
|
};
|
|
88
110
|
|
|
89
111
|
return fetch(endpoint, {
|
package/src/services/api/api.ts
CHANGED
|
@@ -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
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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 {
|