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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wineater-bot",
3
- "version": "4.19.0",
3
+ "version": "4.20.0",
4
4
  "description": "",
5
5
  "main": "dist/wineater-chatbot.mjs",
6
6
  "module": "src/index.js",
package/src/App.svelte CHANGED
@@ -1,23 +1,29 @@
1
1
  <script lang="ts">
2
- import { setCookie, setUniqueUserCookie, trackWidgetEvent } from './services/analytics/analytics';
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
@@ -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 response = await fetch(`${this.#baseUrl}${endpoint}`, {
54
- method: 'POST',
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
- async getWineRecommendationsSearch(text): Promise<RecommendedWine[]> {
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 response = await fetch(`${this.#baseUrl}${endpoint}`, {
80
- method: 'POST',
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 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
-
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 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
-
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
- const response = await fetch(`${this.#baseUrl}stores/store`, {
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
+ }