wineater-bot 4.23.3 → 4.24.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/CLAUDE.md +9 -0
- package/dist/wineater-chatbot.mjs +19 -16
- package/dist/wineater-chatbot.umd.js +19 -16
- package/package.json +1 -1
- package/src/App.svelte +112 -10
- package/src/components/WineTile.svelte +43 -2
- package/src/index.js +3 -1
- package/src/services/analytics/analytics.ts +12 -2
- package/src/services/api/api.ts +59 -7
- package/src/services/i18n/translations.json +36 -9
- package/src/services/profile/consent.svelte.ts +11 -0
- package/src/services/shopify/cart.ts +105 -0
- package/src/services/shopify/shopify.spec.ts +66 -0
- package/src/services/shopify/shopify.ts +150 -0
- package/src/services/store/app-store.svelte.ts +57 -2
package/package.json
CHANGED
package/src/App.svelte
CHANGED
|
@@ -18,6 +18,17 @@
|
|
|
18
18
|
import { appStore } from './services/store/app-store.svelte';
|
|
19
19
|
import { getTranslation, translateTexts } from './services/i18n/i18n';
|
|
20
20
|
import { generateInlineStyle, preconnectFontHosts } from './services/ui-config/utils';
|
|
21
|
+
import {
|
|
22
|
+
QuotaExceededError,
|
|
23
|
+
shopifyContext,
|
|
24
|
+
storefrontSessionId,
|
|
25
|
+
variantIdOf
|
|
26
|
+
} from './services/shopify/shopify';
|
|
27
|
+
import { addToCart } from './services/shopify/cart';
|
|
28
|
+
|
|
29
|
+
// Shopify storefront (app embed): add to cart instead of a link out, no Wineater link,
|
|
30
|
+
// no cookies of our own, no web fonts; the shop's searches are limited by its plan.
|
|
31
|
+
const shopify = shopifyContext();
|
|
21
32
|
|
|
22
33
|
function initCookies() {
|
|
23
34
|
// chatbotToken — токен магазина, не данные посетителя. uniqueUserID
|
|
@@ -27,8 +38,10 @@
|
|
|
27
38
|
}
|
|
28
39
|
|
|
29
40
|
const DEMO_TOKEN = 'HeUw6IXuhvz6tRuXdPEGEbj3SGQ3Q2YtEc1Z8jd41Kg9LtyPb4Epu0to31SYIadG';
|
|
30
|
-
|
|
31
|
-
|
|
41
|
+
if (!shopify) {
|
|
42
|
+
initCookies();
|
|
43
|
+
preconnectFontHosts();
|
|
44
|
+
}
|
|
32
45
|
|
|
33
46
|
const INSTANT_CACHE_MAX_AGE_MS = 6 * 60 * 60 * 1000;
|
|
34
47
|
// A personal start grid follows the taste profile, which changes with every
|
|
@@ -68,6 +81,10 @@
|
|
|
68
81
|
// Один id на поиск: уходит в тело search/v2 и в каждое событие обратной
|
|
69
82
|
// связи (shown/click/...), чтобы бэкенд связал клики с конкретной выдачей.
|
|
70
83
|
let currentQueryId: string | null = null;
|
|
84
|
+
// Shopify: wines put into the cart from this widget (the button shows "Added")
|
|
85
|
+
let addedIds = $state<string[]>([]);
|
|
86
|
+
// Shopify: the shop used up its monthly searches — instant picks + a soft line instead of an error
|
|
87
|
+
let sommelierResting = $state(false);
|
|
71
88
|
|
|
72
89
|
// Профиль вкуса: что показали и в каком порядке. Пустая выдача не событие.
|
|
73
90
|
function feedbackShown(wines: (Wine | RecommendedWine)[], text: string) {
|
|
@@ -85,6 +102,7 @@
|
|
|
85
102
|
let storeId = $derived(appStore.getStoreId());
|
|
86
103
|
let isStoreInitialized = $derived(appStore.isInitialized());
|
|
87
104
|
let uiConfig = $derived(appStore.getUiConfig());
|
|
105
|
+
let searchAvailable = $derived(appStore.isSearchAvailable());
|
|
88
106
|
|
|
89
107
|
function setSearchError() {
|
|
90
108
|
errorMessage = getTranslation({ key: 'errorMessage', lang: appLanguage });
|
|
@@ -169,6 +187,21 @@
|
|
|
169
187
|
}
|
|
170
188
|
}
|
|
171
189
|
|
|
190
|
+
// Shopify: searches used up — show the start picks again with a soft line, never an error
|
|
191
|
+
async function showRestingPicks() {
|
|
192
|
+
sommelierResting = true;
|
|
193
|
+
appStore.setSearchAvailable(false);
|
|
194
|
+
try {
|
|
195
|
+
const { wines } = await wineaterApi.getInstantRecommendations();
|
|
196
|
+
results = wines;
|
|
197
|
+
translatedPopups = [];
|
|
198
|
+
hasMore = false;
|
|
199
|
+
} catch (error) {
|
|
200
|
+
console.error('[wineater] instant recommendations error:', error);
|
|
201
|
+
results = [];
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
172
205
|
// Простая функция-анимация: поднимаем карточки и ждём
|
|
173
206
|
function animateClearAll(duration = 220): Promise<void> {
|
|
174
207
|
if (!results || results.length === 0) return Promise.resolve();
|
|
@@ -224,7 +257,8 @@
|
|
|
224
257
|
// Пагинация доступна только для search/v2 — у instant/legacy-путей нет
|
|
225
258
|
// "more"-эндпоинта. Неполная выдача (< 4) означает, что кандидаты и так
|
|
226
259
|
// кончились: кнопка в этом случае только мигнула бы впустую.
|
|
227
|
-
|
|
260
|
+
// в Shopify нет "more": каждый запрос считается в лимит плана
|
|
261
|
+
hasMore = apiVersion === '2' && !shopify && searchResults.length >= 4;
|
|
228
262
|
feedbackShown(searchResults, query);
|
|
229
263
|
trackWidgetEvent(WIDGET_EVENTS.SEARCH_RESULTS_DISPLAYED, storeId, {
|
|
230
264
|
results_count: searchResults.length,
|
|
@@ -272,6 +306,10 @@
|
|
|
272
306
|
}
|
|
273
307
|
} catch (error) {
|
|
274
308
|
if (myQueryId !== currentQueryId) return;
|
|
309
|
+
if (error instanceof QuotaExceededError) {
|
|
310
|
+
await showRestingPicks();
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
275
313
|
results = [];
|
|
276
314
|
console.error('[wineater] search error:', error);
|
|
277
315
|
trackWidgetEvent(WIDGET_EVENTS.ERROR_OCCURRED, storeId, {
|
|
@@ -419,6 +457,27 @@
|
|
|
419
457
|
});
|
|
420
458
|
}
|
|
421
459
|
|
|
460
|
+
// Shopify: the BUY button puts the bottle into the shop's cart (on click only)
|
|
461
|
+
async function handleAddToCart(wine: Wine | RecommendedWine, index: number) {
|
|
462
|
+
const variantId = variantIdOf(wine as { url?: string | null; itemId?: string | null });
|
|
463
|
+
if (!shopify || !variantId) return;
|
|
464
|
+
trackFeedback({ type: 'add_to_cart', query_id: currentQueryId ?? undefined, sku_ids: [wine.id], position: index });
|
|
465
|
+
trackWidgetEvent(WIDGET_EVENTS.BUY_BUTTON_CLICKED, storeId, {
|
|
466
|
+
action: 'add_to_cart',
|
|
467
|
+
wine_id: wine.id,
|
|
468
|
+
wine_title: wine.title,
|
|
469
|
+
wine_url: wine.url,
|
|
470
|
+
variant_id: variantId,
|
|
471
|
+
query: lastSearchedQuery || undefined,
|
|
472
|
+
is_mobile: window.innerWidth <= 768
|
|
473
|
+
});
|
|
474
|
+
const result = await addToCart(variantId, { root: shopify.root, sessionId: storefrontSessionId() });
|
|
475
|
+
if (!result.ok) return; // the cart page is opening
|
|
476
|
+
addedIds = [...addedIds, wine.id];
|
|
477
|
+
// the embed closes its panel so the theme's cart drawer is visible
|
|
478
|
+
window.dispatchEvent(new CustomEvent('wineater:added-to-cart', { detail: { variantId, via: result.via } }));
|
|
479
|
+
}
|
|
480
|
+
|
|
422
481
|
function closePopup() {
|
|
423
482
|
const wine = results.find((w) => w.id === selectedWineId);
|
|
424
483
|
trackWidgetEvent(WIDGET_EVENTS.WINE_DETAILS_CLOSED, storeId, {
|
|
@@ -465,7 +524,9 @@
|
|
|
465
524
|
}
|
|
466
525
|
|
|
467
526
|
function handleClickOutside(event: MouseEvent) {
|
|
468
|
-
|
|
527
|
+
// inside a shadow root (Shopify embed) document listeners see the host as the target
|
|
528
|
+
const target = (event.composedPath?.()[0] ?? event.target) as HTMLElement;
|
|
529
|
+
if (!target?.closest) return;
|
|
469
530
|
const isInsidePopup = target.closest('.js-WinePopup') || target.closest('.js-WineTileAction');
|
|
470
531
|
|
|
471
532
|
if (!isInsidePopup) {
|
|
@@ -474,12 +535,16 @@
|
|
|
474
535
|
}
|
|
475
536
|
|
|
476
537
|
// Set CSS variable from config reactively
|
|
538
|
+
// Shopify: variables go on the shadow host, not on the theme's <html>
|
|
539
|
+
const cssVarTarget: HTMLElement =
|
|
540
|
+
((window?.wineaterData?.mountTarget?.getRootNode?.() as ShadowRoot | undefined)?.host as HTMLElement) ||
|
|
541
|
+
document.documentElement;
|
|
477
542
|
$effect(() => {
|
|
478
543
|
if (uiConfig?.brandColor) {
|
|
479
|
-
|
|
544
|
+
cssVarTarget.style.setProperty('--wr-brand-color', uiConfig.brandColor);
|
|
480
545
|
}
|
|
481
546
|
if (uiConfig?.wineaterColor) {
|
|
482
|
-
|
|
547
|
+
cssVarTarget.style.setProperty('--wr-wineater-color', uiConfig.wineaterColor);
|
|
483
548
|
}
|
|
484
549
|
});
|
|
485
550
|
|
|
@@ -504,6 +569,7 @@
|
|
|
504
569
|
}
|
|
505
570
|
|
|
506
571
|
initialized = true;
|
|
572
|
+
if (shopify && !searchAvailable) sommelierResting = true;
|
|
507
573
|
// без согласия (EU-магазин) событие уходит обезличенно — не повторяем
|
|
508
574
|
// его после «Принять», иначе инициализация посчиталась бы дважды
|
|
509
575
|
trackWidgetEvent(WIDGET_EVENTS.WIDGET_INITIALIZED, storeId);
|
|
@@ -518,7 +584,7 @@
|
|
|
518
584
|
{#if isStoreInitialized}
|
|
519
585
|
<!-- wr-Custom is used only for custom styling -->
|
|
520
586
|
<!-- id is used to increase specificity -->
|
|
521
|
-
<div id="wr-Custom" class="wr-App">
|
|
587
|
+
<div id="wr-Custom" class="wr-App" class:wr-App--embedded={!!shopify}>
|
|
522
588
|
{#if uiConfig.logoUrl}
|
|
523
589
|
<div class="wr-App-picture">
|
|
524
590
|
<img class="wr-App-logo" src={uiConfig.logoUrl} alt="" />
|
|
@@ -529,6 +595,11 @@
|
|
|
529
595
|
<Search bind:searchQuery onSubmit={handleSearch} {initialized} />
|
|
530
596
|
|
|
531
597
|
<div class="wr-App-content">
|
|
598
|
+
{#if sommelierResting && !errorMessage}
|
|
599
|
+
<p class="wr-App-resting" role="status">
|
|
600
|
+
{getTranslation({ key: 'sommelierResting', lang: appLanguage })}
|
|
601
|
+
</p>
|
|
602
|
+
{/if}
|
|
532
603
|
{#if errorMessage}
|
|
533
604
|
<div class="wr-App-error">
|
|
534
605
|
<p>{errorMessage}</p>
|
|
@@ -545,8 +616,10 @@
|
|
|
545
616
|
translatedPopup={translatedPopups[index]}
|
|
546
617
|
onBuyClick={handleBuyBtnClick}
|
|
547
618
|
onBuyLinkClick={handleBuyLinkClick}
|
|
619
|
+
onAddToCart={shopify ? handleAddToCart : undefined}
|
|
620
|
+
addedToCart={addedIds.includes(wine.id)}
|
|
548
621
|
onPopupClose={closePopup}
|
|
549
|
-
onSimilarClick={handleSimilarClick}
|
|
622
|
+
onSimilarClick={shopify ? undefined : handleSimilarClick}
|
|
550
623
|
{onTileEnter}
|
|
551
624
|
{onTileLeave}
|
|
552
625
|
{onTileClick}
|
|
@@ -569,11 +642,13 @@
|
|
|
569
642
|
<CookieConsent lang={appLanguage} />
|
|
570
643
|
{/if}
|
|
571
644
|
|
|
572
|
-
|
|
645
|
+
{#if !shopify}
|
|
646
|
+
<Footer {countryCode} />
|
|
647
|
+
{/if}
|
|
573
648
|
</div>
|
|
574
649
|
{:else if !configFailed}
|
|
575
650
|
<!-- Shell with the same footprint as the widget while the store config loads -->
|
|
576
|
-
<div class="wr-App wr-App--loading" aria-busy="true">
|
|
651
|
+
<div class="wr-App wr-App--loading" class:wr-App--embedded={!!shopify} aria-busy="true">
|
|
577
652
|
<div class="wr-App-loadingTop"></div>
|
|
578
653
|
<SkeletonTiles />
|
|
579
654
|
</div>
|
|
@@ -616,6 +691,28 @@
|
|
|
616
691
|
position: relative;
|
|
617
692
|
}
|
|
618
693
|
|
|
694
|
+
/* Shopify: the embed's panel is the frame — fill it, no card of our own */
|
|
695
|
+
.wr-App.wr-App--embedded {
|
|
696
|
+
min-width: 0;
|
|
697
|
+
max-width: none;
|
|
698
|
+
width: 100%;
|
|
699
|
+
margin: 0;
|
|
700
|
+
box-shadow: none;
|
|
701
|
+
border-radius: 0;
|
|
702
|
+
min-height: 0;
|
|
703
|
+
padding-bottom: 12px;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
.wr-App-resting {
|
|
707
|
+
margin: 0 0 8px;
|
|
708
|
+
padding: 10px 14px;
|
|
709
|
+
border-radius: 10px;
|
|
710
|
+
background: #f6f7fd;
|
|
711
|
+
color: #444;
|
|
712
|
+
font-size: 14px;
|
|
713
|
+
text-align: center;
|
|
714
|
+
}
|
|
715
|
+
|
|
619
716
|
.wr-App-loadingTop {
|
|
620
717
|
/* roughly the height of Header + Search, so tiles do not jump once they render */
|
|
621
718
|
height: 150px;
|
|
@@ -669,6 +766,11 @@
|
|
|
669
766
|
min-width: 100vw;
|
|
670
767
|
}
|
|
671
768
|
|
|
769
|
+
.wr-App.wr-App--embedded {
|
|
770
|
+
min-width: 0;
|
|
771
|
+
padding: 0 12px 12px;
|
|
772
|
+
}
|
|
773
|
+
|
|
672
774
|
.wr-App-placeholder {
|
|
673
775
|
display: flex;
|
|
674
776
|
gap: 8px;
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import Action from './ui/Action.svelte';
|
|
8
8
|
import WineBottle from './WineBottle.svelte';
|
|
9
9
|
import { hasImage } from '../services/bottle/bottle';
|
|
10
|
+
import { variantIdOf } from '../services/shopify/shopify';
|
|
10
11
|
|
|
11
12
|
// Desktop grid has 4 columns: the first row is above the fold
|
|
12
13
|
const FIRST_ROW_SIZE = 4;
|
|
@@ -26,6 +27,8 @@
|
|
|
26
27
|
onTileClick,
|
|
27
28
|
onBuyClick,
|
|
28
29
|
onBuyLinkClick,
|
|
30
|
+
onAddToCart,
|
|
31
|
+
addedToCart = false,
|
|
29
32
|
onPopupClose,
|
|
30
33
|
onSimilarClick,
|
|
31
34
|
isAnimating,
|
|
@@ -40,6 +43,9 @@
|
|
|
40
43
|
onTileClick: (event: MouseEvent, wine: Wine | RecommendedWine) => void;
|
|
41
44
|
onBuyClick: (wine: Wine | RecommendedWine, index: number) => void;
|
|
42
45
|
onBuyLinkClick: (wine: Wine | RecommendedWine, index: number) => void;
|
|
46
|
+
/** Shopify mode: BUY puts the bottle into the shop's cart */
|
|
47
|
+
onAddToCart?: (wine: Wine | RecommendedWine, index: number) => void;
|
|
48
|
+
addedToCart?: boolean;
|
|
43
49
|
onPopupClose: () => void;
|
|
44
50
|
onSimilarClick?: (wine: Wine | RecommendedWine) => void;
|
|
45
51
|
isAnimating: boolean;
|
|
@@ -78,6 +84,18 @@
|
|
|
78
84
|
.filter(Boolean);
|
|
79
85
|
}
|
|
80
86
|
let isMobile = $state(false);
|
|
87
|
+
let canAddToCart = $derived(!!onAddToCart && !!variantIdOf(wine as { url?: string | null; itemId?: string | null }));
|
|
88
|
+
let adding = $state(false);
|
|
89
|
+
|
|
90
|
+
async function addToCartClick() {
|
|
91
|
+
if (adding || addedToCart) return;
|
|
92
|
+
adding = true;
|
|
93
|
+
try {
|
|
94
|
+
await onAddToCart?.(wine, index);
|
|
95
|
+
} finally {
|
|
96
|
+
adding = false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
81
99
|
|
|
82
100
|
onMount(() => {
|
|
83
101
|
const checkIsMobile = () => {
|
|
@@ -129,7 +147,13 @@
|
|
|
129
147
|
</div>
|
|
130
148
|
{/if}
|
|
131
149
|
|
|
132
|
-
|
|
150
|
+
{#if canAddToCart && wine.url}
|
|
151
|
+
<h3 class="wr-WineTile-title">
|
|
152
|
+
<a class="wr-WineTile-titleLink js-WineTileAction" href={wine.url}>{wine.title}</a>
|
|
153
|
+
</h3>
|
|
154
|
+
{:else}
|
|
155
|
+
<h3 class="wr-WineTile-title">{wine.title}</h3>
|
|
156
|
+
{/if}
|
|
133
157
|
</header>
|
|
134
158
|
|
|
135
159
|
{#if joinParams(wine, ['type', 'country', 'region', 'grape'])}
|
|
@@ -141,7 +165,15 @@
|
|
|
141
165
|
<footer class="wr-WineTile-footer">
|
|
142
166
|
<!-- We need this wrapper for top margin -->
|
|
143
167
|
<div class="wr-WineTile-actions">
|
|
144
|
-
{#if
|
|
168
|
+
{#if canAddToCart}
|
|
169
|
+
<Action
|
|
170
|
+
handleClick={addToCartClick}
|
|
171
|
+
variant={addedToCart ? 'outlined' : 'primary'}
|
|
172
|
+
className="wr-WineTile-action wr-WineTile-action--cart js-WineTileAction"
|
|
173
|
+
>
|
|
174
|
+
{getTranslation({ key: addedToCart ? 'addedToCart' : 'addToCart', lang })}
|
|
175
|
+
</Action>
|
|
176
|
+
{:else if wine.url}
|
|
145
177
|
<Action
|
|
146
178
|
href={wine.url}
|
|
147
179
|
handleClick={() => onBuyLinkClick(wine, index)}
|
|
@@ -275,6 +307,15 @@
|
|
|
275
307
|
color: #000;
|
|
276
308
|
}
|
|
277
309
|
|
|
310
|
+
.wr-WineTile-titleLink {
|
|
311
|
+
color: inherit;
|
|
312
|
+
text-decoration: none;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
.wr-WineTile-titleLink:hover {
|
|
316
|
+
text-decoration: underline;
|
|
317
|
+
}
|
|
318
|
+
|
|
278
319
|
.wr-WineTile-params {
|
|
279
320
|
font-size: 14px;
|
|
280
321
|
color: #666;
|
package/src/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import SvelteApp from './App.svelte'
|
|
2
2
|
import { mount } from 'svelte';
|
|
3
3
|
|
|
4
|
-
|
|
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 });
|
|
5
7
|
export default app
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { trackingAllowed } from '../profile/consent.svelte';
|
|
2
|
+
import { shopifyContext } from '../shopify/shopify';
|
|
2
3
|
|
|
3
4
|
function generateUUID(): string {
|
|
4
5
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
|
@@ -43,7 +44,12 @@ export function setUniqueUserCookie(): string {
|
|
|
43
44
|
export function trackWidgetEvent(event_type: string, store_id, data = {}, meta = {}) {
|
|
44
45
|
// Тот же маршрут бэкенда, что и на admin-api.wineater.com (wineater-api выключен, домен admin-api
|
|
45
46
|
// смотрит на бэкенд ради уже опубликованных версий — его не снимать, пока они живы).
|
|
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 } : {};
|
|
47
53
|
const event_time = new Date().toISOString();
|
|
48
54
|
const region = navigator.language.split('-')[1] || null;
|
|
49
55
|
const language = navigator.language.split('-')[0] || 'en';
|
|
@@ -54,6 +60,7 @@ export function trackWidgetEvent(event_type: string, store_id, data = {}, meta =
|
|
|
54
60
|
// можно опознать). Грубый класс устройства оставляем для статистики.
|
|
55
61
|
if (!trackingAllowed()) {
|
|
56
62
|
const anonymousPayload = {
|
|
63
|
+
...target,
|
|
57
64
|
event_type,
|
|
58
65
|
store_id,
|
|
59
66
|
event_time,
|
|
@@ -96,6 +103,7 @@ export function trackWidgetEvent(event_type: string, store_id, data = {}, meta =
|
|
|
96
103
|
}
|
|
97
104
|
|
|
98
105
|
const payload = {
|
|
106
|
+
...target,
|
|
99
107
|
event_type,
|
|
100
108
|
store_id,
|
|
101
109
|
session_id,
|
|
@@ -115,7 +123,9 @@ export function trackWidgetEvent(event_type: string, store_id, data = {}, meta =
|
|
|
115
123
|
headers: {
|
|
116
124
|
'Content-Type': 'application/json'
|
|
117
125
|
},
|
|
118
|
-
body: JSON.stringify(payload)
|
|
126
|
+
body: JSON.stringify(payload),
|
|
127
|
+
// Shopify: add to cart may fall back to the cart page right after this event
|
|
128
|
+
keepalive: !!shop
|
|
119
129
|
})
|
|
120
130
|
.then(response => {
|
|
121
131
|
if (!response.ok) {
|
package/src/services/api/api.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { deserializeInstantRecommendation, deserializeRecommendedWine, deserializeMoreRecommendations } from "./deserialize";
|
|
2
2
|
import { getUid } from "../profile/uid";
|
|
3
3
|
import { initFeedback, type FeedbackEvent } from "../profile/events";
|
|
4
|
+
import { QuotaExceededError, quotaErrorOf, shopifyContext, storefrontSessionId } from "../shopify/shopify";
|
|
4
5
|
|
|
5
6
|
export type WineId = string;
|
|
6
7
|
export type WinePrice = number;
|
|
@@ -39,6 +40,17 @@ export type RecommendedWine = Omit<Wine, "shortDescription" | "producer" | "pair
|
|
|
39
40
|
|
|
40
41
|
const UID_HEADER = 'X-Wineater-Uid';
|
|
41
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
|
+
|
|
42
54
|
class WineaterApi {
|
|
43
55
|
#baseUrl = 'https://api.wineater.com/';
|
|
44
56
|
#token: string;
|
|
@@ -47,6 +59,8 @@ class WineaterApi {
|
|
|
47
59
|
// preflight rejected by a backend that does not allow it yet): we then
|
|
48
60
|
// retry without the header and stop sending it for this page.
|
|
49
61
|
#uidHeaderUnsupported = false;
|
|
62
|
+
// Shopify mode only: answers in the storefront language (set by appStore)
|
|
63
|
+
#language: string | null = null;
|
|
50
64
|
|
|
51
65
|
constructor(token: string, apiVersion: string = '1') {
|
|
52
66
|
this.#token = token;
|
|
@@ -93,9 +107,14 @@ class WineaterApi {
|
|
|
93
107
|
}
|
|
94
108
|
|
|
95
109
|
const text = await response.text();
|
|
96
|
-
|
|
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
|
+
}
|
|
97
116
|
if (!response.ok) {
|
|
98
|
-
throw new
|
|
117
|
+
throw new ApiError(response.status, data);
|
|
99
118
|
}
|
|
100
119
|
return { data: data as T, headers: response.headers };
|
|
101
120
|
}
|
|
@@ -105,10 +124,13 @@ class WineaterApi {
|
|
|
105
124
|
// its instant cache by it, so a recomputed profile is picked up next visit.
|
|
106
125
|
async getInstantRecommendations(): Promise<{ wines: Wine[]; profileHash: string | null }> {
|
|
107
126
|
try {
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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({}) : {});
|
|
112
134
|
return {
|
|
113
135
|
wines: data.filter(Boolean).map(deserializeInstantRecommendation),
|
|
114
136
|
profileHash: headers.get('X-Wineater-Profile') || null,
|
|
@@ -121,6 +143,7 @@ class WineaterApi {
|
|
|
121
143
|
// `queryId` is generated by the widget per search and echoed in feedback
|
|
122
144
|
// events, so the backend can tie clicks to the exact result set.
|
|
123
145
|
async getWineRecommendationsSearch(text: string, queryId?: string): Promise<RecommendedWine[]> {
|
|
146
|
+
if (shopifyContext()) return this.#shopifySearch(text, queryId);
|
|
124
147
|
try {
|
|
125
148
|
const endpoint = this.#apiVersion === '2'
|
|
126
149
|
? 'dish/recommendations/search/v2'
|
|
@@ -145,7 +168,8 @@ class WineaterApi {
|
|
|
145
168
|
onWines: (wines: RecommendedWine[]) => void,
|
|
146
169
|
): Promise<RecommendedWine[]> {
|
|
147
170
|
const fallback = () => this.getWineRecommendationsSearch(text, queryId);
|
|
148
|
-
|
|
171
|
+
// the Shopify storefront search counts toward the plan and has no stream
|
|
172
|
+
if (this.#apiVersion !== '2' || shopifyContext()) return fallback();
|
|
149
173
|
|
|
150
174
|
let response: Response;
|
|
151
175
|
try {
|
|
@@ -218,6 +242,34 @@ class WineaterApi {
|
|
|
218
242
|
await this.#request('events', batch, { keepalive: true });
|
|
219
243
|
}
|
|
220
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
|
+
|
|
221
273
|
// TODO: define store type
|
|
222
274
|
async getStoreConfig(): Promise<any> {
|
|
223
275
|
try {
|
|
@@ -67,7 +67,10 @@
|
|
|
67
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
68
|
"consentAccept": "Accepter",
|
|
69
69
|
"consentDecline": "Refuser",
|
|
70
|
-
"consentPrivacy": "Politique de confidentialité"
|
|
70
|
+
"consentPrivacy": "Politique de confidentialité",
|
|
71
|
+
"addToCart": "Ajouter au panier",
|
|
72
|
+
"addedToCart": "Ajouté",
|
|
73
|
+
"sommelierResting": "Notre sommelier se repose. Découvrez notre sélection ci-dessous."
|
|
71
74
|
},
|
|
72
75
|
"es": {
|
|
73
76
|
"lang": "es",
|
|
@@ -137,7 +140,10 @@
|
|
|
137
140
|
"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
141
|
"consentAccept": "Aceptar",
|
|
139
142
|
"consentDecline": "Rechazar",
|
|
140
|
-
"consentPrivacy": "Política de privacidad"
|
|
143
|
+
"consentPrivacy": "Política de privacidad",
|
|
144
|
+
"addToCart": "Añadir al carrito",
|
|
145
|
+
"addedToCart": "Añadido",
|
|
146
|
+
"sommelierResting": "Nuestro sumiller está descansando. Descubre nuestra selección."
|
|
141
147
|
},
|
|
142
148
|
"en": {
|
|
143
149
|
"lang": "en",
|
|
@@ -207,7 +213,10 @@
|
|
|
207
213
|
"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
214
|
"consentAccept": "Accept",
|
|
209
215
|
"consentDecline": "Decline",
|
|
210
|
-
"consentPrivacy": "Privacy policy"
|
|
216
|
+
"consentPrivacy": "Privacy policy",
|
|
217
|
+
"addToCart": "Add to cart",
|
|
218
|
+
"addedToCart": "Added",
|
|
219
|
+
"sommelierResting": "Our sommelier is resting. Browse our picks below."
|
|
211
220
|
},
|
|
212
221
|
"sr": {
|
|
213
222
|
"lang": "sr",
|
|
@@ -277,7 +286,10 @@
|
|
|
277
286
|
"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
287
|
"consentAccept": "Prihvati",
|
|
279
288
|
"consentDecline": "Odbij",
|
|
280
|
-
"consentPrivacy": "Politika privatnosti"
|
|
289
|
+
"consentPrivacy": "Politika privatnosti",
|
|
290
|
+
"addToCart": "Dodaj u korpu",
|
|
291
|
+
"addedToCart": "Dodato",
|
|
292
|
+
"sommelierResting": "Naš somelijer se odmara. Pogledajte naš izbor ispod."
|
|
281
293
|
},
|
|
282
294
|
"de": {
|
|
283
295
|
"lang": "de",
|
|
@@ -346,7 +358,10 @@
|
|
|
346
358
|
"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
359
|
"consentAccept": "Akzeptieren",
|
|
348
360
|
"consentDecline": "Ablehnen",
|
|
349
|
-
"consentPrivacy": "Datenschutzerklärung"
|
|
361
|
+
"consentPrivacy": "Datenschutzerklärung",
|
|
362
|
+
"addToCart": "In den Warenkorb",
|
|
363
|
+
"addedToCart": "Hinzugefügt",
|
|
364
|
+
"sommelierResting": "Unser Sommelier macht gerade Pause. Entdecken Sie unsere Auswahl."
|
|
350
365
|
},
|
|
351
366
|
"ukr": {
|
|
352
367
|
"lang": "ukr",
|
|
@@ -416,7 +431,10 @@
|
|
|
416
431
|
"consentText": "Винний помічник зберігає на цьому пристрої анонімний ідентифікатор, щоб враховувати ваш смак і покращувати рекомендації. Користуватися можна й без згоди.",
|
|
417
432
|
"consentAccept": "Прийняти",
|
|
418
433
|
"consentDecline": "Відхилити",
|
|
419
|
-
"consentPrivacy": "Політика конфіденційності"
|
|
434
|
+
"consentPrivacy": "Політика конфіденційності",
|
|
435
|
+
"addToCart": "Додати в кошик",
|
|
436
|
+
"addedToCart": "Додано",
|
|
437
|
+
"sommelierResting": "Наш сомельє відпочиває. Перегляньте нашу добірку нижче."
|
|
420
438
|
},
|
|
421
439
|
"ru": {
|
|
422
440
|
"lang": "ru",
|
|
@@ -486,7 +504,10 @@
|
|
|
486
504
|
"consentText": "Винный помощник сохраняет на этом устройстве анонимный идентификатор, чтобы учитывать ваш вкус и улучшать рекомендации. Пользоваться можно и без согласия.",
|
|
487
505
|
"consentAccept": "Принять",
|
|
488
506
|
"consentDecline": "Отклонить",
|
|
489
|
-
"consentPrivacy": "Политика конфиденциальности"
|
|
507
|
+
"consentPrivacy": "Политика конфиденциальности",
|
|
508
|
+
"addToCart": "В корзину",
|
|
509
|
+
"addedToCart": "Добавлено",
|
|
510
|
+
"sommelierResting": "Наш сомелье отдыхает. Посмотрите нашу подборку ниже."
|
|
490
511
|
},
|
|
491
512
|
"kk": {
|
|
492
513
|
"lang": "kk",
|
|
@@ -556,7 +577,10 @@
|
|
|
556
577
|
"consentText": "Шарап көмекшісі сіздің талғамыңызды ескеріп, ұсыныстарды жақсарту үшін осы құрылғыда анонимді идентификатор сақтайды. Келісімсіз де пайдалана аласыз.",
|
|
557
578
|
"consentAccept": "Қабылдау",
|
|
558
579
|
"consentDecline": "Бас тарту",
|
|
559
|
-
"consentPrivacy": "Құпиялылық саясаты"
|
|
580
|
+
"consentPrivacy": "Құпиялылық саясаты",
|
|
581
|
+
"addToCart": "Себетке қосу",
|
|
582
|
+
"addedToCart": "Қосылды",
|
|
583
|
+
"sommelierResting": "Біздің сомелье демалып жатыр. Төмендегі таңдауымызды қараңыз."
|
|
560
584
|
},
|
|
561
585
|
"pt": {
|
|
562
586
|
"lang": "pt",
|
|
@@ -626,6 +650,9 @@
|
|
|
626
650
|
"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
651
|
"consentAccept": "Aceitar",
|
|
628
652
|
"consentDecline": "Recusar",
|
|
629
|
-
"consentPrivacy": "Política de privacidade"
|
|
653
|
+
"consentPrivacy": "Política de privacidade",
|
|
654
|
+
"addToCart": "Adicionar ao carrinho",
|
|
655
|
+
"addedToCart": "Adicionado",
|
|
656
|
+
"sommelierResting": "Nosso sommelier está descansando. Veja nossas sugestões abaixo."
|
|
630
657
|
}
|
|
631
658
|
}
|
|
@@ -37,6 +37,15 @@ let choice = $state<ConsentChoice | null>(readChoice());
|
|
|
37
37
|
/** null — store config not loaded yet (unknown whether consent is required). */
|
|
38
38
|
let storeRequires = $state<boolean | null>(null);
|
|
39
39
|
|
|
40
|
+
// Shopify storefronts have their own cookie banner (Customer Privacy API): there the host decides
|
|
41
|
+
// and we never show ours.
|
|
42
|
+
let externalGate: (() => boolean) | null = null;
|
|
43
|
+
|
|
44
|
+
/** Hand the consent decision to the host platform (Shopify mode). */
|
|
45
|
+
export function setConsentGate(gate: () => boolean): void {
|
|
46
|
+
externalGate = gate;
|
|
47
|
+
}
|
|
48
|
+
|
|
40
49
|
/** Called by appStore once the store config is applied. */
|
|
41
50
|
export function setStoreConsentPolicy(requiresConsent: boolean): void {
|
|
42
51
|
storeRequires = requiresConsent;
|
|
@@ -54,6 +63,7 @@ export function consentChoice(): ConsentChoice | null {
|
|
|
54
63
|
|
|
55
64
|
/** Show the banner: consent needed and the visitor has not chosen yet. */
|
|
56
65
|
export function shouldAskConsent(): boolean {
|
|
66
|
+
if (externalGate) return false;
|
|
57
67
|
return storeRequires !== null && consentRequired() && choice === null;
|
|
58
68
|
}
|
|
59
69
|
|
|
@@ -64,6 +74,7 @@ export function shouldAskConsent(): boolean {
|
|
|
64
74
|
*/
|
|
65
75
|
export function trackingAllowed(): boolean {
|
|
66
76
|
if ((window as any)?.wineaterData?.tracking === false) return false;
|
|
77
|
+
if (externalGate) return externalGate();
|
|
67
78
|
if (storeRequires === null) return false;
|
|
68
79
|
if (!consentRequired()) return true;
|
|
69
80
|
return choice === 'granted';
|