File size: 34,862 Bytes
3d35cce
bacf93f
 
ce92add
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66b5884
 
 
 
 
 
 
ce92add
bacf93f
 
ce92add
 
66b5884
ce92add
66b5884
ce92add
66b5884
 
 
ce92add
 
 
 
 
66b5884
 
 
 
 
 
 
 
 
ce92add
 
 
 
66b5884
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ce92add
 
 
 
 
 
 
66b5884
 
 
 
 
 
ce92add
 
 
 
 
 
 
 
 
 
 
 
 
 
66b5884
 
 
 
 
ce92add
 
 
66b5884
ce92add
 
66b5884
 
ce92add
 
 
 
 
 
 
 
66b5884
ce92add
 
 
 
 
 
66b5884
 
 
 
 
 
ce92add
 
 
 
 
 
 
 
66b5884
 
ce92add
66b5884
ce92add
66b5884
ce92add
66b5884
 
ce92add
66b5884
 
 
 
 
 
 
 
ce92add
 
66b5884
ce92add
 
66b5884
 
 
 
 
ce92add
 
 
66b5884
 
 
 
 
ce92add
 
 
 
 
66b5884
 
 
 
 
 
ce92add
 
 
66b5884
ce92add
 
66b5884
ce92add
 
 
66b5884
 
ce92add
66b5884
ce92add
 
 
 
66b5884
 
 
 
 
 
 
 
 
ce92add
 
 
 
 
66b5884
ce92add
 
66b5884
 
 
ce92add
 
 
66b5884
 
ce92add
66b5884
 
 
 
 
ce92add
d30de3a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66b5884
 
 
 
 
d30de3a
 
 
 
66b5884
 
 
 
 
d30de3a
 
 
 
 
 
 
 
 
 
 
 
bacf93f
3d35cce
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>GRAPP</title>
<meta name="description" content="L'app pour choisir ton vin sans te prendre la grappe.">
<meta name="theme-color" content="#7D1C4F">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="GRAPP">
<link rel="manifest" href="manifest.json">
<link rel="icon" href="icon-192.png">
<link rel="apple-touch-icon" href="icon-192.png">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bowlby+One&family=Instrument+Sans:ital,wght@0,400;0,600;0,700;1,400&display=swap">
<style>
/* Layout : un téléphone au centre (l'app), une colonne de notes à côté sur grand écran. Monde unique rose/prune du dossier PFE. */
:root{
  --rose:#FF5F8A; --prune:#7D1C4F; --corail:#F5472C; --peche:#F4C4B6; --creme:#FAEFE7; --lilas:#D5B5D2;
  --encre:#2A0E1D; --blanc:#FFFFFF; --gris:#6E5563;
  --display:"Bowlby One","Arial Black",Impact,sans-serif;
  --body:"Instrument Sans",system-ui,-apple-system,"Segoe UI",sans-serif;
  color-scheme:light;
}
html,body{background:var(--creme);color:var(--encre)}
body{font-family:var(--body);font-size:15px;line-height:1.45;padding-block:28px 40px;
  padding-left:max(16px,env(safe-area-inset-left,0px));padding-right:max(16px,env(safe-area-inset-right,0px))}
.page{max-width:980px;margin:0 auto;display:grid;grid-template-columns:minmax(0,400px) minmax(0,1fr);gap:48px;align-items:start}
@media (max-width:820px){.page{grid-template-columns:minmax(0,1fr)}.notes{order:2}}
.phone{background:linear-gradient(170deg,var(--peche),var(--lilas));border:10px solid var(--encre);border-radius:44px;
  padding:22px 18px 20px;min-height:720px;box-shadow:10px 10px 0 var(--prune);position:relative;max-width:100%}
.logo{font-family:var(--display);font-size:2.6rem;color:var(--prune);font-style:italic;line-height:1;transform:rotate(-4deg);display:inline-block}
.tag{color:var(--prune);font-weight:700;font-size:.85rem;margin:.3rem 0 1rem}
.tabs{display:flex;gap:6px;margin-bottom:14px}
.tabs button{flex:1;font:700 .78rem var(--body);border:2px solid var(--prune);background:var(--blanc);color:var(--prune);
  border-radius:999px;padding:.45rem .2rem;cursor:pointer}
.tabs button[aria-selected="true"]{background:var(--prune);color:var(--blanc)}
button:focus-visible,input:focus-visible{outline:3px solid var(--corail);outline-offset:2px}
h2{font-family:var(--display);font-style:italic;color:var(--prune);font-size:1.55rem;line-height:1.05;margin:.2rem 0 .2rem;text-transform:uppercase;text-wrap:balance}
.sous{font-style:italic;color:var(--prune);margin:0 0 1rem}
.prog{display:flex;justify-content:space-between;align-items:center;margin-bottom:.5rem}
.prog b{font-family:var(--display);color:var(--prune);font-size:1.2rem}
.bar{height:6px;background:var(--blanc);border-radius:9px;overflow:hidden;margin-bottom:1rem}
.bar i{display:block;height:100%;background:var(--rose);transition:width .3s}
.opts{display:grid;gap:10px}
.opt{font:700 .95rem var(--body);text-align:left;background:var(--prune);color:var(--blanc);border:none;border-radius:12px;
  padding:.8rem 1rem;cursor:pointer;box-shadow:3px 3px 0 var(--rose);transition:transform .1s}
