grapp / index.html
adrescop's picture
App bilingue FR/EN
66b5884 verified
Raw History Blame Contribute Delete
34.9 kB
<!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>