wineater-bot 4.22.0 → 4.23.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/wineater-chatbot.mjs +14 -14
- package/dist/wineater-chatbot.umd.js +15 -15
- package/package.json +1 -1
- package/src/App.svelte +18 -4
- package/src/components/WineBottle.svelte +56 -0
- package/src/components/WineTile.svelte +16 -1
- package/src/services/api/api.ts +16 -4
- package/src/services/bottle/bottle.spec.ts +32 -0
- package/src/services/bottle/bottle.ts +78 -0
package/package.json
CHANGED
package/src/App.svelte
CHANGED
|
@@ -31,7 +31,14 @@
|
|
|
31
31
|
preconnectFontHosts();
|
|
32
32
|
|
|
33
33
|
const INSTANT_CACHE_MAX_AGE_MS = 6 * 60 * 60 * 1000;
|
|
34
|
-
|
|
34
|
+
// A personal start grid follows the taste profile, which changes with every
|
|
35
|
+
// click — keep it for 1 h only; the general grid stays cached for 6 h.
|
|
36
|
+
const PERSONAL_INSTANT_CACHE_MAX_AGE_MS = 60 * 60 * 1000;
|
|
37
|
+
// The cache key carries the profile version from the last response
|
|
38
|
+
// (X-Wineater-Profile), remembered separately: a new profile → new key.
|
|
39
|
+
const instantProfileKey = `${cacheScope}:instant-profile`;
|
|
40
|
+
const instantCacheKey = (profileHash: string | null) =>
|
|
41
|
+
`${cacheScope}:instant${profileHash ? `:${profileHash}` : ''}`;
|
|
35
42
|
|
|
36
43
|
const widgetType = window?.wineaterData?.WIDGET_TYPE || 'store';
|
|
37
44
|
const countryCode = window?.wineaterData?.COUNTRY_CODE || null;
|
|
@@ -132,7 +139,11 @@
|
|
|
132
139
|
// response only refreshes the cache for the next visit, so the grid the
|
|
133
140
|
// visitor is already looking at never swaps under their cursor.
|
|
134
141
|
async function loadInstantRecommendations(configReady: Promise<void>) {
|
|
135
|
-
const
|
|
142
|
+
const lastProfile = readCache<string>(instantProfileKey, 30 * 24 * 60 * 60 * 1000);
|
|
143
|
+
const cached = readCache<Wine[]>(
|
|
144
|
+
instantCacheKey(lastProfile),
|
|
145
|
+
lastProfile ? PERSONAL_INSTANT_CACHE_MAX_AGE_MS : INSTANT_CACHE_MAX_AGE_MS
|
|
146
|
+
);
|
|
136
147
|
const hasCached = !!cached?.length;
|
|
137
148
|
if (hasCached) {
|
|
138
149
|
results = cached;
|
|
@@ -140,8 +151,11 @@
|
|
|
140
151
|
isLoading = true;
|
|
141
152
|
}
|
|
142
153
|
try {
|
|
143
|
-
const fresh = await wineaterApi.getInstantRecommendations();
|
|
144
|
-
if (fresh.length > 0)
|
|
154
|
+
const { wines: fresh, profileHash } = await wineaterApi.getInstantRecommendations();
|
|
155
|
+
if (fresh.length > 0) {
|
|
156
|
+
writeCache(instantCacheKey(profileHash), fresh);
|
|
157
|
+
writeCache(instantProfileKey, profileHash);
|
|
158
|
+
}
|
|
145
159
|
if (!hasCached && !userActed) results = fresh;
|
|
146
160
|
} catch (error) {
|
|
147
161
|
console.error('[wineater] instant recommendations error:', error);
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { BOTTLES, BUBBLES, SHAPES, bottleKind } from '../services/bottle/bottle';
|
|
3
|
+
|
|
4
|
+
let { type }: { type?: string | null } = $props();
|
|
5
|
+
|
|
6
|
+
const kind = $derived(bottleKind(type));
|
|
7
|
+
const k = $derived(BOTTLES[kind]);
|
|
8
|
+
const sh = $derived(SHAPES[k.shoulder]);
|
|
9
|
+
const gid = `wr-bottle-${Math.random().toString(36).slice(2, 8)}`;
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<svg class="wr-WineBottle" viewBox="0 0 44 130" aria-hidden="true" focusable="false">
|
|
13
|
+
<defs>
|
|
14
|
+
<linearGradient id={gid} x1="0" x2="1" y1="0" y2="0">
|
|
15
|
+
<stop offset="0" stop-color={k.glass} stop-opacity="0.78" />
|
|
16
|
+
<stop offset="0.45" stop-color={k.glass} />
|
|
17
|
+
<stop offset="1" stop-color={k.glass} stop-opacity="0.88" />
|
|
18
|
+
</linearGradient>
|
|
19
|
+
</defs>
|
|
20
|
+
<path d={sh.body} fill="url(#{gid})" />
|
|
21
|
+
{#if kind === 'sparkling'}
|
|
22
|
+
<path d="M16.5 10h11l1.5 26h-14z" fill={k.foil} />
|
|
23
|
+
<rect x="14" y="2" width="16" height="9" rx="3.5" fill={k.foil} />
|
|
24
|
+
<rect x="14" y="8" width="16" height="2.4" fill="#000" opacity="0.12" />
|
|
25
|
+
<path
|
|
26
|
+
d="M16 15h12M15.6 20h12.8M15.2 25h13.6M14.8 30h14.4M22 11v21"
|
|
27
|
+
stroke="#8a6d12"
|
|
28
|
+
stroke-opacity="0.55"
|
|
29
|
+
stroke-width="0.9"
|
|
30
|
+
fill="none"
|
|
31
|
+
/>
|
|
32
|
+
<circle cx="22" cy="33" r="1.6" fill="none" stroke="#8a6d12" stroke-opacity="0.55" stroke-width="0.9" />
|
|
33
|
+
<g fill="#fff" opacity="0.4">
|
|
34
|
+
{#each BUBBLES as [cx, cy, r] (`${cx}-${cy}`)}
|
|
35
|
+
<circle {cx} {cy} {r} />
|
|
36
|
+
{/each}
|
|
37
|
+
</g>
|
|
38
|
+
{:else}
|
|
39
|
+
<rect x={sh.cap.x} y={sh.cap.y} width={sh.cap.w} height={sh.cap.h} rx="2.5" fill={k.foil} />
|
|
40
|
+
<rect x={sh.cap.x} y={sh.cap.y + sh.cap.h - 3} width={sh.cap.w} height="2.5" fill="#000" opacity="0.12" />
|
|
41
|
+
{/if}
|
|
42
|
+
<rect x={sh.label.x} y={sh.label.y} width={sh.label.w} height={sh.label.h} rx="2.5" fill="#fffdf8" opacity="0.94" />
|
|
43
|
+
<rect x={sh.label.x + sh.label.w * 0.2} y={sh.label.y + 8} width={sh.label.w * 0.6} height="2.4" rx="1.2" fill={k.accent} />
|
|
44
|
+
<rect x={sh.label.x + sh.label.w * 0.3} y={sh.label.y + 14} width={sh.label.w * 0.4} height="1.8" rx="0.9" fill={k.accent} opacity="0.5" />
|
|
45
|
+
<rect x={sh.label.x + sh.label.w * 0.3} y={sh.label.y + 19} width={sh.label.w * 0.4} height="1.8" rx="0.9" fill={k.accent} opacity="0.5" />
|
|
46
|
+
<path d={sh.shine} stroke="#fff" stroke-opacity="0.22" stroke-width="2.2" stroke-linecap="round" fill="none" />
|
|
47
|
+
</svg>
|
|
48
|
+
|
|
49
|
+
<style>
|
|
50
|
+
.wr-WineBottle {
|
|
51
|
+
display: block;
|
|
52
|
+
height: 100%;
|
|
53
|
+
width: auto;
|
|
54
|
+
margin: 0 auto;
|
|
55
|
+
}
|
|
56
|
+
</style>
|
|
@@ -5,12 +5,17 @@
|
|
|
5
5
|
import { onMount } from 'svelte';
|
|
6
6
|
import { appStore } from '../services/store/app-store.svelte';
|
|
7
7
|
import Action from './ui/Action.svelte';
|
|
8
|
+
import WineBottle from './WineBottle.svelte';
|
|
9
|
+
import { hasImage } from '../services/bottle/bottle';
|
|
8
10
|
|
|
9
11
|
// Desktop grid has 4 columns: the first row is above the fold
|
|
10
12
|
const FIRST_ROW_SIZE = 4;
|
|
11
13
|
|
|
12
14
|
let lang = $derived(appStore.getAppLanguage());
|
|
13
15
|
|
|
16
|
+
// A broken image URL falls back to the drawn bottle, same as an empty one
|
|
17
|
+
let imageFailed = $state(false);
|
|
18
|
+
|
|
14
19
|
let {
|
|
15
20
|
wine,
|
|
16
21
|
index,
|
|
@@ -97,7 +102,7 @@
|
|
|
97
102
|
onclick={(event) => onTileClick(event, wine)}
|
|
98
103
|
>
|
|
99
104
|
<header class="wr-WineTile-header">
|
|
100
|
-
{#if wine.image}
|
|
105
|
+
{#if hasImage(wine.image) && !imageFailed}
|
|
101
106
|
<img
|
|
102
107
|
src={wine.image}
|
|
103
108
|
alt={wine.title}
|
|
@@ -105,7 +110,12 @@
|
|
|
105
110
|
loading={index < FIRST_ROW_SIZE ? 'eager' : 'lazy'}
|
|
106
111
|
fetchpriority={index < FIRST_ROW_SIZE ? 'high' : 'auto'}
|
|
107
112
|
decoding="async"
|
|
113
|
+
onerror={() => (imageFailed = true)}
|
|
108
114
|
/>
|
|
115
|
+
{:else}
|
|
116
|
+
<div class="wr-WineTile-image wr-WineTile-bottle">
|
|
117
|
+
<WineBottle type={wine.type} />
|
|
118
|
+
</div>
|
|
109
119
|
{/if}
|
|
110
120
|
{#if wine.total}
|
|
111
121
|
<div class="wr-WineTile-price">
|
|
@@ -250,6 +260,11 @@
|
|
|
250
260
|
display: block;
|
|
251
261
|
}
|
|
252
262
|
|
|
263
|
+
.wr-WineTile-bottle {
|
|
264
|
+
padding: 4px 0;
|
|
265
|
+
box-sizing: border-box;
|
|
266
|
+
}
|
|
267
|
+
|
|
253
268
|
.wr-WineTile-title {
|
|
254
269
|
font-size: 15px;
|
|
255
270
|
font-weight: 500;
|
package/src/services/api/api.ts
CHANGED
|
@@ -68,6 +68,12 @@ class WineaterApi {
|
|
|
68
68
|
// One place for every backend call: JSON body, auth, anonymous uid header,
|
|
69
69
|
// uniform error. `keepalive` lets the last feedback batch out-live the page.
|
|
70
70
|
async #request<T>(endpoint: string, body?: unknown, options: { keepalive?: boolean; method?: string } = {}): Promise<T> {
|
|
71
|
+
return (await this.#send<T>(endpoint, body, options)).data;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Same as #request, but also hands back the response headers (instant/v2
|
|
75
|
+
// returns the taste-profile version in X-Wineater-Profile).
|
|
76
|
+
async #send<T>(endpoint: string, body?: unknown, options: { keepalive?: boolean; method?: string } = {}): Promise<{ data: T; headers: Headers }> {
|
|
71
77
|
const init = (): RequestInit => ({
|
|
72
78
|
method: options.method ?? 'POST',
|
|
73
79
|
headers: this.#headers(),
|
|
@@ -91,16 +97,22 @@ class WineaterApi {
|
|
|
91
97
|
if (!response.ok) {
|
|
92
98
|
throw new Error(`API call failed with status ${response.status}: ${data?.message || 'No additional error information'}`);
|
|
93
99
|
}
|
|
94
|
-
return data as T;
|
|
100
|
+
return { data: data as T, headers: response.headers };
|
|
95
101
|
}
|
|
96
102
|
|
|
97
|
-
|
|
103
|
+
// `profileHash` is set when the backend served the visitor's personal start
|
|
104
|
+
// grid (anonymous taste profile); null for the general grid. The widget keys
|
|
105
|
+
// its instant cache by it, so a recomputed profile is picked up next visit.
|
|
106
|
+
async getInstantRecommendations(): Promise<{ wines: Wine[]; profileHash: string | null }> {
|
|
98
107
|
try {
|
|
99
108
|
const endpoint = this.#apiVersion === '2'
|
|
100
109
|
? 'dish/recommendations/instant/v2'
|
|
101
110
|
: 'dish/recommendations/instant';
|
|
102
|
-
const data = await this.#
|
|
103
|
-
return
|
|
111
|
+
const { data, headers } = await this.#send<any[]>(endpoint, {});
|
|
112
|
+
return {
|
|
113
|
+
wines: data.filter(Boolean).map(deserializeInstantRecommendation),
|
|
114
|
+
profileHash: headers.get('X-Wineater-Profile') || null,
|
|
115
|
+
};
|
|
104
116
|
} catch (error) {
|
|
105
117
|
throw new Error(`Error in getInstantRecommendations: ${error.message}`);
|
|
106
118
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { bottleKind, hasImage } from './bottle';
|
|
3
|
+
|
|
4
|
+
describe('bottleKind', () => {
|
|
5
|
+
it('maps the types the API sends', () => {
|
|
6
|
+
expect(bottleKind('Red')).toBe('red');
|
|
7
|
+
expect(bottleKind('Rosé')).toBe('rose');
|
|
8
|
+
expect(bottleKind('Rose')).toBe('rose');
|
|
9
|
+
expect(bottleKind('Sparkling')).toBe('sparkling');
|
|
10
|
+
expect(bottleKind('Champagne brut')).toBe('sparkling');
|
|
11
|
+
expect(bottleKind('sweet')).toBe('sweet');
|
|
12
|
+
expect(bottleKind('Port')).toBe('fortified');
|
|
13
|
+
expect(bottleKind('fortified')).toBe('fortified');
|
|
14
|
+
});
|
|
15
|
+
it('falls back to white', () => {
|
|
16
|
+
expect(bottleKind('White')).toBe('white');
|
|
17
|
+
expect(bottleKind('')).toBe('white');
|
|
18
|
+
expect(bottleKind(null)).toBe('white');
|
|
19
|
+
expect(bottleKind('orange')).toBe('white');
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('hasImage', () => {
|
|
24
|
+
it('rejects empty and stringified nulls', () => {
|
|
25
|
+
expect(hasImage('')).toBe(false);
|
|
26
|
+
expect(hasImage(' ')).toBe(false);
|
|
27
|
+
expect(hasImage('null')).toBe(false);
|
|
28
|
+
expect(hasImage('undefined')).toBe(false);
|
|
29
|
+
expect(hasImage(null)).toBe(false);
|
|
30
|
+
expect(hasImage('https://cdn.example/a.png')).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fallback bottle drawn from the wine type, used when a wine has no image or its image fails to load.
|
|
3
|
+
* Pure data: the same type always gives the same drawing. Keep in sync with the admin onboarding
|
|
4
|
+
* (wineater-admin-panel/src/pages/OnboardingPage.tsx), which draws the same bottles.
|
|
5
|
+
*/
|
|
6
|
+
export type Shoulder = 'bordeaux' | 'burgundy' | 'flute' | 'champagne' | 'port';
|
|
7
|
+
export type BottleKind = 'red' | 'white' | 'rose' | 'sparkling' | 'sweet' | 'fortified';
|
|
8
|
+
|
|
9
|
+
export const BOTTLES: Record<BottleKind, { glass: string; foil: string; accent: string; shoulder: Shoulder }> = {
|
|
10
|
+
red: { glass: '#3b1230', foil: '#7a1f3d', accent: '#7a1f3d', shoulder: 'bordeaux' },
|
|
11
|
+
white: { glass: '#a9b568', foil: '#c9b458', accent: '#8a8f3c', shoulder: 'flute' },
|
|
12
|
+
rose: { glass: '#d98a9d', foil: '#e8a3b4', accent: '#b8566f', shoulder: 'burgundy' },
|
|
13
|
+
sparkling: { glass: '#5f6b3a', foil: '#d9c26a', accent: '#b89b2e', shoulder: 'champagne' },
|
|
14
|
+
sweet: { glass: '#b8862e', foil: '#d9a441', accent: '#9a6a1c', shoulder: 'flute' },
|
|
15
|
+
fortified: { glass: '#4a2a1a', foil: '#8a5a2b', accent: '#8a5a2b', shoulder: 'port' },
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type Box = { x: number; y: number; w: number; h: number };
|
|
19
|
+
|
|
20
|
+
/** Silhouettes on a 44x130 canvas; every bottle spans y=2..123. */
|
|
21
|
+
export const SHAPES: Record<Shoulder, { body: string; cap: Box; label: Box; shine: string }> = {
|
|
22
|
+
flute: {
|
|
23
|
+
body: 'M17 4h10v34c0 6 8 10 8 24v56c0 3-2 5-5 5H14c-3 0-5-2-5-5V62c0-14 8-18 8-24z',
|
|
24
|
+
cap: { x: 16, y: 2, w: 12, h: 20 },
|
|
25
|
+
label: { x: 9, y: 72, w: 26, h: 34 },
|
|
26
|
+
shine: 'M12 66c0 18 0 40 0 52',
|
|
27
|
+
},
|
|
28
|
+
burgundy: {
|
|
29
|
+
body: 'M17 4h10v30c0 10 11 14 11 30v54c0 3-2 5-5 5H11c-3 0-5-2-5-5V64c0-16 11-20 11-30z',
|
|
30
|
+
cap: { x: 16, y: 2, w: 12, h: 20 },
|
|
31
|
+
label: { x: 8, y: 72, w: 28, h: 34 },
|
|
32
|
+
shine: 'M11 68c0 18 0 38 0 50',
|
|
33
|
+
},
|
|
34
|
+
bordeaux: {
|
|
35
|
+
body: 'M17 4h10v32c0 4 1 6 4 9 4 4 6 7 6 14v59c0 3-2 5-5 5H12c-3 0-5-2-5-5V59c0-7 2-10 6-14 3-3 4-5 4-9z',
|
|
36
|
+
cap: { x: 16, y: 2, w: 12, h: 20 },
|
|
37
|
+
label: { x: 9, y: 72, w: 26, h: 34 },
|
|
38
|
+
shine: 'M12 64c0 18 0 40 0 54',
|
|
39
|
+
},
|
|
40
|
+
champagne: {
|
|
41
|
+
body: 'M17 4h10v34c0 12 15 18 15 42v35c0 5-3 8-7 8H9c-4 0-7-3-7-8V80c0-24 15-30 15-42z',
|
|
42
|
+
cap: { x: 15, y: 2, w: 14, h: 34 },
|
|
43
|
+
label: { x: 6, y: 78, w: 32, h: 36 },
|
|
44
|
+
shine: 'M8 82c0 14 0 28 0 36',
|
|
45
|
+
},
|
|
46
|
+
port: {
|
|
47
|
+
body: 'M16 4h12v16c0 3-2 4-2 7s3 4 3 8c0 6 9 6 9 18v65c0 3-2 5-5 5H11c-3 0-5-2-5-5V53c0-12 9-12 9-18 0-4 3-5 3-8s-2-4-2-7z',
|
|
48
|
+
cap: { x: 15, y: 2, w: 14, h: 15 },
|
|
49
|
+
label: { x: 8, y: 66, w: 28, h: 42 },
|
|
50
|
+
shine: 'M11 60c0 20 0 40 0 56',
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** Bubbles of the sparkling bottle: cx, cy, r. */
|
|
55
|
+
export const BUBBLES: [number, number, number][] = [
|
|
56
|
+
[20, 58, 1.5], [26, 50, 1.1], [23, 44, 0.8], [14, 66, 1.2], [30, 63, 1.7], [9, 86, 1.1],
|
|
57
|
+
[34, 92, 1.3], [8, 106, 1.6], [35, 112, 1], [25, 70, 0.9], [17, 73, 0.7],
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
/** "Red", "Rosé", "Rouge", "Champagne", "dessert wine", ... -> one of six kinds; unknown -> white. */
|
|
61
|
+
export function bottleKind(type: string | null | undefined): BottleKind {
|
|
62
|
+
const v = (type ?? '')
|
|
63
|
+
.toLowerCase()
|
|
64
|
+
.normalize('NFD')
|
|
65
|
+
.replace(/[̀-ͯ]/g, '');
|
|
66
|
+
if (/(spark|champ|cava|prosecco|cremant|brut|pet.?nat|espum|petillant)/.test(v)) return 'sparkling';
|
|
67
|
+
if (/(rose|pink|rosado)/.test(v)) return 'rose';
|
|
68
|
+
if (/(fortif|port|sherry|madeira|marsala)/.test(v)) return 'fortified';
|
|
69
|
+
if (/(sweet|dessert|late|sauternes|ice|moelleux|dulce)/.test(v)) return 'sweet';
|
|
70
|
+
if (/(red|rouge|tinto|rosso|krasn)/.test(v)) return 'red';
|
|
71
|
+
return 'white';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A usable image URL: the API sometimes sends "", "null" or "undefined" as a string. */
|
|
75
|
+
export function hasImage(src: string | null | undefined): src is string {
|
|
76
|
+
const s = (src ?? '').trim();
|
|
77
|
+
return !!s && s !== 'null' && s !== 'undefined';
|
|
78
|
+
}
|