.opt[aria-pressed="true"]{background:var(--rose);box-shadow:3px 3px 0 var(--prune)}
.opt:hover{transform:translate(-1px,-1px)}
.chipsel{display:flex;flex-wrap:wrap;gap:8px}
.chipsel .opt{padding:.5rem .8rem;font-size:.85rem}
label.l{font-weight:700;color:var(--prune);display:block;margin:1rem 0 .4rem}
input[type=text]{width:100%;box-sizing:border-box;font:inherit;border:2px solid var(--prune);border-radius:12px;padding:.6rem .8rem;background:var(--blanc);color:var(--encre)}
input[type=range]{width:100%;accent-color:var(--rose)}
.nav{display:flex;justify-content:space-between;gap:10px;margin-top:1.4rem}
.btn{font:800 .85rem var(--body);text-transform:uppercase;letter-spacing:.04em;border-radius:999px;padding:.6rem 1.1rem;cursor:pointer;
  border:2px solid var(--prune);background:var(--blanc);color:var(--prune)}
.btn.go{background:var(--corail);border-color:var(--corail);color:var(--blanc)}
.menu{background:var(--blanc);border-radius:12px;padding:8px;margin:.6rem 0;max-height:230px;overflow:auto}
.menu img{display:block;width:100%}
.ok{background:var(--blanc);border:2px dashed var(--prune);border-radius:12px;padding:.6rem .8rem;font-size:.85rem;margin-bottom:1rem}
.vin{background:var(--blanc);border:3px solid var(--prune);border-radius:16px;padding:.9rem 1rem;box-shadow:4px 4px 0 var(--rose)}
.vins{display:grid;gap:14px}
.vin header{display:flex;justify-content:space-between;align-items:flex-start;gap:8px}
.vin h3{font-family:var(--display);color:var(--prune);font-size:1.05rem;margin:0;text-transform:uppercase;line-height:1.1}
.pct{background:var(--corail);color:var(--blanc);font-weight:800;border-radius:999px;padding:.15rem .6rem;white-space:nowrap;font-variant-numeric:tabular-nums}
.meta{font-size:.82rem;color:var(--gris);margin:.2rem 0}
.pitch{font-style:italic;margin:.35rem 0}
.chips{display:flex;flex-wrap:wrap;gap:5px;margin:.4rem 0}
.chip{background:var(--peche);color:var(--prune);border-radius:999px;padding:.1rem .55rem;font-size:.75rem;font-weight:700}
.row{display:flex;gap:8px;flex-wrap:wrap;margin-top:.5rem}
.row .btn{padding:.35rem .8rem;font-size:.72rem}
.simi{font-size:.82rem;color:var(--prune);margin-top:.4rem}
.empty{background:var(--blanc);border-radius:12px;padding:1rem;color:var(--prune)}
.def h3{font-family:var(--display);color:var(--prune);margin:0 0 .3rem;font-size:1.05rem;text-transform:uppercase}
.def p{margin:.2rem 0}
.notes{padding-top:10px;max-width:62ch}
.notes h1{font-family:var(--display);font-style:italic;color:var(--prune);font-size:2rem;line-height:1.05;margin:0 0 .6rem;text-wrap:balance}
.notes p,.notes li{color:var(--encre)}
.notes .k{font-size:.75rem;text-transform:uppercase;letter-spacing:.08em;font-weight:700;color:var(--rose);margin-top:1.4rem}
.notes ol{padding-left:1.2rem}
.notes code{background:var(--blanc);padding:.05rem .35rem;border-radius:5px;font-size:.88em}
@media (prefers-reduced-motion:reduce){*{transition:none!important}}
/* Sur téléphone : plein écran, comme une vraie app */
@media (max-width:520px){
  body{padding:0}
  .page{display:block}
  .phone{border:0;border-radius:0;box-shadow:none;min-height:100vh;min-height:100dvh;
    padding:calc(env(safe-area-inset-top,0px) + 18px) max(16px,env(safe-area-inset-right,0px)) calc(env(safe-area-inset-bottom,0px) + 24px) max(16px,env(safe-area-inset-left,0px))}
  .notes{display:none}
}
.src{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:.6rem}
.src .opt{flex:1;min-width:0;text-align:center;padding:.55rem .4rem;font-size:.8rem}
.load{color:var(--prune);font-weight:700;font-size:.9rem}
.err{background:var(--blanc);border:2px solid var(--corail);border-radius:12px;padding:.6rem .8rem;font-size:.85rem;color:var(--encre)}
textarea{width:100%;box-sizing:border-box;font:inherit;border:2px solid var(--prune);border-radius:12px;padding:.6rem .8rem;background:var(--blanc);color:var(--encre);min-height:140px}

/* Sélecteur de langue : discret, en haut à droite du téléphone */
.lang{position:absolute;top:24px;right:20px;display:flex;align-items:center;gap:2px;font:700 .72rem var(--body);letter-spacing:.06em}
.lang button{background:none;border:0;padding:.25rem .35rem;color:var(--prune);opacity:.45;cursor:pointer;font:inherit;border-radius:6px}
.lang button[aria-pressed="true"]{opacity:1;text-decoration:underline;text-underline-offset:3px;text-decoration-thickness:2px}
.lang span{color:var(--prune);opacity:.35}
@media (max-width:520px){.lang{top:calc(env(safe-area-inset-top,0px) + 20px);right:max(14px,env(safe-area-inset-right,0px))}}
</style>
</head>
<body>
<div class="page">
  <div class="phone" id="phone">
    <div class="lang" role="group" aria-label="Langue / Language"><button data-lang="fr">FR</button><span>·</span><button data-lang="en">EN</button></div>
    <div class="logo">GRAPP’</div>
    <p class="tag" data-i="tag"></p>
    <div class="tabs" role="tablist">
      <button id="t0" role="tab" aria-selected="true" data-t="0" data-i="tab0"></button>
      <button id="t1" role="tab" aria-selected="false" data-t="1" data-i="tab1"></button>
      <button id="t2" role="tab" aria-selected="false" data-t="2" data-i="tab2"></button>
    </div>
    <div id="ecran"></div>
  </div>

  <aside class="notes">
    <h1 data-i="n_h"></h1>
    <p data-i="n_p"></p>
    <p class="k" data-i="n_k1"></p>
    <p data-i="n_p1"></p>
    <p class="k" data-i="n_k2"></p>
    <p data-i="n_p2"></p>
    <p class="k" data-i="n_k3"></p>
    <p data-i="n_p3"></p>
    <p style="font-size:.8rem;color:var(--gris)" data-i="legal"></p>
  </aside>
