Initial commit - MealPlanner
This commit is contained in:
commit
3ea2c3fd82
19 files changed
+1241
No files matched your search
Executable
+8
@@ -0,0 +1,8 @@
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
EXPOSE 3000
|
||||
CMD ["npm", "run", "start"]
|
||||
Executable
+68
@@ -0,0 +1,68 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;600;700;800&family=Manrope:wght@600;700;800&display=swap');
|
||||
*{box-sizing:border-box}body{margin:0;background:#f7f8f3;color:#203329;font-family:'DM Sans',sans-serif}main{min-height:100vh}aside{position:fixed;inset:0 auto 0 0;width:220px;background:white;border-right:1px solid #e8ece5;padding:30px 22px;display:flex;flex-direction:column;gap:22px}aside h2{font-family:Manrope;color:#183c2d}aside p,small{font-size:10px;letter-spacing:1.4px;color:#819087;font-weight:800}aside a{color:#26734d;text-decoration:none;padding:12px;border-radius:10px}section{margin-left:220px;padding:38px 5vw;max-width:1500px}header{display:flex;justify-content:space-between;align-items:center;margin-bottom:28px}header h1{font:800 30px Manrope;color:#183c2d;margin:8px 0}header p{color:#758078}.hero{border-radius:22px;background:linear-gradient(110deg,#204e38,#397d51);color:white;padding:35px 42px;display:flex;justify-content:space-between;align-items:center}.hero h1{font:800 38px Manrope;line-height:1.15}.hero p{max-width:420px;color:#dcebe0}.hero a{display:inline-block;background:white;color:#24563c;text-decoration:none;padding:12px 17px;border-radius:10px;font-weight:800;font-size:13px}.hero span{font-size:115px}.stats,.stores,.fields{display:grid;grid-template-columns:repeat(3,1fr);gap:15px;margin:22px 0 35px}.stats article,article,.empty,#settings{background:white;border:1px solid #e8ece5;border-radius:15px;padding:18px}.stats h2{font:800 25px Manrope;color:#183c2d;margin:8px 0}section>div:not(.hero){margin-top:35px}section h2{font:800 21px Manrope;color:#183c2d}.empty{padding:25px;color:#758078}.stores article{font-weight:700}#settings{margin-top:35px}.fields{margin:18px 0}.fields label{display:grid;gap:8px;font-size:12px;color:#69776e;font-weight:700}.fields input{padding:12px;border:1px solid #dfe6df;border-radius:9px;font:600 14px 'DM Sans' }button{background:#26734d;border:0;color:white;padding:12px 18px;border-radius:9px;font-weight:700;cursor:pointer}footer{text-align:center;color:#9aa49c;font-size:11px;padding:30px}@media(max-width:750px){aside{width:65px;padding:20px 8px}aside h2{font-size:0}aside a{font-size:0}section{margin-left:65px;padding:20px 14px}.hero{padding:24px}.hero span{font-size:60px}.hero h1{font-size:28px}.stats,.fields{grid-template-columns:1fr}.stores{grid-template-columns:repeat(2,1fr)}header{align-items:flex-start;gap:12px;flex-direction:column}}
|
||||
|
||||
/* Module Circulaires — ajout à la présentation V1 */
|
||||
#flyers{margin-top:35px}
|
||||
.flyer-control{display:flex;align-items:center;justify-content:space-between;gap:18px;flex-wrap:wrap}
|
||||
.flyer-control p{max-width:650px;color:#758078;font-size:13px;line-height:1.6}
|
||||
.import-message{width:100%;margin:0}
|
||||
.flyer-sources{grid-template-columns:repeat(2,1fr)}
|
||||
.flyer-sources article{display:flex;flex-direction:column;gap:8px}
|
||||
.store-address{font-size:12px;color:#758078;margin:5px 0}
|
||||
.source-error{font-size:12px;color:#a34b35;line-height:1.5}
|
||||
.flyer-list{display:grid;grid-template-columns:repeat(2,1fr);gap:15px;margin-top:16px}
|
||||
.flyer-list article{display:flex;flex-direction:column;gap:8px}
|
||||
.flyer-list h3{font:700 16px Manrope;color:#183c2d;margin:0}
|
||||
.flyer-list p{font-size:12px;color:#758078;margin:0}
|
||||
.flyer-list a{font-size:12px;color:#26734d;font-weight:700;text-decoration:none}
|
||||
@media(max-width:750px){.flyer-sources,.flyer-list{grid-template-columns:1fr}.flyer-control{align-items:flex-start}}
|
||||
|
||||
/* Promotions Flipp — ajout au module Circulaires V1 */
|
||||
.flyer-thumb{width:100%;max-height:170px;object-fit:cover;border-radius:10px;margin-bottom:5px}
|
||||
.offers-intro{font-size:13px;color:#758078;line-height:1.6}
|
||||
.offer-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;margin-top:16px}
|
||||
.offer-card{display:grid;grid-template-columns:92px minmax(0,1fr);gap:12px;align-items:center;padding:12px;min-width:0}
|
||||
.offer-card>img,.offer-placeholder{width:92px;height:92px;object-fit:contain;border-radius:10px;background:#f4f6f1}
|
||||
.offer-placeholder{display:grid;place-items:center;font-size:34px}
|
||||
.offer-detail{display:flex;flex-direction:column;gap:5px;min-width:0}
|
||||
.offer-detail small{font-size:9px;letter-spacing:.7px}
|
||||
.offer-detail strong{font-size:12px;line-height:1.35;color:#203329}
|
||||
.offer-detail span{font-size:11px;color:#758078}
|
||||
.offer-detail b{font:800 17px Manrope;color:#26734d}
|
||||
@media(max-width:1100px){.offer-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||
@media(max-width:750px){.offer-grid{grid-template-columns:1fr}.offer-card{grid-template-columns:78px minmax(0,1fr)}.offer-card>img,.offer-placeholder{width:78px;height:78px}}
|
||||
|
||||
|
||||
/* Générateur de soupers — extension de l'interface V1 */
|
||||
.meal-generator{padding:22px;margin-top:14px}
|
||||
.meal-generator-head{display:flex;justify-content:space-between;align-items:center;gap:18px;flex-wrap:wrap}
|
||||
.meal-generator-head strong{font:800 16px Manrope;color:#183c2d}
|
||||
.meal-generator-head p,.estimate-note{font-size:12px;color:#758078;line-height:1.6;max-width:700px}
|
||||
.selection-summary{display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap;margin:22px 0 14px;padding:13px;background:#f4f7f1;border-radius:10px;color:#315640;font-size:12px}
|
||||
.selection-summary span{color:#758078}
|
||||
.suggestion-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;margin:14px 0}
|
||||
.suggestion-card{padding:16px;min-width:0;transition:border-color .15s,box-shadow .15s}
|
||||
.suggestion-card.selected{border-color:#7db58d;box-shadow:0 0 0 2px #e5f1e7}
|
||||
.suggestion-top{display:flex;justify-content:space-between;align-items:center;gap:10px}
|
||||
.suggestion-top label{display:flex;align-items:center;gap:6px;color:#64736a;font-size:11px;font-weight:700}
|
||||
.suggestion-top input,.shopping-list input{accent-color:#26734d}
|
||||
.meal-icon{font-size:25px}
|
||||
.suggestion-card h3,.saved-plan h3{font:800 15px Manrope;color:#183c2d;margin:12px 0 6px}
|
||||
.suggestion-card>p{font-size:12px;color:#758078;line-height:1.5;margin:0}
|
||||
.recipe-meta{display:flex;justify-content:space-between;align-items:center;margin:12px 0;font-size:11px;color:#758078}
|
||||
.recipe-meta b{font:800 16px Manrope;color:#26734d}
|
||||
.promotion-tags{display:flex;flex-direction:column;gap:5px;padding:10px;background:#f1f7ef;border-radius:9px}
|
||||
.promotion-tags strong{font-size:11px;color:#26734d}
|
||||
.promotion-tags small{font-size:10px;letter-spacing:0;color:#63766a}
|
||||
.no-promo{font-size:10px;letter-spacing:0;color:#89948d}
|
||||
.suggestion-card details{margin-top:12px;border-top:1px solid #edf0eb;padding-top:10px;font-size:11px;color:#637168}
|
||||
.suggestion-card summary{cursor:pointer;font-weight:700;color:#26734d}
|
||||
.suggestion-card details ul{padding-left:18px;line-height:1.7}
|
||||
.suggestion-card details p{line-height:1.6}
|
||||
.estimate-note{margin:14px 0}
|
||||
.saved-plan{margin-top:16px}
|
||||
.saved-plan>p{font-size:13px;color:#637168}
|
||||
.shopping-list{display:grid;gap:10px;margin-top:15px;padding-top:14px;border-top:1px solid #edf0eb}
|
||||
.shopping-list h3{margin:0}
|
||||
.shopping-list label{display:flex;align-items:center;gap:8px;font-size:12px;color:#405448}
|
||||
@media(max-width:750px){.suggestion-grid{grid-template-columns:1fr}.meal-generator{padding:16px}.meal-generator-head{align-items:flex-start}.selection-summary{flex-direction:column}}
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "MealPlanner",
|
||||
description: "Planificateur de repas hebdomadaire"
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
return <html lang="fr"><body>{children}</body></html>;
|
||||
}
|
||||
Executable
+156
@@ -0,0 +1,156 @@
|
||||
"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>
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
async rewrites() {
|
||||
return [{
|
||||
source: "/api/:path*",
|
||||
destination: "http://api:8000/api/:path*"
|
||||
}];
|
||||
}
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "mealplanner-frontend",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "15.2.4",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "22.13.10",
|
||||
"@types/react": "19.0.10",
|
||||
"@types/react-dom": "19.0.4",
|
||||
"typescript": "5.8.2"
|
||||
}
|
||||
}
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }]
|
||||
},
|
||||
"include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in new issue
Block a user