wineater-bot 4.19.0 → 4.20.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/dist/wineater-chatbot.mjs +13 -11
- package/dist/wineater-chatbot.umd.js +13 -11
- package/package.json +1 -1
- package/src/App.svelte +57 -3
- package/src/components/CookieConsent.svelte +73 -0
- package/src/services/analytics/analytics.ts +5 -0
- package/src/services/api/api.ts +65 -73
- package/src/services/i18n/translations.json +46 -10
- package/src/services/profile/consent.svelte.ts +103 -0
- package/src/services/profile/events.ts +73 -0
- package/src/services/profile/uid.ts +87 -0
- package/src/services/store/app-store.svelte.ts +7 -1
- package/src/services/store/deserialize.ts +2 -0
- package/src/services/store/types.ts +2 -0
package/package.json
CHANGED
package/src/App.svelte
CHANGED
|
@@ -1,23 +1,29 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import { setCookie,
|
|
2
|
+
import { setCookie, trackWidgetEvent } from './services/analytics/analytics';
|
|
3
3
|
import type { RecommendedWine, Wine, WineId } from './services/api/api';
|
|
4
4
|
import { wineaterApi, apiVersion, cacheScope } from './services/api/api';
|
|
5
5
|
import { readCache, writeCache } from './services/cache';
|
|
6
6
|
import { onMount } from 'svelte';
|
|
7
7
|
import { WIDGET_EVENTS } from './services/analytics/constants';
|
|
8
|
+
import { trackFeedback } from './services/profile/events';
|
|
9
|
+
import { newQueryId } from './services/profile/uid';
|
|
8
10
|
import Header from './components/Header.svelte';
|
|
9
11
|
import Footer from './components/Footer.svelte';
|
|
10
12
|
import Search from './components/Search.svelte';
|
|
11
13
|
import SkeletonTiles from './components/SkeletonTiles.svelte';
|
|
12
14
|
import WineTile from './components/WineTile.svelte';
|
|
13
15
|
import Action from './components/ui/Action.svelte';
|
|
16
|
+
import CookieConsent from './components/CookieConsent.svelte';
|
|
17
|
+
import { shouldAskConsent, onConsentChange } from './services/profile/consent.svelte';
|
|
14
18
|
import { appStore } from './services/store/app-store.svelte';
|
|
15
19
|
import { getTranslation, translateTexts } from './services/i18n/i18n';
|
|
16
20
|
import { generateInlineStyle, preconnectFontHosts } from './services/ui-config/utils';
|
|
17
21
|
|
|
18
22
|
function initCookies() {
|
|
23
|
+
// chatbotToken — токен магазина, не данные посетителя. uniqueUserID
|
|
24
|
+
// больше не ставится при загрузке: его создаёт trackWidgetEvent, и только
|
|
25
|
+
// когда трекинг разрешён (consent.svelte.ts).
|
|
19
26
|
setCookie('chatbotToken', window.wineaterData?.CLIENT_TOKEN || DEMO_TOKEN, 365);
|
|
20
|
-
setUniqueUserCookie();
|
|
21
27
|
}
|
|
22
28
|
|
|
23
29
|
const DEMO_TOKEN = 'HeUw6IXuhvz6tRuXdPEGEbj3SGQ3Q2YtEc1Z8jd41Kg9LtyPb4Epu0to31SYIadG';
|
|
@@ -52,6 +58,20 @@
|
|
|
52
58
|
// Всё, что уже показывали по текущему запросу — «Try again» заменяет выдачу,
|
|
53
59
|
// и без накопления повторный клик вернул бы те же вина.
|
|
54
60
|
let seenIds = $state<string[]>([]);
|
|
61
|
+
// Один id на поиск: уходит в тело search/v2 и в каждое событие обратной
|
|
62
|
+
// связи (shown/click/...), чтобы бэкенд связал клики с конкретной выдачей.
|
|
63
|
+
let currentQueryId: string | null = null;
|
|
64
|
+
|
|
65
|
+
// Профиль вкуса: что показали и в каком порядке. Пустая выдача не событие.
|
|
66
|
+
function feedbackShown(wines: (Wine | RecommendedWine)[], text: string) {
|
|
67
|
+
if (!wines?.length) return;
|
|
68
|
+
trackFeedback({
|
|
69
|
+
type: 'shown',
|
|
70
|
+
query_id: currentQueryId ?? undefined,
|
|
71
|
+
sku_ids: wines.map((w) => w.id),
|
|
72
|
+
text: text || undefined
|
|
73
|
+
});
|
|
74
|
+
}
|
|
55
75
|
let appLanguage = $derived(appStore.getAppLanguage());
|
|
56
76
|
let storeLanguage = $derived(appStore.getStoreLanguage());
|
|
57
77
|
let apiKey = $derived(appStore.getApiKey());
|
|
@@ -157,6 +177,7 @@
|
|
|
157
177
|
|
|
158
178
|
query = searchQuery.trim() + ';';
|
|
159
179
|
lastSearchedQuery = query;
|
|
180
|
+
currentQueryId = newQueryId();
|
|
160
181
|
try {
|
|
161
182
|
trackWidgetEvent(WIDGET_EVENTS.SEARCH_PERFORMED, storeId, { query });
|
|
162
183
|
} catch (error) {
|
|
@@ -166,7 +187,7 @@
|
|
|
166
187
|
// Запрос уходит сразу, карточки уезжают вверх, пока он в полёте.
|
|
167
188
|
// Пустой catch нужен, чтобы отказ запроса во время анимации не стал
|
|
168
189
|
// unhandled rejection: ошибку обработает await ниже.
|
|
169
|
-
const request = wineaterApi.getWineRecommendationsSearch(query);
|
|
190
|
+
const request = wineaterApi.getWineRecommendationsSearch(query, currentQueryId);
|
|
170
191
|
request.catch(() => {});
|
|
171
192
|
|
|
172
193
|
// шаг 1: плавно поднять карточки
|
|
@@ -192,6 +213,7 @@
|
|
|
192
213
|
// "more"-эндпоинта. Неполная выдача (< 4) означает, что кандидаты и так
|
|
193
214
|
// кончились: кнопка в этом случае только мигнула бы впустую.
|
|
194
215
|
hasMore = apiVersion === '2' && searchResults.length >= 4;
|
|
216
|
+
feedbackShown(searchResults, query);
|
|
195
217
|
trackWidgetEvent(WIDGET_EVENTS.SEARCH_RESULTS_DISPLAYED, storeId, {
|
|
196
218
|
results_count: searchResults.length,
|
|
197
219
|
results: searchResults,
|
|
@@ -235,6 +257,12 @@
|
|
|
235
257
|
isAnimating = false;
|
|
236
258
|
|
|
237
259
|
const { recommendations, hasMore: more } = await request;
|
|
260
|
+
trackFeedback({
|
|
261
|
+
type: 'more',
|
|
262
|
+
query_id: currentQueryId ?? undefined,
|
|
263
|
+
text: lastSearchedQuery || undefined
|
|
264
|
+
});
|
|
265
|
+
feedbackShown(recommendations, lastSearchedQuery);
|
|
238
266
|
trackWidgetEvent(WIDGET_EVENTS.SHOW_MORE_CLICKED, storeId, {
|
|
239
267
|
query: lastSearchedQuery,
|
|
240
268
|
results_count: recommendations.length
|
|
@@ -260,6 +288,10 @@
|
|
|
260
288
|
wine_id: wine.id,
|
|
261
289
|
wine_title: wine.title
|
|
262
290
|
});
|
|
291
|
+
trackFeedback({ type: 'similar', query_id: currentQueryId ?? undefined, sku_ids: [wine.id] });
|
|
292
|
+
// «Похожие» — новая выдача без текста запроса: свой id, чтобы shown не
|
|
293
|
+
// приписались к предыдущему поиску.
|
|
294
|
+
currentQueryId = newQueryId();
|
|
263
295
|
|
|
264
296
|
const request = wineaterApi.getSimilarWines(wine.id);
|
|
265
297
|
request.catch(() => {});
|
|
@@ -279,6 +311,7 @@
|
|
|
279
311
|
errorMessage = getTranslation({ key: 'noResultsMessage', lang: appLanguage });
|
|
280
312
|
} else {
|
|
281
313
|
results = similarWines;
|
|
314
|
+
feedbackShown(similarWines, '');
|
|
282
315
|
}
|
|
283
316
|
} catch (error) {
|
|
284
317
|
results = [];
|
|
@@ -315,6 +348,7 @@
|
|
|
315
348
|
|
|
316
349
|
selectedWineId = wine.id;
|
|
317
350
|
|
|
351
|
+
trackFeedback({ type: 'click', query_id: currentQueryId ?? undefined, sku_ids: [wine.id], position: index });
|
|
318
352
|
trackWidgetEvent(WIDGET_EVENTS.WINE_DETAILS_VIEWED, storeId, {
|
|
319
353
|
wine_id: wine.id,
|
|
320
354
|
wine_title: wine.title,
|
|
@@ -323,6 +357,9 @@
|
|
|
323
357
|
}
|
|
324
358
|
|
|
325
359
|
function handleBuyLinkClick(wine: Wine | RecommendedWine, index: number) {
|
|
360
|
+
// Переход на страницу товара — самый сильный сигнал, который виджет видит
|
|
361
|
+
// (корзины внутри виджета нет). keepalive-батч доедет и после ухода со страницы.
|
|
362
|
+
trackFeedback({ type: 'buy_click', query_id: currentQueryId ?? undefined, sku_ids: [wine.id], position: index });
|
|
326
363
|
trackWidgetEvent(WIDGET_EVENTS.BUY_BUTTON_CLICKED, storeId, {
|
|
327
364
|
wine_id: wine.id,
|
|
328
365
|
wine_title: wine.title,
|
|
@@ -364,6 +401,12 @@
|
|
|
364
401
|
return;
|
|
365
402
|
}
|
|
366
403
|
|
|
404
|
+
trackFeedback({
|
|
405
|
+
type: 'click',
|
|
406
|
+
query_id: currentQueryId ?? undefined,
|
|
407
|
+
sku_ids: [wine.id],
|
|
408
|
+
position: results.findIndex((w) => w.id === wine.id)
|
|
409
|
+
});
|
|
367
410
|
trackWidgetEvent(WIDGET_EVENTS.WINE_CARD_CLICK, storeId, {
|
|
368
411
|
wine_id: wine.id,
|
|
369
412
|
wine_title: wine.title
|
|
@@ -410,11 +453,18 @@
|
|
|
410
453
|
}
|
|
411
454
|
|
|
412
455
|
initialized = true;
|
|
456
|
+
// без согласия (EU-магазин) trackWidgetEvent — no-op; после «Принять»
|
|
457
|
+
// событие инициализации отправляется один раз, см. onConsentChange ниже
|
|
413
458
|
trackWidgetEvent(WIDGET_EVENTS.WIDGET_INITIALIZED, storeId);
|
|
414
459
|
})();
|
|
415
460
|
|
|
461
|
+
const offConsent = onConsentChange((c) => {
|
|
462
|
+
if (c === 'granted') trackWidgetEvent(WIDGET_EVENTS.WIDGET_INITIALIZED, storeId);
|
|
463
|
+
});
|
|
464
|
+
|
|
416
465
|
return () => {
|
|
417
466
|
document.removeEventListener('click', handleClickOutside);
|
|
467
|
+
offConsent();
|
|
418
468
|
};
|
|
419
469
|
});
|
|
420
470
|
</script>
|
|
@@ -469,6 +519,10 @@
|
|
|
469
519
|
{/if}
|
|
470
520
|
</div>
|
|
471
521
|
|
|
522
|
+
{#if shouldAskConsent()}
|
|
523
|
+
<CookieConsent lang={appLanguage} />
|
|
524
|
+
{/if}
|
|
525
|
+
|
|
472
526
|
<Footer {countryCode} />
|
|
473
527
|
</div>
|
|
474
528
|
{:else if !configFailed}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// Consent banner for shops with Stores.requires_cookie_consent (EU).
|
|
3
|
+
// Rendered inside the widget, not over the host page: the shop has its own
|
|
4
|
+
// site-wide banner, ours covers only what the widget itself stores/sends.
|
|
5
|
+
import Action from './ui/Action.svelte';
|
|
6
|
+
import { getTranslation } from '../services/i18n/i18n';
|
|
7
|
+
import { setConsent } from '../services/profile/consent.svelte';
|
|
8
|
+
|
|
9
|
+
let { lang }: { lang: string } = $props();
|
|
10
|
+
|
|
11
|
+
// optional link to the shop's privacy policy
|
|
12
|
+
const privacyUrl: string | undefined = window?.wineaterData?.PRIVACY_URL;
|
|
13
|
+
|
|
14
|
+
const t = (key: string) => getTranslation({ key, lang });
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<div class="wr-Consent" role="region" aria-label="Cookies">
|
|
18
|
+
<p class="wr-Consent-text">
|
|
19
|
+
{t('consentText')}
|
|
20
|
+
{#if privacyUrl}
|
|
21
|
+
<a class="wr-Consent-link" href={privacyUrl} target="_blank" rel="noopener noreferrer">
|
|
22
|
+
{t('consentPrivacy')}
|
|
23
|
+
</a>
|
|
24
|
+
{/if}
|
|
25
|
+
</p>
|
|
26
|
+
<div class="wr-Consent-actions">
|
|
27
|
+
<Action variant="outlined" handleClick={() => setConsent('denied')}>
|
|
28
|
+
{t('consentDecline')}
|
|
29
|
+
</Action>
|
|
30
|
+
<Action variant="primary" handleClick={() => setConsent('granted')}>
|
|
31
|
+
{t('consentAccept')}
|
|
32
|
+
</Action>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<style>
|
|
37
|
+
.wr-Consent {
|
|
38
|
+
display: flex;
|
|
39
|
+
align-items: center;
|
|
40
|
+
justify-content: space-between;
|
|
41
|
+
gap: 16px;
|
|
42
|
+
margin: 12px 0 0;
|
|
43
|
+
padding: 12px 16px;
|
|
44
|
+
border-radius: 12px;
|
|
45
|
+
background: #f6f4f2;
|
|
46
|
+
font-size: 13px;
|
|
47
|
+
line-height: 1.4;
|
|
48
|
+
color: #333;
|
|
49
|
+
}
|
|
50
|
+
.wr-Consent-text {
|
|
51
|
+
margin: 0;
|
|
52
|
+
flex: 1;
|
|
53
|
+
}
|
|
54
|
+
.wr-Consent-link {
|
|
55
|
+
color: inherit;
|
|
56
|
+
text-decoration: underline;
|
|
57
|
+
margin-left: 4px;
|
|
58
|
+
}
|
|
59
|
+
.wr-Consent-actions {
|
|
60
|
+
display: flex;
|
|
61
|
+
gap: 8px;
|
|
62
|
+
flex-shrink: 0;
|
|
63
|
+
}
|
|
64
|
+
@media (max-width: 640px) {
|
|
65
|
+
.wr-Consent {
|
|
66
|
+
flex-direction: column;
|
|
67
|
+
align-items: stretch;
|
|
68
|
+
}
|
|
69
|
+
.wr-Consent-actions {
|
|
70
|
+
justify-content: flex-end;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
</style>
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { trackingAllowed } from '../profile/consent.svelte';
|
|
2
|
+
|
|
1
3
|
function generateUUID(): string {
|
|
2
4
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
|
3
5
|
const r = Math.random() * 16 | 0,
|
|
@@ -39,6 +41,9 @@ export function setUniqueUserCookie(): string {
|
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
export function trackWidgetEvent(event_type: string, store_id, data = {}, meta = {}) {
|
|
44
|
+
// Аналитика идентифицирует посетителя (uniqueUserID, user agent, экран) —
|
|
45
|
+
// в магазинах с requires_cookie_consent только после «Принять».
|
|
46
|
+
if (!trackingAllowed()) return;
|
|
42
47
|
const endpoint = 'https://admin-api.wineater.com/api/widget-events';
|
|
43
48
|
|
|
44
49
|
// Ensure we have a unique user ID
|
package/src/services/api/api.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { deserializeInstantRecommendation, deserializeRecommendedWine, deserializeMoreRecommendations } from "./deserialize";
|
|
2
|
+
import { getUid } from "../profile/uid";
|
|
3
|
+
import { initFeedback, type FeedbackEvent } from "../profile/events";
|
|
2
4
|
|
|
3
5
|
export type WineId = string;
|
|
4
6
|
export type WinePrice = number;
|
|
@@ -35,64 +37,84 @@ export type RecommendedWine = Omit<Wine, "shortDescription" | "producer" | "pair
|
|
|
35
37
|
reason: string;
|
|
36
38
|
};
|
|
37
39
|
|
|
40
|
+
const UID_HEADER = 'X-Wineater-Uid';
|
|
41
|
+
|
|
38
42
|
class WineaterApi {
|
|
39
43
|
#baseUrl = 'https://api.wineater.com/';
|
|
40
44
|
#token: string;
|
|
41
45
|
#apiVersion: string;
|
|
46
|
+
// Set when a request with the uid header fails at the network level (CORS
|
|
47
|
+
// preflight rejected by a backend that does not allow it yet): we then
|
|
48
|
+
// retry without the header and stop sending it for this page.
|
|
49
|
+
#uidHeaderUnsupported = false;
|
|
42
50
|
|
|
43
51
|
constructor(token: string, apiVersion: string = '1') {
|
|
44
52
|
this.#token = token;
|
|
45
53
|
this.#apiVersion = apiVersion;
|
|
46
54
|
}
|
|
47
55
|
|
|
56
|
+
#headers(): Record<string, string> {
|
|
57
|
+
const headers: Record<string, string> = {
|
|
58
|
+
'Content-Type': 'application/json',
|
|
59
|
+
'Authorization': `Bearer ${this.#token}`
|
|
60
|
+
};
|
|
61
|
+
if (!this.#uidHeaderUnsupported) {
|
|
62
|
+
const uid = getUid();
|
|
63
|
+
if (uid) headers[UID_HEADER] = uid;
|
|
64
|
+
}
|
|
65
|
+
return headers;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// One place for every backend call: JSON body, auth, anonymous uid header,
|
|
69
|
+
// uniform error. `keepalive` lets the last feedback batch out-live the page.
|
|
70
|
+
async #request<T>(endpoint: string, body?: unknown, options: { keepalive?: boolean; method?: string } = {}): Promise<T> {
|
|
71
|
+
const init = (): RequestInit => ({
|
|
72
|
+
method: options.method ?? 'POST',
|
|
73
|
+
headers: this.#headers(),
|
|
74
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
75
|
+
keepalive: options.keepalive
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
let response: Response;
|
|
79
|
+
try {
|
|
80
|
+
response = await fetch(`${this.#baseUrl}${endpoint}`, init());
|
|
81
|
+
} catch (error) {
|
|
82
|
+
// A TypeError here is a network/CORS failure, not an HTTP status.
|
|
83
|
+
// If we were sending the uid header, retry once without it.
|
|
84
|
+
if (this.#uidHeaderUnsupported || !getUid()) throw error;
|
|
85
|
+
this.#uidHeaderUnsupported = true;
|
|
86
|
+
response = await fetch(`${this.#baseUrl}${endpoint}`, init());
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const text = await response.text();
|
|
90
|
+
const data = text ? JSON.parse(text) : null;
|
|
91
|
+
if (!response.ok) {
|
|
92
|
+
throw new Error(`API call failed with status ${response.status}: ${data?.message || 'No additional error information'}`);
|
|
93
|
+
}
|
|
94
|
+
return data as T;
|
|
95
|
+
}
|
|
96
|
+
|
|
48
97
|
async getInstantRecommendations(): Promise<Wine[]> {
|
|
49
98
|
try {
|
|
50
99
|
const endpoint = this.#apiVersion === '2'
|
|
51
100
|
? 'dish/recommendations/instant/v2'
|
|
52
101
|
: 'dish/recommendations/instant';
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
headers: {
|
|
56
|
-
'Content-Type': 'application/json',
|
|
57
|
-
'Authorization': `Bearer ${this.#token}`
|
|
58
|
-
}
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
const data = await response.json();
|
|
62
|
-
|
|
63
|
-
if (!response.ok) {
|
|
64
|
-
throw new Error(`API call failed with status ${response.status}: ${data.message || 'No additional error information'}`);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const deserializedData: Wine[] = data.filter(Boolean).map(deserializeInstantRecommendation);
|
|
68
|
-
return deserializedData;
|
|
102
|
+
const data = await this.#request<any[]>(endpoint, {});
|
|
103
|
+
return data.filter(Boolean).map(deserializeInstantRecommendation);
|
|
69
104
|
} catch (error) {
|
|
70
105
|
throw new Error(`Error in getInstantRecommendations: ${error.message}`);
|
|
71
106
|
}
|
|
72
107
|
}
|
|
73
108
|
|
|
74
|
-
|
|
109
|
+
// `queryId` is generated by the widget per search and echoed in feedback
|
|
110
|
+
// events, so the backend can tie clicks to the exact result set.
|
|
111
|
+
async getWineRecommendationsSearch(text: string, queryId?: string): Promise<RecommendedWine[]> {
|
|
75
112
|
try {
|
|
76
113
|
const endpoint = this.#apiVersion === '2'
|
|
77
114
|
? 'dish/recommendations/search/v2'
|
|
78
115
|
: 'dish/recommendations/search';
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
headers: {
|
|
82
|
-
'Content-Type': 'application/json',
|
|
83
|
-
'Authorization': `Bearer ${this.#token}`
|
|
84
|
-
},
|
|
85
|
-
body: JSON.stringify({ text: text })
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
const data = await response.json();
|
|
89
|
-
|
|
90
|
-
if (!response.ok) {
|
|
91
|
-
throw new Error(`API call failed with status ${response.status}: ${data.message || 'No additional error information'}`);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const deserializedData: RecommendedWine[] = data.filter(Boolean).map(deserializeRecommendedWine);
|
|
95
|
-
return deserializedData;
|
|
116
|
+
const data = await this.#request<any[]>(endpoint, queryId ? { text, query_id: queryId } : { text });
|
|
117
|
+
return data.filter(Boolean).map(deserializeRecommendedWine);
|
|
96
118
|
} catch (error) {
|
|
97
119
|
throw new Error(`Error in getWineRecommendations: ${error.message}`);
|
|
98
120
|
}
|
|
@@ -100,21 +122,7 @@ class WineaterApi {
|
|
|
100
122
|
|
|
101
123
|
async getMoreWineRecommendations(text: string, excludeIds: string[]): Promise<{ recommendations: RecommendedWine[]; hasMore: boolean }> {
|
|
102
124
|
try {
|
|
103
|
-
const
|
|
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
|
-
|
|
125
|
+
const data = await this.#request<any>('dish/recommendations/search/v2/more', { text, exclude_ids: excludeIds });
|
|
118
126
|
return deserializeMoreRecommendations(data);
|
|
119
127
|
} catch (error) {
|
|
120
128
|
throw new Error(`Error in getMoreWineRecommendations: ${error.message}`);
|
|
@@ -123,39 +131,22 @@ class WineaterApi {
|
|
|
123
131
|
|
|
124
132
|
async getSimilarWines(id: WineId): Promise<RecommendedWine[]> {
|
|
125
133
|
try {
|
|
126
|
-
const
|
|
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
|
-
|
|
134
|
+
const data = await this.#request<any[]>('dish/recommendations/similar', { id });
|
|
141
135
|
return data.filter(Boolean).map(deserializeRecommendedWine);
|
|
142
136
|
} catch (error) {
|
|
143
137
|
throw new Error(`Error in getSimilarWines: ${error.message}`);
|
|
144
138
|
}
|
|
145
139
|
}
|
|
146
140
|
|
|
141
|
+
// Implicit-feedback batch for the anonymous taste profile (backend docs/PROFILE.md).
|
|
142
|
+
async postEvents(batch: { uid: string; events: FeedbackEvent[] }): Promise<void> {
|
|
143
|
+
await this.#request('events', batch, { keepalive: true });
|
|
144
|
+
}
|
|
145
|
+
|
|
147
146
|
// TODO: define store type
|
|
148
147
|
async getStoreConfig(): Promise<any> {
|
|
149
148
|
try {
|
|
150
|
-
|
|
151
|
-
method: 'POST',
|
|
152
|
-
headers: {
|
|
153
|
-
'Content-Type': 'application/json',
|
|
154
|
-
'Authorization': `Bearer ${this.#token}`
|
|
155
|
-
}
|
|
156
|
-
});
|
|
157
|
-
if (!response.ok) throw new Error(`HTTP error! Status: ${response.status}`);
|
|
158
|
-
return await response.json();
|
|
149
|
+
return await this.#request<any>('stores/store', {});
|
|
159
150
|
} catch (error) {
|
|
160
151
|
console.error('Error fetching store configuration:', error);
|
|
161
152
|
throw error;
|
|
@@ -167,6 +158,7 @@ const DEMO_TOKEN = 'HeUw6IXuhvz6tRuXdPEGEbj3SGQ3Q2YtEc1Z8jd41Kg9LtyPb4Epu0to31SY
|
|
|
167
158
|
const token = window?.wineaterData?.CLIENT_TOKEN || DEMO_TOKEN;
|
|
168
159
|
export const apiVersion = String(window?.wineaterData?.apiVersion || '1');
|
|
169
160
|
export const wineaterApi = new WineaterApi(token, apiVersion)
|
|
161
|
+
initFeedback((batch) => wineaterApi.postEvents(batch));
|
|
170
162
|
|
|
171
163
|
// Namespaces cached responses per client token and API version, so two widgets
|
|
172
164
|
// on one origin (or a token switch) never see each other's data. Not a secret:
|
|
@@ -63,7 +63,11 @@
|
|
|
63
63
|
"headerOr": "ou",
|
|
64
64
|
"errorMessage": "Une erreur est survenue. Veuillez réessayer plus tard.",
|
|
65
65
|
"noResultsMessage": "Aucun résultat trouvé pour votre recherche. Veuillez essayer une autre requête.",
|
|
66
|
-
"ai_sommelier": "AI sommelier"
|
|
66
|
+
"ai_sommelier": "AI sommelier",
|
|
67
|
+
"consentText": "L'assistant vin enregistre un identifiant anonyme sur cet appareil pour apprendre vos goûts et améliorer ses recommandations. Vous pouvez l'utiliser sans accepter.",
|
|
68
|
+
"consentAccept": "Accepter",
|
|
69
|
+
"consentDecline": "Refuser",
|
|
70
|
+
"consentPrivacy": "Politique de confidentialité"
|
|
67
71
|
},
|
|
68
72
|
"es": {
|
|
69
73
|
"lang": "es",
|
|
@@ -129,7 +133,11 @@
|
|
|
129
133
|
"headerOr": "o",
|
|
130
134
|
"errorMessage": "Algo salió mal. Por favor, inténtalo de nuevo más tarde.",
|
|
131
135
|
"noResultsMessage": "No se encontraron resultados para tu búsqueda. Por favor, intenta con otra consulta.",
|
|
132
|
-
"ai_sommelier": "AI sommelier"
|
|
136
|
+
"ai_sommelier": "AI sommelier",
|
|
137
|
+
"consentText": "El asistente de vinos guarda un identificador anónimo en este dispositivo para conocer tus gustos y mejorar las recomendaciones. Puedes usarlo sin aceptar.",
|
|
138
|
+
"consentAccept": "Aceptar",
|
|
139
|
+
"consentDecline": "Rechazar",
|
|
140
|
+
"consentPrivacy": "Política de privacidad"
|
|
133
141
|
},
|
|
134
142
|
"en": {
|
|
135
143
|
"lang": "en",
|
|
@@ -195,7 +203,11 @@
|
|
|
195
203
|
"headerOr": "or",
|
|
196
204
|
"errorMessage": "Something went wrong. Please try again later.",
|
|
197
205
|
"noResultsMessage": "No results found for your search. Please try a different query.",
|
|
198
|
-
"ai_sommelier": "AI sommelier"
|
|
206
|
+
"ai_sommelier": "AI sommelier",
|
|
207
|
+
"consentText": "The wine assistant stores an anonymous identifier on this device to learn your taste and improve recommendations. You can use it without accepting.",
|
|
208
|
+
"consentAccept": "Accept",
|
|
209
|
+
"consentDecline": "Decline",
|
|
210
|
+
"consentPrivacy": "Privacy policy"
|
|
199
211
|
},
|
|
200
212
|
"sr": {
|
|
201
213
|
"lang": "sr",
|
|
@@ -261,7 +273,11 @@
|
|
|
261
273
|
"headerOr": "ili",
|
|
262
274
|
"errorMessage": "Nešto je pošlo po zlu. Molimo pokušajte ponovo kasnije.",
|
|
263
275
|
"noResultsMessage": "Nema rezultata za vašu pretragu. Molimo pokušajte sa drugačijim upitom.",
|
|
264
|
-
"ai_sommelier": "AI sommelier"
|
|
276
|
+
"ai_sommelier": "AI sommelier",
|
|
277
|
+
"consentText": "Vinski asistent čuva anonimni identifikator na ovom uređaju kako bi upoznao vaš ukus i poboljšao preporuke. Možete ga koristiti i bez prihvatanja.",
|
|
278
|
+
"consentAccept": "Prihvati",
|
|
279
|
+
"consentDecline": "Odbij",
|
|
280
|
+
"consentPrivacy": "Politika privatnosti"
|
|
265
281
|
},
|
|
266
282
|
"de": {
|
|
267
283
|
"lang": "de",
|
|
@@ -326,7 +342,11 @@
|
|
|
326
342
|
"headerOr": "oder",
|
|
327
343
|
"errorMessage": "Etwas ist schief gelaufen. Bitte versuchen Sie es später erneut.",
|
|
328
344
|
"noResultsMessage": "Keine Ergebnisse für Ihre Suche gefunden. Bitte versuchen Sie es mit einer anderen Anfrage.",
|
|
329
|
-
"ai_sommelier": "AI sommelier"
|
|
345
|
+
"ai_sommelier": "AI sommelier",
|
|
346
|
+
"consentText": "Der Weinassistent speichert eine anonyme Kennung auf diesem Gerät, um Ihren Geschmack kennenzulernen und Empfehlungen zu verbessern. Sie können ihn auch ohne Zustimmung nutzen.",
|
|
347
|
+
"consentAccept": "Akzeptieren",
|
|
348
|
+
"consentDecline": "Ablehnen",
|
|
349
|
+
"consentPrivacy": "Datenschutzerklärung"
|
|
330
350
|
},
|
|
331
351
|
"ukr": {
|
|
332
352
|
"lang": "ukr",
|
|
@@ -392,7 +412,11 @@
|
|
|
392
412
|
"headerOr": "або",
|
|
393
413
|
"errorMessage": "Щось пішло не так. Будь ласка, спробуйте пізніше.",
|
|
394
414
|
"noResultsMessage": "За вашим запитом не знайдено результатів. Спробуйте інший запит.",
|
|
395
|
-
"ai_sommelier": "AI sommelier"
|
|
415
|
+
"ai_sommelier": "AI sommelier",
|
|
416
|
+
"consentText": "Винний помічник зберігає на цьому пристрої анонімний ідентифікатор, щоб враховувати ваш смак і покращувати рекомендації. Користуватися можна й без згоди.",
|
|
417
|
+
"consentAccept": "Прийняти",
|
|
418
|
+
"consentDecline": "Відхилити",
|
|
419
|
+
"consentPrivacy": "Політика конфіденційності"
|
|
396
420
|
},
|
|
397
421
|
"ru": {
|
|
398
422
|
"lang": "ru",
|
|
@@ -458,7 +482,11 @@
|
|
|
458
482
|
"headerOr": "или",
|
|
459
483
|
"errorMessage": "Что-то пошло не так. Пожалуйста, попробуйте позже.",
|
|
460
484
|
"noResultsMessage": "По вашему запросу не найдено результатов. Попробуйте другой запрос.",
|
|
461
|
-
"ai_sommelier": "AI sommelier"
|
|
485
|
+
"ai_sommelier": "AI sommelier",
|
|
486
|
+
"consentText": "Винный помощник сохраняет на этом устройстве анонимный идентификатор, чтобы учитывать ваш вкус и улучшать рекомендации. Пользоваться можно и без согласия.",
|
|
487
|
+
"consentAccept": "Принять",
|
|
488
|
+
"consentDecline": "Отклонить",
|
|
489
|
+
"consentPrivacy": "Политика конфиденциальности"
|
|
462
490
|
},
|
|
463
491
|
"kk": {
|
|
464
492
|
"lang": "kk",
|
|
@@ -524,7 +552,11 @@
|
|
|
524
552
|
"headerOr": "немесе",
|
|
525
553
|
"errorMessage": "Бірдеңе дұрыс болмады. Кейінірек қайта көріңіз.",
|
|
526
554
|
"noResultsMessage": "Іздеуіңізге сәйкес нәтиже табылмады. Басқа сұрау қолданып көріңіз.",
|
|
527
|
-
"ai_sommelier": "AI sommelier"
|
|
555
|
+
"ai_sommelier": "AI sommelier",
|
|
556
|
+
"consentText": "Шарап көмекшісі сіздің талғамыңызды ескеріп, ұсыныстарды жақсарту үшін осы құрылғыда анонимді идентификатор сақтайды. Келісімсіз де пайдалана аласыз.",
|
|
557
|
+
"consentAccept": "Қабылдау",
|
|
558
|
+
"consentDecline": "Бас тарту",
|
|
559
|
+
"consentPrivacy": "Құпиялылық саясаты"
|
|
528
560
|
},
|
|
529
561
|
"pt": {
|
|
530
562
|
"lang": "pt",
|
|
@@ -590,6 +622,10 @@
|
|
|
590
622
|
"headerOr": "ou",
|
|
591
623
|
"errorMessage": "Algo deu errado. Por favor, tente novamente mais tarde.",
|
|
592
624
|
"noResultsMessage": "Nenhum resultado encontrado para sua pesquisa. Por favor, tente uma consulta diferente.",
|
|
593
|
-
"ai_sommelier": "AI sommelier"
|
|
625
|
+
"ai_sommelier": "AI sommelier",
|
|
626
|
+
"consentText": "O assistente de vinhos guarda um identificador anónimo neste dispositivo para conhecer o seu gosto e melhorar as recomendações. Pode usá-lo sem aceitar.",
|
|
627
|
+
"consentAccept": "Aceitar",
|
|
628
|
+
"consentDecline": "Recusar",
|
|
629
|
+
"consentPrivacy": "Política de privacidade"
|
|
594
630
|
}
|
|
595
|
-
}
|
|
631
|
+
}
|