</div>
<script src="https://cdn.jsdelivr.net/npm/tesseract.js@5.1.1/dist/tesseract.min.js"></script>
<script>
/* ---------- Textes FR / EN ---------- */
const TXT={
fr:{tag:"L’app pour choisir ton vin sans te prendre la grappe 🍇",tab0:"Tchin Tchin !",tab1:"Mes favoris",tab2:"Dico Vino",
 n_h:"GRAPP, ton sommelier de poche",n_p:"Prends en photo la carte des vins du resto, réponds à 7 questions sans jargon, et GRAPP te sort ton top 3 avec un % de match. Uniquement des vins qui sont sur la carte.",
 n_k1:"Sur ton téléphone",n_p1:"Ouvre ce lien sur ton téléphone, puis <b>Partager → Sur l'écran d'accueil</b> (iPhone) ou <b>⋮ → Ajouter à l'écran d'accueil</b> (Android) : GRAPP s'ouvre alors en plein écran, comme une app.",
 n_k2:"Comment ça marche",n_p2:"Lecture de la photo par OCR (Tesseract.js), reconnaissance des vins tolérante aux fautes, puis recommandation hybride : tes réponses au quiz + ta phrase libre.",
 n_k3:"Version Python",n_p3:"Le code Python (Streamlit) est dans les fichiers du projet. Il tourne aussi dans le navigateur : <a href=\"python.html\">version Streamlit</a>.",
 legal:"À consommer avec modération. L’abus d’alcool est dangereux pour la santé.",
 s1:"Étape 1 · Scanne la carte",s1b:"GRAPP ne te propose que des vins qui sont sur la carte du resto.",photo:"📷 Photo",exemple:"🧾 Exemple",texte:"⌨️ Texte",
 phl:"Prends la carte des vins en photo",txl:"Colle ou tape les vins de la carte",imgalt:"Carte des vins d'exemple : bulles, blancs, rosés, rouges et douceurs avec leurs prix",
 skip:"Pas de carte",lire:"🔍 Lire la carte",none:"Je n'ai reconnu aucun vin de ma base sur cette carte 🤔 Essaie une photo plus nette, ou tape les noms.",
 needphoto:"Ajoute d'abord une photo 😉",ocr:"🔍 L'IA déchiffre la carte… (quelques secondes)",ocrerr:e=>`Lecture impossible (${e}). Tape plutôt les noms des vins.`,
 found:n=>`${n} vins repérés`,question:"Question",back:"⬅ Retour",next:"Suivant ➡",find:"🍇 Trouve mon vin !",
 flav:"Les saveurs qui te font kiffer",budget:f=>`Budget max pour ${f==="verre"?"le verre":"la bouteille"}`,envie:"Ton envie en une phrase",enviePh:"ex : un truc frais avec de la charcuterie",
 res:"Plus qu'à kiffer ✨",top:c=>`Ton top 3${c?" sur la carte du resto":""}`,nores:"Aucun vin ne colle à tes critères. Essaie « Surprends-moi » ou « Peu importe » !",again:"🔄 Recommencer",
 smells:"Ça sent :",with:"Avec :",fav:"❤️ Mes fav",unfav:"💔 Retirer",same:"🔁 Dans le même style :",ifyou:"🔁 Si tu aimes, essaie :",
 favh:"Mes vins pépites",nofav:"Pas encore de favori. Fais le quiz et clique sur ❤️ !",remove:"Retirer",
 dicoh:"Je décrypte le vin !",dicob:"Un mot bizarre sur la carte ? Tape-le.",dicoph:"ex : tanins, brut, carafer…",dicono:"Rien trouvé. Essaie « sec », « robe » ou « crémant ».",
 loading:"🍇 GRAPP se prépare…",loaderr:e=>`Impossible de charger les données (${e}). Recharge la page.`,
 r:{sucre:["Sec, comme tu aimes","Presque sec","Une pointe de douceur","Doux comme demandé","Bien sucré, team gourmand"],light:"Léger et facile",mid:"Équilibré",bold:"Costaud, du caractère",
    prof:"Profil ",occ:"Parfait pour : ",bud:(p,u)=>`Dans ton budget (${p} € ${u==="verre"?"le verre":"la bouteille"})`,envie:"Colle à ton envie du moment"},
 fam:{fruite:"Fruité 🍓",floral:"Floral 🌸",epice:"Épicé 🌶️",boise:"Boisé / vanillé 🪵",mineral:"Frais et minéral 🌊",gourmand:"Gourmand / sucré 🍯"},
 occ:{apero:"Apéro entre potes",repas:"Avec un bon repas",dessert:"Pour le dessert",grande_occasion:"Grosse occasion"},
 Q:[["Alors, on trinque ?","C'est quoi le plan du jour ?"],["Verre ou bouteille ?","Tu la joues solo ou on partage ?"],["La vie en couleur !","T'es dans quelle team ?"],
    ["Plus ça pétille…","Un peu de pep's dans ton verre ?"],["Sucré ou pas ?","Sois honnête, personne ne juge."],["Léger ou costaud ?","Plutôt plume ou plutôt haltère ?"],
    ["C'est parfait !","Dernière ligne droite : tes goûts et ton budget."]],
 O:{verre:"Juste un p'tit verre (ou 2)",bouteille:"On prend une bouteille",rouge:"Rouge",blanc:"Blanc",rose:"Rosé",peu_importe:"Surprends-moi",
    oui:"J'adore le pétillant",non:"Sans bulles",bpi:"Peu importe",s1:"Sec",s2:"Presque sec",s3:"Une pointe de douceur",s4:"Doux",s5:"Team dessert",
    c1:"Tout léger",c2:"Léger",c3:"Entre les deux",c4:"Costaud",c5:"Ultra costaud"},
 ios:{h:"Mets GRAPP sur ton iPhone 🍇",p:"En 3 secondes, GRAPP s'ouvre en plein écran comme une vraie app.",
   s:['Touche <b>Partager</b> ICO en bas de Safari (ou dans le menu <b>•••</b>)','Choisis <b>Sur l\'écran d\'accueil</b>','Touche <b>Ajouter</b> : c\'est fait !'],ok:"Compris",close:"Fermer"}},
en:{tag:"The app to pick your wine without the headache 🍇",tab0:"Cheers!",tab1:"My favourites",tab2:"Wine Dico",
 n_h:"GRAPP, your pocket sommelier",n_p:"Snap the restaurant’s wine list, answer 7 jargon-free questions, and GRAPP gives you your top 3 with a match score. Only wines that are actually on the list.",
 n_k1:"On your phone",n_p1:"Open this link on your phone, then <b>Share → Add to Home Screen</b> (iPhone) or <b>⋮ → Add to Home screen</b> (Android): GRAPP then opens full screen, like a real app.",
 n_k2:"How it works",n_p2:"The photo is read by OCR (Tesseract.js), wines are matched despite reading errors, then a hybrid recommendation combines your quiz answers and your free-text craving.",
 n_k3:"Python version",n_p3:"The Python code (Streamlit) is in the project files. It also runs in the browser: <a href=\"python.html\">Streamlit version</a>.",
 legal:"Please drink responsibly. Alcohol abuse is dangerous for your health.",
 s1:"Step 1 · Scan the wine list",s1b:"GRAPP only suggests wines that are on the restaurant’s list.",photo:"📷 Photo",exemple:"🧾 Sample",texte:"⌨️ Text",
 phl:"Take a photo of the wine list",txl:"Paste or type the wines on the list",imgalt:"Sample wine list: sparkling, whites, rosés, reds and sweet wines with prices",
 skip:"No list",lire:"🔍 Read the list",none:"I couldn't recognise any wine from my database on this list 🤔 Try a sharper photo, or type the names.",
 needphoto:"Add a photo first 😉",ocr:"🔍 The AI is reading the list… (a few seconds)",ocrerr:e=>`Couldn't read it (${e}). Type the wine names instead.`,
 found:n=>`${n} wines found`,question:"Question",back:"⬅ Back",next:"Next ➡",find:"🍇 Find my wine!",
 flav:"Flavours you love",budget:f=>`Max budget per ${f==="verre"?"glass":"bottle"}`,envie:"Your craving in one sentence",enviePh:"e.g. something fresh with charcuterie",
 res:"Time to enjoy ✨",top:c=>`Your top 3${c?" from the restaurant’s list":""}`,nores:"No wine matches your criteria. Try “Surprise me” or “Don’t mind”!",again:"🔄 Start over",
 smells:"Smells like:",with:"Goes with:",fav:"❤️ Save",unfav:"💔 Remove",same:"🔁 Same style:",ifyou:"🔁 If you like it, try:",
 favh:"My gem wines",nofav:"No favourites yet. Take the quiz and tap ❤️!",remove:"Remove",
 dicoh:"Wine, decoded!",dicob:"A weird word on the list? Type it.",dicoph:"e.g. tannins, brut, decanting…",dicono:"Nothing found. Try “dry”, “body” or “crémant”.",
 loading:"🍇 GRAPP is getting ready…",loaderr:e=>`Couldn't load the data (${e}). Please reload.`,
 r:{sucre:["Dry, just how you like it","Almost dry","A touch of sweetness","Sweet, as requested","Very sweet, dessert team"],light:"Light and easy",mid:"Balanced",bold:"Bold, full of character",
    prof:"Profile: ",occ:"Perfect for: ",bud:(p,u)=>`Within your budget (€${p} per ${u==="verre"?"glass":"bottle"})`,envie:"Matches your craving"},
 fam:{fruite:"Fruity 🍓",floral:"Floral 🌸",epice:"Spicy 🌶️",boise:"Oaky / vanilla 🪵",mineral:"Fresh & mineral 🌊",gourmand:"Indulgent / sweet 🍯"},
 occ:{apero:"Drinks with friends",repas:"With a good meal",dessert:"With dessert",grande_occasion:"Big occasion"},
 Q:[["So, let’s toast?","What’s the plan today?"],["Glass or bottle?","Going solo or sharing?"],["Life in colour!","Which team are you?"],
    ["The more bubbles…","A little fizz in your glass?"],["Sweet or not?","Be honest, nobody’s judging."],["Light or bold?","Feather or dumbbell?"],
    ["Perfect!","Last step: your tastes and your budget."]],
 O:{verre:"Just a glass (or two)",bouteille:"Let’s get a bottle",rouge:"Red",blanc:"White",rose:"Rosé",peu_importe:"Surprise me",
    oui:"I love bubbles",non:"No bubbles",bpi:"Don’t mind",s1:"Dry",s2:"Almost dry",s3:"A touch of sweetness",s4:"Sweet",s5:"Dessert team",
    c1:"Very light",c2:"Light",c3:"In between",c4:"Bold",c5:"Very bold"},
 ios:{h:"Put GRAPP on your iPhone 🍇",p:"In 3 seconds, GRAPP opens full screen like a real app.",
   s:['Tap <b>Share</b> ICO at the bottom of Safari (or in the <b>•••</b> menu)','Choose <b>Add to Home Screen</b>','Tap <b>Add</b>: done!'],ok:"Got it",close:"Close"}}};
