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=>({"&":"&","<":"<",">":">",'"':"""}[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 € 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>
|