157 lines
13 KiB
TypeScript
Executable File
157 lines
13 KiB
TypeScript
Executable File
"use client";
|
|
import {useEffect,useState} from "react";
|
|
|
|
type Flyer = {
|
|
id:number; title:string; source_url:string; valid_from:string|null; valid_to:string|null;
|
|
downloaded_at:string; page_count:number; extraction_status:string; store_name:string; address:string|null;
|
|
offer_count:number; thumbnail_url:string|null; external_flyer_id:number|null; merchant:string|null; postal_code:string|null;
|
|
};
|
|
type Offer = {id:number;item_name:string;brand:string|null;price:number|string;price_text:string|null;image_url:string|null;valid_from:string|null;valid_to:string|null;merchant:string|null;flyer_title:string|null;store_name:string;};
|
|
type Ingredient = {name:string;quantity:number;unit:string};
|
|
type Suggestion = {id:number;name:string;description:string;servings:number;instructions:string;estimated_cost:number;ingredients:Ingredient[];promotion_matches:{ingredient:string;item_name:string;price:number;price_text:string|null;price_unit:string;calculated_cost:number|null;store_name:string;valid_to:string|null}[];promotion_count:number;cost_is_partial:boolean};
|
|
type ShoppingItem = {ingredient_name:string;unit:string;quantity:number;recipe_count:number};
|
|
type SourceStatus = {
|
|
store_name:string; address:string; source_url:string; last_checked_at:string|null;
|
|
last_success_at:string|null; last_error:string|null; flyer_count:number; offer_count:number;
|
|
};
|
|
export default function Home(){
|
|
const [s,setS]=useState({household_size:4,weekly_budget:150,dinners_per_week:7,currency:"CAD"});
|
|
const [stores,setStores]=useState<any[]>([]);
|
|
const [flyers,setFlyers]=useState<Flyer[]>([]);
|
|
const [offers,setOffers]=useState<Offer[]>([]);
|
|
const [sources,setSources]=useState<SourceStatus[]>([]);
|
|
const [msg,setMsg]=useState("Connexion…");
|
|
const [importMsg,setImportMsg]=useState("");
|
|
const [importing,setImporting]=useState(false);
|
|
const [suggestions,setSuggestions]=useState<Suggestion[]>([]);
|
|
const [selectedIds,setSelectedIds]=useState<number[]>([]);
|
|
const [generating,setGenerating]=useState(false);
|
|
const [savingPlan,setSavingPlan]=useState(false);
|
|
const [planMsg,setPlanMsg]=useState("");
|
|
const [shoppingList,setShoppingList]=useState<ShoppingItem[]>([]);
|
|
const [savedMeals,setSavedMeals]=useState<any[]>([]);
|
|
const [showShopping,setShowShopping]=useState(false);
|
|
const today=new Date();
|
|
const monday=new Date(today);
|
|
monday.setDate(today.getDate()-((today.getDay()+6)%7));
|
|
const weekStart=`${monday.getFullYear()}-${String(monday.getMonth()+1).padStart(2,"0")}-${String(monday.getDate()).padStart(2,"0")}`;
|
|
const selectedRecipes=suggestions.filter(x=>selectedIds.includes(x.id));
|
|
const selectedCost=selectedRecipes.reduce((sum,x)=>sum+(x.estimated_cost===null?0:Number(x.estimated_cost)),0);
|
|
const selectedCostsComplete=selectedRecipes.every(x=>!x.cost_is_partial&&x.estimated_cost!==null);
|
|
async function loadFlyers(){
|
|
try{
|
|
const [f,st,o]=await Promise.all([
|
|
fetch("/api/flyers").then(r=>r.ok?r.json():[]),
|
|
fetch("/api/flyer-import/status").then(r=>r.ok?r.json():{sources:[]}),
|
|
fetch("/api/offers").then(r=>r.ok?r.json():[])
|
|
]);
|
|
setFlyers(f); setSources(st.sources||[]); setOffers(o);
|
|
}catch{}
|
|
}
|
|
useEffect(()=>{
|
|
Promise.all([fetch("/api/settings").then(r=>r.json()),fetch("/api/stores").then(r=>r.json())])
|
|
.then(([a,b])=>{setS(a);setStores(b);setMsg("Application connectée")})
|
|
.catch(()=>setMsg("API non disponible"));
|
|
loadFlyers();
|
|
},[]);
|
|
async function save(){
|
|
const r=await fetch("/api/settings",{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(s)});
|
|
setMsg(r.ok?"Paramètres enregistrés":"Erreur d'enregistrement");
|
|
}
|
|
async function generateSuggestions(){
|
|
setGenerating(true); setPlanMsg("Analyse des promotions et préparation des idées de soupers…");
|
|
try{
|
|
const r=await fetch("/api/meal-suggestions");
|
|
if(!r.ok) throw new Error("Impossible de générer les suggestions");
|
|
const data=await r.json();
|
|
setSuggestions(data.suggestions||[]);
|
|
setSelectedIds((data.suggestions||[]).slice(0,Number(s.dinners_per_week)||7).map((x:Suggestion)=>x.id));
|
|
setPlanMsg(`${(data.suggestions||[]).length} idées analysées selon les promotions disponibles.`);
|
|
}catch(e:any){setPlanMsg(e.message||"Erreur lors de la génération");}
|
|
finally{setGenerating(false);}
|
|
}
|
|
async function saveMealPlan(){
|
|
if(!selectedIds.length){setPlanMsg("Sélectionne au moins un souper.");return;}
|
|
setSavingPlan(true);setPlanMsg("Enregistrement du menu…");
|
|
try{
|
|
const r=await fetch("/api/meal-plan",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({week_start:weekStart,recipe_ids:selectedIds})});
|
|
const data=await r.json();
|
|
if(!r.ok) throw new Error(data.detail||"Impossible d'enregistrer le menu");
|
|
const [meals,list]=await Promise.all([
|
|
fetch(`/api/meal-plan?week_start=${weekStart}`).then(x=>x.json()),
|
|
fetch(`/api/shopping-list?week_start=${weekStart}`).then(x=>x.json())
|
|
]);
|
|
setSavedMeals(meals);setShoppingList(list);setShowShopping(true);
|
|
setPlanMsg(`Menu enregistré : ${data.meals_saved} soupers planifiés.`);
|
|
}catch(e:any){setPlanMsg(e.message||"Erreur d'enregistrement");}
|
|
finally{setSavingPlan(false);}
|
|
}
|
|
function toggleRecipe(id:number){
|
|
setSelectedIds(current=>current.includes(id)?current.filter(x=>x!==id):current.length>=Number(s.dinners_per_week)?current:[...current,id]);
|
|
}
|
|
async function checkFlyers(){
|
|
setImporting(true); setImportMsg("Vérification des circulaires en cours…");
|
|
try{
|
|
const r=await fetch("/api/flyer-import/run",{method:"POST"});
|
|
const data=await r.json();
|
|
if(!r.ok) throw new Error(data.detail||"Impossible de lancer l'importation");
|
|
setImportMsg("Vérification lancée. Actualise la page dans quelques instants pour voir le résultat.");
|
|
window.setTimeout(loadFlyers,15000);
|
|
}catch(e:any){setImportMsg(e.message||"Erreur");}
|
|
finally{setImporting(false);}
|
|
}
|
|
const dateLabel=(v:string|null)=>v?new Date(v).toLocaleString("fr-CA"):"—";
|
|
return <main><aside><h2>🥑 MealPlanner</h2><p>TON ASSISTANT CULINAIRE</p><a href="#menu">Mon menu</a><a href="#stores">Épiceries</a><a href="#flyers">Circulaires</a><a href="#settings">Paramètres</a></aside><section><header><div><small>TON ASSISTANT CULINAIRE</small><h1>Bonjour ! 👋</h1><p>Préparons une semaine de soupers savoureux sans dépasser ton budget.</p></div><label>{msg}</label></header><div className="hero"><div><small>CETTE SEMAINE</small><h1>Les bons repas<br/>commencent ici.</h1><p>Des menus personnalisés selon les rabais de tes épiceries préférées.</p><a href="#settings">Configurer mon budget →</a></div><span>🥗</span></div><div className="stats"><article><small>Budget hebdomadaire</small><h2>{Number(s.weekly_budget).toFixed(2)} $</h2></article><article><small>Soupers à planifier</small><h2>{s.dinners_per_week}</h2></article><article><small>Personnes</small><h2>{s.household_size}</h2></article></div><div id="menu"><small>PLANIFICATION</small><h2>Ton menu de la semaine</h2>
|
|
<article className="meal-generator">
|
|
<div className="meal-generator-head"><div><strong>Des idées selon les rabais</strong><p>MealPlanner compare les ingrédients des recettes aux promotions IGA et Metro actuellement importées.</p></div><button onClick={generateSuggestions} disabled={generating}>{generating?"Analyse…":"Générer mes soupers"}</button></div>
|
|
{planMsg&&<p className="import-message">{planMsg}</p>}
|
|
{suggestions.length>0&&<><div className="selection-summary"><strong>{selectedIds.length} / {Number(s.dinners_per_week)} soupers sélectionnés</strong><span>{selectedCostsComplete?`Coût calculé : ${selectedCost.toFixed(2)} $`:`Coût partiel : ${selectedCost.toFixed(2)} $`} · Budget : {Number(s.weekly_budget).toFixed(2)} $</span></div>
|
|
<div className="suggestion-grid">{suggestions.map(recipe=><article className={`suggestion-card ${selectedIds.includes(recipe.id)?"selected":""}`} key={recipe.id}>
|
|
<div className="suggestion-top"><span className="meal-icon">🍽️</span><label><input type="checkbox" checked={selectedIds.includes(recipe.id)} onChange={()=>toggleRecipe(recipe.id)} disabled={!selectedIds.includes(recipe.id)&&selectedIds.length>=Number(s.dinners_per_week)}/> Ajouter au menu</label></div>
|
|
<h3>{recipe.name}</h3><p>{recipe.description}</p>
|
|
<div className="recipe-meta"><b>{recipe.estimated_cost===null?"Coût à compléter":`≈ ${Number(recipe.estimated_cost).toFixed(2)} $`}</b><span>{recipe.ingredients.length} ingrédients</span></div>
|
|
{recipe.promotion_count>0?<div className="promotion-tags"><strong>🏷️ {recipe.promotion_count} rabais repéré(s)</strong>{recipe.promotion_matches.slice(0,3).map((m,i)=><small key={i}>{m.ingredient} : {m.price_text||`${Number(m.price).toFixed(2)} $`}{m.price_unit==="lb"?" / lb":m.price_unit==="kg"?" / kg":m.price_unit==="100g"?" / 100 g":""} ({m.store_name})</small>)}</div>:<small className="no-promo">Aucun rabais correspondant repéré</small>}
|
|
<details><summary>Voir ingrédients et préparation</summary><ul>{recipe.ingredients.map((x,i)=><li key={i}>{x.quantity} {x.unit} — {x.name}</li>)}</ul><p>{recipe.instructions}</p></details>
|
|
</article>)}</div>
|
|
<p className="estimate-note">Le coût affiché est calculé seulement lorsque l'unité du prix promotionnel et la quantité de recette sont compatibles. Les autres ingrédients restent à chiffrer; aucun prix à la livre n'est supposé sans indication de l'unité dans les données Flipp.</p>
|
|
<button onClick={saveMealPlan} disabled={savingPlan||selectedIds.length===0}>{savingPlan?"Enregistrement…":"Enregistrer mon menu et créer la liste d'épicerie"}</button></>}
|
|
</article>
|
|
{savedMeals.length>0&&<article className="saved-plan"><h3>Menu enregistré</h3>{savedMeals.map((m,i)=><p key={m.id}> <strong>{new Date(`${m.meal_date}T12:00:00`).toLocaleDateString("fr-CA",{weekday:"long",day:"numeric",month:"long"})} :</strong> {m.name}</p>)}
|
|
<button onClick={()=>setShowShopping(!showShopping)}>{showShopping?"Masquer":"Afficher"} la liste d'épicerie</button>
|
|
{showShopping&&<div className="shopping-list"><h3>Liste d'épicerie regroupée</h3>{shoppingList.map((x,i)=><label key={i}><input type="checkbox"/> {x.ingredient_name} — {Number(x.quantity).toLocaleString("fr-CA")} {x.unit}</label>)}</div>}</article>}
|
|
</div><div id="stores"><small>TES COMMERCES</small><h2>Épiceries suivies</h2><div className="stores">{stores.map(x=><article key={x.id}>✓ {x.name}{x.address&&<p className="store-address">{x.address}</p>}</article>)}</div></div>
|
|
<div id="flyers"><small>RABAIS DE TES MAGASINS</small><h2>Circulaires IGA et Metro</h2>
|
|
<article className="flyer-control">
|
|
<p>Importation automatique quotidienne à 6 h (heure de Montréal) des circulaires régionales Flipp pour J7R 6S4. Les articles avec nom et prix sont enregistrés dans ta base locale.</p>
|
|
<button onClick={checkFlyers} disabled={importing}>{importing?"Vérification…":"Vérifier les circulaires maintenant"}</button>
|
|
{importMsg&&<p className="import-message">{importMsg}</p>}
|
|
</article>
|
|
<div className="stores flyer-sources">{sources.map(x=><article key={x.store_name}>
|
|
<strong>{x.store_name}</strong><p className="store-address">{x.address}</p>
|
|
<small>Dernière vérification : {dateLabel(x.last_checked_at)}</small>
|
|
<small>Circulaires actives : {x.flyer_count}</small><small>Promotions disponibles : {x.offer_count}</small>
|
|
{x.last_error&&<p className="source-error">{x.last_error}</p>}
|
|
</article>)}</div>
|
|
<div className="flyer-list">{flyers.map(f=><article key={f.id}>
|
|
{f.thumbnail_url&&<img className="flyer-thumb" src={f.thumbnail_url} alt="" loading="lazy"/>}
|
|
<strong>{f.store_name}</strong><h3>{f.title}</h3>
|
|
<p>{f.offer_count} promotions · Valide du {f.valid_from||"—"} au {f.valid_to||"—"}</p>
|
|
<small>Circulaire régionale · Code postal {f.postal_code||"J7R6S4"}</small>
|
|
<small>Importée : {dateLabel(f.downloaded_at)}</small>
|
|
<a href={f.source_url} target="_blank" rel="noreferrer">Source Flipp ↗</a>
|
|
</article>)}
|
|
{flyers.length===0&&<article className="empty">Aucune circulaire régionale importée pour le moment. Utilise le bouton pour lancer une vérification.</article>}
|
|
</div>
|
|
<h2>Promotions en cours</h2>
|
|
<p className="offers-intro">Prix des circulaires régionales; ils peuvent varier selon la succursale et la disponibilité.</p>
|
|
<div className="offer-grid">{offers.slice(0,60).map(o=><article className="offer-card" key={o.id}>
|
|
{o.image_url?<img src={o.image_url} alt="" loading="lazy"/>:<div className="offer-placeholder">🛒</div>}
|
|
<div className="offer-detail"><small>{o.store_name}</small><strong>{o.item_name}</strong>
|
|
{o.brand&&<span>{o.brand}</span>}<b>{o.price_text||`${Number(o.price).toFixed(2)} $`}</b>
|
|
<small>Jusqu'au {o.valid_to||"—"}</small></div>
|
|
</article>)}</div>
|
|
{offers.length===0&&<article className="empty">Les promotions apparaîtront ici après la première importation Flipp.</article>}
|
|
</div>
|
|
<div id="settings"><small>PERSONNALISATION</small><h2>Ton budget et ton foyer</h2><div className="fields"><label>Budget par semaine ($)<input type="number" value={s.weekly_budget} onChange={e=>setS({...s,weekly_budget:Number(e.target.value)})}/></label><label>Nombre de personnes<input type="number" value={s.household_size} onChange={e=>setS({...s,household_size:Number(e.target.value)})}/></label><label>Soupers par semaine<input type="number" value={s.dinners_per_week} onChange={e=>setS({...s,dinners_per_week:Number(e.target.value)})}/></label></div><button onClick={save}>Enregistrer mes paramètres</button></div><footer>MealPlanner · Mieux manger et économiser.</footer></section></main>
|
|
}
|