let LANG="fr";try{const l=localStorage.getItem("grapp_lang");if(l==="en"||l==="fr")LANG=l}catch(e){}
const T=()=>TXT[LANG];

/* ---------- Données ---------- */
const D={vins:[],en:{},dico:{fr:[],en:[]},carteTexte:"",img:"assets/carte_exemple.png"};
function parseCSV(txt){const rows=[];let row=[],f="",q=false;
  for(let i=0;i<txt.length;i++){const c=txt[i];
    if(q){if(c=='"'){if(txt[i+1]=='"'){f+='"';i++}else q=false}else f+=c}
    else if(c=='"')q=true;else if(c==','){row.push(f);f=""}
    else if(c=='\n'){row.push(f);rows.push(row);row=[];f=""}else if(c!='\r')f+=c}
  if(f||row.length){row.push(f);rows.push(row)}
  const h=rows.shift();return rows.filter(r=>r.length===h.length).map(r=>Object.fromEntries(h.map((k,i)=>[k,isNaN(r[i])||r[i]===""?r[i]:+r[i]])));}
/* texte d'un vin dans la langue choisie (le nom de l'appellation ne se traduit pas) */
const V=(v,k)=>LANG==="en"&&D.en[v.nom]?D.en[v.nom][k]:v[k];

/* ---------- Lecture de la carte : même logique que grapp/carte.py ---------- */
const norm=t=>String(t).normalize("NFD").replace(/[\u0300-\u036f]/g,"").toLowerCase().replace(/[^a-z0-9]+/g," ").trim();
const esc=t=>String(t).replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
const VIDES=new Set("vin de du des la le d rouge blanc rose brut sec pays oc aop igp cotes villages".split(" "));
function ratio(a,b){if(!a.length&&!b.length)return 1;const m=Array.from({length:a.length+1},()=>new Array(b.length+1).fill(0));
  for(let i=1;i<=a.length;i++)for(let j=1;j<=b.length;j++)m[i][j]=a[i-1]===b[j-1]?m[i-1][j-1]+1:Math.max(m[i-1][j],m[i][j-1]);
  return 2*m[a.length][b.length]/(a.length+b.length);}
