// TontiPay v2 — clavier continu + steppers corrigés
import { useState, useEffect } from "react";
const uid = () => Date.now().toString(36) + Math.random().toString(36).slice(2);
const addDays = (s, n) => { const d = new Date(s+"T00:00:00"); d.setDate(d.getDate()+n); return d.toISOString().split("T")[0]; };
const fmtDate = (s) => { const d=new Date(s+"T00:00:00"),J=["Dim","Lun","Mar","Mer","Jeu","Ven","Sam"],M=["Jan","Fév","Mar","Avr","Mai","Jun","Jul","Aoû","Sep","Oct","Nov","Déc"]; return `${J[d.getDay()]} ${d.getDate()} ${M[d.getMonth()]} ${d.getFullYear()}`; };
const fmtS = (s) => { const d=new Date(s+"T00:00:00"),M=["Jan","Fév","Mar","Avr","Mai","Jun","Jul","Aoû","Sep","Oct","Nov","Déc"]; return `${d.getDate()} ${M[d.getMonth()]} ${d.getFullYear()}`; };
const fmtAmt = (n) => Number(n).toLocaleString("fr-FR")+" FCFA";
const dayDiff = (s) => { const t=new Date(); t.setHours(0,0,0,0); return Math.round((new Date(s+"T00:00:00")-t)/86400000); };
const todayStr= () => new Date().toISOString().split("T")[0];
const getRounds=(t)=> Array.from({length:t.memberCount},(_,i)=>({num:i+1,isMe:i+1===t.myTurn,date:addDays(t.startDate,i*t.frequency),pot:t.memberCount*t.amount}));
const KEY="tontipay_v5";
const loadDb=async()=>{ try{ const r=await window.storage.get(KEY); return r?JSON.parse(r.value):{tontines:[]}; }catch{return{tontines:[]};} };
const saveDb=async(db)=>{ try{ await window.storage.set(KEY,JSON.stringify(db)); }catch{} };
const C={bg:"#F9F6F1",card:"#FFFFFF",border:"#E5DDD0",ink:"#1C1712",muted:"#7A7060",gold:"#B8831A",goldBg:"#FDF3DC",goldBd:"#E8C97A",green:"#1A6E50",greenBg:"#E6F4EF",red:"#B83232"};
const S={
page:{background:C.bg,minHeight:"100vh",padding:"20px 16px 70px",maxWidth:480,margin:"0 auto",fontFamily:"'Segoe UI',system-ui,sans-serif"},
card:{background:C.card,border:`1px solid ${C.border}`,borderRadius:16,padding:"16px",marginBottom:10},
btn:{border:"none",borderRadius:12,padding:"13px 20px",fontSize:14,fontWeight:700,cursor:"pointer",display:"inline-flex",alignItems:"center",justifyContent:"center",gap:6,fontFamily:"inherit"},
inp:{width:"100%",border:`1px solid ${C.border}`,borderRadius:10,padding:"12px 14px",fontSize:15,background:C.card,color:C.ink,outline:"none",fontFamily:"inherit",boxSizing:"border-box"},
lbl:{display:"block",fontSize:11,fontWeight:700,letterSpacing:1,textTransform:"uppercase",color:C.muted,marginBottom:8},
row:{display:"flex",gap:10},
nav:{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:16},
back:{background:"none",border:"none",fontSize:13,color:C.muted,cursor:"pointer",fontWeight:600,padding:0,fontFamily:"inherit"},
track:{background:C.border,borderRadius:99,height:8,overflow:"hidden"},
fill:{height:"100%",borderRadius:99,background:C.gold,transition:"width 0.5s"},
chip:{background:C.bg,border:`1px solid ${C.border}`,borderRadius:20,padding:"4px 10px",fontSize:11,color:C.muted,fontWeight:500},
empty:{textAlign:"center",padding:"40px 20px",background:C.card,border:`1px solid ${C.border}`,borderRadius:16,marginTop:8},
};
/* ═══════════════════════════════════════════════════════
STEPPER — Défini HORS du composant principal
Boutons +/− ET saisie directe dans le champ
═══════════════════════════════════════════════════════ */
function Stepper({label,value,min,max,onChange,note}){
const [raw,setRaw]=useState(String(value));
useEffect(()=>{ setRaw(String(value)); },[value]);
const set=(v)=>{ const c=Math.max(min,Math.min(max,v)); onChange(c); setRaw(String(c)); };
return(
{ setRaw(e.target.value); const n=parseInt(e.target.value,10); if(!isNaN(n)&&n>=min&&n<=max) onChange(n); }}
onBlur={()=>{ const n=parseInt(raw,10); if(isNaN(n)||nmax) set(max); else set(n); }}
style={{flex:1,border:"none",borderLeft:`1px solid ${C.goldBd}`,borderRight:`1px solid ${C.goldBd}`,textAlign:"center",fontSize:22,fontWeight:900,color:C.gold,background:C.card,outline:"none",fontFamily:"inherit",width:0,padding:"10px 0"}}
/>
{note&&
{note}
}
);
}
/* ═══════════════════════════════════════════════════════
HOME VIEW — Défini HORS du composant principal
═══════════════════════════════════════════════════════ */
function HomeView({active,archived,db,setView,archiveCurrent,startCreate}){
const [conf,setConf]=useState(false);
const rounds=active?getRounds(active):[];
const done=active?Object.values(active.checks||{}).filter(Boolean).length:0;
const pct=active?Math.round(done/rounds.length*100):0;
const myR=active?rounds[active.myTurn-1]:null;
const d=myR?dayDiff(myR.date):null;
const now=todayStr();
return(
{active?(
<>
EN COURS
{active.name}
{active.memberCount} membres · {fmtAmt(active.amount)}/tour · tous les {active.frequency}j
ACTIVE
{done}/{rounds.length} tours cochés
{pct}%
{myR&&(
👑 Mon tour (#{active.myTurn})
{fmtDate(myR.date)}
{fmtAmt(myR.pot)}
{myR.date===now?"🎉 Aujourd'hui !":d>0?`⏳ Dans ${d}j`:"✅ Passé"}
)}
{(()=>{const next=rounds.find(r=>!active.checks[r.num]&&r.date>=now&&!r.isMe);if(!next)return null;return(
Prochain paiementTour {next.num} · {fmtS(next.date)}
);})()}
{!conf
?
:
}
>
):(
🪙
Aucune tontine active
Crée ta première tontine pour commencer
)}
{archived.length>0&&(
{archived.length}
Archivées
{db.tontines.reduce((a,t)=>a+(t.memberCount||0)*(t.amount||0),0).toLocaleString("fr-FR")}
FCFA géré
)}
);
}
/* ═══════════════════════════════════════════════════════
CREATE VIEW — Défini HORS du composant principal
→ clavier ne disparaît plus entre chaque touche ✓
═══════════════════════════════════════════════════════ */
function CreateView({form,setForm,onCreate,onBack,saving}){
const mc=Number(form.memberCount)||2;
const mt=Number(form.myTurn)||1;
const myDate=form.startDate&&form.frequency?addDays(form.startDate,(mt-1)*Number(form.frequency)):null;
const cagnotte=Number(form.amount)*mc;
const diff=myDate?dayDiff(myDate):null;
return(
Nouvelle tontine
Configurer
Pas besoin des noms — juste les chiffres
{/* Nom */}
setForm(f=>({...f,name:e.target.value}))} />
{/* Cotisation + Fréquence */}
{/* Date */}
setForm(f=>({...f,startDate:e.target.value}))} />
{/* Steppers — HORS composant principal donc stables */}
setForm(f=>({...f,memberCount:v,myTurn:Math.min(Number(f.myTurn)||1,v)}))}
note="min 2 · max 50" />
setForm(f=>({...f,myTurn:v}))}
note={`sur ${mc} tours`} />
{/* Aperçu live */}
{myDate&&cagnotte>0&&(
📊 Aperçu en direct
{[
["Je reçois",
{fmtAmt(cagnotte)}],
["Mon jour",
{fmtDate(myDate)}],
["Compte à rebours",
0?C.green:C.red}}>{diff>0?`⏳ ${diff}j`:diff===0?"🎉 Aujourd'hui":"Passé"}],
["Durée totale",
{(mc-1)*Number(form.frequency)} jours],
["Total que je cotise",
{fmtAmt(cagnotte)}],
].map(([l,v],i)=>(
{l}{v}
))}
)}
);
}
/* ═══════════════════════════════════════════════════════
TRACKER VIEW
═══════════════════════════════════════════════════════ */
function TrackerView({active,onToggle,setView}){
if(!active) return
Aucune tontine active.
;
const rounds=getRounds(active);
const done=Object.values(active.checks||{}).filter(Boolean).length;
const pct=Math.round(done/rounds.length*100);
const now=todayStr();
const myR=rounds[active.myTurn-1];
const d=myR?dayDiff(myR.date):null;
return(
{done}/{rounds.length} cochés
{active.name}
{active.memberCount} membres · {fmtAmt(active.amount)}/tour · tous les {active.frequency}j
Progression{pct}%
{myR&&(
👑 Mon tour — #{active.myTurn}
{fmtDate(myR.date)}
{myR.date===now?"🎉 C'est aujourd'hui !":d>0?`⏳ Dans ${d}j`:"✅ Passé"}
{fmtAmt(myR.pot)}
je reçois
)}
☑️ Coche chaque paiement effectué
{rounds.map(r=>{
const checked=!!active.checks[r.num],isToday=r.date===now,isPast=r.date
onToggle(active.id,r.num)}
style={{...S.card,display:"flex",alignItems:"center",gap:12,marginBottom:8,cursor:"pointer",
opacity:checked&&!r.isMe?0.5:1,background:r.isMe?C.goldBg:C.card,
border:`${r.isMe?"1.5":"1"}px solid ${isToday?C.green:r.isMe?C.goldBd:C.border}`}}>
{checked&&✓}
{r.num}
{r.isMe?"👑 MOI":`Tour ${r.num}`}
{fmtDate(r.date)}
{isToday&&⚡ Aujourd'hui}
{isPast&&!isToday&& · Passé}
{(r.pot/1000).toFixed(0)}k
FCFA
);
})}
);
}
/* ═══════════════════════════════════════════════════════
HISTORY VIEW
═══════════════════════════════════════════════════════ */
function HistoryView({archived,setView,setDetail}){
return(
{archived.length} tontine{archived.length!==1?"s":""}
Historique
Tape sur une carte pour voir le détail
{archived.length===0?(
📋
Aucune tontine archivée
Elles apparaissent automatiquement
):archived.map(t=>{
const rounds=getRounds(t),done=Object.values(t.checks||{}).filter(Boolean).length,pct=Math.round(done/rounds.length*100);
return(
{setDetail(t);setView("detail");}}>
{t.name}
{t.memberCount} membres · {fmtAmt(t.amount)}/tour · {fmtS(t.startDate)}
→
{t.memberCount} mbr
{fmtAmt(t.memberCount*t.amount)}
{done===rounds.length?"✓ Complet":`${done}/${rounds.length}`}
{pct}%
);
})}
);
}
/* ═══════════════════════════════════════════════════════
DETAIL VIEW
═══════════════════════════════════════════════════════ */
function DetailView({detail,setView,deleteTontine}){
const [conf,setConf]=useState(false);
if(!detail) return null;
const rounds=getRounds(detail),done=Object.values(detail.checks||{}).filter(Boolean).length,pct=Math.round(done/rounds.length*100),myR=rounds[detail.myTurn-1];
return(
{!conf?
:
}
{detail.name}
{detail.memberCount} membres
{fmtAmt(detail.amount)}/tour
Tous les {detail.frequency}j
📦 Archivée
Résumé
{[["Démarrée le",fmtDate(detail.startDate)],["Cagnotte / tour",fmtAmt(detail.memberCount*detail.amount)],["Mon tour",`#${detail.myTurn} · ${myR?fmtDate(myR.date):"—"}`],["Tours cochés",`${done}/${rounds.length} (${pct}%)`],["Durée totale",`${(rounds.length-1)*detail.frequency} jours`]].map(([l,v],i)=>(
{l}{v}
))}
{rounds.map(r=>(
{detail.checks[r.num]&&✓}
{r.num}
{r.isMe?"👑 MOI":`Tour ${r.num}`}
{fmtDate(r.date)}
{(r.pot/1000).toFixed(0)}k FCFA
))}
);
}
/* ═══════════════════════════════════════════════════════
APP PRINCIPAL
Contient uniquement la logique d'état.
Tous les views/composants sont définis EN DEHORS
→ les inputs ne perdent plus le focus ✓
═══════════════════════════════════════════════════════ */
export default function TontiPay(){
const [view, setView] =useState("load");
const [db, setDb] =useState({tontines:[]});
const [active,setActive]=useState(null);
const [detail,setDetail]=useState(null);
const [toast, setToast] =useState(null);
const [saving,setSaving]=useState(false);
const [form, setForm] =useState({name:"",amount:20000,frequency:5,startDate:todayStr(),myTurn:1,memberCount:5});
useEffect(()=>{
loadDb().then(d=>{ setDb(d); const a=d.tontines.find(t=>t.status==="active"); if(a) setActive(a); setView("home"); });
},[]);
const persist=async(newDb)=>{ setDb(newDb); await saveDb(newDb); };
const toast2=(msg,type="gold")=>{ setToast({msg,type}); setTimeout(()=>setToast(null),2400); };
const onToggle=async(tId,num)=>{
const was=db.tontines.find(t=>t.id===tId)?.checks?.[num];
const newDb={...db,tontines:db.tontines.map(t=>t.id!==tId?t:{...t,checks:{...t.checks,[num]:!t.checks[num]}})};
const upd=newDb.tontines.find(t=>t.id===tId);
setActive(upd); if(detail?.id===tId) setDetail(upd);
await persist(newDb);
toast2(was?"↩ Décoché":"✓ Coché !",was?"gold":"green");
};
const startCreate=()=>{ setForm({name:"",amount:20000,frequency:5,startDate:todayStr(),myTurn:1,memberCount:5}); setView("create"); };
const onCreate=async()=>{
setSaving(true);
const newT={id:uid(),name:form.name.trim()||"Ma Tontine",amount:Number(form.amount),frequency:Number(form.frequency),startDate:form.startDate,myTurn:Number(form.myTurn),memberCount:Number(form.memberCount),checks:{},status:"active",createdAt:new Date().toISOString(),archivedAt:null};
const list=db.tontines.map(t=>t.status==="active"?{...t,status:"archived",archivedAt:new Date().toISOString()}:t);
list.push(newT);
const newDb={...db,tontines:list};
await persist(newDb); setActive(newT); setSaving(false);
toast2("🎉 Tontine créée !"); setView("tracker");
};
const archiveCurrent=async()=>{
if(!active) return;
const newDb={...db,tontines:db.tontines.map(t=>t.id===active.id?{...t,status:"archived",archivedAt:new Date().toISOString()}:t)};
await persist(newDb); setActive(null); toast2("📦 Archivée");
};
const deleteTontine=async(id)=>{
const newDb={...db,tontines:db.tontines.filter(t=>t.id!==id)};
await persist(newDb); setDetail(null); toast2("🗑️ Supprimée"); setView("history");
};
const archived=db.tontines.filter(t=>t.status==="archived").sort((a,b)=>new Date(b.archivedAt||b.createdAt)-new Date(a.archivedAt||a.createdAt));
if(view==="load") return
🪙
;
return(
{toast&&
{toast.msg}
}
{view==="home" &&
}
{view==="create" && setView("home")} saving={saving}/>}
{view==="tracker" && }
{view==="history" && }
{view==="detail" && }
);
}