wineater-bot 4.24.0 → 4.24.2

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.
Files changed (57) hide show
  1. package/package.json +5 -1
  2. package/.claude/settings.local.json +0 -10
  3. package/.prettierignore +0 -9
  4. package/.prettierrc +0 -16
  5. package/CLAUDE.md +0 -115
  6. package/docs/CUSTOMIZATION.md +0 -133
  7. package/docs/SVELTE.md +0 -146
  8. package/e2e/demo.test.ts +0 -6
  9. package/eslint.config.js +0 -41
  10. package/index.html +0 -23
  11. package/playwright.config.ts +0 -9
  12. package/pnpm-workspace.yaml +0 -2
  13. package/src/App.svelte +0 -784
  14. package/src/App.svelte.test.ts +0 -13
  15. package/src/assets/biobio.png +0 -0
  16. package/src/components/CookieConsent.svelte +0 -73
  17. package/src/components/Footer.svelte +0 -122
  18. package/src/components/Header.svelte +0 -99
  19. package/src/components/Search.svelte +0 -177
  20. package/src/components/SkeletonTiles.svelte +0 -94
  21. package/src/components/WineBottle.svelte +0 -56
  22. package/src/components/WinePopup.svelte +0 -192
  23. package/src/components/WineTile.svelte +0 -458
  24. package/src/components/icons/Budget.svelte +0 -35
  25. package/src/components/icons/Diamond.svelte +0 -35
  26. package/src/components/icons/Fish.svelte +0 -56
  27. package/src/components/icons/Heart.svelte +0 -28
  28. package/src/components/icons/Meat.svelte +0 -32
  29. package/src/components/ui/Action.svelte +0 -83
  30. package/src/demo.spec.ts +0 -13
  31. package/src/index.js +0 -7
  32. package/src/services/analytics/analytics.ts +0 -142
  33. package/src/services/analytics/constants.ts +0 -18
  34. package/src/services/api/api.ts +0 -298
  35. package/src/services/api/deserialize.ts +0 -120
  36. package/src/services/bottle/bottle.spec.ts +0 -32
  37. package/src/services/bottle/bottle.ts +0 -78
  38. package/src/services/cache.ts +0 -28
  39. package/src/services/i18n/i18n.ts +0 -146
  40. package/src/services/i18n/translations.json +0 -658
  41. package/src/services/profile/consent.svelte.ts +0 -115
  42. package/src/services/profile/events.ts +0 -73
  43. package/src/services/profile/uid.ts +0 -87
  44. package/src/services/shopify/cart.ts +0 -105
  45. package/src/services/shopify/shopify.spec.ts +0 -66
  46. package/src/services/shopify/shopify.ts +0 -150
  47. package/src/services/store/app-store.svelte.ts +0 -172
  48. package/src/services/store/deserialize.ts +0 -25
  49. package/src/services/store/types.ts +0 -12
  50. package/src/services/ui-config/get-config.ts +0 -25
  51. package/src/services/ui-config/projects-config.ts +0 -156
  52. package/src/services/ui-config/types.ts +0 -15
  53. package/src/services/ui-config/utils.ts +0 -40
  54. package/tsconfig.json +0 -23
  55. package/vite.config.js +0 -84
  56. package/vite.config.js.timestamp-1772121492941-e00587396dc48.mjs +0 -71
  57. package/vitest-setup-client.ts +0 -2