function scoreLigne(l,nom){const n=norm(nom);if(n&&l.includes(n))return 1;
  const mots=n.split(" ").filter(m=>!VIDES.has(m)&&m.length>2);if(!mots.length)return 0;
  const toks=l.split(" ");return mots.filter(m=>Math.max(0,...toks.map(t=>ratio(m,t)))>=.8).length/mots.length;}
function trouverVins(texte){const res={};
  for(const brute of texte.split("\n")){const l=norm(brute);if(l.length<4)continue;
    let best=null;for(const v of D.vins){const s=scoreLigne(l,v.nom),r=ratio(norm(v.nom),l);
      if(!best||s>best.s||(s===best.s&&r>best.r))best={s,r,nom:v.nom}}
    if(best&&best.s>=.99&&!(best.nom in res)){const m=brute.match(/(\d{1,3}(?:[.,]\d{1,2})?)\s*(?:€|eur)/i)||brute.trim().match(/(\d{1,3}(?:[.,]\d{1,2})?)\s*$/);
      res[best.nom]=m?parseFloat(m[1].replace(",",".")):null}}
  return res;}

/* ---------- Quiz : les réponses sont stockées par identifiant, pas par texte ---------- */
const QK=[["occasion",["apero","repas","dessert","grande_occasion"]],["format",["verre","bouteille"]],["couleur",["rouge","blanc","rose","peu_importe"]],
  ["bulles",["oui","non","bpi"]],["sucre",["s1","s2","s3","s4","s5"]],["corps",["c1","c2","c3","c4","c5"]],["final",null]];
const lab=(cle,id)=>cle==="occasion"?T().occ[id]:T().O[id];
let S={tab:0,etape:0,rep:{},carte:null,src:"exemple",fav:[]};
try{S.fav=JSON.parse(localStorage.getItem("grapp_fav")||"[]")}catch(e){}
const saveFav=()=>{try{localStorage.setItem("grapp_fav",JSON.stringify(S.fav))}catch(e){}};
const fiche=v=>`${v.nom} ${v.couleur} ${v.region} ${v.aromes} ${v.accords} ${v.pitch} `+(D.en[v.nom]?`${D.en[v.nom].region} ${D.en[v.nom].aromes} ${D.en[v.nom].accords} ${D.en[v.nom].pitch}`:"");

