wineater-bot 4.24.1 → 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.
- package/package.json +5 -1
- package/.claude/settings.local.json +0 -10
- package/.prettierignore +0 -9
- package/.prettierrc +0 -16
- package/CLAUDE.md +0 -115
- package/docs/CUSTOMIZATION.md +0 -133
- package/docs/SVELTE.md +0 -146
- package/e2e/demo.test.ts +0 -6
- package/eslint.config.js +0 -41
- package/index.html +0 -23
- package/playwright.config.ts +0 -9
- package/pnpm-workspace.yaml +0 -2
- package/src/App.svelte +0 -784
- package/src/App.svelte.test.ts +0 -13
- package/src/assets/biobio.png +0 -0
- package/src/components/CookieConsent.svelte +0 -73
- package/src/components/Footer.svelte +0 -122
- package/src/components/Header.svelte +0 -99
- package/src/components/Search.svelte +0 -177
- package/src/components/SkeletonTiles.svelte +0 -94
- package/src/components/WineBottle.svelte +0 -56
- package/src/components/WinePopup.svelte +0 -192
- package/src/components/WineTile.svelte +0 -458
- package/src/components/icons/Budget.svelte +0 -35
- package/src/components/icons/Diamond.svelte +0 -35
- package/src/components/icons/Fish.svelte +0 -56
- package/src/components/icons/Heart.svelte +0 -28
- package/src/components/icons/Meat.svelte +0 -32
- package/src/components/ui/Action.svelte +0 -83
- package/src/demo.spec.ts +0 -13
- package/src/index.js +0 -7
- package/src/services/analytics/analytics.ts +0 -142
- package/src/services/analytics/constants.ts +0 -18
- package/src/services/api/api.ts +0 -298
- package/src/services/api/deserialize.ts +0 -120
- package/src/services/bottle/bottle.spec.ts +0 -32
- package/src/services/bottle/bottle.ts +0 -78
- package/src/services/cache.ts +0 -28
- package/src/services/i18n/i18n.ts +0 -146
- package/src/services/i18n/translations.json +0 -658
- package/src/services/profile/consent.svelte.ts +0 -115
- package/src/services/profile/events.ts +0 -73
- package/src/services/profile/uid.ts +0 -87
- package/src/services/shopify/cart.ts +0 -105
- package/src/services/shopify/shopify.spec.ts +0 -66
- package/src/services/shopify/shopify.ts +0 -150
- package/src/services/store/app-store.svelte.ts +0 -172
- package/src/services/store/deserialize.ts +0 -25
- package/src/services/store/types.ts +0 -12
- package/src/services/ui-config/get-config.ts +0 -25
- package/src/services/ui-config/projects-config.ts +0 -156
- package/src/services/ui-config/types.ts +0 -15
- package/src/services/ui-config/utils.ts +0 -40
- package/tsconfig.json +0 -23
- package/vite.config.js +0 -84
- package/vite.config.js.timestamp-1772121492941-e00587396dc48.mjs +0 -71
- 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
|
-
};
|
package/src/services/api/api.ts
DELETED
|
@@ -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}`;
|