wineater-bot 4.23.0 → 4.23.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/dist/wineater-chatbot.mjs +14 -14
- package/dist/wineater-chatbot.umd.js +15 -15
- package/package.json +1 -1
- package/src/components/WineBottle.svelte +56 -0
- package/src/components/WineTile.svelte +16 -1
- package/src/services/bottle/bottle.spec.ts +32 -0
- package/src/services/bottle/bottle.ts +78 -0
package/package.json
CHANGED
|
@@ -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;
|
|
@@ -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
|
+
}
|