function prefs(r){
  return {occasion:r.occasion||"apero",format:r.format||"verre",couleur:r.couleur||"rouge",bulles:r.bulles==="bpi"?"peu_importe":(r.bulles||"oui"),
    sucre:+(r.sucre||"s1").slice(1),corps:+(r.corps||"c1").slice(1),familles:r.familles||[],budget:+(r.budget||30),envie:r.envie||""};
}
const P={sucre:.18,corps:.18,famille:.17,occasion:.12,budget:.15,semantique:.20};
function recommander(p,k,carte){
  let df=D.vins.filter(v=>(p.couleur==="peu_importe"||v.couleur===p.couleur)&&(p.bulles==="peu_importe"||v.bulles===(p.bulles==="oui"?1:0))&&(!carte||v.nom in carte));
  const mots=new Set(norm(p.envie).split(" ").filter(w=>w.length>3));
  let sims=df.map(v=>{const f=new Set(norm(fiche(v)).split(" "));let n=0;mots.forEach(w=>{if([...f].some(x=>x.startsWith(w.slice(0,5))))n++});return n});
  const lo=Math.min(...sims),hi=Math.max(...sims);
  sims=sims.map(s=>hi>lo?(s-lo)/(hi-lo):1);
  const R=T().r;
  return df.map((v,i)=>{
    let prix=p.format==="verre"?v.prix_verre:v.prix_bouteille; if(carte&&carte[v.nom])prix=carte[v.nom];
    const s={sucre:1-Math.abs(v.sucre-p.sucre)/4,corps:1-Math.abs(v.corps-p.corps)/4,
      famille:!p.familles.length||p.familles.includes(v.famille)?1:.25,occasion:v.occasion===p.occasion?1:.4,
      budget:prix<=p.budget?1:Math.max(0,1-(prix-p.budget)/p.budget),semantique:p.envie.trim()?sims[i]:.5};
    const r=[];
    if(s.sucre>=.75)r.push(R.sucre[v.sucre-1]);
    if(s.corps>=.75)r.push(v.corps<=2?R.light:v.corps===3?R.mid:R.bold);
    if(p.familles.includes(v.famille))r.push(R.prof+T().fam[v.famille]);
    if(v.occasion===p.occasion)r.push(R.occ+T().occ[v.occasion].toLowerCase());
    if(prix<=p.budget)r.push(R.bud(Math.round(prix),p.format));
    if(p.envie.trim()&&s.semantique>=.7)r.push(R.envie);
    return {v,prix,raisons:r,match:Math.round(100*Object.keys(P).reduce((a,c)=>a+P[c]*s[c],0))};
  }).sort((a,b)=>b.match-a.match).slice(0,k);
}
function similaires(nom){
  const v=D.vins.find(x=>x.nom===nom); const vec=x=>[x.sucre,x.corps,x.acidite,x.tanins,x.bulles*3];
  const a=vec(v);
  return D.vins.filter(x=>x.nom!==nom).map(x=>({x,d:vec(x).reduce((s,t,i)=>s+(t-a[i])**2,0)+(x.famille===v.famille?0:2)+(x.couleur===v.couleur?0:4)})).sort((p,q)=>p.d-q.d).slice(0,3).map(o=>o.x.nom);
}