@@ -1,56 +0,0 @@
1
- <script lang="ts">
2
- export let className: string = '';
3
- export let size: number = 20;
4
- </script>
5
-
6
- <svg
7
- class={className}
8
- xmlns="http://www.w3.org/2000/svg"
9
- width={size}
10
- height={size}
11
- viewBox="0 0 34 34"
12
- fill="none"
13
- >
14
- <path
15
- d="M9.03846 17.0001C10.3456 12.1888 15.9077 8.65686 20.858 8.65686C25.8083 8.65686 29.2846 12.1888 30.5917 17.0001C29.2846 21.8252 25.8083 25.3432 20.858 25.3432C15.9077 25.3432 10.3456 21.8252 9.03846 17.0001Z"
16
- stroke="currentColor"
17
- stroke-width="2.78106"
18
- stroke-linecap="round"
19
- stroke-linejoin="round"
20
- />
21
- <path
22
- d="M25.0296 17V17.6953"
23
- stroke="currentColor"
24
- stroke-width="2.78106"
25
- stroke-linecap="round"
26
- stroke-linejoin="round"
27
- />
28
- <path
29
- d="M22.2485 25.2457C20.4397 22.8774 19.4599 19.98 19.4599 16.9999C19.4599 14.0198 20.4397 11.1224 22.2485 8.75403"
30
- stroke="currentColor"
31
- stroke-width="2.78106"
32
- stroke-linecap="round"
33
- stroke-linejoin="round"
34
- />
35
- <path
36
- d="M9.73372 15.1506C9.73372 11.4379 7.75916 8.6151 3.79615 7.96155C2.40562 10.0473 2.40562 14.9142 4.11597 17C2.39171 19.0858 2.39171 23.9527 3.79615 26.0385C7.75916 25.3849 9.73372 22.5621 9.73372 18.8494"
37
- stroke="currentColor"
38
- stroke-width="2.78106"
39
- stroke-linecap="round"
40
- stroke-linejoin="round"
41
- />
42
- <path
43
- d="M14.545 10.4089C14.1834 8.48998 12.7512 6.20951 11.1243 4.48525H19.1893C19.8491 4.4828 20.4883 4.71502 20.9927 5.14042C21.497 5.56582 21.8337 6.15668 21.9426 6.80744L22.2624 8.75418"
44
- stroke="currentColor"
45
- stroke-width="2.78106"
46
- stroke-linecap="round"
47
- stroke-linejoin="round"
48
- />
49
- <path
50
- d="M22.2624 25.2459L21.9426 27.1926C21.8337 27.8434 21.497 28.4342 20.9927 28.8596C20.4883 29.285 19.8491 29.5172 19.1893 29.5148H13.2101C14.5573 27.9873 15.2948 26.0172 15.2819 23.9805"
51
- stroke="currentColor"
52
- stroke-width="2.78106"
53
- stroke-linecap="round"
54
- stroke-linejoin="round"
55
- />
56
- </svg>
@@ -1,28 +0,0 @@
1
- <script lang="ts">
2
- export let className: string = '';
3
- export let size: number = 20;
4
- </script>
5
-
6
- <svg
7
- class={className}
8
- xmlns="http://www.w3.org/2000/svg"
9
- width={size}
10
- height={size}
11
- viewBox="0 0 38 38"
12
- fill="none"
13
- >
14
- <path
15
- d="M29.533 21.4034C31.7986 19.1834 34.0946 16.5224 34.0946 13.0404C34.0946 10.8224 33.2135 8.69521 31.6451 7.12684C30.0768 5.55847 27.9496 4.67737 25.7316 4.67737C23.0554 4.67737 21.1699 5.43764 18.8891 7.71847C16.6083 5.43764 14.7228 4.67737 12.0467 4.67737C9.82865 4.67737 7.70148 5.55847 6.13311 7.12684C4.56474 8.69521 3.68364 10.8224 3.68364 13.0404C3.68364 16.5376 5.96446 19.1986 8.24528 21.4034L18.8891 32.0472L29.533 21.4034Z"
16
- stroke="currentColor"
17
- stroke-width="3.0411"
18
- stroke-linecap="round"
19
- stroke-linejoin="round"
20
- />
21
- <path
22
- d="M18.8891 19.8829L17.3686 18.3623L20.4097 15.3212L15.848 10.7596L18.8891 7.71851"
23
- stroke="currentColor"
24
- stroke-width="3.0411"
25
- stroke-linecap="round"
26
- stroke-linejoin="round"
27
- />
28
- </svg>
@@ -1,32 +0,0 @@
1
- <script lang="ts">
2
- export let className: string = '';
3
- export let size: number = 20;
4
- </script>
5
-
6
- <svg
7
- class={className}
8
- viewBox="0 0 38 38"
9
- xmlns="http://www.w3.org/2000/svg"
10
- width={size}
11
- height={size}
12
- fill="none"
13
- ><path
14
- stroke="currentColor"
15
- stroke-linecap="round"
16
- stroke-linejoin="round"
17
- stroke-width="3.041"
18
- d="M19.57 17.655a3.801 3.801 0 1 0 0-7.602 3.801 3.801 0 0 0 0 7.602Z"
19
- /><path
20
- stroke="currentColor"
21
- stroke-linecap="round"
22
- stroke-linejoin="round"
23
- stroke-width="3.041"
24
- d="M19.57 3.97a9.884 9.884 0 0 0-9.458 6.995c-1.673 4.76-1.186 5.93-4.835 9.245a4.562 4.562 0 0 0 2.889 8.09c6.082 0 12.772-2.738 17.334-6.54a9.885 9.885 0 0 0-5.93-17.79Z"
25
- /><path
26
- stroke="currentColor"
27
- stroke-linecap="round"
28
- stroke-linejoin="round"
29
- stroke-width="3.041"
30
- d="m28.693 10.053 3.33 6.842c.316.983.475 2.009.471 3.041a9.867 9.867 0 0 1-3.953 7.907c-4.562 3.801-11.252 6.538-17.334 6.538a4.562 4.562 0 0 1-4.075-2.524l-2.92-5.839"
31
- /></svg
32
- >
@@ -1,83 +0,0 @@
1
- <script lang="ts">
2
- import { appStore } from '../../services/store/app-store.svelte';
3
-
4
- type ActionProps = {
5
- type?: 'button' | 'submit' | 'reset';
6
- handleClick?: () => void;
7
- variant?: 'primary' | 'outlined' | 'text';
8
- className?: string;
9
- id?: string;
10
- href?: string;
11
- target?: string;
12
- };
13
- let {
14
- type = 'button',
15
- handleClick,
16
- variant = 'primary',
17
- className,
18
- id,
19
- href,
20
- target = '_self'
21
- }: ActionProps = $props();
22
- let appUiConfig = appStore.getUiConfig();
23
- let calculatedClassName =
24
- `wr-Action ${variant} ${className || ''} ${appUiConfig.buttonUppercase ? 'wr-Action--uppercase' : ''}`.trim();
25
- </script>
26
-
27
- {#if href}
28
- <a class={calculatedClassName} {href} {id} {target} rel="noopener noreferrer" onclick={handleClick}>
29
- <slot></slot>
30
- </a>
31
- {:else}
32
- <button class={calculatedClassName} {type} onclick={handleClick} {id}>
33
- <slot></slot>
34
- </button>
35
- {/if}
36
-
37
- <style>
38
- .wr-Action {
39
- background: transparent;
40
- border: none;
41
- font-family: inherit;
42
- cursor: pointer;
43
- display: block;
44
- text-decoration: none;
45
- transition:
46
- background 0.2s,
47
- color 0.2s,
48
- box-shadow 0.2s;
49
- }
50
-
51
- .wr-Action.primary {
52
- background: var(--wr-brand-color);
53
- color: #fff;
54
- border-radius: 6px;
55
- padding: 8px 16px;
56
- font-size: 14px;
57
- font-weight: 500;
58
-
59
- &:hover {
60
- opacity: 0.8;
61
- }
62
- }
63
-
64
- .wr-Action.outlined {
65
- background: transparent;
66
- color: var(--wr-brand-color);
67
- border: 2px solid var(--wr-brand-color);
68
- border-radius: 6px;
69
- padding: 8px 16px;
70
- font-size: 14px;
71
- font-weight: 500;
72
-
73
- &:hover {
74
- box-shadow: 0 4px 8px rgba(123, 43, 235, 0.25);
75
- background-color: var(--wr-brand-color);
76
- color: #fff;
77
- }
78
- }
79
-
80
- .wr-Action--uppercase {
81
- text-transform: uppercase;
82
- }
83
- </style>
package/src/demo.spec.ts DELETED
@@ -1,13 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
-
3
- describe('sum test', () => {
4
- it('adds 1 + 2 to equal 3', () => {
5
- expect(1 + 2).toBe(3);
6
- });
7
-
8
- it('handles ESM imports correctly', () => {
9
- // Test that verifies ESM module loading works
10
- const result = typeof import.meta !== 'undefined';
11
- expect(result).toBe(true);
12
- });
13
- });
package/src/index.js DELETED
@@ -1,7 +0,0 @@
1
- import SvelteApp from './App.svelte'
2
- import { mount } from 'svelte';
3
-
4
- // Shopify app embed passes its own target (inside a shadow root); everyone else uses the page div
5
- const target = window.wineaterData?.mountTarget || document.getElementById("wineater-widget-conteiner");
6
- const app = mount(SvelteApp, { target });
7
- export default app
@@ -1,142 +0,0 @@
1
- import { trackingAllowed } from '../profile/consent.svelte';
2
- import { shopifyContext } from '../shopify/shopify';
3
-
4
- function generateUUID(): string {
5
- return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
6
- const r = Math.random() * 16 | 0,
7
- v = c === 'x' ? r : (r & 0x3 | 0x8);
8
- return v.toString(16);
9
- });
10
- }
11
-
12
- export function setCookie(name: string, value: string, days: number) {
13
- let expires = "";
14
- if (days) {
15
- const date = new Date();
16
- date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
17
- expires = "; expires=" + date.toUTCString();
18
- }
19
- document.cookie = name + "=" + (value || "") + expires + "; path=/; secure; samesite=strict";
20
- }
21
-
22
- function getCookie(name: string): string | null {
23
- const nameEQ = name + "=";
24
- const ca = document.cookie.split(';');
25
- for (let i = 0; i < ca.length; i++) {
26
- let c = ca[i];
27
- while (c.charAt(0) === ' ') c = c.substring(1, c.length);
28
- if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length);
29
- }
30
- return null;
31
- }
32
-
33
- export function setUniqueUserCookie(): string {
34
- const cookieName = 'uniqueUserID';
35
- let userId = getCookie(cookieName);
36
-
37
- if (!userId) {
38
- userId = generateUUID();
39
- setCookie(cookieName, userId, 365);
40
- }
41
- return userId;
42
- }
43
-
44
- export function trackWidgetEvent(event_type: string, store_id, data = {}, meta = {}) {
45
- // Тот же маршрут бэкенда, что и на admin-api.wineater.com (wineater-api выключен, домен admin-api
46
- // смотрит на бэкенд ради уже опубликованных версий — его не снимать, пока они живы).
47
- // Shopify storefront: the backend finds the store by `shop` (store_id from the client is ignored)
48
- const shop = shopifyContext()?.shop;
49
- const endpoint = shop
50
- ? 'https://api.wineater.com/shopify/storefront/events'
51
- : 'https://api.wineater.com/api/widget-events';
52
- const target = shop ? { shop } : {};
53
- const event_time = new Date().toISOString();
54
- const region = navigator.language.split('-')[1] || null;
55
- const language = navigator.language.split('-')[0] || 'en';
56
-
57
- // Без согласия (EU-магазин, отказ или ещё не выбрал) событие всё равно
58
- // уходит — на нём строится монетизация, — но обезличенно: без user_id,
59
- // session_id, cookie и без user agent / точного экрана (по ним посетителя
60
- // можно опознать). Грубый класс устройства оставляем для статистики.
61
- if (!trackingAllowed()) {
62
- const anonymousPayload = {
63
- ...target,
64
- event_type,
65
- store_id,
66
- event_time,
67
- data,
68
- meta: { ...meta, anonymous: true },
69
- device_info: { device: window.innerWidth < 768 ? 'mobile' : 'desktop' },
70
- region,
71
- language,
72
- };
73
- return fetch(endpoint, {
74
- method: 'POST',
75
- headers: { 'Content-Type': 'application/json' },
76
- body: JSON.stringify(anonymousPayload),
77
- keepalive: true,
78
- }).catch((error) => {
79
- console.debug('[wineater] anonymous event failed:', error?.message);
80
- });
81
- }
82
-
83
- // Ensure we have a unique user ID
84
- let user_id = getCookie('uniqueUserID');
85
- if (!user_id) {
86
- user_id = setUniqueUserCookie();
87
- }
88
-
89
- // Get device info
90
- const device_info = {
91
- user_agent: navigator.userAgent,
92
- screen_width: window.screen.width,
93
- screen_height: window.screen.height,
94
- language: navigator.language,
95
- platform: navigator.platform
96
- };
97
-
98
- // Generate session ID if not exists
99
- let session_id = getCookie('sessionID');
100
- if (!session_id) {
101
- session_id = generateUUID();
102
- setCookie('sessionID', session_id, 1); // Session cookie expires in 1 day
103
- }
104
-
105
- const payload = {
106
- ...target,
107
- event_type,
108
- store_id,
109
- session_id,
110
- event_time,
111
- data,
112
- meta: {
113
- ...meta,
114
- user_id
115
- },
116
- device_info,
117
- region,
118
- language
119
- };
120
-
121
- return fetch(endpoint, {
122
- method: 'POST',
123
- headers: {
124
- 'Content-Type': 'application/json'
125
- },
126
- body: JSON.stringify(payload),
127
- // Shopify: add to cart may fall back to the cart page right after this event
128
- keepalive: !!shop
129
- })
130
- .then(response => {
131
- if (!response.ok) {
132
- return response.json().then(err => {
133
- throw new Error(err.message || 'Failed to track widget event');
134
- });
135
- }
136
- return response.json();
137
- })
138
- .catch(error => {
139
- console.error('Widget event tracking error:', error);
140
- throw error;
141
- });
142
- }
@@ -1,18 +0,0 @@
1
- export const WIDGET_EVENTS = {
2
- WIDGET_INITIALIZED: 'widget_initialized',
3
- WIDGET_LOADED: 'widget_loaded',
4
- SEARCH_PERFORMED: 'search_performed',
5
- SEARCH_RESULTS_DISPLAYED: 'search_results_displayed',
6
- NO_RESULTS_FOUND: 'no_results_found',
7
- WINE_CARD_HOVER: 'wine_card_hover',
8
- WINE_CARD_CLICK: 'wine_card_click',
9
- WINE_DETAILS_VIEWED: 'wine_details_viewed',
10
- WINE_DETAILS_CLOSED: 'wine_details_closed',
11
- BUY_BUTTON_CLICKED: 'buy_button_clicked',
12
- SHOW_MORE_CLICKED: 'show_more_clicked',
13
- SIMILAR_WINES_CLICKED: 'similar_wines_clicked',
14
- FILTER_APPLIED: 'filter_applied',
15
- LANGUAGE_CHANGED: 'language_changed',
16
- ERROR_OCCURRED: 'error_occurred',
17
- MOBILE_VIEW_ACTIVATED: 'mobile_view_activated'
18
- };
@@ -1,298 +0,0 @@
1
- import { deserializeInstantRecommendation, deserializeRecommendedWine, deserializeMoreRecommendations } from "./deserialize";
2
- import { getUid } from "../profile/uid";
3
- import { initFeedback, type FeedbackEvent } from "../profile/events";
4
- import { QuotaExceededError, quotaErrorOf, shopifyContext, storefrontSessionId } from "../shopify/shopify";
5
-
6
- export type WineId = string;
7
- export type WinePrice = number;
8
- export type Wine = {
9
- id: WineId;
10
- title: string;
11
- price: WinePrice;
12
- type: string;
13
- region: string;
14
- grape: string;
15
- appellation: string | null;
16
- url: string | null;
17
- store: string;
18
- storeId: string | null;
19
- image: string;
20
- producer: string | null;
21
- country: string;
22
- vintage: number | null;
23
- tags: string[];
24
- pairingRecommendation: string | null;
25
- shortDescription: string;
26
- score: number | null;
27
- total: number | null;
28
- itemId: string | null;
29
- isPromote: boolean | null;
30
- stock: number | null;
31
- byTheGlass: boolean;
32
- glassPrice: number | null;
33
- };
34
-
35
- export type RecommendedWine = Omit<Wine, "shortDescription" | "producer" | "pairingRecommendation" | "itemId"> & {
36
- description: string;
37
- distance: number;
38
- reason: string;
39
- };
40
-
41
- const UID_HEADER = 'X-Wineater-Uid';
42
-
43
- /** HTTP error with the status and parsed body (the message keeps the old format). */
44
- export class ApiError extends Error {
45
- status: number;
46
- data: any;
47
- constructor(status: number, data: any) {
48
- super(`API call failed with status ${status}: ${data?.message || 'No additional error information'}`);
49
- this.status = status;
50
- this.data = data;
51
- }
52
- }
53
-
54
- class WineaterApi {
55
- #baseUrl = 'https://api.wineater.com/';
56
- #token: string;
57
- #apiVersion: string;
58
- // Set when a request with the uid header fails at the network level (CORS
59
- // preflight rejected by a backend that does not allow it yet): we then
60
- // retry without the header and stop sending it for this page.
61
- #uidHeaderUnsupported = false;
62
- // Shopify mode only: answers in the storefront language (set by appStore)
63
- #language: string | null = null;
64
-
65
- constructor(token: string, apiVersion: string = '1') {
66
- this.#token = token;
67
- this.#apiVersion = apiVersion;
68
- }
69
-
70
- #headers(): Record<string, string> {
71
- const headers: Record<string, string> = {
72
- 'Content-Type': 'application/json',
73
- 'Authorization': `Bearer ${this.#token}`
74
- };
75
- if (!this.#uidHeaderUnsupported) {
76
- const uid = getUid();
77
- if (uid) headers[UID_HEADER] = uid;
78
- }
79
- return headers;
80
- }
81
-
82
- // One place for every backend call: JSON body, auth, anonymous uid header,
83
- // uniform error. `keepalive` lets the last feedback batch out-live the page.
84
- async #request<T>(endpoint: string, body?: unknown, options: { keepalive?: boolean; method?: string } = {}): Promise<T> {
85
- return (await this.#send<T>(endpoint, body, options)).data;
86
- }
87
-
88
- // Same as #request, but also hands back the response headers (instant/v2
89
- // returns the taste-profile version in X-Wineater-Profile).
90
- async #send<T>(endpoint: string, body?: unknown, options: { keepalive?: boolean; method?: string } = {}): Promise<{ data: T; headers: Headers }> {
91
- const init = (): RequestInit => ({
92
- method: options.method ?? 'POST',
93
- headers: this.#headers(),
94
- body: body === undefined ? undefined : JSON.stringify(body),
95
- keepalive: options.keepalive
96
- });
97
-
98
- let response: Response;
99
- try {
100
- response = await fetch(`${this.#baseUrl}${endpoint}`, init());
101
- } catch (error) {
102
- // A TypeError here is a network/CORS failure, not an HTTP status.
103
- // If we were sending the uid header, retry once without it.
104
- if (this.#uidHeaderUnsupported || !getUid()) throw error;
105
- this.#uidHeaderUnsupported = true;
106
- response = await fetch(`${this.#baseUrl}${endpoint}`, init());
107
- }
108
-
109
- const text = await response.text();
110
- let data = null;
111
- try {
112
- data = text ? JSON.parse(text) : null;
113
- } catch {
114
- if (response.ok) throw new Error('API returned invalid JSON');
115
- }
116
- if (!response.ok) {
117
- throw new ApiError(response.status, data);
118
- }
119
- return { data: data as T, headers: response.headers };
120
- }
121
-
122
- // `profileHash` is set when the backend served the visitor's personal start
123
- // grid (anonymous taste profile); null for the general grid. The widget keys
124
- // its instant cache by it, so a recomputed profile is picked up next visit.
125
- async getInstantRecommendations(): Promise<{ wines: Wine[]; profileHash: string | null }> {
126
- try {
127
- const shop = shopifyContext()?.shop;
128
- const endpoint = shop
129
- ? 'shopify/storefront/instant'
130
- : this.#apiVersion === '2'
131
- ? 'dish/recommendations/instant/v2'
132
- : 'dish/recommendations/instant';
133
- const { data, headers } = await this.#send<any[]>(endpoint, shop ? this.#shopBody({}) : {});
134
- return {
135
- wines: data.filter(Boolean).map(deserializeInstantRecommendation),
136
- profileHash: headers.get('X-Wineater-Profile') || null,
137
- };
138
- } catch (error) {
139
- throw new Error(`Error in getInstantRecommendations: ${error.message}`);
140
- }
141
- }
142
-
143
- // `queryId` is generated by the widget per search and echoed in feedback
144
- // events, so the backend can tie clicks to the exact result set.
145
- async getWineRecommendationsSearch(text: string, queryId?: string): Promise<RecommendedWine[]> {
146
- if (shopifyContext()) return this.#shopifySearch(text, queryId);
147
- try {
148
- const endpoint = this.#apiVersion === '2'
149
- ? 'dish/recommendations/search/v2'
150
- : 'dish/recommendations/search';
151
- const data = await this.#request<any[]>(endpoint, queryId ? { text, query_id: queryId } : { text });
152
- return data.filter(Boolean).map(deserializeRecommendedWine);
153
- } catch (error) {
154
- throw new Error(`Error in getWineRecommendations: ${error.message}`);
155
- }
156
- }
157
-
158
- /**
159
- * v2 search over NDJSON (`search/v2/stream`): `onWines` fires as soon as the
160
- * reranker has picked the 4 wines (~1 s before the reasons are written), with
161
- * `reason` empty; the promise resolves with the final wines including reasons.
162
- * Any transport problem (no stream support, HTTP error, old backend without
163
- * the endpoint) falls back to the plain JSON request — same result, later.
164
- */
165
- async searchStream(
166
- text: string,
167
- queryId: string | undefined,
168
- onWines: (wines: RecommendedWine[]) => void,
169
- ): Promise<RecommendedWine[]> {
170
- const fallback = () => this.getWineRecommendationsSearch(text, queryId);
171
- // the Shopify storefront search counts toward the plan and has no stream
172
- if (this.#apiVersion !== '2' || shopifyContext()) return fallback();
173
-
174
- let response: Response;
175
- try {
176
- response = await fetch(`${this.#baseUrl}dish/recommendations/search/v2/stream`, {
177
- method: 'POST',
178
- headers: this.#headers(),
179
- body: JSON.stringify(queryId ? { text, query_id: queryId } : { text }),
180
- });
181
- } catch {
182
- // network / CORS (incl. the uid header) — the JSON path knows how to recover
183
- return fallback();
184
- }
185
- if (!response.ok || !response.body) return fallback();
186
-
187
- const reader = response.body.getReader();
188
- const decoder = new TextDecoder();
189
- let buffer = '';
190
- let early: RecommendedWine[] | null = null;
191
- const toWines = (recs: any[]) => (recs ?? []).filter(Boolean).map(deserializeRecommendedWine);
192
-
193
- try {
194
- for (;;) {
195
- const { done, value } = await reader.read();
196
- if (value) buffer += decoder.decode(value, { stream: true });
197
- const lines = buffer.split('\n');
198
- buffer = done ? '' : lines.pop() ?? '';
199
- for (const line of done ? lines.filter(Boolean) : lines) {
200
- if (!line.trim()) continue;
201
- const event = JSON.parse(line);
202
- if (event.type === 'wines') {
203
- // reasons are not written yet — show none rather than a placeholder
204
- early = toWines(event.recommendations).map((w) => ({ ...w, reason: '' }));
205
- onWines(early);
206
- } else if (event.type === 'final') {
207
- return toWines(event.recommendations);
208
- } else if (event.type === 'error') {
209
- if (early) return early;
210
- return fallback();
211
- }
212
- }
213
- if (done) break;
214
- }
215
- } catch (error) {
216
- if (early) return early;
217
- return fallback();
218
- }
219
- return early ?? fallback();
220
- }
221
-
222
- async getMoreWineRecommendations(text: string, excludeIds: string[]): Promise<{ recommendations: RecommendedWine[]; hasMore: boolean }> {
223
- try {
224
- const data = await this.#request<any>('dish/recommendations/search/v2/more', { text, exclude_ids: excludeIds });
225
- return deserializeMoreRecommendations(data);
226
- } catch (error) {
227
- throw new Error(`Error in getMoreWineRecommendations: ${error.message}`);
228
- }
229
- }
230
-
231
- async getSimilarWines(id: WineId): Promise<RecommendedWine[]> {
232
- try {
233
- const data = await this.#request<any[]>('dish/recommendations/similar', { id });
234
- return data.filter(Boolean).map(deserializeRecommendedWine);
235
- } catch (error) {
236
- throw new Error(`Error in getSimilarWines: ${error.message}`);
237
- }
238
- }
239
-
240
- // Implicit-feedback batch for the anonymous taste profile (backend docs/PROFILE.md).
241
- async postEvents(batch: { uid: string; events: FeedbackEvent[] }): Promise<void> {
242
- await this.#request('events', batch, { keepalive: true });
243
- }
244
-
245
- setLanguage(lang: string | null) {
246
- this.#language = lang;
247
- }
248
-
249
- #shopBody<T extends object>(body: T): T & { shop: string; language?: string } {
250
- const shop = shopifyContext()!.shop;
251
- return this.#language ? { ...body, shop, language: this.#language } : { ...body, shop };
252
- }
253
-
254
- // Shopify storefront search: the backend counts it toward the shop's plan and answers
255
- // 429 quota_exceeded once the monthly searches are used up.
256
- async #shopifySearch(text: string, queryId?: string): Promise<RecommendedWine[]> {
257
- try {
258
- const body = this.#shopBody({ text, session_id: storefrontSessionId(), ...(queryId ? { query_id: queryId } : {}) });
259
- const data = await this.#request<any[]>('shopify/storefront/search', body);
260
- return (data ?? []).filter(Boolean).map(deserializeRecommendedWine);
261
- } catch (error) {
262
- if (error instanceof ApiError && quotaErrorOf(error.status, error.data)) throw new QuotaExceededError();
263
- throw new Error(`Error in getWineRecommendations: ${error.message}`);
264
- }
265
- }
266
-
267
- /** Public config of a Shopify shop when the embed had no app-data metafield. */
268
- async getShopifyConfig(): Promise<any> {
269
- const shop = shopifyContext()!.shop;
270
- return this.#request<any>(`shopify/storefront/config?shop=${encodeURIComponent(shop)}`, undefined, { method: 'GET' });
271
- }
272
-
273
- // TODO: define store type
274
- async getStoreConfig(): Promise<any> {
275
- try {
276
- return await this.#request<any>('stores/store', {});
277
- } catch (error) {
278
- console.error('Error fetching store configuration:', error);
279
- throw error;
280
- }
281
- }
282
- }
283
-
284
- const DEMO_TOKEN = 'HeUw6IXuhvz6tRuXdPEGEbj3SGQ3Q2YtEc1Z8jd41Kg9LtyPb4Epu0to31SYIadG';
285
- const token = window?.wineaterData?.CLIENT_TOKEN || DEMO_TOKEN;
286
- export const apiVersion = String(window?.wineaterData?.apiVersion || '1');
287
- export const wineaterApi = new WineaterApi(token, apiVersion)
288
- initFeedback((batch) => wineaterApi.postEvents(batch));
289
-
290
- // Namespaces cached responses per client token and API version, so two widgets
291
- // on one origin (or a token switch) never see each other's data. Not a secret:
292
- // only a short non-reversible hash of the token ends up in the storage key.
293
- function hashString(input: string): string {
294
- let h = 5381;
295
- for (let i = 0; i < input.length; i++) h = ((h << 5) + h + input.charCodeAt(i)) | 0;
296
- return (h >>> 0).toString(36);
297
- }
298
- export const cacheScope = `${hashString(token)}:${apiVersion}`;