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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wineater-bot",
3
- "version": "4.22.0",
3
+ "version": "4.23.1",
4
4
  "description": "",
5
5
  "main": "dist/wineater-chatbot.mjs",
6
6
  "module": "src/index.js",
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
- const instantCacheKey = `${cacheScope}:instant`;
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 cached = readCache<Wine[]>(instantCacheKey, INSTANT_CACHE_MAX_AGE_MS);
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) writeCache(instantCacheKey, fresh);
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;
@@ -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
- async getInstantRecommendations(): Promise<Wine[]> {
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.#request<any[]>(endpoint, {});
103
- return data.filter(Boolean).map(deserializeInstantRecommendation);
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
+ }