/* ---------- Affichage ---------- */
const E=document.getElementById("ecran");
const EMO={rouge:"🍷",blanc:"🥂",rose:"🌸"};
function carteVin(r){
  const v=r.v,fav=S.fav.includes(v.nom),t=T();
  return `<article class="vin"><header><h3>${EMO[v.couleur]} ${esc(v.nom)}</h3><span class="pct">${r.match}% match</span></header>
  <div class="meta">${esc(V(v,"region"))} · ~${LANG==="en"?"€"+Math.round(r.prix):Math.round(r.prix)+" €"}</div><p class="pitch">${LANG==="en"?"“":"« "}${esc(V(v,"pitch"))}${LANG==="en"?"”":" »"}</p>
  <div class="meta"><b>${t.smells}</b> ${esc(V(v,"aromes"))} · <b>${t.with}</b> ${esc(V(v,"accords"))}</div>
  <div class="chips">${r.raisons.map(x=>`<span class="chip">${esc(x)}</span>`).join("")}</div>
  <div class="row"><button class="btn" data-fav="${esc(v.nom)}">${fav?t.unfav:t.fav}</button></div>
  <div class="simi">${t.same} ${similaires(v.nom).map(esc).join(", ")}</div></article>`;
}
function statique(){
  document.documentElement.lang=LANG;
  document.querySelectorAll("[data-i]").forEach(el=>{el.innerHTML=T()[el.dataset.i]});
  document.querySelectorAll("[data-lang]").forEach(b=>b.setAttribute("aria-pressed",b.dataset.lang===LANG));
  if(window.iosTextes)iosTextes();
}
function render(){
  const t=T();
  document.querySelectorAll(".tabs button").forEach(b=>b.setAttribute("aria-selected",+b.dataset.t===S.tab));
  if(S.tab===1){
    E.innerHTML=`<h2>${t.favh}</h2>`+(S.fav.length?`<div class="vins">${S.fav.map(n=>{const v=D.vins.find(x=>x.nom===n);if(!v)return"";
      return `<article class="vin"><header><h3>${EMO[v.couleur]} ${esc(n)}</h3><button class="btn" data-fav="${esc(n)}" aria-label="${t.remove}">🗑️</button></header>
      <div class="meta">${esc(V(v,"region"))} · ~${LANG==="en"?"€"+v.prix_bouteille:v.prix_bouteille+" €"}</div><p class="pitch">${esc(V(v,"pitch"))}</p>
      <div class="simi">${t.ifyou} ${similaires(n).map(esc).join(", ")}</div></article>`}).join("")}</div>`
      :`<p class="empty">${t.nofav}</p>`);
    return;
  }
  if(S.tab===2){
    E.innerHTML=`<h2>${t.dicoh}</h2><p class="sous">${t.dicob}</p>
    <input type="text" id="dq" placeholder="${esc(t.dicoph)}" value="${esc(S.dq||"")}"><div class="vins" id="dres" style="margin-top:12px"></div>`;
    const dico=D.dico[LANG];
    const go=()=>{S.dq=dq.value;const q=norm(dq.value);const res=q?dico.map(d=>{const tt=norm(d.terme),f=norm(d.definition);let s=tt.includes(q)||q.includes(tt)?10:0;q.split(" ").filter(w=>w.length>3).forEach(w=>{if(f.includes(w.slice(0,5)))s++});return {d,s}}).filter(o=>o.s>0).sort((a,b)=>b.s-a.s).slice(0,3):dico.slice(0,3).map(d=>({d}));
      dres.innerHTML=res.length?res.map(({d})=>`<article class="vin def"><h3>${esc(d.terme)}</h3><p>${esc(d.definition)}</p><p class="pitch">👉 ${esc(d.exemple)}</p></article>`).join(""):`<p class="empty">${t.dicono}</p>`};
    const dq=document.getElementById("dq"),dres=document.getElementById("dres");dq.oninput=go;go();return;
  }
  const e=S.etape;
  if(e===0){
    const src=S.src||"exemple";
    let h=`<h2>${t.s1}</h2><p class="sous">${t.s1b}</p>
    <div class="src"><button class="opt" data-s="photo" aria-pressed="${src==="photo"}">${t.photo}</button><button class="opt" data-s="exemple" aria-pressed="${src==="exemple"}">${t.exemple}</button><button class="opt" data-s="texte" aria-pressed="${src==="texte"}">${t.texte}</button></div>`;
    if(src==="photo")h+=`<label class="l" for="ph">${t.phl}</label><input type="file" id="ph" accept="image/*" capture="environment"><div id="prev"></div>`;
    else if(src==="exemple")h+=`<div class="menu"><img src="${D.img}" alt="${esc(t.imgalt)}"></div>`;
    else h+=`<label class="l" for="tx">${t.txl}</label><textarea id="tx" placeholder="Chinon 2020 29 €&#10;Sancerre 42 €">${esc(S.tx||"")}</textarea>`;
    h+=`<div id="msg"></div><div class="nav"><button class="btn" id="skip">${t.skip}</button><button class="btn go" id="lire">${t.lire}</button></div>`;
    E.innerHTML=h;
    E.querySelectorAll("[data-s]").forEach(b=>b.onclick=()=>{S.src=b.dataset.s;render()});
    const ph=document.getElementById("ph");let fichier=null;
    if(ph)ph.onchange=()=>{fichier=ph.files[0];if(fichier)prev.innerHTML=`<div class="menu"><img src="${URL.createObjectURL(fichier)}" alt=""></div>`};
    const tx=document.getElementById("tx");if(tx)tx.oninput=()=>{S.tx=tx.value};
    const msg=document.getElementById("msg");
    const fini=c=>{if(Object.keys(c).length){S.carte=c;S.etape=1;render()}else msg.innerHTML=`<p class="err">${t.none}</p>`};
    lire.onclick=async()=>{
      if(src==="exemple"){fini(trouverVins(D.carteTexte));return}
      if(src==="texte"){fini(trouverVins(S.tx||""));return}
      if(!fichier){msg.innerHTML=`<p class="err">${t.needphoto}</p>`;return}
      msg.innerHTML=`<p class="load">${t.ocr}</p>`;lire.disabled=true;
      try{const r=await Tesseract.recognize(fichier,"fra");fini(trouverVins(r.data.text))}
      catch(err){msg.innerHTML=`<p class="err">${esc(t.ocrerr(err.message||err))}</p>`}
      finally{lire.disabled=false}};
    skip.onclick=()=>{S.carte=null;S.etape=1;render()};return;
  }
  if(e<=7){
    const [cle,ids]=QK[e-1],[titre,sous]=t.Q[e-1];
    let h=(S.carte?`<div class="ok">🧾 <b>${t.found(Object.keys(S.carte).length)}</b>${LANG==="en"?":":" :"} ${Object.keys(S.carte).map(esc).join(", ")}</div>`:"")+
    `<div class="prog"><span class="sous" style="margin:0">${t.question}</span><b>${e}/7</b></div><div class="bar"><i style="width:${e/7*100}%"></i></div><h2>${titre}</h2><p class="sous">${sous}</p>`;
    if(ids){h+=`<div class="opts">${ids.map(id=>`<button class="opt" aria-pressed="${(S.rep[cle]||ids[0])===id}" data-o="${id}">${esc(lab(cle,id))}</button>`).join("")}</div>`}
    else{const fmt=S.rep.format||"verre";S.rep.budget=S.rep.budget||(fmt==="verre"?8:30);S.rep.familles=S.rep.familles||[];
      h+=`<label class="l">${t.flav}</label><div class="chipsel">${Object.keys(t.fam).map(f=>`<button class="opt" aria-pressed="${S.rep.familles.includes(f)}" data-f="${f}">${t.fam[f]}</button>`).join("")}</div>
      <label class="l" for="bud">${t.budget(fmt)}${LANG==="en"?": €":" : "}<span id="bv">${S.rep.budget}</span>${LANG==="en"?"":" €"}</label><input type="range" id="bud" min="3" max="80" value="${S.rep.budget}">
      <label class="l" for="env">${t.envie}</label><input type="text" id="env" value="${esc(S.rep.envie||"")}" placeholder="${esc(t.enviePh)}">`}
    h+=`<div class="nav"><button class="btn" id="ret">${t.back}</button><button class="btn go" id="suiv">${e<7?t.next:t.find}</button></div>`;
    E.innerHTML=h;
    E.querySelectorAll("[data-o]").forEach(b=>b.onclick=()=>{S.rep[cle]=b.dataset.o;render()});
    E.querySelectorAll("[data-f]").forEach(b=>b.onclick=()=>{const f=b.dataset.f,L=S.rep.familles;S.rep.familles=L.includes(f)?L.filter(x=>x!==f):[...L,f];render()});
    const bud=document.getElementById("bud");if(bud){bud.oninput=()=>{S.rep.budget=+bud.value;bv.textContent=bud.value};env.oninput=()=>{S.rep.envie=env.value}}
    ret.onclick=()=>{S.etape--;render()};
    suiv.onclick=()=>{if(ids&&!S.rep[cle])S.rep[cle]=ids[0];S.etape++;render()};return;
  }
  const r=recommander(prefs(S.rep),3,S.carte);
  E.innerHTML=`<h2>${t.res}</h2><p class="sous">${t.top(!!S.carte)}</p>`+
   (r.length?`<div class="vins">${r.map(carteVin).join("")}</div>`:`<p class="empty">${t.nores}</p>`)+
   `<div class="nav"><button class="btn" id="re">${t.again}</button></div>`;
  re.onclick=()=>{S.etape=0;S.rep={};S.carte=null;render()};
}
document.querySelector(".tabs").onclick=ev=>{const b=ev.target.closest("[data-t]");if(b){S.tab=+b.dataset.t;render()}};
document.querySelector(".lang").onclick=ev=>{const b=ev.target.closest("[data-lang]");if(!b)return;LANG=b.dataset.lang;try{localStorage.setItem("grapp_lang",LANG)}catch(e){}
  S.dq="";statique();if(D.vins.length)render()};
E.addEventListener("click",ev=>{const b=ev.target.closest("[data-fav]");if(!b)return;const n=b.dataset.fav;S.fav=S.fav.includes(n)?S.fav.filter(x=>x!==n):[...S.fav,n];saveFav();render()});
statique();
E.innerHTML=`<p class="load">${T().loading}</p>`;
Promise.all(["data/vins.csv","data/vins_en.csv","data/dico.csv","data/dico_en.csv","data/carte_exemple.txt"].map(u=>fetch(u).then(r=>{if(!r.ok)throw new Error(u);return r.text()})))
  .then(([v,ve,d,de,c])=>{D.vins=parseCSV(v);parseCSV(ve).forEach(x=>D.en[x.nom]=x);D.dico.fr=parseCSV(d);D.dico.en=parseCSV(de);D.carteTexte=c;render()})
  .catch(err=>{E.innerHTML=`<p class="err">${esc(T().loaderr(err.message))}</p>`});
</script>
<!-- Aide à l'installation : iPhone uniquement, dans le navigateur (pas en mode app) -->
<style>
.ios-voile{position:fixed;inset:0;background:rgba(42,14,29,.35);z-index:50}
.ios-pop{position:fixed;left:max(12px,env(safe-area-inset-left,0px));right:max(12px,env(safe-area-inset-right,0px));
  bottom:calc(env(safe-area-inset-bottom,0px) + 12px);z-index:51;background:var(--creme);border:3px solid var(--prune);
  border-radius:22px;box-shadow:5px 5px 0 var(--rose);padding:18px 18px 16px;animation:iosIn .35s ease-out}
@keyframes iosIn{from{transform:translateY(30px);opacity:0}to{transform:none;opacity:1}}
@media (prefers-reduced-motion:reduce){.ios-pop{animation:none}}
.ios-pop h3{font-family:var(--display);font-style:italic;text-transform:uppercase;color:var(--prune);font-size:1.15rem;margin:0 0 .3rem;line-height:1.1}
.ios-pop p{margin:0 0 .7rem;font-size:.9rem;color:var(--encre)}
.ios-pop ol{margin:0 0 .9rem;padding-left:1.3rem;font-size:.9rem;color:var(--encre);display:grid;gap:.35rem}
.ios-pop b{color:var(--prune)}
.ios-ico{display:inline-block;vertical-align:-3px;width:16px;height:18px}
.ios-pop .btn{width:100%}
.ios-x{position:absolute;top:8px;right:10px;background:none;border:0;font-size:1.4rem;color:var(--prune);cursor:pointer;line-height:1;padding:4px}
</style>
<div id="iosAide" hidden>
  <div class="ios-voile" data-ferme></div>
  <div class="ios-pop" role="dialog" aria-labelledby="iosTitre">
    <button class="ios-x" data-ferme id="iosX">×</button>
    <h3 id="iosTitre"></h3>
    <p id="iosP"></p>
    <ol id="iosOl"></ol>
    <button class="btn go" data-ferme id="iosOk"></button>
  </div>
</div>
<script>
(function(){
  var ICO='<svg class="ios-ico" viewBox="0 0 16 18" aria-hidden="true"><path d="M8 1v10M4.5 4.5 8 1l3.5 3.5M3 8H2v9h12V8h-1" fill="none" stroke="#7D1C4F" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>';
  window.iosTextes=function(){var t=TXT[LANG].ios;document.getElementById("iosTitre").textContent=t.h;document.getElementById("iosP").textContent=t.p;
    document.getElementById("iosOl").innerHTML=t.s.map(function(x){return "<li>"+x.replace("ICO",ICO)+"</li>"}).join("");
    document.getElementById("iosOk").textContent=t.ok;document.getElementById("iosX").setAttribute("aria-label",t.close)};
  iosTextes();
  var ua=navigator.userAgent||"";
  var iphone=/iPhone|iPod/.test(ua);
  var appli=window.navigator.standalone===true||(window.matchMedia&&matchMedia("(display-mode: standalone)").matches);
  var vu=false;try{vu=localStorage.getItem("grapp_ios_aide")==="1"}catch(e){}
  if(!iphone||appli||vu)return;
  var el=document.getElementById("iosAide");
  setTimeout(function(){el.hidden=false},1200);
  el.addEventListener("click",function(e){
    if(e.target.closest("[data-ferme]")){el.hidden=true;try{localStorage.setItem("grapp_ios_aide","1")}catch(x){}}
  });
})();
</script>
</body>
</html>