function Login({ onSluit }){
  const [email,setEmail]=useState(""); const [gestuurd,setGestuurd]=useState(false);
  const [f,setF]=useState(null); const [bezig,setBezig]=useState(false);
  async function stuur(){ setBezig(true); setF(null);
    const r=await db.auth.signInWithOtp({email:email.trim(),options:{emailRedirectTo:window.location.origin}});
    if(r.error){ setF(r.error.message); setBezig(false); return; } setGestuurd(true); setBezig(false); }
  return (
    <div className="scrim" onClick={e=>e.target===e.currentTarget&&onSluit()}>
      <div className="blad"><div className="greep"/>
        {gestuurd
          ? <div><h3>Kijk in je mail</h3><p className="bij">We hebben een inloglink gestuurd naar {email}. Open die op dit apparaat, anders logt hij op de verkeerde plek in.</p>
              <button className="knop vol stil" onClick={onSluit}>Sluiten</button></div>
          : <div><h3>Inloggen</h3><p className="bij">Voor seizoens- en strippenkaarthouders. Vul je e-mail in, dan sturen we een inloglink.</p>
              <label>E-mailadres</label>
              <input type="email" value={email} onChange={e=>setEmail(e.target.value)} placeholder="jij@voorbeeld.nl" onKeyDown={e=>e.key==="Enter"&&geldigEmail(email)&&stuur()}/>
              {/* Dezelfde controle en dezelfde zin als in het boekingsblad. Zonder dit ging een tikfout
                  gewoon naar de inlogdienst en kreeg de gast daar een Engelse foutmelding terug.
                  Gemeten op 12 augustus 2026: met alleen het woord "roy" in het veld werd de knop
                  actief. */}
              {email.trim() && !geldigEmail(email) && <div className="fout">Dit lijkt geen e-mailadres. Controleer of er een apenstaartje in staat en een punt erachter.</div>}
              {f && <div className="fout">{f}</div>}
              <button className="knop vol" onClick={stuur} disabled={bezig||!geldigEmail(email)}>{bezig?"Versturen…":"Stuur inloglink"}</button>
              <button className="terug" onClick={onSluit}>Annuleren</button></div>}
      </div>
    </div>
  );
}

// Bevestigingsstap vlak voor de betaalknop. Aanleiding, letterlijk uit het gesprek van 21 augustus
// 2026: "Je moet bij een boeking in een pop-up komen. Of een bericht. Weet je het zeker. Dit kan
// niet geannuleerd worden." De gast moet dit venster actief wegklikken, het staat dus niet als losse
// waarschuwing op de pagina maar blokkeert de weg naar de betaalknop (of naar "bevestigen" als er
// niets te betalen valt). Toont alleen waarden die op dit punt in de code al zeker zijn: datum,
// starttijd, aantal spelers en het bedrag staan er al in stap 3, dus die gaan hier gewoon mee.
function BevestigStap({ datum, slot, aantal, totaal, mollie, paar18, onDoorgaan, onTerug }){
  // Punt 6 (17 september 2026): akkoord met de voorwaarden is verplicht voordat er geboekt
  // of betaald kan worden. Het vinkje staat hier, in de laatste stap voor de betaling, en
  // niet eerder: dit is het moment waarop de gast zich vastlegt.
  const [akkoord,setAkkoord]=useState(false);
  const venster = React.useRef(null);
  useEffect(()=>{
    if(venster.current) venster.current.focus();
    function opToets(e){ if(e.key==="Escape") onTerug(); }
    window.addEventListener("keydown", opToets);
    return ()=>window.removeEventListener("keydown", opToets);
  },[]);
  const heeftBedrag = mollie && totaal>0;
  return (
    <div className="scrim" onClick={e=>e.target===e.currentTarget&&onTerug()}>
      <div className="blad" role="dialog" aria-modal="true" aria-labelledby="bevestig-titel" ref={venster} tabIndex={-1}>
        <div className="greep"/>
        <h3 id="bevestig-titel">{siteTekst("boek_kop_zeker")}</h3>
        <div className="details">
          <div><span>Datum</span><span>{lang(datum)}</span></div>
          <div><span>{paar18?"Eerste starttijd":"Starttijd"}</span><span>{slot.starttijd.slice(0,5)}</span></div>
          {paar18 && <div><span>Tweede starttijd</span><span>{String(slot.tweede_starttijd).slice(0,5)}</span></div>}
          <div><span>Spelers</span><span>{aantal}</span></div>
          <div><span>Bedrag</span><span>{euro(totaal)}</span></div>
        </div>
        {/* Het geld-punt en het verplaats-punt staan hier bewust altijd samen: de harde mededeling
            ("geen geld terug") naast de geruststelling ("verplaatsen kan wel, zelf"), zodat het
            tweede niet als kleine lettertjes achter het eerste wegvalt. Bij nul euro blijft de
            tekst over geld kloppen door het simpelweg niet te noemen. */}
        <p className="bij">
          {heeftBedrag
            ? "Als je doorgaat en betaalt, kun je deze reservering niet meer annuleren. Er komt dan geen geld terug. Wil je toch een andere datum of tijd, dan kun je de boeking zelf verplaatsen."
            : "Als je doorgaat, kun je deze reservering niet meer annuleren. Wil je toch een andere datum of tijd, dan kun je de boeking zelf verplaatsen."}
        </p>
        <label className="akkoordregel">
          <input type="checkbox" checked={akkoord} onChange={e=>setAkkoord(e.target.checked)}/>
          <span>Ik ga akkoord met de <VoorwaardenLink/>.</span>
        </label>
        <button className="knop vol" style={knopBreek()} onClick={onDoorgaan} disabled={!akkoord}>{heeftBedrag ? siteTekst("boek_knop_zeker_betalen") : siteTekst("boek_knop_zeker_bevestigen")}</button>
        <button className="terug" onClick={onTerug}>Terug</button>
      </div>
    </div>
  );
}

// Ronde 9: foutteksten bij een losse greenfee via Recras. De teksten die niet over een kaart gaan komen uit
// recrasFoutTekst; voor de rest een zin zonder "kaart", want de gast heeft er geen.
function losseRecrasFoutTekst(reden, referentie){
  if(["turnstile","invoer","recras_onzeker","te_veel_pogingen"].indexOf(reden)>=0) return recrasFoutTekst(reden, referentie);
  if(reden==="extras" || reden==="extras_vol" || reden==="prijs_verschil") return kaartFoutTekst({reden:reden}, 1, referentie);
  return "Online boeken lukt nu niet. Probeer het zo nog eens, of bel ons op "+TELEFOON+".";
}

// ---- KAART-PATCH 20260929 (besluit 39 aanvulling, 48, 49, 51) ------------------------------------
// Boeken met een kaart: alleen met de code (geen zoeken op naam meer), een code voor alle spelers, het echte
// saldo uit recras-kaart-controle, 2x 9 holes met twee starttijden, en naam en e-mailadres van de kaarthouder
// vanzelf in de boeking. Alles wat de gast leest komt uit kaartFoutTekst of recrasMelding.
async function recrasControleKaart(code, datum, spelers, holes){
  const u = await recrasPost("recras-kaart-controle", {code:code, datum:datum, spelers:spelers, holes:holes});
  if(u.d && typeof u.d.geldig==="boolean") return u.d;
  return {geldig:false, reden:"recras_onbereikbaar"};
}
function kaartFoutTekst(k, aantal, referentie){
  const r = k && k.reden;
  if(r==="op") return "Deze kaart is op.";
  if(r==="te_weinig"){
    const o = k.strippen_over;
    return "Op deze kaart "+(o===1?"staat nog 1 strip":"staan nog "+o+" strippen")+". Voor "+aantal+" speler"+(aantal===1?"":"s")+
      " heb je er "+k.strippen_nodig+" nodig.";
  }
  if(r==="verlopen") return "Deze kaart is niet meer geldig op de gekozen dag."+(k.geldig_tot?" Hij loopt tot en met "+metJaar(k.geldig_tot)+".":"");
  if(r==="nog_niet_geldig") return "Deze jaarkaart gaat in op "+metJaar(k.geldig_vanaf)+". Kies een dag vanaf die datum.";
  if(r==="alleen_met_code") return "Boeken met een kaart kan alleen met de code van je kaart.";
  // GEMENGD-PATCH 20260930
  if(r==="abonnement_een_speler") return "Een abonnement geldt voor 1 speler per reservering. Laat de andere spelers een eigen kaart of abonnement gebruiken, of laat ze betalen.";
  if(r==="al_bezig") return "Je reservering "+(referentie||"")+" is al aangemaakt en wacht op betaling. Rond die betaling af, of wacht een half uur en boek dan opnieuw.";
  if(r==="prijs_verschil") return "De prijs is net veranderd. Ververs deze pagina en probeer het opnieuw.";
  if(r==="extras") return EXTRA_TEKSTEN.fout;
  if(r==="extras_vol") return EXTRA_TEKSTEN.vol;
  if(r==="vorm") return "Deze code is niet goed ingevuld. Een kaartcode heeft vijftien tekens, zoals ABCDE-FGHIJ-KLMNO.";
  // KAART-OP-NAAM-20261008: de reden bij een kaart die op naam is gevonden.
  if(r==="naam_onbekend") return NAAM_TEKSTEN.nietGevonden;
  if(r==="druk") return NAAM_TEKSTEN.druk;
  return recrasFoutTekst(r, referentie);
}
const KAART_KOOP_REDENEN = ["op","te_weinig","verlopen"];

// ---- KAART-OP-NAAM-20261008 (besluit 99 punt 1, 8 oktober 2026) ---------------------------------------------------------------------------------------------------
// Boeken met een kaart of abonnement mag weer met naam en e-mailadres, naast de code (de code onthouden blijft zoals het is). Het gaat in twee stappen: recras-houder (doel boeken) zoekt de kaart en geeft een nummer
// (kaart_ref), NOOIT de code; recras-kaart-controle en recras-reserveer koppelen dat nummer opnieuw aan naam en e-mailadres. De link staat er alleen als de schakelaar in Boekproces aan staat en Turnstile is ingericht;
// de server beslist, deze knop is alleen een gemak. Naam en e-mailadres blijven in het geheugen van dit scherm en gaan NOOIT naar de opslag van de browser.
async function recrasControleRef(ref, datum, spelers, holes){
  const u = await recrasPost("recras-kaart-controle", {kaart_ref:ref.kaart_ref, naam:ref.naam, email:ref.email, datum:datum, spelers:spelers, holes:holes});
  if(u.d && typeof u.d.geldig==="boolean") return u.d;
  return {geldig:false, reden:"recras_onbereikbaar"};
}
async function recrasZoekOpNaam(naam, email, datum, token){
  const u = await recrasPost("recras-houder", {zoek:"naam_email", doel:"boeken", naam:naam, email:email, datum:datum, turnstile_token:token});
  if(u.d && typeof u.d.gevonden==="boolean") return u.d;
  return {gevonden:false, reden:"recras_onbereikbaar"};
}
async function naamBoekenInstelling(){
  try{
    const r = await db.from("instellingen").select("sleutel,waarde").in("sleutel",["kaart_boeken_op_naam_actief","kaart_gebruikt_mail_actief"]);
    const w = Object.fromEntries(((r && r.data)||[]).map(x=>[x.sleutel,x.waarde]));
    return w.kaart_boeken_op_naam_actief==="true" && w.kaart_gebruikt_mail_actief==="true";
  }catch(e){ return false; }
}
// Alle teksten van het zoeken op naam staan hier op een plek (de tekst is van UP, besluit 80). Elke fout bij naam of e-mailadres is dezelfde zin.
const NAAM_TEKSTEN = {
  link: "Geen code bij de hand? Zoek je kaart met naam en e-mailadres",
  kop: "Zoek je kaart", naam: "Naam", naamHint: "Voor- en achternaam", email: "E-mailadres waarmee je de kaart kocht",
  mailBericht: "De houder van de kaart krijgt van elke reservering een e-mail.",
  zoek: "Kaart zoeken", bezig: "Even zoeken…", terug: "Ik heb de code toch bij de hand",
  nietGevonden: "We vinden geen kaart bij deze naam en dit e-mailadres. Controleer beide, of vul de code in.",
  tePasVaak: "Er is net te vaak gezocht. Wacht een paar minuten, of vul de code in.",
  druk: "Zoeken op naam kan vandaag even niet. Vul de code van je kaart in.",
  uit: "Zoeken op naam kan nu niet. Vul de code van je kaart in.",
  turnstile: "We konden niet controleren dat je geen robot bent. Probeer het nog eens.",
  onbereikbaar: "We kunnen de kaart nu niet opzoeken. Probeer het zo nog eens, of vul de code in.",
  op: "Deze kaart is op.", verlopen: "Deze kaart is niet meer geldig.", geblokkeerd: "Deze kaart is geblokkeerd. Meld je bij de balie, dan lossen we het op.",
  wijzig: "Een andere kaart zoeken"
};
function naamZoekFoutTekst(reden){
  if(reden===undefined || reden===null) return NAAM_TEKSTEN.nietGevonden;
  if(reden==="te_veel_pogingen") return NAAM_TEKSTEN.tePasVaak;
  if(reden==="druk") return NAAM_TEKSTEN.druk;
  if(reden==="uit") return NAAM_TEKSTEN.uit;
  if(reden==="turnstile" || reden==="turnstile_onbereikbaar") return NAAM_TEKSTEN.turnstile;
  return NAAM_TEKSTEN.onbereikbaar;
}

// EXTRA-STAP-20261005 (besluit 36 en 87, Roy 5 oktober 2026: extra's in het volgende koopscherm van de gast). Layout en teksten zijn van UP (besluit 80): alle woorden van de extra stap staan hier op een plek, zodat UP ze kan aanpassen. Het scherm gebruikt bestaande klassen (spelerrij, teller,
// som, details, akkoordregel). Productnaam, prijs en aantal komen uit de database; naam en prijs worden nergens uit de browser teruggestuurd, de server rekent opnieuw uit (boeking_extras_leg_vast).
// B9R2-TEKSTEN-20261007: kop en knop uit het beheer (siteTekst() in data.js), de oude tekst is de standaard.
const EXTRA_TEKSTEN = { get kop(){ return siteTekst("boek_kop_extras"); }, totaal: "Totaal", get verder(){ return siteTekst("boek_knop_extras_verder"); }, fout: "De extra's konden niet worden toegevoegd. Ververs deze pagina en probeer het opnieuw.",
  // EXTRAS-MAXIMUM-20261008: een extra heeft een maximum (de buggy's). Voorstel van de bouwer, de tekst is van UP (besluit 80): wat er vrij is, en de melding als een ander net de laatste nam.
  geenVrij: function(voorraad){ return "Op deze tijd zijn er geen "+String(voorraad).toLowerCase()+" meer vrij."; }, nogVrij: function(n){ return "nog "+n+" vrij"; },
  vol: "Een van je extra's is net niet meer vrij. Kies een andere tijd, of ga verder zonder die extra." };
// Wat de gast mag zien: de lijst uit extras_voor_gast() (alleen wat de beheerder heeft aangevinkt, die Recras vrijgeeft en waarvan de prijs klopt) en de schakelaars uit instellingen. De stap bestaat
// ALLEEN als instelling extras_stap_actief op "true" staat: die rij ontstaat met de database-uitrol, dus een voorkant die te vroeg uitgaat laat de stap gewoon weg.
async function extrasLaden(){
  const uit = { actief:false, aantallen:true, max:10, lijst:[] };
  try{
    const i = await db.from("instellingen").select("sleutel,waarde").in("sleutel",["extras_stap_actief","extras_aantallen","extras_max_per_product"]);
    const w = Object.fromEntries(((i && i.data) || []).map(x=>[x.sleutel,x.waarde]));
    if(w.extras_stap_actief !== "true") return uit;
    const r = await db.rpc("extras_voor_gast");
    const lijst = ((r && r.data) || []).filter(p=>p && Number.isInteger(p.recras_product_id) && p.recras_product_id>0 && Number.isInteger(p.prijs_cent) && p.prijs_cent>0 && typeof p.weergavenaam==="string" && p.weergavenaam.trim())
      .map(p=>({ id:p.recras_product_id, naam:p.weergavenaam, prijs:p.prijs_cent }));
    const aantallen = w.extras_aantallen !== "false";
    const max = aantallen ? Math.min(Math.max(parseInt(w.extras_max_per_product,10)||10,1),25) : 1;
    return { actief: lijst.length>0, aantallen:aantallen, max:max, lijst:lijst };
  }catch(e){ return uit; }
}
// EXTRAS-MAXIMUM-20261008: wat er op dit tijdslot nog vrij is van de extra's met een maximum (extras_vrij_voor_tijdslot: per extra het aantal vrij en de naam van de voorraad, niets over andere gasten).
// Het scherm laat alleen zien wat vrij is; de bewaking zit in de database. Faalt de aanroep (bijvoorbeeld voor de database is bijgewerkt), dan toont het scherm geen beperking en beslist de server.
async function extrasVrijLaden(slotId){
  try{
    const r = await db.rpc("extras_vrij_voor_tijdslot",{p_tijdslot_id:slotId});
    const l = (r && r.data) || [];
    const uit = {};
    (Array.isArray(l) ? l : []).forEach(x=>{ if(x && Number.isInteger(x.recras_product_id) && Number.isInteger(x.vrij) && x.vrij>=0 && typeof x.voorraad==="string") uit[x.recras_product_id] = {vrij:x.vrij, voorraad:x.voorraad}; });
    return uit;
  }catch(e){ return {}; }
}
// De weigering van de database of de edge function als een zin voor de gast (nooit de ruwe code). Geen herkende code: null, dan gaat de gewone foutTekst zijn gang.
function extrasFout(e){
  const m = String((e && e.message) || "").trim();
  if(m === "prijs_verschil") return "De prijs is net veranderd. Ververs deze pagina en probeer het opnieuw.";
  if(m === "extras_vol") return EXTRA_TEKSTEN.vol;
  if(/^extras_[a-z_]+$/.test(m)) return EXTRA_TEKSTEN.fout;
  return null;
}

// CODE-ONTHOUDEN-20261005 (besluit 92, Roy: de kaartcode van een echte jaarkaart is lang, dus de gast mag hem op zijn eigen apparaat laten onthouden). Layout en teksten zijn van UP (besluit 80): alle woorden staan hier op een plek.
// Wat er wordt bewaard: ALLEEN de kaartcode, in localStorage van deze browser, en alleen als de gast het vinkje zelf aanzet (standaard uit) en de kaartcontrole de code geldig vond. Geen naam, geen saldo, geen e-mailadres, geen rij-gegevens.
// Lezen en schrijven gaan in try/catch: in een privevenster of met uitgeschakelde opslag gooit de browser een fout, en dan werkt de boekroute gewoon zonder onthouden (het vinkje is er dan niet).
const ONTHOUD_TEKSTEN = { vinkje: "Onthoud mijn code op dit apparaat", vergeten: "Vergeten" };
const ONTHOUD_SLEUTEL = "upgolf_kaartcode";
function onthoudLees(){ try{ const v = window.localStorage.getItem(ONTHOUD_SLEUTEL); return (typeof v==="string" && v.trim()) ? v.trim() : null; }catch(e){ return undefined; } }
function onthoudSchrijf(code){ try{ window.localStorage.setItem(ONTHOUD_SLEUTEL, code); return true; }catch(e){ return false; } }
function onthoudWis(){ try{ window.localStorage.removeItem(ONTHOUD_SLEUTEL); return true; }catch(e){ return false; } }
function Boeken({ slot, datum, types, regels, producten, lid, mollie, holes, onSluit, onKlaar }){
  // Ronde 6 (18 september 2026): 18 holes is twee keer de negen van Weesp, dus twee starttijden.
  // De tweede starttijd komt uit de view en staat op het slot dat de gast heeft gekozen.
  // Bij 18 holes sturen we het product WEL mee naar de database. Bij 9 holes niet, precies zoals
  // het was: dan kiest boek_tijdslot zelf het greenfeeproduct met een strip.
  const paar18 = holes===18 && !!slot.tweede_starttijd;
  const product = productVoorHoles(producten, slot.sport, paar18 ? 18 : 9);
  const productId = product ? product.id : null;
  // Bij 18 holes bepaalt de krapste van de twee starttijden hoeveel spelers erin passen, en
  // daarboven geldt de grens van vier: meer dan vier gaat via de balie.
  const plekken18 = paar18 ? Math.min(slot.vrij, slot.tweede_vrij) : slot.vrij;
  const baanId = slot.baan_id || null;
  const gast = types.filter(t=>!t.is_lid)[0];
  const lidType = types.filter(t=>t.is_lid)[0];
  // Kaarthouderstarieven gelden per sport. Staan ze voor deze ingang uit (zie SPORTEN in data.js),
  // dan bestaan ze hier niet: niet in de opties, niet in de beginstand van de rijen en niet in de
  // meldingen. Een optie tonen die de database daarna weigert, is erger dan hem weglaten.
  const kaartTarief = !!PROFIEL.kaarttarieven;
  const heeftStrip = kaartTarief && lid && lid.kaarten && lid.kaarten.length>0;
  const heeftSeizoen = kaartTarief && lid && lid.seizoenskaart;
  const strip = heeftStrip ? lid.kaarten[0] : null;
  const [aantal,setAantal]=useState(1);
  // KAART-PATCH 20260929 (security-poort B1): een willekeurig token per boekpoging (per geopende dialoog). Twee boekers op
  // dezelfde kaart hebben dezelfde naam en hetzelfde e-mailadres (die van de kaarthouder), dus het onderscheid tussen hen
  // en een dubbelklik van een boeker komt uit dit token. Een dubbelklik in dezelfde dialoog geeft hetzelfde token.
  const [pogingToken]=useState(()=>{ try{ return window.crypto.randomUUID(); }catch(e){ return null; } });
  // Het ledentarief bestaat alleen als er een prijsregel voor is. Voor golf is die er (nul euro),
  // voor footgolf niet; dan zou `boek_tijdslot` afbreken met "Geen prijsregel gevonden voor Lid".
  const lidPrijs = (kaartTarief && lidType)
    ? prijsVan(regels, lidType.id, datum, slot.starttijd, product?product.id:null, slot.baan_id||null) : null;
  const [rijen,setRijen]=useState([
    (lid && kaartTarief && (heeftSeizoen || heeftStrip || lidPrijs!==null))
      ? { betaal: heeftSeizoen ? "seizoen" : (heeftStrip ? "kaart" : "lid"), type: gast?gast.id:"" }
      : { betaal:"gast", type: gast?gast.id:"" }]);
  const [naam,setNaam]=useState(lid?lid.naam:""); const [email,setEmail]=useState(lid?lid.email:"");
  const [tel,setTel]=useState(""); const [f,setF]=useState(null); const [bezig,setBezig]=useState(false);
  // Ronde 10 (15 september 2026): GSN-nummer (Golf Service Nummer van de NGF) verplicht bij elke online
  // golfreservering, voor minimaal een speler. Alleen golf; footgolf vraagt het niet. De database dwingt het
  // ook af (boek_tijdslot en boek_groep, schakelaar golf_gsn_verplicht_online). Alleen trimmen en een ruime
  // maximumlengte: de NGF zegt alleen dat het nummer met NL begint, en dat controleren we (nog) niet.
  const [gsn,setGsn]=useState("");
  const gsnNodig = false;  // punt 2: niet meer aan de gast vragen; p_gsn blijft bestaan voor de balie
  const gsnSchoon = gsn.trim();
  const gsnTeLang = gsnSchoon.length>20;
  const gsnKlaar = !gsnNodig || (gsnSchoon.length>=1 && !gsnTeLang);
  const gsnDeel = gsnNodig ? { p_gsn: gsnSchoon } : {};
  // Ronde 3 (security-oordeel V2): Turnstile bij een Recras-reservering, alleen als de instelling aan staat.
  const [ts,setTs]=useState({actief:false, sitekey:null});
  const [tsToken,setTsToken]=useState(null);
  const [tsRonde,setTsRonde]=useState(0);
  useEffect(()=>{ turnstileInstelling().then(setTs); },[]);
  // KAART-OP-NAAM-20261008: staat boeken op naam aan (Boekproces)? Elke keer dat dit scherm opent opnieuw gelezen; de server controleert het zelf nog een keer.
  const [naamBoeken,setNaamBoeken]=useState(false);
  useEffect(()=>{ let weg=false; naamBoekenInstelling().then(a=>{ if(!weg) setNaamBoeken(a); }); return ()=>{ weg=true; }; },[]);
  // Ronde 9 (15 september 2026, besluit Roy: een online losse greenfee moet in Recras, hoeft niet op naam).
  // Staat de instelling recras_losse_greenfee_actief op "true", dan gaat een greenfee zonder kaart niet
  // meer via boek_tijdslot plus onze eigen Mollie, maar via recras-reserveer (soort losse_greenfee): die
  // maakt onze boeking en de reservering in Recras, en de gast betaalt via de Mollie-koppeling van Recras.
  // Staat de instelling uit of ontbreekt hij, dan verandert er niets aan dit scherm.
  const [losseRecras,setLosseRecras]=useState(false);
  useEffect(()=>{ db.from("instellingen").select("waarde").eq("sleutel","recras_losse_greenfee_actief").maybeSingle()
    .then(r=>setLosseRecras(!!(r && r.data && r.data.waarde==="true")), ()=>setLosseRecras(false)); },[]);
  // Drie stappen in plaats van een lang formulier: eerst spelers en tarief, dan gegevens, dan
  // controleren en betalen. Op een telefoon paste het oude scherm niet in beeld, waardoor de knop
  // onder de vouw viel en de gast moest scrollen om te zien wat hij ging betalen.
  const [stap,setStap]=useState(1);
  // De bevestigingspop-up ("weet je het zeker") staat los van de drie stappen: hij komt in beeld
  // BOVENOP stap 3, pas nadat de gast al op de echte betaal- of bevestigknop heeft gedrukt, en gaat
  // pas naar reserveer() als de gast hem actief wegklikt met "Ja". Terug sluit hem alleen, de gast
  // blijft dan gewoon op stap 3 staan met alles nog ingevuld.
  const [bevestigOpen,setBevestigOpen]=useState(false);
  // Bij 9 holes onveranderd: meer spelers dan er in dit slot passen betekent de groepsroute
  // (boek_groep verdeelt ze over opeenvolgende starttijden). Bij 18 holes bestaat die route niet:
  // boek_groep kent de tweede starttijd niet en weigert het 18-holesproduct. Daarom gaat het daar
  // nooit via boek_groep, en vangt het scherm het af met de melding hieronder.
  const groot = !paar18 && aantal > slot.vrij;
  // Ronde 6: de gast heeft meer dan vier spelers en wil 18 holes. Dan is dit geen fout maar een
  // doorverwijzing: hij kan bellen, of 9 holes kiezen.
  const teGrootVoor18 = paar18 && aantal > HOLES_18_MAX_SPELERS;
  const teVolVoor18 = paar18 && !teGrootVoor18 && aantal > plekken18;

  // De teller blijft tot PROFIEL.maxSpelers lopen, ook bij 18 holes. Zou hij daar op vier
  // vastlopen, dan gebeurt er bij de vijfde tik niets en snapt de gast niet waarom. Nu kan hij
  // wel verder tellen, ziet hij de melding met het nummer, en is alleen de knop uit.
  function zetAantal(n){ if(n<1||n>PROFIEL.maxSpelers) return; setAantal(n);
    setRijen(r=>{ const k=r.slice(0,n); const kaart=r.some(x=>x.betaal==="code");
      while(k.length<n) k.push({betaal:kaart?"code":"gast",type:gast?gast.id:""}); return k; }); }
  function wz(i,patch){ setRijen(rijen.map((r,n)=>n===i?Object.assign({},r,patch):r)); }
  // Een kaartcode wordt gecontroleerd zodra de gast klaar is met typen, niet bij elke toetsaanslag.
  // `kaart_bij_code` spiegelt de wachters van `boek_tijdslot`, dus wat hier groen is gaat er ook
  // doorheen. De uitkomst blijft bij de rij staan, want daar hoort hij ook op het scherm.
  // De setState gaat hier met een functie en niet met de rijen uit de closure. Er zit een
  // netwerkaanroep tussen twee updates, en dan is de lijst uit de closure verouderd: de tweede
  // update draaide de eerste terug en de uitkomst kwam nooit in beeld. Gemeten op 14 augustus.
  function wzFn(i, patch){ setRijen(rs=>rs.map((r,n)=>n===i?Object.assign({},r,patch):r)); }
  async function controleerCode(i, ruw){
    const code = (ruw||"").trim();
    if(!code){ wzFn(i,{kaart:null, kaartBezig:false}); return; }
    wzFn(i,{kaartBezig:true});
    const r = await db.rpc("kaart_bij_code", { p_code: code, p_sport: SPORT, p_datum: datum });
    if(r.error){
      console.warn("[upgolf] code controleren mislukt: " + r.error.message);
      wzFn(i,{kaart:{gevonden:false}, kaartBezig:false, gecontroleerdeCode:code}); return;
    }
    wzFn(i,{kaart:r.data, kaartBezig:false, gecontroleerdeCode:code});
  }
  // Recras-kaart (bouwspec Recras-kaart 4.2, 14 september 2026). De uitkomst komt op de rij als
  // kaart:{bron:"recras", ...antwoord}, via wzFn om de closurefout van 14 augustus niet terug te halen.
  // Het aantal spelers gaat mee, want Recras rekent uit hoeveel spelers de kaart dekt.
  async function controleerRecrasCode(i, ruw, spelers){
    const code = (ruw||"").trim();
    if(!code){ wzFn(i,{kaart:null, kaartBezig:false}); return; }
    wzFn(i,{kaartBezig:true});
    const n = spelers || Math.max(1, codeGroepGrootte({code:code}));
    const d = await recrasControleKaart(code, datum, n, paar18?18:9);
    wzFn(i,{kaart:Object.assign({bron:"recras", spelers:n}, d), kaartBezig:false, gecontroleerdeCode:code});
  }
  // Ronde 2 (bouwspec 4.2 punt 1): een Recras-kaart die op naam is gevonden. Het codeveld blijft leeg;
  // de controle gaat met kaart_ref plus de naam en het e-mailadres waarmee de gast zocht.
  async function controleerRecrasRef(i, keuze, spelers){
    const ref = {kaart_ref:keuze.kaart_ref, naam:keuze.naam, email:keuze.email};
    const n = spelers || Math.max(1, codeGroepGrootte({recrasRef:ref}));
    wzFn(i,{kaartBezig:true, code:"", recrasRef:Object.assign({}, keuze)});
    const d = await recrasControleRef(ref, datum, n, paar18?18:9);
    // KAART-OP-NAAM-20261008: elke weigering bij naam of e-mailadres is dezelfde zin; de server geeft onbekend.
    const dd = d.reden==="onbekend" ? Object.assign({}, d, {reden:"naam_onbekend"}) : d;
    wzFn(i,{kaart:Object.assign({bron:"recras", spelers:n, viaNaam:true, kaart_ref:keuze.kaart_ref}, dd, {kaartnaam:dd.kaartnaam||keuze.kaartnaam}), kaartBezig:false});
  }
  // KAART-OP-NAAM-20261008: de kaart zoeken op naam en e-mailadres (stap 1; de code komt er nooit bij). Een Turnstile-token is maar een keer bruikbaar: na elke poging een nieuw vak.
  async function zoekKaartOpNaam(i){
    const r = rijen[i]; const nm=(r.zoekNaam||"").trim(), em=(r.zoekEmail||"").trim();
    if(!nm || !em || !r.zoekToken) return;
    wzFn(i,{zoekBezig:true, zoekFout:null, recrasKeuzes:null, recrasRef:null, kaart:null});
    const h = await recrasZoekOpNaam(nm, em, datum, r.zoekToken);
    setRijen(rs=>rs.map((x,n)=>n===i ? Object.assign({},x,{zoekToken:null, zoekRonde:(x.zoekRonde||0)+1}) : x));
    if(h.gevonden && Array.isArray(h.kaarten) && h.kaarten.length){
      const keuzes = h.kaarten.map(k=>Object.assign({}, k, {naam:nm, email:em}));
      wzFn(i,{zoekBezig:false, recrasKeuzes:keuzes});
      const bruikbaar = keuzes.filter(k=>k.staat==="bruikbaar");
      if(keuzes.length===1 && bruikbaar.length===1) await controleerRecrasRef(i, keuzes[0]);
      return;
    }
    wzFn(i,{zoekBezig:false, zoekFout:naamZoekFoutTekst(h.reden)});
  }
  const NAAM_RESET = {zoekModus:false, zoekFout:null, recrasKeuzes:null, recrasRef:null, kaart:null, kaartBezig:false, zoekToken:null, zoekNaam:"", zoekEmail:""};
  // Welke weg een ingetypte code gaat. Een eigen code van 9 tekens gaat exact de oude weg.
  function controleerVolgensSoort(i, v, bijVerlaten){
    const soort = codeSoort(v);
    if(soort==="eigen") controleerCode(i, v);
    else if(soort==="recras") controleerRecrasCode(i, v);
    else if(soort==="onbekend" && bijVerlaten) controleerCode(i, v);
  }
  // Staat deze code al nagekeken op de rij? Dan hoeft het niet nog een keer bij het verlaten van
  // het veld. Waarom dat nodig is, gemeten op 20 september 2026: de gast typt zijn code, de kaart
  // wordt goedgekeurd, hij klikt op "Verder naar je gegevens", en er gebeurt niets. De muisknop
  // indrukken haalt de aandacht uit het codeveld, onBlur startte dan dezelfde controle opnieuw,
  // `kaartBezig` werd waar en de knop ging op dat moment uit. Een knop die uit staat krijgt geen
  // klik meer, dus de klik ging verloren en pas de tweede klik werkte. Gemeten: direct na het
  // indrukken stond de knop 0 tot 100 ms uit, en na ongeveer 125 ms weer aan.
  // De bescherming blijft heel: dit slaat alleen een HERHALING over van een controle die al een
  // uitkomst heeft voor exact dezelfde code. Verandert de gast ook maar een teken, dan zet de
  // onChange hierboven `kaart` op null en wordt er gewoon opnieuw gekeken; en zolang er geen
  // bruikbare uitkomst ligt, houdt `codeNietKlaar` de knop nog steeds tegen.
  function alGecontroleerd(r, v){
    return !!r && !!r.kaart && !r.kaartBezig && (r.gecontroleerdeCode||"") === (v||"").trim();
  }
  // Is het aantal spelers veranderd terwijl er een Recras-kaart op een rij staat, dan opnieuw
  // controleren: hoeveel spelers de kaart dekt en wat er bij moet, hangt van dat aantal af.
  // GEMENGD-PATCH 20260930: hoeveel spelers op een code staan bepaalt hoeveel strippen er nodig zijn (en een abonnement dekt er maar een). Verandert dat
  // aantal (een speler erbij, een speler naar een andere code of naar betalen), dan wordt die code opnieuw gecontroleerd met het nieuwe aantal.
  const normCode = s => String(s||"").toUpperCase().replace(/[^A-Z0-9]/g,"");
  const codeGroepGrootte = r => {
    // KAART-OP-NAAM-20261008: een kaart op naam telt per kaart_ref, een kaart met code per code.
    if(r.recrasRef) return rijen.filter(x=>x.betaal==="code" && x.recrasRef && x.recrasRef.kaart_ref===r.recrasRef.kaart_ref).length;
    const c = normCode(r.code); return c ? rijen.filter(x=>x.betaal==="code" && !x.recrasRef && normCode(x.code)===c).length : 0; };
  const codeStand = rijen.map(r=>r.betaal+":"+normCode(r.code)+(r.recrasRef?":ref"+r.recrasRef.kaart_ref:"")+":"+(r.kaart && r.kaart.spelers ? r.kaart.spelers : "")+":"+(r.kaartBezig?"b":"")).join("|");
  useEffect(()=>{
    rijen.forEach((r,i)=>{
      if(r.betaal!=="code" || r.kaartBezig || !r.kaart || r.kaart.bron!=="recras" || (!r.code && !r.recrasRef)) return;
      const g = codeGroepGrootte(r);
      if(g>0 && r.kaart.spelers!==g){ if(r.recrasRef) controleerRecrasRef(i, r.recrasRef, g); else controleerRecrasCode(i, r.code, g); }
    });
  },[codeStand, aantal]);
  // KAART-PATCH 20260929 (besluit 49): boeken met een kaart kan alleen met de code. Het zoeken op naam en
  // e-mailadres is uit de boekroute gehaald; saldo checken op naam blijft in SaldoCheck.
  // Wat de gast leest als de code niet bruikbaar is. Elke regel zegt wat er aan de hand is en
  // wat hij kan doen; "ongeldige code" staat er nergens.
  function codeFout(k){
    if(!k) return null;
    if(!k.gevonden) return "Deze code kennen we niet. Kijk of je hem goed hebt overgetypt.";
    if(k.bruikbaar) return null;
    if(k.reden==="geblokkeerd") return "Deze kaart is geblokkeerd. Meld je bij de balie, dan lossen we het op.";
    if(k.reden==="geen_ritten") return "Op deze kaart staan geen ritten meer. Koop een nieuwe via <a href=\""+kaartKoopLink("strippenkaart",true).href+"\">Kaart kopen</a> of aan de balie.";
    if(k.reden==="verlopen") return "Deze kaart is niet meer geldig. Kies een ander tarief of bel ons op "+TELEFOON+".";
    if(k.reden==="geen_kaart") return "Bij deze gegevens staat geen actieve kaart of abonnement. Bel ons op "+TELEFOON+".";
    if(k.reden==="andere_sport") return "Deze kaart geldt voor " + (k.sport||"een andere sport") + ", niet voor " + PROFIEL.naam.toLowerCase() + ".";
    return "Deze kaart kunnen we nu niet gebruiken. Bel ons op "+TELEFOON+".";
  }
  // Eén tarief voor het hele gezelschap. Bij footgolf staan er tot veertig spelers in één
  // starttijd, en dan is een keuzelijst per speler geen keuze meer maar een formulier.
  function zetAlleTypes(typeId){ setRijen(rijen.map(()=>({betaal:"gast", type:typeId}))); }
  // KAART-PATCH 20260929 (besluit 51): met een kaart of abonnement gaan alle spelers op die ene code.
  function zetAlleCode(){ setRijen(rijen.map(r=>({betaal:"code", type:r.type}))); }
  // GEMENGD-PATCH 20260930: alleen DEZE speler op een kaart. Een tweede kaartspeler krijgt de code van de eerste alvast ingevuld (dezelfde code bij
  // meer spelers telt meer strippen van die kaart); de gast kan hem wijzigen als deze speler een eigen kaart heeft.
  function zetRijCode(i){
    const anders = rijen.find((r,n)=>n!==i && r.betaal==="code" && (r.code||"").trim());
    // KAART-OP-NAAM-20261008: staat een andere speler op een kaart die op naam is gevonden, dan gaat deze speler op diezelfde kaart (de kaart telt dan een speler meer).
    const opNaam = !anders ? rijen.find((r,n)=>n!==i && r.betaal==="code" && r.recrasRef && r.kaart && r.kaart.geldig===true) : null;
    if(opNaam){
      const ref = Object.assign({}, opNaam.recrasRef);
      setRijen(rs=>rs.map((r,n)=>n===i ? {betaal:"code", type:r.type, code:"", recrasRef:ref, zoekModus:true} : r));
      controleerRecrasRef(i, ref, rijen.filter((r,n)=>n!==i && r.betaal==="code" && r.recrasRef && r.recrasRef.kaart_ref===ref.kaart_ref).length+1);
      return;
    }
    const code = anders ? anders.code : (bewaardeCode||"");  // CODE-ONTHOUDEN-20261005
    setRijen(rs=>rs.map((r,n)=>n===i ? {betaal:"code", type:r.type, code:code} : r));
    if(code) controleerVolgensSoort(i, code, true);
  }
  function zetGeenKaart(){ setRijen(rijen.map(()=>({betaal:"gast", type:gast?gast.id:""}))); }
  // Kiest de gast per speler een tarief, of één tarief voor iedereen? De grens staat per sport
  // in data.js. Bij een gezelschap dat over meerdere starttijden verdeeld wordt (groot) was er
  // al één tarief, want boek_groep kent er ook maar één.
  const apartTarief = !groot && aantal <= PROFIEL.tariefApartTot;
  const samenType = rijen[0] ? (rijen[0].betaal==="gast" ? rijen[0].type : (gast?gast.id:"")) : "";
  const prijsGast=t=>prijsVan(regels,t,datum,slot.starttijd,productId,baanId);
  // GEMENGD-PATCH 20260930: een tarief zonder prijsregel voor deze ronde (bijvoorbeeld Jeugd bij 2x 9 holes) tonen we niet: de database weigert het en de gast zag 0,00.
  const gastTypen = (()=>{ const l = types.filter(t=>!t.is_lid); const m = l.filter(t=>prijsGast(t.id)!==null); return m.length ? m : l; })();
  function prijsRij(r){ if(r.betaal==="seizoen"||r.betaal==="kaart"||r.betaal==="code") return 0;
    if(r.betaal==="lid") return prijsVan(regels, lidType?lidType.id:r.type, datum, slot.starttijd, productId, baanId)||0;
    return prijsGast(r.type)||0; }
  const strepen = rijen.filter(r=>r.betaal==="kaart").length;
  const stripTekort = strip && strepen>strip.saldo;
  // Rijen die op een ingetypte code betalen. Zolang er een code loopt die nog niet gecontroleerd
  // is of die niet bruikbaar bleek, mag de knop niet door: anders weigert de database het pas
  // nadat de gast al twee schermen verder is.
  const codeRijen = rijen.filter(r=>r.betaal==="code");
  const kaartModus = codeRijen.length>0;
  const eersteCode = rijen.findIndex(r=>r.betaal==="code");
  const kaartKlaar = k => !!k && (k.bron==="recras" ? k.geldig===true : !!k.bruikbaar);
  const codeNietKlaar = kaartModus && codeRijen.some(r=>!kaartKlaar(r.kaart) || !!r.kaartBezig);
  // Recras-kaart (bouwspec 4.2 punt 3 en 4). De eerste rij met een geldige Recras-code draagt de hele
  // reservering. Een tweede Recras-code, een eigen code of een eigen kaart ernaast kan online niet.
  const recrasIndex = rijen.findIndex(r=>r.betaal==="code" && r.kaart && r.kaart.bron==="recras" && r.kaart.geldig===true);
  const recras = recrasIndex>=0 ? rijen[recrasIndex].kaart : null;
  const recrasCode = recrasIndex>=0 ? rijen[recrasIndex].code : null;
  // Ronde 2: een op naam gevonden Recras-kaart heeft geen code in de browser, wel een kaart_ref.
  const recrasRef = recrasIndex>=0 && !recrasCode ? rijen[recrasIndex].recrasRef : null;
  const recrasTeGroot = !!recras && (groot || aantal>4);
  // KAART-PATCH 20260929: naam en e-mailadres van de kaarthouder komen vanzelf in de boeking als de server ze uit
  // Recras heeft (houder_compleet). Dan slaat de gast de gegevensstap over en ziet hij de korte naam (bijvoorbeeld Jan V.) en geen e-mailadres.
  // GEMENGD-PATCH 20260930: alleen als ALLE spelers op een kaart staan komen naam en e-mailadres van de kaarthouder; staat er een betalende speler
  // bij, dan is de boeker de betaler en vult hij zelf naam en e-mailadres in.
  const alleKaart = rijen.every(r=>r.betaal==="code");
  const houderAuto = alleKaart && !!recras && recras.houder_compleet===true;
  function recrasMelding(r, i){
    const k = r.kaart;
    if(!k || k.bron!=="recras") return null;
    if(k.geldig!==true) return {klasse:"fout", tekst:kaartFoutTekst(k, k.spelers||aantal), koop:KAART_KOOP_REDENEN.indexOf(k.reden)>=0};
    const g = codeGroepGrootte(r);
    const naamK = k.kaartnaam || "Strippenkaart";
    const van = k.houder_naam ? " van "+k.houder_naam : "";
    const strip = n => n + " strip" + (n===1?"":"pen");
    if(k.soort==="abonnement" || k.strippen_over===null || k.strippen_over===undefined)
      return {klasse:"let groen", tekst:naamK+van+" is geldig"+(k.geldig_tot?" tot en met "+metJaar(k.geldig_tot):"")+". Dekt "+
        (aantal===1&&g<=1?"je reservering":(g===aantal?"alle "+aantal+" spelers":(g<=1?"1 speler":g+" spelers")))+"."};
    return {klasse:"let groen", tekst:naamK+van+", nog "+strip(k.strippen_over)+". Deze reservering kost "+strip(k.strippen_nodig)+
      (paar18 && k.strippen_per_speler>1 ? " ("+k.strippen_per_speler+" per speler voor 2x 9 holes)" : "")+"."};
  }
  // Rijen die niet samen met een Recras-kaart kunnen: een tweede Recras-code, een eigen code, of een
  // eigen strippen- of seizoenskaart van een ingelogde houder.
  // GEMENGD-PATCH 20260930: meer Recras-codes in een reservering mag nu (elke speler een eigen kaart), dus dit is geen blokkade meer.
  const recrasDubbel = false;
  // KAART-PATCH 20260929: alle code-rijen zijn dezelfde kaart; alleen een eigen strippen- of seizoenskaart van een
  // ingelogde houder ernaast is een echte combinatie.
  const recrasGemengd = !!recras && rijen.some((r,i)=>i!==recrasIndex && (r.betaal==="kaart" || r.betaal==="seizoen"));
  const recrasBlokkeert = recrasTeGroot || recrasDubbel || recrasGemengd;
  // Dezelfde rittenkaart mag meerdere plekken betalen, maar niet meer dan er ritten op staan.
  // Dat rekent de database ook na; dit is de vriendelijke variant ervoor.
  const codeTekort = (() => {
    const k = kaartModus ? rijen[eersteCode].kaart : null;
    if(k && k.bron!=="recras" && k.bruikbaar && k.soort==="strippenkaart" && codeRijen.length>k.saldo)
      return {n:codeRijen.length, saldo:k.saldo, houder:k.houder};
    return null;
  })();
  // Een abonnement geldt voor de houder zelf, niet voor het hele gezelschap. De database laat de
  // tweede speler op hetzelfde abonnement gewoon het normale tarief betalen; hier zeggen we dat
  // vooraf, want anders staat er ineens een bedrag dat de gast niet verwacht.
  const aboDubbel = (() => {
    const k = kaartModus ? rijen[eersteCode].kaart : null;
    if(k && k.bron!=="recras" && k.bruikbaar && k.soort==="abonnement" && codeRijen.length>1) return k.houder || "dit abonnement";
    return null;
  })();
  const totaalEigen = groot ? (prijsGast(rijen[0].type)||0)*aantal : rijen.reduce((s,r)=>s+prijsRij(r),0);
  // Met een Recras-kaart is Recras de betaalmeester: het bedrag komt uit recras-kaart-controle, niet uit prijsVan.
  // GEMENGD-PATCH 20260930: te betalen is de som van de betalende spelers; kaartspelers tellen voor 0 (prijsRij). Met alleen kaartspelers is dat 0 zoals voorheen.
  const totaal = totaalEigen;
  const gemengd = kaartModus && rijen.some(r=>r.betaal==="gast");
  // Een kaart van het oude type (negen tekens) kan niet gecombineerd worden met andere spelers of andere kaarten; en betalen met een kaart erbij vraagt Mollie.
  const eigenCodeGemengd = codeRijen.some(r=>r.kaart && r.kaart.bron!=="recras") && (rijen.some(r=>r.betaal!=="code") || new Set(codeRijen.map(r=>normCode(r.code))).size>1);
  const mixedZonderMollie = gemengd && totaal>0 && !mollie;
  const gemengdBlokkeert = eigenCodeGemengd || mixedZonderMollie;
  const kaartGroepen = (()=>{ const m=[]; codeRijen.forEach(r=>{ const c=r.recrasRef ? "ref"+r.recrasRef.kaart_ref : normCode(r.code); if(!c||!r.kaart||r.kaart.bron!=="recras"||r.kaart.geldig!==true) return;
    const g=m.find(x=>x.code===c); if(g) g.n++; else m.push({code:c,n:1,kaart:r.kaart}); }); return m; })();
  const betaalGroepen = (()=>{ const m=[]; rijen.forEach(r=>{ if(r.betaal!=="gast") return; const t=types.find(x=>x.id===r.type); if(!t) return;
    const g=m.find(x=>x.t.id===t.id); if(g) g.n++; else m.push({t:t,n:1}); }); return m; })();
  // Ronde 9: alleen golf, een starttijd, hoogstens 4 spelers, iedereen op het gasttarief Greenfee, geen kaart
  // of code op een rij, en er valt iets te betalen. Alles daarbuiten (jeugd, lid, kaart, footgolf, groepen)
  // gaat precies de oude weg: Recras kent daar in de testomgeving nog geen product voor.
  // Bevinding 1, security-poort (19 september 2026): !paar18 toegevoegd. Zonder deze uitsluiting
  // koos 2x 9 holes zonder kaart ook deze route, terwijl `boek_tijdslot` daar met p_product_id
  // hard null krijgt en zelf het 9-holesproduct kiest (order by strippen, code limit 1). Gevolg:
  // het scherm toonde 36,50, er werd 21,00 afgerekend en de tweede starttijd bleef onvastgelegd.
  // 2x 9 holes zonder kaart gaat nu weer precies de oude weg (recras-reserveer met p_product_id
  // van het 18-holesproduct), die het goede bedrag afrekent; negen holes zonder kaart blijft de
  // nieuwe, starttijdloze Recras-route volgen.
  const losseViaRecras = losseRecras && !!mollie && !recras && !groot && !paar18 && SPORT==="golf" && slot.sport==="golf"
    && aantal<=4 && !!gast && gast.code==="GAST" && totaalEigen>0
    && rijen.every(r=>r.betaal==="gast" && r.type===gast.id);

  // EXTRA-STAP-20261005: de extra's. De stap bestaat als er online iets te betalen valt (totaal boven nul: een losse greenfee, een groep of een gemengde boeking) of als alle spelers op een geldige Recras-code staan
  // (besluit 89: dan betaalt de kaarthouder alleen de extra's) en de beheerder extra's heeft aangevinkt. Oude eigen kaarten (9 tekens) en ingelogde leden met een eigen kaart krijgen hem niet.
  const [extrasInst,setExtrasInst]=useState({actief:false, aantallen:true, max:10, lijst:[]});
  const [extrasKeuze,setExtrasKeuze]=useState({});
  // EXTRAS-MAXIMUM-20261008: wat er vrij is per extra (id naar {vrij, voorraad}); leeg zolang het niet gelezen is of de database het nog niet kent. Opnieuw gelezen bij elke keer dat de extra stap opent.
  const [extrasVrij,setExtrasVrij]=useState({});
  // CODE-ONTHOUDEN-20261005: bewaardeCode is wat er nu in de opslag staat (null: niets; undefined bij de eerste lezing: de opslag is niet bruikbaar, dan is er geen vinkje). onthoud is het vinkje.
  const [bewaardeCode,setBewaardeCode]=useState(()=>{ const v=onthoudLees(); return v===undefined ? null : v; });
  const [opslagOk,setOpslagOk]=useState(()=>onthoudLees()!==undefined);
  const [onthoud,setOnthoud]=useState(()=>{ const v=onthoudLees(); return !!v; });
  // Bewaren: alleen met het vinkje aan, alleen voor de eerste code-rij, alleen als de uitkomst van de controle bij exact de code in het veld hoort, en alleen als die uitkomst geldig is (kaartKlaar: dezelfde maat als de knop).
  // Dit loopt na elke wijziging van de rijen, maar schrijft alleen als de te bewaren code verschilt van wat er al staat, dus niet bij elke toetsaanslag.
  useEffect(()=>{
    if(!onthoud || !opslagOk || eersteCode<0) return;
    const r = rijen[eersteCode]; if(!r || !r.kaart || r.kaartBezig) return;
    const c = (r.gecontroleerdeCode||"").trim();
    if(!c || (r.code||"").trim()!==c || !kaartKlaar(r.kaart) || c===bewaardeCode) return;
    if(onthoudSchrijf(c)) setBewaardeCode(c); else { setOpslagOk(false); setOnthoud(false); }
  },[onthoud, opslagOk, rijen, eersteCode, bewaardeCode]);
  function zetOnthoud(aan){ setOnthoud(aan); if(!aan){ onthoudWis(); setBewaardeCode(null); } }
  function vergeetCode(){ const weg = normCode(bewaardeCode); onthoudWis(); setBewaardeCode(null); setOnthoud(false);
    setRijen(rs=>rs.map(r=> (weg && normCode(r.code)===weg) ? Object.assign({},r,{code:"", kaart:null, kaartBezig:false, gecontroleerdeCode:""}) : r)); }
  // KAART-AUTOMATISCH-20261007 (besluit 93, Roy: staat er een onthouden kaartcode, dan springt de betaalkeuze vanzelf op kaart). Geen nieuwe tekst, alleen gedrag.
  // Alleen speler 1, alleen bij het openen van dit boekscherm, alleen als er een code onthouden is, de kaartopties voor deze ingang bestaan (kaartTarief) en speler 1 nog op de standaardkeuze "gast" staat
  // (een ingelogd lid dat al op seizoen, strip of lid staat, houdt zijn keuze). Daarna de bestaande weg: zetRijCode zet de rij op "code", vult de onthouden code in en start de gewone controle.
  // Kiest de gast daarna "Toch zonder kaart betalen", dan blijft dat zo: de vlag is al gebruikt, dus dit effect doet in dezelfde boeking niets meer.
  const autoKaartGedaan = React.useRef(false);
  React.useLayoutEffect(()=>{
    if(autoKaartGedaan.current) return; autoKaartGedaan.current = true;
    if(!kaartTarief || !bewaardeCode || !rijen[0] || rijen[0].betaal!=="gast") return;
    zetRijCode(0);
  },[]);
  useEffect(()=>{ let weg=false; if(!mollie) return;
    extrasLaden().then(x=>{ if(!weg) setExtrasInst(x); }, ()=>{}); return ()=>{ weg=true; }; },[mollie]);
  const extrasZichtbaar = extrasInst.actief && !!mollie && SPORT==="golf" && slot.sport==="golf" && (totaal>0 || (!!recras && alleKaart)) && !gemengdBlokkeert && !recrasBlokkeert;
  const extrasRegels = extrasZichtbaar ? extrasInst.lijst.filter(p=>(extrasKeuze[p.id]||0)>0).map(p=>({id:p.id, naam:p.naam, prijs:p.prijs, aantal:extrasKeuze[p.id]})) : [];
  // GREENFEE-REGEL-20261005 (bevinding Roy bij proefboeking C6, stap 4 van 4): het overzicht toonde de extra's maar niet de greenfee, terwijl het totaal die wel bevatte. Per soort speler die online betaalt een regel met
  // aantal en bedrag. Het bedrag komt uit prijsRij (en bij een groep uit prijsGast), dus uit exact dezelfde bron als het totaal: de som van deze regels is het totaal van de spelers (zonder extra's). Spelers op een kaart of
  // abonnement hebben geen greenfeeregel (hun kaart staat in het kaartblok eronder, zoals voorheen).
  const greenfeeRegels = (()=>{ const m=[]; const voeg=(naam,n,stuk)=>{ const g=m.find(x=>x.naam===naam && x.stuk===stuk); if(g) g.n+=n; else m.push({naam:naam,n:n,stuk:stuk}); };
    if(groot) voeg(tariefNaam(types.find(x=>x.id===rijen[0].type)), aantal, prijsGast(rijen[0].type)||0);
    else rijen.forEach(r=>{ if(r.betaal==="gast") voeg(tariefNaam(types.find(x=>x.id===r.type)), 1, prijsRij(r)); else if(r.betaal==="lid") voeg(tariefNaam(lidType)||"Ledentarief", 1, prijsRij(r)); });
    return m.map(g=>({naam:g.naam, n:g.n, cent:g.n*g.stuk})); })();
  const extrasCent = extrasRegels.reduce((s,x)=>s+x.aantal*x.prijs,0);
  const extrasPayload = extrasRegels.map(x=>({recras_product_id:x.id, aantal:x.aantal}));
  const extrasDeel = extrasPayload.length ? { extras: extrasPayload, extras_verwacht_cent: extrasCent } : {};
  const extrasRpcDeel = extrasPayload.length ? { p_extras: extrasPayload, p_verwacht_cent: extrasCent } : {};
  const totaalBetalen = totaal + extrasCent;
  // Het hoogste aantal dat de gast van deze extra kan kiezen: het algemene maximum, en voor een extra met een voorraad ook wat er nog vrij is min wat hij al van andere extra's uit dezelfde voorraad koos.
  function extraMax(p, keuze){
    const v = extrasVrij[p.id]; if(!v) return extrasInst.max;
    const anderen = extrasInst.lijst.filter(q=>q.id!==p.id && extrasVrij[q.id] && extrasVrij[q.id].voorraad===v.voorraad).reduce((som,q)=>som+((keuze||extrasKeuze)[q.id]||0),0);
    return Math.max(0, Math.min(extrasInst.max, v.vrij-anderen));
  }
  function zetExtra(id,n){ setExtrasKeuze(k=>{ const p=extrasInst.lijst.find(x=>x.id===id); const mx = p ? extraMax(p,k) : extrasInst.max; return Object.assign({},k,{[id]:Math.max(0,Math.min(mx,n))}); }); }
  useEffect(()=>{ let weg=false; if(stap!=="e" || !extrasInst.actief) return;
    extrasVrijLaden(slot.id).then(x=>{ if(!weg) setExtrasVrij(x); }, ()=>{}); return ()=>{ weg=true; }; },[stap, extrasInst.actief, slot.id]);
  // Staat er na het lezen minder vrij dan de gast al koos (een ander was sneller), dan gaat zijn keuze omlaag tot wat er is.
  useEffect(()=>{ setExtrasKeuze(k=>{ let veranderd=false; const nieuw=Object.assign({},k);
    extrasInst.lijst.forEach(p=>{ const mx=extraMax(p,nieuw); if((nieuw[p.id]||0)>mx){ nieuw[p.id]=mx; veranderd=true; } }); return veranderd?nieuw:k; }); },[extrasVrij]);
  const stapNr = stap==="e" ? 2 : (extrasZichtbaar && typeof stap==="number" && stap>=2 ? stap+1 : stap);
  const stapTot = extrasZichtbaar ? 4 : 3;
  function vorigeStap(){ if(stap==="e") return 1; if(stap===2 && extrasZichtbaar) return "e"; if(stap===3 && houderAuto) return extrasZichtbaar?"e":1; return stap-1; }

  async function reserveer(){
    setBezig(true); setF(null);
    // Recras-kaart (bouwspec 4.2 punt 5): NIET boek_tijdslot en probeerBetaling, maar recras-reserveer.
    // Die maakt zelf eerst onze boeking en daarna de reservering in Recras. Bewust geen stuurMail:
    // nog niet vastgesteld dat de bevestigingsmail klopt voor dit pad.
    if(recras){
      if(recrasBlokkeert || gemengdBlokkeert){ setBezig(false); return; }
      // KAART-PATCH 20260929: alleen de code (besluit 49), holes 9 of 18 (2x 9 holes = twee starttijden), en naam en
      // e-mailadres alleen als de server ze niet uit Recras kan halen (houderAuto).
      // GEMENGD-PATCH 20260930: een code voor alle spelers gaat precies zoals voorheen (besluit 51). Alles daarbuiten (een speler op een kaart en de
      // andere betalen, of meer dan een code) gaat met per speler een keuze: greenfee, jeugd of kaart met de eigen code.
      // KAART-OP-NAAM-20261008: een kaart op naam gaat met kaart_ref (en de naam en het adres waarmee de gast zocht); de server koppelt ze opnieuw. Hoogstens een kaart op naam per reservering.
      const refRijen = codeRijen.filter(r=>r.recrasRef);
      const eenRefVoorAlle = alleKaart && refRijen.length===rijen.length && new Set(refRijen.map(r=>r.recrasRef.kaart_ref)).size===1;
      const eenCodeVoorAlle = alleKaart && refRijen.length===0 && new Set(codeRijen.map(r=>normCode(r.code))).size===1;
      const perSpeler = rijen.map(r=>{
        if(r.betaal==="code" && r.recrasRef) return { keuze:"kaart", kaart_ref:r.recrasRef.kaart_ref };
        if(r.betaal==="code") return { keuze:"kaart", code:(r.code||"").trim() };
        const t = types.find(x=>x.id===r.type);
        return { keuze: t && t.code==="JEUGD" ? "jeugd" : "greenfee" };
      });
      const refDeel = refRijen.length ? { kaart_naam: refRijen[0].recrasRef.naam, kaart_email: refRijen[0].recrasRef.email } : {};
      const kaartDeel = Object.assign(eenCodeVoorAlle ? { code: recrasCode } : (eenRefVoorAlle ? { kaart_ref: refRijen[0].recrasRef.kaart_ref } : {}), refDeel, { holes: paar18 ? 18 : 9 }, pogingToken ? { poging: pogingToken } : {});
      const gegevens = houderAuto ? {} : { naam: naam.trim(), email: email.trim(), telefoon: tel.trim()||null };
      const u = await recrasReserveer(Object.assign({ tijdslot_id: slot.id }, gegevens, gsnNodig ? { gsn: gsnSchoon } : {},
        kaartDeel, { deelnemers: (eenCodeVoorAlle || eenRefVoorAlle) ? rijen.map(()=>houderAuto ? { spelerstype_id: gast?gast.id:"" } : { spelerstype_id: gast?gast.id:"", naam: naam.trim() }) : perSpeler,
        terugkeer: window.location.origin },
        ts.actief ? { turnstile_token: tsToken } : {}, extrasDeel));
      if(u.status==="betalen"){
        if(u.referentie) zetInAdres("wacht", u.referentie);
        window.location.href=u.url; return;
      }
      if(u.status==="bevestigd"){
        onKlaar({referentie:u.referentie, flights:paar18?2:1, paar:paar18, spelers:aantal, bedrag:0, betaald:true, recras:true});
        return;
      }
      console.warn("[upgolf] recras-reserveer: " + u.reden);
      // Een Turnstile-token is maar een keer bruikbaar: na een weigering een nieuw vak.
      if(ts.actief){ setTsToken(null); setTsRonde(n=>n+1); }
      const rijMetFout = u.speler ? rijen[u.speler-1] : null;
      setF(u.reden==="boeken" ? foutTekst({message:u.melding}) :
        (u.speler && aantal>1 && u.reden!=="al_bezig" ? "Speler "+u.speler+": " : "")+
        kaartFoutTekst({reden:u.reden, strippen_over:u.strippen_over, strippen_nodig:u.strippen_nodig, geldig_tot:u.geldig_tot, geldig_vanaf:u.geldig_vanaf},
          rijMetFout ? Math.max(1, codeGroepGrootte(rijMetFout)) : aantal, u.referentie));
      setBezig(false); return;
    }
    // Ronde 9: losse greenfee via Recras. Zelfde afhandeling als de Recras-kaart hierboven, maar zonder kaartdeel.
    if(losseViaRecras){
      const u = await recrasReserveer(Object.assign({ soort:"losse_greenfee", tijdslot_id: slot.id, naam: naam.trim(),
        email: email.trim(), telefoon: tel.trim()||null, gsn: gsnSchoon,
        deelnemers: rijen.map(()=>({ spelerstype_id: gast.id, naam: naam.trim() })),
        terugkeer: window.location.origin },
        ts.actief ? { turnstile_token: tsToken } : {}, extrasDeel));
      if(u.status==="betalen"){
        if(u.referentie) zetInAdres("wacht", u.referentie);
        window.location.href=u.url; return;
      }
      if(u.status==="bevestigd"){
        onKlaar({referentie:u.referentie, flights:1, spelers:aantal, bedrag:totaalEigen+extrasCent, betaald:true, recras:true});
        return;
      }
      console.warn("[upgolf] recras-reserveer (losse greenfee): " + u.reden);
      if(ts.actief){ setTsToken(null); setTsRonde(n=>n+1); }
      setF(u.reden==="boeken" ? foutTekst({message:u.melding}) : losseRecrasFoutTekst(u.reden, u.referentie));
      setBezig(false); return;
    }
    // De boeking staat op dit punt al in de database. Lukt het starten van de betaling niet,
    // dan mogen we dat niet wegpoetsen: geen bevestigingsmail sturen en op de bon eerlijk
    // melden dat er nog niets betaald is. "gratis" is nadrukkelijk geen fout.
    let betaalFout = null;
    // Zet de bon meteen goed bij een reservering van nul euro. De echte stand komt daarna toch
    // uit de database, maar zonder dit staat er een tel lang "nog niet betaald" op het scherm van
    // iemand die zojuist met zijn kaart heeft betaald.
    let gratisAfgerond = false;
    // Het reserveringsnummer gaat MEE in het webadres voordat we naar de betaaldienst vertrekken.
    // Komt de gast terug met de terugknop, dan is dat het enige spoor dat er al een reservering
    // bestaat: het scherm dat hij dan ziet komt uit het geheugen van de browser en weet van niets.
    // Zie de toelichting bij de pageshow-luisteraar in data.js.
    async function probeerBetaling(bId, referentie){
      const u = await startBetaling(bId);
      if(u.status==="betalen"){
        if(referentie) zetInAdres("wacht", referentie);
        window.location.href=u.url; return true;
      }
      // De REDEN gaat naar de console en NIET naar het scherm. Hij komt ongefilterd uit de
      // betaalfunctie (`String(d.error)`) of is systeemtaal zoals "de betaaldienst gaf status 500".
      // Zulke tekst tussen haakjes in een zin voor een gast is precies wat een demo laat ontsporen.
      // De bouwer heeft hem wel nodig, dus hij blijft in de console staan.
      if(u.status==="gratis"){ gratisAfgerond = true; }
      if(u.status==="fout"){
        console.warn("[upgolf] betaling niet gestart, boeking " + bId + ": " + u.reden);
        betaalFout = true;
      }
      return false;
    }
    if(groot){
      const r=await db.rpc(extrasPayload.length?"boek_groep_extras":"boek_groep",Object.assign({p_start_tijdslot_id:slot.id,p_naam:naam.trim(),p_email:email.trim(),
        p_aantal:aantal,p_spelerstype_id:rijen[0].type,p_telefoon:tel.trim()||null}, gsnDeel, extrasRpcDeel));
      if(r.error){ setF(extrasFout(r.error) || foutTekst(r.error)); setBezig(false); return; }
      const bId = r.data.boeking_id || null;
      // Ook bij nul euro. Een reservering die helemaal met een kaart of abonnement betaald is,
      // heeft niets af te rekenen, maar stond wel als "nog niet betaald" in de database en dus
      // ook zo op de bon en op de tee sheet. Het gratis-pad in mollie-betaling zet hem netjes op
      // betaald zonder iets bij de betaaldienst aan te maken; dat pad bestond al en werd alleen
      // niet aangeroepen. Gemeten op 14 augustus: reservering D5FN-NMZP, nul euro, meldde
      // "Nog niet betaald, bel ons op "+TELEFOON+"".
      if(mollie && r.data.bedrag_cent>=0 && bId){
        if(await probeerBetaling(bId, r.data.referenties && r.data.referenties[0])) return;
      }
      // Punt 1 (17 september 2026): bij een echte betaling stuurt mollie-webhook de mail,
      // zodra de betaling op betaald staat. Hier alleen nog als er niets af te rekenen valt.
      if(bId && !betaalFout && !(mollie && r.data.bedrag_cent>0)) stuurMail(bId,"bevestiging");
      onKlaar({referentie:r.data.referenties[0],flights:r.data.flights,spelers:aantal,bedrag:r.data.bedrag_cent,betaald:gratisAfgerond,betaalFout:betaalFout});
    } else {
      const deeln = rijen.map(r=>{
        const o={ spelerstype_id: r.betaal==="gast"?r.type:(lidType?lidType.id:r.type), naam:naam.trim() };
        // Een code wijst de kaart zelf aan. Er gaat dan bewust GEEN lid_id mee: bij een
        // rittenkaart hoort de plek bij de speler en niet bij de houder, en bij een abonnement
        // zoekt de database de houder zelf op via de code.
        if(r.betaal==="code"){ o.kaartcode = (r.code||"").trim(); return o; }
        if(r.betaal!=="gast") o.lid_id = lid.id;
        if(r.betaal==="kaart") o.kaart_id = strip.id; return o; });
      // Ronde 6: bij 18 holes MOET het product mee, want daar hangt de tweede starttijd aan. Bij
      // 9 holes gaat er net als voorheen geen product mee en kiest de database het zelf.
      const productDeel = (paar18 && productId) ? { p_product_id: productId } : {};
      const r=await db.rpc(extrasPayload.length?"boek_tijdslot_extras":"boek_tijdslot",Object.assign({p_tijdslot_id:slot.id,p_naam:naam.trim(),p_email:email.trim(),
        p_deelnemers:deeln,p_telefoon:tel.trim()||null}, productDeel, gsnDeel, extrasRpcDeel));
      if(r.error){ setF(extrasFout(r.error) || foutTekst(r.error)); setBezig(false); return; }
      const bId = r.data.id;
      // Ook bij nul euro. Een reservering die helemaal met een kaart of abonnement betaald is,
      // heeft niets af te rekenen, maar stond wel als "nog niet betaald" in de database en dus
      // ook zo op de bon en op de tee sheet. Het gratis-pad in mollie-betaling zet hem netjes op
      // betaald zonder iets bij de betaaldienst aan te maken; dat pad bestond al en werd alleen
      // niet aangeroepen. Gemeten op 14 augustus: reservering D5FN-NMZP, nul euro, meldde
      // "Nog niet betaald, bel ons op "+TELEFOON+"".
      if(mollie && r.data.bedrag_cent>=0 && bId){
        if(await probeerBetaling(bId, r.data.boekingsreferentie)) return;
      }
      // Punt 1 (17 september 2026): bij een echte betaling stuurt mollie-webhook de mail,
      // zodra de betaling op betaald staat. Hier alleen nog als er niets af te rekenen valt.
      if(bId && !betaalFout && !(mollie && r.data.bedrag_cent>0)) stuurMail(bId,"bevestiging");
      onKlaar({referentie:r.data.boekingsreferentie,flights:paar18?2:1,paar:paar18,spelers:aantal,bedrag:r.data.bedrag_cent,betaald:gratisAfgerond,betaalFout:betaalFout});
    }
  }

  // B9R2-TEKSTEN-20261007: de twee knopteksten uit het beheer; {bedrag} is het te betalen bedrag.
  const knoptekst = bezig ? "Bezig…" : (recras
    ? (totaalBetalen>0 ? siteTekst("boek_knop_betalen",{bedrag:euro(totaalBetalen)}) : siteTekst("boek_knop_bevestigen"))
    : (mollie && totaalBetalen>0 ? siteTekst("boek_knop_betalen",{bedrag:euro(totaalBetalen)}) : siteTekst("boek_knop_bevestigen")));

  return (
    <>
    <div className="scrim" onClick={e=>e.target===e.currentTarget&&onSluit()}>
      <div className="blad"><div className="greep"/>
        <h3>{stap===1?siteTekst("boek_kop_spelers"):stap==="e"?EXTRA_TEKSTEN.kop:stap===2?siteTekst("boek_kop_gegevens"):siteTekst("boek_kop_controle")}</h3>{/* B9R2-TEKSTEN-20261007: koppen uit het beheer, de oude tekst is de standaard */}
        <p className="bij">{lang(datum)} om {slot.starttijd.slice(0,5)} · stap {stapNr} van {stapTot}</p>

      {stap===1 && <>
        <label>Aantal spelers</label>
        {/* Snelkeuzes staan boven de teller en niet in plaats daarvan. Bij footgolf passen er
            veertig spelers in één starttijd, en dat zijn negenendertig tikken op de plusknop.
            De teller blijft eronder staan voor alles wat niet in de rij past, bijvoorbeeld elf.
            Een keuze die groter is dan wat er nog vrij is tonen we niet: die zou een gast naar
            een melding leiden in plaats van naar een reservering. */}
        {PROFIEL.snelkeuzes &&
          <div className="snelkeuze">
            {PROFIEL.snelkeuzes.filter(n=>n<=Math.min(slot.vrij, PROFIEL.maxSpelers)).map(n=>
              <button key={n} type="button" className={"sk"+(aantal===n?" aan":"")}
                aria-pressed={aantal===n} onClick={()=>zetAantal(n)}>{n}</button>)}
          </div>}
        <div className="teller"><button onClick={()=>zetAantal(aantal-1)} aria-label="Eén speler minder">-</button><div className="n">{aantal}</div><button onClick={()=>zetAantal(aantal+1)} aria-label="Eén speler erbij">+</button></div>
        {groot && <div className="let">Met {aantal} spelers passen jullie niet in één {PROFIEL.groepWoord}. Jullie starten in groepen na elkaar, de eerste om {slot.starttijd.slice(0,5)}. Op het scherm hierna staan alle starttijden.</div>}
        {/* Ronde 6: boven de vier spelers kan 18 holes online niet. Tekst goedgekeurd door Roy op
            18 september 2026, staat in data.js zodat hij op een plek staat. */}
        {teGrootVoor18 && <div className="let deels">{MELDING_18_MEER_DAN_VIER}</div>}
        {teVolVoor18 && <div className="let deels">Op deze twee starttijden {plekken18===1?"is nog maar 1 plek":"zijn nog maar "+plekken18+" plekken"} vrij. Kies een andere tijd of speel met minder spelers.</div>}
        {/* EIS 5, tweede plek: bij de spelerskeuze, dus voordat de gast zijn gegevens invult.
            Tekst wacht op goedkeuring; leeg betekent geen blok. */}
        {paar18 && uitleg18(UITLEG_18_BIJ_TIJD, slot.starttijd, slot.tweede_starttijd) &&
          <div className="let">{uitleg18(UITLEG_18_BIJ_TIJD, slot.starttijd, slot.tweede_starttijd)}</div>}
        {!groot && !apartTarief && <div><label>Tarief voor het hele gezelschap</label>
          <select value={samenType} onChange={e=>zetAlleTypes(e.target.value)}>
            {gastTypen.map(t=><option key={t.id} value={t.id}>{tariefNaam(t)}, {euro(prijsGast(t.id)||0)} per speler</option>)}
          </select>
          <div className="let">Jullie spelen samen op één starttijd. Wil je per speler een ander tarief, dan regelt de balie dat bij aankomst.</div>
        </div>}
        {!groot && apartTarief && <div><label>Spelers</label>
          {rijen.map((r,i)=>(
            <div className="spelerrij" key={i}><div className="nr">{i+1}</div>
              {/* EEN keuzelijst per speler, met een unieke waarde per optie. Hier stonden er twee, en de
                  eerste gaf ELK gasttype dezelfde waarde "gast". Twee opties met dezelfde waarde kan een
                  keuzelijst niet uit elkaar houden, dus wie Jeugd koos kreeg Greenfee: gemeten op 11
                  augustus, de keuze sprong terug en het bedrag bleef 21,00. De tweede lijst zette het
                  type wel goed, maar dat is een tweede handeling voor wat een gast als een keuze ziet.
                  Nu draagt elke optie zijn eigen waarde: gast:<typeId> voor een gasttarief, en
                  seizoen, kaart of lid voor de kaarthoudersopties. */}
              <select value={r.betaal==="gast" ? "gast:"+r.type : r.betaal}
                onChange={e=>{ const v=e.target.value;
                  if(v.indexOf("gast:")===0) wz(i,{betaal:"gast", type:v.slice(5)}); else if(v==="code") zetRijCode(i); else wz(i,{betaal:v}); }}>
                {lid && heeftSeizoen && i===0 && <option value="seizoen">Seizoenskaart, gratis</option>}
                {lid && heeftStrip && <option value="kaart">Strippenkaart, 1 streep</option>}
                {lid && kaartTarief && lidPrijs!==null && <option value="lid">Ledentarief, {euro(lidPrijs)}</option>}
                {gastTypen.map(t=><option key={t.id} value={"gast:"+t.id}>{tariefNaam(t)}, {euro(prijsGast(t.id)||0)}</option>)}
                {/* Een keuze voor allebei de soorten. De code zegt zelf of het een rittenkaart of
                    een abonnement is, dus de gast hoeft dat niet te weten. */}
                {kaartTarief && <option value="code">Ik heb een kaart of abonnement</option>}
              </select>
            </div>))}
          {/* Het codeveld staat onder de rij en niet ernaast: op een telefoon is een veld van
              negen tekens naast een keuzelijst te smal om te lezen wat je typt. */}
          {rijen.map((r,i)=> (r.betaal!=="code") ? null : (
            <div className="codeveld" key={"c"+i}>
              {!r.zoekModus && <>
              <label htmlFor={"code"+i}>{codeRijen.length===1 && aantal===1 ? "Code van je kaart of abonnement" : "Code van speler "+(i+1)}</label>
              <input id={"code"+i} className="code-in" value={r.code||""} autoComplete="off" spellCheck="false"
                placeholder="ABCDE-FGHIJ-KLMNO"
                onChange={e=>{ const v=e.target.value; wz(i,{code:v, kaart:null});
                  controleerVolgensSoort(i, v, false); }}
                onBlur={e=>{ if(alGecontroleerd(r, e.target.value)) return; controleerVolgensSoort(i, e.target.value, true); }}
                onKeyDown={e=>{ if(e.key==="Enter"){ e.preventDefault(); if(alGecontroleerd(r, e.target.value)) return; controleerVolgensSoort(i, e.target.value, true); } }}/>
              </>}
              {r.kaartBezig && <div className="let">Even kijken…</div>}
              {!r.kaartBezig && r.kaart && r.kaart.bron!=="recras" && r.kaart.bruikbaar && r.kaart.soort==="strippenkaart" &&
                <div className="let groen">Kaart van {r.kaart.houder}, nog {r.kaart.saldo} rit{r.kaart.saldo===1?"":"ten"}.</div>}
              {!r.kaartBezig && r.kaart && r.kaart.bron!=="recras" && r.kaart.bruikbaar && r.kaart.soort==="abonnement" &&
                <div className="let groen">Abonnement van {r.kaart.houder}, geldig tot {kort(r.kaart.geldig_tot)}.</div>}
              {!r.kaartBezig && r.kaart && r.kaart.bron!=="recras" && codeFout(r.kaart) && <div className="fout">{codeFout(r.kaart)}</div>}
              {!r.kaartBezig && r.kaart && r.kaart.bron==="recras" && recrasMelding(r, i) && <>
                <div className={recrasMelding(r, i).klasse}>{recrasMelding(r, i).tekst}</div>
                {recrasMelding(r, i).koop && <a className="knop stil kaart-koop" {...kaartKoopLink(r.kaart && r.kaart.soort==="abonnement" ? "abonnement" : "strippenkaart", true)} style={{display:"block",textAlign:"center",textDecoration:"none",marginTop:8}}>Nieuwe kaart kopen</a>}
              </>}
              {!r.kaartBezig && !r.kaart && (r.code||"").trim() &&
                <div className="let">De code van een nieuwe kaart heeft vijftien tekens, zoals ABCDE-FGHIJ-KLMNO. Vul hem helemaal in, dan kijken we hem meteen na.</div>}
              {/* KAART-OP-NAAM-20261008: de tweede weg naast de code. Hoogstens een kaart op naam per reservering (de link verdwijnt als een andere speler er al een heeft). Niets hiervan gaat naar de opslag van de browser. */}
              {naamBoeken && ts.actief && !!ts.sitekey && !r.zoekModus && !rijen.some((x,n)=>n!==i && x.betaal==="code" && x.recrasRef) &&
                <button type="button" className="terug" onClick={()=>wz(i,{zoekModus:true, zoekFout:null, kaart:null, code:""})}>{NAAM_TEKSTEN.link}</button>}
              {r.zoekModus && <>
                {!(r.recrasRef && r.kaart) && <>
                  <label htmlFor={"zoeknaam"+i}>{NAAM_TEKSTEN.naam}</label>
                  <input id={"zoeknaam"+i} value={r.zoekNaam||""} autoComplete="off" placeholder={NAAM_TEKSTEN.naamHint}
                    onChange={e=>wzFn(i,{zoekNaam:e.target.value})}/>
                  <label htmlFor={"zoekemail"+i}>{NAAM_TEKSTEN.email}</label>
                  <input id={"zoekemail"+i} type="email" value={r.zoekEmail||""} autoComplete="off" placeholder="jij@voorbeeld.nl"
                    onChange={e=>wzFn(i,{zoekEmail:e.target.value})}/>
                  <div className="let">{NAAM_TEKSTEN.mailBericht}</div>
                  <TurnstileVak key={"zt"+i+"-"+(r.zoekRonde||0)} sitekey={ts.sitekey} onToken={t=>wzFn(i,{zoekToken:t})}/>
                  <button type="button" className="knop stil" disabled={!!r.zoekBezig || !r.zoekToken || !(r.zoekNaam||"").trim() || !(r.zoekEmail||"").trim()} onClick={()=>zoekKaartOpNaam(i)}>
                    {r.zoekBezig ? NAAM_TEKSTEN.bezig : NAAM_TEKSTEN.zoek}
                  </button>
                  {r.zoekFout && <div className="fout" role="alert">{r.zoekFout}</div>}
                  {!r.zoekBezig && r.recrasKeuzes && r.recrasKeuzes.length>1 && r.recrasKeuzes.map(k=>(
                    <div key={"rk"+k.kaart_ref} style={{marginTop:6}}>
                      <button type="button" className="knop stil" style={{whiteSpace:"normal", width:"100%", height:"auto", lineHeight:1.3, padding:"12px 14px"}} disabled={k.staat!=="bruikbaar" || !!r.kaartBezig} onClick={()=>controleerRecrasRef(i, k)}>
                        {k.kaartnaam}{k.code_eind ? ", eindigt op "+k.code_eind : ""}
                      </button>
                      {k.staat!=="bruikbaar" && <div className="fout">{NAAM_TEKSTEN[k.staat] || NAAM_TEKSTEN.op}</div>}
                    </div>))}
                  {!r.zoekBezig && r.recrasKeuzes && r.recrasKeuzes.length===1 && r.recrasKeuzes[0].staat!=="bruikbaar" &&
                    <div className="fout">{NAAM_TEKSTEN[r.recrasKeuzes[0].staat] || NAAM_TEKSTEN.op}</div>}
                </>}
                {r.recrasRef && r.kaartBezig && <div className="let">Even kijken…</div>}
                {r.recrasRef && r.kaart && r.kaart.geldig===true && <div className="let">{NAAM_TEKSTEN.mailBericht}</div>}
                <button type="button" className="terug" onClick={()=>wz(i,NAAM_RESET)}>{r.recrasRef && r.kaart ? NAAM_TEKSTEN.wijzig : NAAM_TEKSTEN.terug}</button>
              </>}
              {!(r.kaart && (r.kaart.bron==="recras" ? r.kaart.geldig!==true : !!codeFout(r.kaart))) && !(r.zoekModus && !r.kaart) &&
                <div className="let">{aantal===1 ? "Je speelt met deze kaart." : (codeGroepGrootte(r)===aantal ? "Alle "+aantal+" spelers gaan op deze kaart." : (codeGroepGrootte(r)>1 ? "Deze kaart geldt voor "+codeGroepGrootte(r)+" spelers." : "Speler "+(i+1)+" speelt met deze kaart."))}</div>}
              {i===eersteCode && opslagOk && !r.zoekModus && !r.recrasRef && <>
                <label className="akkoordregel" style={{display:"flex", fontWeight:400, color:"inherit", fontSize:14, margin:"14px 0 6px"}}><input type="checkbox" checked={onthoud} onChange={e=>zetOnthoud(e.target.checked)} style={{width:18, height:18, minHeight:0, padding:0, flex:"0 0 auto", marginTop:2}}/><span>{ONTHOUD_TEKSTEN.vinkje}</span></label>
                {bewaardeCode && <button type="button" className="terug" onClick={vergeetCode}>{ONTHOUD_TEKSTEN.vergeten}</button>}
              </>}
              <button type="button" className="terug" onClick={()=>wz(i,{betaal:"gast", type:gast?gast.id:""})}>{codeRijen.length>1 || aantal>1 ? "Speler "+(i+1)+" toch zonder kaart betalen" : "Toch zonder kaart betalen"}</button>
            </div>))}
        </div>}
        {groot && <div><label>Tarief</label>
          <select value={rijen[0].type} onChange={e=>setRijen([{betaal:"gast",type:e.target.value}])}>
            {gastTypen.map(t=><option key={t.id} value={t.id}>{tariefNaam(t)}, {euro(prijsGast(t.id)||0)}</option>)}</select></div>}
        {/* Een ingelogde kaarthouder ziet op deze ingang geen kaartopties. Dat moet hij weten,
            anders denkt hij dat er iets stuk is. Alleen tonen als hij ook echt een kaart heeft. */}
        {!kaartTarief && lid && ((lid.kaarten&&lid.kaarten.length>0)||lid.seizoenskaart) &&
          <div className="let">Je strippen- en seizoenskaart gelden voor golf. Voor {PROFIEL.naam.toLowerCase()} reken je per speler af.</div>}
        {heeftSeizoen && !groot && <div className="let groen">Seizoenskaart geldig tot {kort(lid.seizoenskaart.geldig_tot)}, jij speelt gratis.</div>}
        {strip && !groot && <div className="let groen">Strippenkaart: {strip.saldo} streep{strip.saldo===1?"":"en"} over{strepen>0?", deze boeking gebruikt er "+strepen:""}.</div>}
        {stripTekort && <div className="fout">Je strippenkaart heeft maar {strip.saldo} streep{strip.saldo===1?"":"en"}.</div>}
        {codeTekort && <div className="fout">Deze kaart heeft nog {codeTekort.saldo} rit{codeTekort.saldo===1?"":"ten"}, en je zet er {codeTekort.n} spelers op. Zet een speler op een ander tarief.</div>}
        {aboDubbel && <div className="let">Een abonnement geldt voor {aboDubbel} zelf. De tweede speler op dezelfde code betaalt het gewone tarief.</div>}
        {recrasTeGroot && <div className="fout">Met deze kaart boek je online maximaal 4 spelers per reservering.</div>}
        {recrasGemengd && <div className="fout">Combineren van deze kaart met een andere kaart kan online nog niet. Bel ons op {TELEFOON}.</div>}
        {eigenCodeGemengd && <div className="fout">Een kaartcode met negen tekens kun je niet combineren met andere spelers of andere kaarten. Laat alle spelers op die ene kaart staan.</div>}
        {mixedZonderMollie && <div className="fout">Spelers die betalen en spelers met een kaart in een reservering kan nu niet online. Kies voor iedereen een kaart, of voor niemand.</div>}
        {recras && alleKaart && kaartGroepen.length===1
          ? <div className="som"><div className="lb">{recras.kaartnaam||(recras.soort==="abonnement"?"Abonnement":"Strippenkaart")}: {recras.gedekt_spelers} speler{recras.gedekt_spelers===1?"":"s"}</div><div className="bd">{euro(totaal)}</div></div>
          : recras ? <div className="som"><div className="lb">{aantal} speler{aantal===1?"":"s"}{gemengd?" · "+codeRijen.length+" met kaart":""}</div><div className="bd">{euro(totaal)}</div></div>
          : <div className="som"><div className="lb">{aantal} speler{aantal===1?"":"s"}{strepen>0?" · "+strepen+" streep"+(strepen===1?"":"en"):""}</div><div className="bd">{euro(totaal)}</div></div>}
        <button className="knop vol" style={knopBreek()} onClick={()=>setStap(extrasZichtbaar?"e":(houderAuto?3:2))} disabled={stripTekort||codeNietKlaar||!!codeTekort||recrasBlokkeert||gemengdBlokkeert||teGrootVoor18||teVolVoor18}>{extrasZichtbaar?EXTRA_TEKSTEN.verder:(houderAuto?siteTekst("boek_knop_gegevens_verder"):siteTekst("boek_knop_spelers_verder"))}</button>
      </>}

      {stap==="e" && <>
        {extrasInst.lijst.map(p=>{ const n=extrasKeuze[p.id]||0; const mx=extraMax(p); const v=extrasVrij[p.id];
          // EXTRAS-MAXIMUM-20261008: bij een extra met een voorraad staat er wat er vrij is; is er niets vrij, dan kan hij niet gekozen worden en staat er waarom.
          const geen = !!v && v.vrij===0, beperkt = !!v && v.vrij>0 && mx<extrasInst.max;
          const hint = geen ? EXTRA_TEKSTEN.geenVrij(v.voorraad) : (beperkt ? EXTRA_TEKSTEN.nogVrij(mx) : null);
          return extrasInst.aantallen
          ? (<div className="spelerrij" key={p.id}>
              <div style={{flex:1,minWidth:0}}><div>{p.naam}</div><div className="bij" style={{margin:0}}>{euro(p.prijs)}</div>
                {hint && <div className="bij" role="status" style={geen ? {margin:0, color:"var(--up-fout)"} : {margin:0}}>{hint}</div>}</div>
              <div className="teller"><button type="button" onClick={()=>zetExtra(p.id,n-1)} disabled={n<1} aria-label={"Eén minder, "+p.naam}>-</button><div className="n">{n}</div><button type="button" onClick={()=>zetExtra(p.id,n+1)} disabled={n>=mx} aria-label={"Eén erbij, "+p.naam}>+</button></div>
            </div>)
          : (<label className="akkoordregel" key={p.id}><input type="checkbox" checked={n>0} disabled={n===0 && mx<1} onChange={e=>zetExtra(p.id,e.target.checked?1:0)}/><span>{p.naam}, {euro(p.prijs)}{hint ? " ("+hint+")" : ""}</span></label>); })}
        <div className="som"><div className="lb">{EXTRA_TEKSTEN.totaal}</div><div className="bd">{euro(totaalBetalen)}</div></div>
        <button className="knop vol" style={knopBreek()} onClick={()=>setStap(houderAuto?3:2)}>{EXTRA_TEKSTEN.verder}</button>
      </>}

      {stap===2 && <>
        <label>Naam</label><input value={naam} onChange={e=>setNaam(e.target.value)} placeholder="Voor- en achternaam"/>
        <label>E-mail</label><input type="email" value={email} onChange={e=>setEmail(e.target.value)}/>
        {/* Punt 2 (17 september 2026): het GSN-veld is uit het boekscherm gehaald.
            Gemeten: geen enkele edge function stuurt het naar Recras, dus Recras heeft het niet
            nodig. De instelling golf_gsn_verplicht_online en de parameter p_gsn blijven bestaan
            voor de balie; gsnNodig staat daarom hieronder hard op onwaar. */}

        <label>Telefoon (optioneel)</label><input type="tel" value={tel} onChange={e=>setTel(e.target.value)}/>
        <div className="let">Je reserveringsnummer komt op het volgende scherm te staan. Het e-mailadres gebruiken we voor de bevestiging.</div>
        {email.trim() && !geldigEmail(email) && <div className="fout">Dit lijkt geen e-mailadres. Controleer of er een apenstaartje in staat en een punt erachter.</div>}
        <button className="knop vol" style={knopBreek()} onClick={()=>setStap(3)} disabled={!naam.trim()||!geldigEmail(email)||!gsnKlaar}>{siteTekst("boek_knop_gegevens_verder")}</button>
      </>}

      {stap===3 && <>
        <div className="details">
          <div><span>Datum</span><span>{lang(datum)}</span></div>
          <div><span>{(groot||paar18)?"Eerste starttijd":"Starttijd"}</span><span>{slot.starttijd.slice(0,5)}</span></div>
          {paar18 && <div><span>Tweede starttijd</span><span>{String(slot.tweede_starttijd).slice(0,5)}</span></div>}
          {paar18 && <div><span>Holes</span><span>2x 9 holes, twee starttijden</span></div>}
          <div><span>Spelers</span><span>{aantal}</span></div>
          <div><span>Naam</span><span>{houderAuto ? recras.houder_naam : naam.trim()}</span></div>
          <div><span>E-mail</span><span>{houderAuto ? "Van de kaarthouder" : email.trim()}</span></div>
          {!houderAuto && tel.trim() && <div><span>Telefoon</span><span>{tel.trim()}</span></div>}
          {houderAuto && <div className="let">De boeking staat op naam van de kaarthouder. De bevestiging gaat naar het e-mailadres bij de kaart.</div>}
        </div>
        {(greenfeeRegels.length>0 || extrasRegels.length>0) && <div className="details">
          {greenfeeRegels.map((g,i)=><div key={"g"+i}><span>{g.n}x {g.naam}</span><span>{euro(g.cent)}</span></div>)}
          {extrasRegels.map(x=><div key={"x"+x.id}><span>{x.aantal}x {x.naam}</span><span>{euro(x.aantal*x.prijs)}</span></div>)}
        </div>}
        {recras && <div className="details">
          {kaartGroepen.map(g=><div key={g.code}><span>{g.kaart.kaartnaam||(g.kaart.soort==="abonnement"?"Abonnement":"Strippenkaart")}</span><span>{g.n} speler{g.n===1?"":"s"}</span></div>)}
        </div>}
        {recras
          ? <div className="som"><div className="lb">Te betalen</div><div className="bd">{euro(totaalBetalen)}</div></div>
          : <div className="som"><div className="lb">Totaal{strepen>0?", "+strepen+" streep"+(strepen===1?"":"en"):""}</div><div className="bd">{euro(totaalBetalen)}</div></div>}
        {groot && <div className="let">Jullie starten in groepen na elkaar. Alle starttijden staan op het scherm hierna en in je bevestiging.</div>}
        {/* EIS 5, derde en belangrijkste plek: hier legt de gast zich vast. Tekst wacht op
            goedkeuring van Roy en staat in data.js; leeg betekent geen blok. */}
        {paar18 && uitleg18(UITLEG_18_VOOR_BETALEN, slot.starttijd, slot.tweede_starttijd) &&
          <div className="let">{uitleg18(UITLEG_18_VOOR_BETALEN, slot.starttijd, slot.tweede_starttijd)}</div>}
        {/* UI1-PATCH 20260930: de twee "Goed om te weten"-meldingen zijn een melding met twee regels (eerste regel per variant:
            betalen met Mollie, niets te betalen, online niet afgerekend, of geen eerste regel bij een Recras-reservering met
            bedrag), de voorwaardenregel staat er altijd in. Het Cloudflare-vakje staat eronder, direct boven de knop; een foutmelding
            staat tussen de melding en het vakje. Alle teksten zijn ongewijzigd. */}
        <div className="let">
          {!((recras || losseViaRecras) && totaalBetalen>0) && <div className="let-regel">{(mollie||recras) && totaalBetalen>0
            ? "Je plek staat vast zodra je bevestigt. Betaal je daarna niet, dan komt de starttijd na ongeveer een half uur weer vrij."
            : (totaalBetalen===0
                ? "Voor deze reservering hoef je niets af te rekenen."
                : "Voor deze reservering wordt online niet afgerekend. Bel ons op "+TELEFOON+" als je wilt weten hoe je betaalt.")}</div>}
          <div className="let-regel">Door te boeken ga je akkoord met onze <VoorwaardenLink/>.</div>
        </div>
        {f && <div className="fout">{f}</div>}
        {(recras || losseViaRecras) && ts.actief && ts.sitekey && <TurnstileVak key={tsRonde} sitekey={ts.sitekey} onToken={setTsToken}/>}
        <button className="knop vol" style={knopBreek()} onClick={()=>setBevestigOpen(true)} disabled={(!houderAuto && (!naam.trim()||!geldigEmail(email)))||bezig||stripTekort||recrasBlokkeert||gemengdBlokkeert||((!!recras || losseViaRecras) && ts.actief && !tsToken)}>{knoptekst}</button>
      </>}

      <button className="terug" onClick={()=>stap===1?onSluit():setStap(vorigeStap())}>{stap===1?"Terug naar de tijden":"Terug"}</button>
      </div>
    </div>
    {bevestigOpen && <BevestigStap datum={datum} slot={slot} aantal={aantal} totaal={totaalBetalen} mollie={mollie||!!recras} paar18={paar18}
      onDoorgaan={()=>{ setBevestigOpen(false); reserveer(); }}
      onTerug={()=>setBevestigOpen(false)}/>}
    </>
  );
}

function Klaar({ bon, datum, slot, mollie, opnieuw }){
  const mis = !!bon.betaalFout;
  // De bon komt uit het geheugen van dit scherm. De WAARHEID staat in de database, en die kan al
  // afwijken op het moment dat dit scherm verschijnt (een groepsboeking krijgt zijn tweede starttijd
  // pas in de database, en de betaalstatus wordt daar bijgewerkt). Daarom halen we de stand op met
  // zoek_boeking en tonen we die zodra hij binnen is. Lukt dat niet, dan blijft de bon staan; dan
  // toont dit scherm minder, maar nooit iets onwaars.
  const [stand,setStand]=useState(null);
  useEffect(()=>{ let weg=false;
    (async()=>{ const r=await db.rpc("zoek_boeking",{p_referentie:bon.referentie});
      if(!weg && r.data && r.data.gevonden) setStand(r.data); })();
    return ()=>{ weg=true; };
  },[bon.referentie]);

  const w = stand ? (stand.kaart_bel_balie ? kaartBelWoorden(bon.referentie) : betaalWoorden(stand.betaalstatus)) : null;
  // Bij een Recras-kaart betaalt Recras; het bedrag op onze boeking is dan de greenfee-rekensom en
  // niet wat de gast betaalde. Dan blijft het bedrag van de bon staan.
  const bedrag = (stand && !bon.recras) ? (stand.bedrag_cent||0) : bon.bedrag;
  const dagTekst = stand ? lang(stand.datum) : lang(datum);
  const tijdTekst = stand ? String(stand.starttijd).slice(0,5) : slot.starttijd.slice(0,5);
  const aantalFlights = stand ? stand.aantal_boekingen : bon.flights;

  return (
    <div className="scrim" onClick={e=>e.target===e.currentTarget&&opnieuw()}><div className="blad"><div className="klaar">
      <div className={"vink"+(mis?" letop":"")}>{mis?"!":"✓"}</div>
      <h3>{mis?siteTekst("boek_kop_klaar_mislukt"):siteTekst("boek_kop_klaar")}</h3>
      <p className="bij">{mis
        ? "Je plek is gereserveerd, maar het online afrekenen lukte niet. Er is nog niets betaald. Neem contact op met dit reserveringsnummer."
        : "We zien je graag op de baan. Schrijf je reserveringsnummer over, daarmee vind je deze reservering altijd terug."}</p>
      <div className="codelb">Reserveringsnummer</div>
      <div className="code">{bon.referentie}</div>
      <div className="details">
        <div><span>Datum</span><span>{dagTekst}</span></div>
        <div><span>{aantalFlights>1?"Eerste starttijd":"Starttijd"}</span><span>{tijdTekst}</span></div>
        <div><span>Spelers</span><span>{bon.spelers}</span></div>
        {aantalFlights>1 && <div><span>Starttijden</span><span>{(stand && stand.paar) || (!stand && bon.flights===2 && bon.paar) ? "2, dus 2x 9 holes" : aantalFlights+" na elkaar"}</span></div>}
        <div><span>Bedrag</span><span>{bedrag===0?"Voldaan met kaart":euro(bedrag)}</span></div>
        <div><span>Status</span><span>{w?w.kop:(bon.betaald?"Betaald":"Nog niet betaald")}</span></div>
      </div>
      {w && <div className={stand.betaalstatus==="mislukt"?"fout":"let"}>{w.bij}</div>}
      {!bon.recras && <div className="mailmelding">{mis
        ? "Er is geen bevestiging per e-mail verstuurd, want de boeking is nog niet betaald."
        : "Je krijgt een bevestiging per e-mail. Komt die niet aan, dan blijft je reserveringsnummer gewoon geldig."}</div>}
      <button className="knop vol stil" style={knopBreek()} onClick={opnieuw}>{siteTekst("boek_knop_klaar_terug")}</button>
    </div></div></div>
  );
}

function Wachtlijst({ baan, datum }){
  const [naam,setNaam]=useState(""); const [email,setEmail]=useState("");
  const [tel,setTel]=useState(""); const [dagdeel,setDagdeel]=useState("heledag");
  const [aantal,setAantal]=useState(1); const [bezig,setBezig]=useState(false);
  const [klaar,setKlaar]=useState(false); const [fout,setFout]=useState(null);
  const [open,setOpen]=useState(false);

  async function aanmelden(){
    setBezig(true); setFout(null);
    // Let op: `email` gaat als LEEG TEKSTVELD mee en niet als null. De kolom `wachtlijst.email` staat op
    // NOT NULL, gemeten op 11 augustus: met null komt er een 23502 terug en wordt er niets opgeslagen.
    // Een leeg veld wordt wel geaccepteerd. Zolang die kolom niet nullable is, is dit de enige manier om
    // e-mail echt optioneel te maken voor de gast. Staat als punt op de databasewenslijst.
    // De wachtlijst kent geen sportkolom: `meld_wachtlijst` neemt baan, datum en dagdeel, meer niet.
    // Nagemeten op 12 augustus 2026. Zonder de sport erbij kan de balie een footgolfaanmelding niet
    // onderscheiden van een golfaanmelding, en die twee spelen op dezelfde baan op dezelfde dag.
    // Daarom gaat de sport mee in het vrije opmerkingenveld. Dat is een noodgreep en geen oplossing;
    // een echte `sport`-kolom staat op de databasewenslijst. Voor de golfingang blijft het veld leeg,
    // precies zoals het was.
    const r=await db.rpc("meld_wachtlijst",{p_baan_id:baan.id,p_datum:datum,p_dagdeel:dagdeel,
      p_naam:naam.trim(),p_email:email.trim(),p_aantal:Number(aantal),p_telefoon:tel.trim(),
      p_opmerking:PROFIEL.wachtlijstOpmerking});
    if(r.error){ setFout(foutTekst(r.error)); setBezig(false); return; }
    setKlaar(true); setBezig(false);
  }

  if(klaar) return (
    <div className="wl-klaar">
      <div className="wl-vink">✓</div>
      <div><b>Je staat op de wachtlijst</b><div className="wl-sub">Komt er een plek vrij op {kort(datum)}, dan nemen we contact met je op.</div></div>
    </div>);

  if(!open) return (
    <div className="wl-lok">
      <div className="wl-tekst">Deze dag zit vol. Wil je dat we je bellen zodra er een plek vrijkomt?</div>
      <button className="wl-knop" onClick={()=>setOpen(true)}>Zet mij op de wachtlijst</button>
    </div>);

  return (
    <div className="wl-form">
      <div className="dagdeel" style={{marginTop:0}}>Wachtlijst voor {kort(datum)}</div>
      <label className="wl-lab">Naam</label>
      <input className="wl-in" value={naam} onChange={e=>setNaam(e.target.value)} placeholder="Voor- en achternaam"/>
      {/* Telefoon staat boven e-mail en is verplicht, want de belofte op dit scherm is dat we BELLEN.
          Een wachtlijst met alleen een e-mailadres kan de balie die belofte niet waarmaken. */}
      <label className="wl-lab">Telefoon</label>
      <input className="wl-in" type="tel" value={tel} onChange={e=>setTel(e.target.value)} placeholder="06 12 34 56 78"/>
      <label className="wl-lab">E-mail (optioneel)</label>
      <input className="wl-in" type="email" value={email} onChange={e=>setEmail(e.target.value)} placeholder="jij@voorbeeld.nl"/>
      {email.trim() && !geldigEmail(email) && <div className="wl-fout">Dit lijkt geen e-mailadres. Laat het veld leeg of vul een volledig adres in.</div>}
      <div className="wl-rij">
        <div style={{flex:1}}><label className="wl-lab">Voorkeur</label>
          <select className="wl-in" value={dagdeel} onChange={e=>setDagdeel(e.target.value)}>
            <option value="heledag">Hele dag</option><option value="ochtend">Ochtend</option><option value="middag">Middag</option>
          </select></div>
        <div style={{width:96}}><label className="wl-lab">Spelers</label>
          <input className="wl-in" type="number" min="1" max={PROFIEL.wachtlijstMax} value={aantal} onChange={e=>setAantal(e.target.value)}/></div>
      </div>
      <button className="wl-knop vol" onClick={aanmelden}
        disabled={bezig||!naam.trim()||!tel.trim()||(email.trim()&&!geldigEmail(email))}>
        {bezig?"Bezig…":"Aanmelden voor wachtlijst"}</button>
      {fout && <div className="wl-fout">{fout}</div>}
    </div>);
}

// Betaalstatus in gewone taal. zoek_boeking geeft er vier: open, betaald, mislukt en terugbetaald.
// "mislukt" betekent hier ook "vervallen": markeer_mislukt zet de boeking op geannuleerd en geeft de
// starttijd weer vrij, en sinds de databasewijziging van 10 augustus zetten de annuleerfuncties de
// betaalstatus mee. Een gast moet dus niet lezen dat er "iets mislukt is" maar dat de reservering weg is.
// Let op: de betaalstand alleen is sinds 22 augustus 2026 niet meer het hele verhaal. Een reservering
// kan "Betaald" tonen terwijl hij niet meer doorgaat (betaald en daarna geannuleerd of als no-show
// afgeboekt). Die stand komt niet uit deze functie maar uit het veld gaat_door, zie StandBlok
// hieronder. De twee samen, in die volgorde, geven het eerlijke beeld.
// GEMENGD-VERVOLG V2 20260930 (besluit 69): de gast heeft BETAALD, maar een kaart in de reservering kon niet meer worden gebruikt (geen strippen meer op, of de kaart geldt niet meer).
// Er is niets van de kaart afgegaan en de reservering is niet bevestigd. Eerlijk zeggen wat er is en wat de gast doet: de balie bellen. De OORZAAK wordt niet genoemd (QA F1): het veld kaart_bel_balie zegt alleen dat het zo is, niet waarom (ook een tijdelijke fout telt mee). Het telefoonnummer krijgt vaste spaties zodat het niet afbreekt (QA F2). Geen "wacht even", want dat komt niet vanzelf goed,
// en geen belofte over geld. Dit verschijnt alleen als zoek_boeking het veld kaart_bel_balie op true zet.
function kaartBelWoorden(ref){
  return {kop:"Betaald, kaart niet gebruikt", tt:"Betaling ontvangen, kaart niet gebruikt",
    bij:"Je betaling"+(ref?" voor reservering "+ref:"")+" is bij UP Events Golf binnen, maar je kaart kon er niet voor worden gebruikt. Er is niets van je kaart afgegaan en de reservering is niet bevestigd. Bel de balie op "+TELEFOON.replace(/ /g," ")+(ref?" met je reserveringsnummer "+ref:"")+"."};
}
function betaalWoorden(status){
  if(status==="betaald")      return {kop:"Betaald",           bij:"Het bedrag van deze reservering is voldaan."};
  // Hier stond tot 12 augustus 2026 dat de gast bij aankomst aan de balie kon betalen. Dat is een BELOFTE over wat de
  // balie kan, en wij hebben nergens vastgesteld dat een openstaande online reservering aan de balie
  // alsnog geind kan worden. Staat als vraag voor Stefan en Rene in het meetverslag van die dag.
  // Zolang het antwoord er niet is, wijst dit scherm naar de balie zonder te zeggen wat die gaat doen.
  if(status==="open")         return {kop:"Nog niet betaald",  bij:"De reservering staat, maar er is nog niet afgerekend. Bel ons op "+TELEFOON+" en houd je reserveringsnummer bij de hand."};
  if(status==="mislukt")      return {kop:"Vervallen",         bij:"Deze reservering is komen te vervallen en de starttijd is weer vrij. Maak een nieuwe reservering."};
  if(status==="terugbetaald") return {kop:"Terugbetaald",      bij:"Het bedrag van deze reservering is teruggestort."};
  return {kop:"Onbekend", bij:"We kunnen de stand van deze reservering niet aflezen."};
}

// Hier stond tot 15 september 2026 het component met de bel-link naar de balie. Weggehaald op besluit
// van Roy (context/besluiten.md, blok UPgolf, punt 9): UP wil niet gebeld worden, dus er staat geen
// telefoonnummer en geen bel-link in deze voorkant. Alle plekken die het component toonden, en elke
// tekst die de gast naar de telefoon verwees, zijn in dezelfde ronde aangepast (BOUWLOGBOEK, Ronde 11).

// Zijroute: een gast die zijn reserveringsnummer heeft, kan hier zijn reservering terugvinden.
// Bewust ALLEEN opzoeken en niet annuleren, en dat blijft zo. `zoek_boeking` geeft de interne id met
// opzet niet terug, dus er is voor een gast niets om een annulering op te hangen.
//
// Hier stond dat de annuleerfunctie "open staat voor iedere bezoeker". Dat is op 12 augustus 2026
// nagemeten en het klopt NIET meer: `annuleer_boeking` begint met een controle op `is_medewerker()`
// en gooit anders "Geen toegang" met code 42501. De regel bleef staan omdat de conclusie
// ("wij bouwen hier geen annuleerknop") toevallig dezelfde is, maar een reden die niet klopt is geen
// reden. Een kaarthouder annuleert via Mijn reserveringen. Hoe een gast zonder
// kaart annuleert, is sinds 15 september 2026 een open vraag: de route via de telefoon is weg (besluit 9).
// Een gevonden reservering, in beide routes hetzelfde getekend. LET OP, opnieuw gecorrigeerd op
// 22 augustus 2026 na een tweede meting, dezelfde dag: de constatering hierboven (dat
// boeking_bij_naam_email de drie velden niet gaf) is zelf alweer achterhaald. Enterprise heeft
// boeking_bij_naam_email diezelfde dag uitgebreid met exact dezelfde ladder als zoek_boeking. Nagemeten
// op staging: md5(prosrc) e49340a45dca0787a280f74b80e97e76, def 3893d6ede16dca0ea747164e07ff98f0,
// lengte 5012. Beide routes (nummer via zoek_boeking, en naam plus e-mail via boeking_bij_naam_email)
// geven nu status, gaat_door en actieve_boekingen. Dat betekent dat StandBlok hieronder in de normale
// situatie voor BEIDE routes zichzelf toont. De voorwaarde "alleen tonen als gaat_door aanwezig is"
// blijft staan als vangnet, niet als de normale gang van zaken: een route die ooit weer minder velden
// teruggeeft (een oudere edge function, een teruggedraaide migratie) laat dit component gewoon
// degraderen in plaats van breken.
function StandBlok({ r }){
  if (typeof r.gaat_door === "undefined") return null; // komt uit boeking_bij_naam_email: geen stand bekend
  if (r.gaat_door) {
    if (r.status === "deels_actief") {
      // Het bedrag en de betaalstand hierboven/-onder gelden voor de HELE groep, dus zonder dit
      // aantal erbij klopt het bedrag niet meer met wat de gast op het scherm ziet.
      return <div className="let deels">{r.paar
        ? "Van deze 2x 9 holes staat nog "+r.actieve_boekingen+" van de 2 starttijden; de andere is vervallen."
        : "Niet je hele groep gaat door: "+r.actieve_boekingen+" van "+r.aantal_boekingen+" starttijden staan nog, de rest is vervallen."}</div>;
    }
    return null; // status actief: de gewone betaalstand hieronder is dan het hele verhaal
  }
  // gaat_door is false. Dit weegt zwaarder dan de betaalstand, dus dit blok komt voor de details en
  // gebruikt de opvallendste melding die dit scherm kent (.fout), niet de neutrale .let.
  if (r.status === "no_show") {
    return <div className="fout stand">Deze reservering gaat niet door: hij staat genoteerd als niet verschenen.</div>;
  }
  if (r.status === "geannuleerd") {
    return <div className="fout stand">Deze reservering is geannuleerd en gaat niet door. Je hoeft niet te komen.</div>;
  }
  // status "gemengd": niets meer actief, maar niet alle starttijden om dezelfde reden. Voor de gast
  // maakt dat niets uit, hij hoeft alleen te weten dat er niets meer doorgaat.
  return <div className="fout stand">Deze reservering gaat niet door: de starttijden zijn vervallen (deels geannuleerd, deels als niet verschenen genoteerd).</div>;
}

// De Verplaatsen-knop staat hier alleen, niet op elk gevonden resultaat: hij verschijnt alleen als
// de aanroeper `onVerplaats` meegeeft (dat doet Opzoeken alleen in de reserveringsnummer-route, zie
// hoofdstuk 1 van BOUWOPDRACHT-ENTERPRISE-verplaatsen-en-teller-20260822.md: "Ingang met het
// reserveringsnummer"), en alleen als het zinvol is: de reservering moet nog doorgaan (r.gaat_door,
// dus alleen bekend via zoek_boeking, niet via boeking_bij_naam_email) en het mag geen boeking met
// meerdere starttijden na elkaar zijn. Bij een groep geeft zoek_boeking de VROEGSTE starttijd van
// de hele groep terug, niet per se die van de opgezochte referentie: een verplaatsknop zou dan een
// tijd tonen die niet bij het nummer hoort. Zo'n boeking verplaats je aan de balie.
//
// 18 september 2026, bevinding 1 van de livetest: een 18-holespaar is OOK meer dan een flight, maar
// het is geen groep. Daar is de vroegste starttijd juist precies de goede (de eerste negen), en
// verplaats_boeking_gast verzet sinds vandaag het hele paar in een keer. zoek_boeking geeft daarom
// het veld 'paar' terug. Alleen een ECHTE groep (groep en niet paar) gaat nog naar de balie.
function GevondenReservering({ r, onVerplaats, onTegoed, zonderTegoed }){
  const w = r.kaart_bel_balie ? kaartBelWoorden(r.referentie) : betaalWoorden(r.betaalstatus);
  const echteGroep = !!r.groep && !r.paar;
  const magVerplaatsen = onVerplaats && r.gaat_door && !echteGroep && !r.kaart_bel_balie;
  // GEMENGD-PATCH 20260930: een geannuleerde reservering met een OPEN tegoed blijft in de lijst staan, als tegoed. Het verplaatsscherm (zonderTegoed) heeft zijn eigen tegoedregel.
  const toonTegoed = !zonderTegoed && r.tegoed_status==="open" && !r.gaat_door;
  return (
    <div>
      <div className="code">{r.referentie}</div>
      {toonTegoed ? null : <StandBlok r={r}/>}
      <div className="details">
        <div><span>Datum</span><span>{lang(r.datum)}</span></div>
        <div><span>{r.groep?"Eerste starttijd":"Starttijd"}</span><span>{String(r.starttijd).slice(0,5)}</span></div>
        {r.paar && <div><span>Starttijden</span><span>2, dus 2x 9 holes</span></div>}
        {r.groep && !r.paar && <div><span>Starttijden</span><span>{r.aantal_boekingen} na elkaar</span></div>}
        {r.kaart_voldaan==="alles"
          ? <div><span>Bedrag</span><span>Voldaan met kaart</span></div>
          : r.kaart_voldaan==="deels"
            ? <>
                <div><span>{(r.betaalstatus==="betaald" || r.kaart_bel_balie) ? "Betaald" : "Te betalen"}</span><span>{euro(r.bedrag_cent||0)}</span></div>
                <div><span>Met kaart voldaan</span><span>{r.kaartspelers} speler{r.kaartspelers===1?"":"s"}</span></div>
              </>
            : <div><span>Bedrag</span><span>{euro(r.bedrag_cent||0)}</span></div>}
        <div><span>Status</span><span>{w.kop}</span></div>
      </div>
      {toonTegoed
        ? <div className="let groen">Tegoed tot en met {metJaar(r.tegoed_geldig_tot)}{r.tegoed_spelers ? ", voor "+r.tegoed_spelers+" speler"+(r.tegoed_spelers===1?"":"s") : ""}. Daarmee kies je later zelf een nieuwe starttijd, zonder opnieuw te betalen.</div>
        : <div className={r.betaalstatus==="mislukt"?"fout":"let"}>{w.bij}</div>}
      {toonTegoed && onTegoed &&
        <button type="button" className="wl-knop" style={{marginTop:8}} onClick={()=>onTegoed(r.referentie)}>Kies een nieuwe starttijd</button>}
      {magVerplaatsen &&
        <button type="button" className="wl-knop" style={{marginTop:8}} onClick={()=>onVerplaats(r.referentie)}>
          Verplaatsen naar een andere datum of tijd
        </button>}
      {onVerplaats && r.gaat_door && echteGroep &&
        <div className="let">Deze boeking heeft meerdere starttijden na elkaar. Wil je deze starttijden verplaatsen? Bel ons op {TELEFOON}.</div>}
    </div>
  );
}

// Tekst bij een niet-bruikbare of onbekende kaart, hergebruikt door SaldoCheck en MijnUp. Zelfde
// woorden als codeFout in Boeken, als eigen top-level functie: dat blok zit binnen Boeken en kon
// niet zonder risico verplaatst worden, dus dit is een eigen kopie in plaats van een wijziging aan
// werkende code. Toegevoegd 22 augustus 2026, bouwronde saldo checken en MIJN UP.
// ---- Bevinding 2 van de livetest (18 september 2026): de voorwaarden in een venster ----
//
// Wat er misging: de link naar voorwaarden.html laadde een hele pagina. De boekgegevens staan
// alleen in het geheugen van deze pagina, dus ze waren weg zodra de gast terugkwam, of hij nu de
// link "Terug naar boeken" gebruikte of de terugknop van de browser. Het vinkje is verplicht, dus
// dit was een echte drempel.
//
// En een tweede fout op dezelfde plek: de link bij het akkoordvinkje stond BINNEN het <label>.
// Een klik op een link in een label activeert ook het label, dus het vinkje klapte om zodra de
// gast op "algemene voorwaarden" klikte. Daarvoor is de stopPropagation hieronder.
//
// De oplossing is niet een terugweg bijbouwen maar het herladen helemaal vermijden: de tekst komt
// in een venster over het scherm heen. De tekst wordt uit voorwaarden.html zelf gehaald, zodat er
// maar EEN plek is waar de voorwaarden staan; zou hij hier overgetypt worden, dan lopen de twee
// na de eerste wijziging uit elkaar en klopt er een van de twee niet meer.
// De href blijft staan: zonder javascript, of als het ophalen mislukt, werkt de gewone pagina nog.
// ---- HUISSTIJL-FOOTGOLF-PATCH 20260929 -------------------------------------------------------------------
// Footgolf boeken gaat via Recras (Roy, 29 september 2026), niet meer via deze voorkant. De golfkant blijft precies zoals hij was.
// De pagina UP Events Footgolf heeft het Recras-boekproces ("Kies datum & tijd"). De sportkeuze in de code blijft bestaan,
// maar er komt geen weg meer heen: de knoppen wijzen naar die pagina en een adres met ?sport=footgolf valt terug op golf.
const FOOTGOLF_BOEKEN_URL = "https://upevents.nl/evenement/footgolf/";
// FOOTGOLF-MENU-PATCH 20261001 (besluit 81): de schakelaar staat in data.js. Ontbreekt hij daar (oude data.js in de cache), dan blijft alles zichtbaar zoals voor de patch.
// B9R1-20261007: een functie in plaats van een vaste waarde: de balie kan footgolf_in_menu omzetten, en de waarde komt pas binnen als de database antwoordt (zie useBoekGrenzen in data.js).
const footgolfZichtbaar = () => (typeof FOOTGOLF_IN_MENU === "undefined") || FOOTGOLF_IN_MENU;
if(SPORT!=="golf"){
  zetSport("golf");
  try{ const u=new URL(window.location.href); if(u.searchParams.get("sport")){ u.searchParams.delete("sport"); window.history.replaceState({},"",u.pathname+u.search); } }catch(e){}
}

function VoorwaardenVenster({ onSluit }){
  const [html,setHtml]=useState(null); const [fout,setFout]=useState(false);
  useEffect(()=>{
    let weg=false;
    fetch("voorwaarden.html")
      .then(r=>{ if(!r.ok) throw new Error("status "+r.status); return r.text(); })
      .then(t=>{
        if(weg) return;
        const d=new DOMParser().parseFromString(t,"text/html");
        // Weg met alles wat hier niet hoort: de terugkeerlink (die zou de pagina alsnog herladen),
        // scripts en de eigen stijlkoppelingen van die pagina.
        d.querySelectorAll('a[href="index.html"], script, link, style').forEach(e=>e.remove());
        const bron=d.querySelector("main")||d.body;
        setHtml(bron?bron.innerHTML:"");
      })
      .catch(e=>{ if(!weg){ console.warn("[upgolf] voorwaarden ophalen mislukt: "+(e&&e.message)); setFout(true); } });
    return ()=>{ weg=true; };
  },[]);
  return (
    <div className="scrim" onClick={e=>e.target===e.currentTarget&&onSluit()}>
      <div className="blad"><div className="greep"/>
        <h3>Algemene voorwaarden</h3>
        {!html && !fout && <div className="wl-tekst">Voorwaarden ophalen…</div>}
        {fout && <div className="let">De voorwaarden zijn nu niet op te halen. Ze staan ook op <a href="voorwaarden.html" target="_blank" rel="noopener">deze pagina</a>. Let op: die opent apart, je reservering blijft hier staan.</div>}
        {html && <div className="voorwaardentekst" dangerouslySetInnerHTML={{__html:html}}/>}
        <button className="knop vol stil" onClick={onSluit}>Terug naar mijn reservering</button>
      </div>
    </div>
  );
}

// De link zelf, met zijn eigen venster. preventDefault houdt de pagina staan, stopPropagation
// houdt het akkoordvinkje met rust.
function VoorwaardenLink({ tekst }){
  const [open,setOpen]=useState(false);
  return (
    <>
      <a href="voorwaarden.html" onClick={e=>{ e.preventDefault(); e.stopPropagation(); setOpen(true); }}>{tekst||"algemene voorwaarden"}</a>
      {open && <VoorwaardenVenster onSluit={()=>setOpen(false)}/>}
    </>
  );
}

function codeFoutTekst(k){
  if(!k) return null;
  if(!k.gevonden) return "Deze code kennen we niet. Kijk of je hem goed hebt overgetypt.";
  if(k.bruikbaar) return null;
  if(k.reden==="geblokkeerd") return "Deze kaart is geblokkeerd. Meld je bij de balie, dan lossen we het op.";
  if(k.reden==="geen_ritten") return "Op deze kaart staan geen ritten meer. Koop een nieuwe via <a href=\""+kaartKoopLink("strippenkaart",true).href+"\">Kaart kopen</a> of aan de balie.";
  if(k.reden==="verlopen") return "Deze kaart is niet meer geldig. Kies een ander tarief of bel ons op "+TELEFOON+".";
  if(k.reden==="geen_kaart") return "Bij deze gegevens staat geen actieve kaart of abonnement. Bel ons op "+TELEFOON+".";
  if(k.reden==="andere_sport") return "Deze kaart geldt voor " + (k.sport||"een andere sport") + ".";
  return "Deze kaart kunnen we nu niet gebruiken. Bel ons op "+TELEFOON+".";
}

// Toont het resultaat van kaart_bij_code of kaart_bij_naam_email, hergebruikt door SaldoCheck en
// MijnUp. Zelfde beperkte veldenset als bij het boeken (houder, saldo of geldig_tot, kaartnaam):
// meer geeft de functie toch niet terug. Toegevoegd 22 augustus 2026.
function KaartSaldoResultaat({ k }){
  if(!k || !k.gevonden) return null;
  if(k.bruikbaar && k.soort==="strippenkaart") return (
    <div className="let groen">{k.kaartnaam||"Strippenkaart"} van {k.houder}, nog {k.saldo} rit{k.saldo===1?"":"ten"}.</div>);
  if(k.bruikbaar && k.soort==="abonnement") return (
    <div className="let groen">{k.kaartnaam||"Abonnement"} van {k.houder}, geldig tot {kort(k.geldig_tot)}.</div>);
  return <div className="fout">{codeFoutTekst(k)}</div>;
}

// Ronde 2 (14 september 2026, bouwspec 4.2 punt 2): kaarten uit Recras in Saldo checken en Mijn UP.
// Geen houdernaam (Recras-displaynamen kunnen "K23" zijn). Bij meer dan een kaart per regel "eindigt op".
function RecrasKaartRegels({ res }){
  if(!res || !res.gevonden || !Array.isArray(res.kaarten)) return null;
  const meer = res.kaarten.length > 1;
  return <>{res.kaarten.map((k,n)=>{ const regel = recrasKaartRegel(k);
    return <div key={"rs"+n} className={regel.klasse}>{regel.tekst}{meer ? " Eindigt op "+k.code_eind+"." : ""}</div>; })}</>;
}

// Punt 4 van het plan van 21 augustus 2026 (bouwronde 22 augustus): saldo checken zonder inlog, als
// eigen scherm los van het boekformulier. Met de code (kaart_bij_code) of met naam plus e-mailadres
// (kaart_bij_naam_email), allebei bewust ZONDER p_sport en ZONDER p_datum: dit scherm hoort niet bij
// een specifieke boeking, dus het saldo geldt voor elke sport waarvoor de kaart geldig is. Geen
// inlog, geen account, geen sessie die blijft hangen: elk bezoek begint leeg (besluit van Roy, 14
// augustus 2026, geen kaarthoudersdashboard).
function SaldoCheck({ onSluit }){
  const [modus,setModus]=useState("code");
  const [code,setCode]=useState("");
  const [naam,setNaam]=useState(""); const [email,setEmail]=useState("");
  const [bezig,setBezig]=useState(false);
  const [kaart,setKaart]=useState(null);   // null = nog niet gezocht
  const [recras,setRecras]=useState(null); // ronde 2: uitkomst van recras-houder, null = niet gevraagd
  const [fout,setFout]=useState(null);

  function wissel(nieuw){ setModus(nieuw); setFout(null); setKaart(null); setRecras(null); }

  async function zoekCode(){
    if(!code.trim()) return;
    setBezig(true); setFout(null); setKaart(null); setRecras(null);
    // Ronde 2: een code van 15 tekens is een Recras-kaart; 9 tekens blijft ons eigen systeem.
    if(codeSoort(code)==="recras"){
      const h = await recrasHouder({zoek:"code", code:code.trim()});
      setBezig(false);
      setRecras(h); setKaart({gevonden:false, alleenRecras:true});
      return;
    }
    const r=await db.rpc("kaart_bij_code",{p_code:code.trim()});
    setBezig(false);
    if(r.error){ setFout(foutTekst(r.error)); return; }
    setKaart(r.data);
  }
  async function zoekNaam(){
    if(!naam.trim()||!email.trim()) return;
    setBezig(true); setFout(null); setKaart(null); setRecras(null);
    const [r, h] = await Promise.all([
      db.rpc("kaart_bij_naam_email",{p_naam:naam.trim(), p_email:email.trim()}),
      recrasHouder({zoek:"naam_email", naam:naam.trim(), email:email.trim()})
    ]);
    setBezig(false);
    setRecras(h);
    if(r.error){ if(!(h && h.gevonden)) setFout(foutTekst(r.error)); setKaart({gevonden:false}); return; }
    setKaart(r.data);
  }

  const recrasGevonden = !!(recras && recras.gevonden);
  const remKaart = kaart && !recrasGevonden && (kaart.reden==="te_veel_pogingen" || (recras && recras.reden==="te_veel_pogingen"));
  const recrasWeg = !!(recras && recras.reden==="recras_onbereikbaar");

  return (
    <div className="scrim" onClick={e=>e.target===e.currentTarget&&onSluit()}>
      <div className="blad"><div className="greep"/>
        <h3>Saldo checken</h3>
        <p className="bij">Kijk hoeveel ritten er nog op je strippenkaart staan, of tot wanneer je abonnement geldig is. Geen inlog nodig.</p>

        {modus==="code" && <>
          <label className="wl-lab" htmlFor="saldocode">Code van je kaart of abonnement</label>
          <input id="saldocode" className="wl-in code-in" value={code} autoComplete="off" spellCheck="false"
            placeholder="ABCD-12345"
            onChange={e=>setCode(e.target.value)}
            onKeyDown={e=>{ if(e.key==="Enter"){ e.preventDefault(); zoekCode(); } }}/>
          <button className="wl-knop" onClick={zoekCode} disabled={bezig||!code.trim()}>
            {bezig?"Even kijken…":"Saldo bekijken"}</button>
          <button type="button" className="terug" onClick={()=>wissel("naam")}>
            Code kwijt? Zoek op naam en e-mailadres
          </button>
        </>}

        {modus==="naam" && <>
          <label className="wl-lab" htmlFor="saldonaam">Naam</label>
          <input id="saldonaam" className="wl-in" value={naam} autoComplete="off" placeholder="Voor- en achternaam"
            onChange={e=>setNaam(e.target.value)}
            onKeyDown={e=>{ if(e.key==="Enter"){ e.preventDefault(); zoekNaam(); } }}/>
          <label className="wl-lab" htmlFor="saldoemail">E-mailadres waarmee je de kaart kocht</label>
          <input id="saldoemail" className="wl-in" type="email" value={email} autoComplete="off" placeholder="jij@voorbeeld.nl"
            onChange={e=>setEmail(e.target.value)}
            onKeyDown={e=>{ if(e.key==="Enter"){ e.preventDefault(); zoekNaam(); } }}/>
          <button className="wl-knop" onClick={zoekNaam} disabled={bezig||!naam.trim()||!email.trim()}>
            {bezig?"Even kijken…":"Saldo bekijken"}</button>
          <button type="button" className="terug" onClick={()=>wissel("code")}>
            Ik heb de code toch bij de hand
          </button>
        </>}

        {fout && <div className="fout">{fout}</div>}
        {kaart && remKaart &&
          <div className="fout">Er is net te vaak gezocht. Wacht een paar minuten, of bel ons op {TELEFOON}.</div>}
        {/* Aanvulling van 22 augustus 2026, op verzoek van hoofd-activiteiten: op dit scherm is de
            code het ENIGE wat een gast heeft ingetypt, dus wie hier vastloopt heeft een vervolg
            nodig. De code-tekst kreeg er daarom een tweede zin bij plus een echte bel-link. De naam-tekst
            blijft ongewijzigd: die noemt zelf al "Controleer allebei", en de tekst in het boekformulier
            (Boeken, codeFout) blijft ook ongewijzigd, dat was expliciet niet de vraag.
            15 september 2026 (Ronde 11, besluit 9 van Roy, geen telefoonnummer): de bel-link en het
            belgedeelte van de code-tekst zijn weg. De tweede zin (zoek op naam en e-mailadres) blijft. */}
        {kaart && !remKaart && !kaart.gevonden && !recrasGevonden && recrasWeg &&
          <div className="fout">{RECRAS_OPZOEK_ONBEREIKBAAR}</div>}
        {kaart && !remKaart && !kaart.gevonden && !recrasGevonden && !recrasWeg &&
          <div className="niets">{modus==="code"
            ? "Deze code kennen we niet. Kijk of je hem goed hebt overgetypt. Weet je de code niet meer? Zoek dan op naam en e-mailadres."
            : "We vinden geen kaart bij deze naam en dit e-mailadres. Controleer beide, of bel ons op "+TELEFOON+"."}</div>}
        {kaart && !remKaart && kaart.gevonden && <>
          <KaartSaldoResultaat k={kaart}/>
        </>}
        {recrasGevonden && <RecrasKaartRegels res={recras}/>}

        <button className="terug" onClick={onSluit}>Sluiten</button>
      </div>
    </div>
  );
}

// Punt 5 van het plan van 21 augustus 2026 (bouwronde 22 augustus): MIJN UP als verzamelscherm achter
// de rode knop in de header. Zoekt op naam plus e-mailadres (reserveringen via boeking_bij_naam_email
// EN kaartsaldo via kaart_bij_naam_email, in een keer) of op de code van de kaart (alleen saldo, via
// kaart_bij_code: boeking_bij_naam_email kent geen code-ingang). Geen wachtwoord, geen account, geen
// sessie die blijft hangen: elk bezoek begint leeg. De optie om hier later een echte inlog achter te
// zetten blijft open als latere uitbreiding en is nu bewust niet gebouwd (besluit van Roy, 14 augustus
// 2026, geen kaarthoudersdashboard).
function MijnUp({ onSluit }){
  const [modus,setModus]=useState("naam");
  const [naam,setNaam]=useState(""); const [email,setEmail]=useState("");
  const [code,setCode]=useState("");
  const [bezig,setBezig]=useState(false);
  const [gezocht,setGezocht]=useState(false);
  const [reserveringen,setReserveringen]=useState([]);
  const [kaart,setKaart]=useState(null);
  const [recras,setRecras]=useState(null); // ronde 2: uitkomst van recras-houder
  const [fout,setFout]=useState(null);

  function wissel(nieuw){ setModus(nieuw); setFout(null); setGezocht(false); setReserveringen([]); setKaart(null); setRecras(null); }

  async function zoekOpNaam(){
    if(!naam.trim()||!email.trim()) return;
    setBezig(true); setFout(null);
    // Beide functies tegelijk aanroepen: dit scherm is de synthese van de reservering- en de
    // kaartzoekroute die al bestonden, geen nieuwe database-aanroep die het niet al kon.
    const vragen=[ db.rpc("boeking_bij_naam_email",{p_naam:naam.trim(), p_email:email.trim()}) ];
    if(PROFIEL.kaarttarieven) vragen.push(db.rpc("kaart_bij_naam_email",{p_naam:naam.trim(), p_email:email.trim()}));
    // Ronde 2 (bouwspec 4.2 punt 3): ook de kaarten uit Recras. Reserveringen blijven uit onze database.
    if(PROFIEL.kaarttarieven) vragen.push(recrasHouder({zoek:"naam_email", naam:naam.trim(), email:email.trim()}));
    const uitkomsten = await Promise.all(vragen);
    const rb = uitkomsten[0], rk = uitkomsten[1];
    setRecras(uitkomsten[2]||null);
    setBezig(false); setGezocht(true);
    if(rb.error){ setFout(foutTekst(rb.error)); setReserveringen([]); setKaart(null); return; }
    const d = rb.data||{gevonden:false};
    if(d.reden==="te_veel_pogingen"){
      setFout(null); setReserveringen([]); setKaart({gevonden:false, reden:"te_veel_pogingen"}); return;
    }
    setReserveringen(d.gevonden ? (d.reserveringen||[]) : []);
    setKaart(rk && !rk.error ? rk.data : null);
  }

  async function zoekOpCode(){
    if(!code.trim()) return;
    setBezig(true); setFout(null); setRecras(null);
    if(codeSoort(code)==="recras"){
      const h = await recrasHouder({zoek:"code", code:code.trim()});
      setBezig(false); setGezocht(true); setReserveringen([]); setKaart(null); setRecras(h);
      return;
    }
    const r=await db.rpc("kaart_bij_code",{p_code:code.trim()});
    setBezig(false); setGezocht(true); setReserveringen([]);
    if(r.error){ setFout(foutTekst(r.error)); setKaart(null); return; }
    setKaart(r.data);
  }

  const recrasGevonden = !!(recras && recras.gevonden);
  const remKaart = !recrasGevonden && ((kaart && kaart.reden==="te_veel_pogingen") || (recras && recras.reden==="te_veel_pogingen" && reserveringen.length===0));
  const kaartGevonden = (kaart && kaart.gevonden) || recrasGevonden;
  const recrasWeg = !!(recras && recras.reden==="recras_onbereikbaar");
  const nietsGevonden = gezocht && !fout && !remKaart && reserveringen.length===0 && !kaartGevonden;
  const ietsGevonden = gezocht && !fout && !remKaart && (reserveringen.length>0 || kaartGevonden);

  return (
    <div className="scrim" onClick={e=>e.target===e.currentTarget&&onSluit()}>
      <div className="blad"><div className="greep"/>
        <h3>Mijn UP</h3>
        <p className="bij">Al je reserveringen{PROFIEL.kaarttarieven?" en je kaartsaldo":""} bij elkaar. Zoek op naam en e-mailadres{PROFIEL.kaarttarieven?", of op de code van je kaart":""}. Geen inlog nodig.</p>

        {modus==="naam" && <>
          <label className="wl-lab" htmlFor="mijnupnaam">Naam</label>
          <input id="mijnupnaam" className="wl-in" value={naam} autoComplete="off" placeholder="Voor- en achternaam"
            onChange={e=>setNaam(e.target.value)}
            onKeyDown={e=>{ if(e.key==="Enter"){ e.preventDefault(); zoekOpNaam(); } }}/>
          <label className="wl-lab" htmlFor="mijnupemail">E-mailadres</label>
          <input id="mijnupemail" className="wl-in" type="email" value={email} autoComplete="off" placeholder="jij@voorbeeld.nl"
            onChange={e=>setEmail(e.target.value)}
            onKeyDown={e=>{ if(e.key==="Enter"){ e.preventDefault(); zoekOpNaam(); } }}/>
          <button className="wl-knop" onClick={zoekOpNaam} disabled={bezig||!naam.trim()||!email.trim()}>
            {bezig?"Zoeken…":"Zoek mijn gegevens"}</button>
          {PROFIEL.kaarttarieven &&
            <button type="button" className="terug" onClick={()=>wissel("code")}>
              Ik heb alleen de code van mijn kaart
            </button>}
        </>}

        {modus==="code" && <>
          <label className="wl-lab" htmlFor="mijnupcode">Code van je kaart of abonnement</label>
          <input id="mijnupcode" className="wl-in code-in" value={code} autoComplete="off" spellCheck="false"
            placeholder="ABCD-12345"
            onChange={e=>setCode(e.target.value)}
            onKeyDown={e=>{ if(e.key==="Enter"){ e.preventDefault(); zoekOpCode(); } }}/>
          <button className="wl-knop" onClick={zoekOpCode} disabled={bezig||!code.trim()}>
            {bezig?"Zoeken…":"Kaart bekijken"}</button>
          <div className="let">Met de code zien we alleen je kaartsaldo. Voor je reserveringen zoek je op naam en e-mailadres.</div>
          <button type="button" className="terug" onClick={()=>wissel("naam")}>
            Zoek op naam en e-mailadres
          </button>
        </>}

        {fout && <div className="fout">{fout}</div>}
        {gezocht && !fout && remKaart &&
          <div className="fout">Er is net te vaak gezocht. Wacht een paar minuten.</div>}
        {nietsGevonden && recrasWeg && <div className="fout">{RECRAS_OPZOEK_ONBEREIKBAAR}</div>}
        {nietsGevonden && modus==="naam" &&
          <div className="niets">We vinden niets bij deze naam en dit e-mailadres. Controleer allebei.</div>}
        {/* Aanvulling van 22 augustus 2026, zelfde reden en zelfde tekst als bij SaldoCheck: hier is
            de code het enige wat is ingetypt, dus de tweede zin hoort er ook hier bij. De knop "Zoek op
            naam en e-mailadres" staat al op dit scherm (zie modus==="code" hierboven), dus de belofte
            in de tekst klopt met wat er echt staat. De bel-link die hier ook stond is op 15 september
            2026 weggehaald (Ronde 11, besluit 9 van Roy, geen telefoonnummer). */}
        {nietsGevonden && modus==="code" && <>
          <div className="niets">Deze code kennen we niet. Kijk of je hem goed hebt overgetypt. Weet je de code niet meer? Zoek dan op naam en e-mailadres.</div>
        </>}

        {ietsGevonden && reserveringen.length>0 && <div>
          <div className="dagdeel" style={{marginTop:0}}>Reserveringen</div>
          {reserveringen.length>1 && <div className="wl-tekst">We vinden {reserveringen.length} reserveringen.</div>}
          {reserveringen.map(r=><GevondenReservering key={r.referentie} r={r}/>)}
        </div>}

        {ietsGevonden && kaartGevonden && <div>
          <div className="dagdeel">Kaartsaldo</div>
          <KaartSaldoResultaat k={kaart}/>
          <RecrasKaartRegels res={recras}/>
        </div>}

        {/* Afkeurpunt 1 van de anti-ai-poort, 22 augustus 2026: deze zin verscheen ook in code-modus,
            waar boeking_bij_naam_email niet wordt aangeroepen (zie zoekOpCode hierboven) en er dus
            nooit reserveringen bekend zijn. De zin beloofde dan iets wat het scherm niet weet. Nu
            voorwaardelijk op wat er echt getoond is: met reserveringen de zin over de status, zonder
            reserveringen (code-modus, of naam-modus waarbij alleen het kaartsaldo iets opleverde) de
            kortere zin die alleen het saldo noemt. Herzien op 22 augustus 2026, zelfde dag, tweede
            keer: boeking_bij_naam_email geeft nu zelf status/gaat_door/actieve_boekingen terug (zie
            de toelichting bij StandBlok hierboven), dus de zin "of een reservering nog doorgaat, weet
            de balie zeker" was voor het geval MET reserveringen niet meer waar: dit scherm weet het
            zelf. Voor het geval ZONDER reserveringen is er niets om die status over te tonen, dus
            die zin is daar geschrapt in plaats van herschreven. */}
        {ietsGevonden && reserveringen.length>0 &&
          <div className="let">Dit scherm toont de betaling van je reserveringen, je kaartsaldo en of een reservering nog doorgaat.</div>}
        {ietsGevonden && reserveringen.length===0 &&
          <div className="let">Dit scherm toont je kaartsaldo.</div>}

        <button className="terug" onClick={onSluit}>Sluiten</button>
      </div>
    </div>
  );
}

function Opzoeken({ onVerplaats }){
  // Naam plus e-mailadres is de STANDAARDroute, het reserveringsnummer de terugval. Precies andersom
  // dan bij de kaart, en met opzet: een reserveringsnummer heeft een gast eenmalig gekregen en kent
  // hij niet uit zijn hoofd, zijn eigen naam en e-mailadres wel.
  // De databasefunctie boeking_bij_naam_email eist beide velden en zegt nooit welk van de twee fout
  // was. Bewust geen automatisch zoeken tijdens het typen: elke poging telt mee tegen een rem.
  const [modus,setModus]=useState("naam");
  const [naam,setNaam]=useState(""); const [email,setEmail]=useState("");
  const [ref,setRef]=useState("");
  const [bezig,setBezig]=useState(false);
  // null = nog niet gezocht, [] = wel gezocht en niets gevonden.
  const [lijst,setLijst]=useState(null);
  const [fout,setFout]=useState(null);

  function wissel(nieuw){ setModus(nieuw); setFout(null); setLijst(null); }

  // Alleen zoek_boeking (modus nummer) geeft gaat_door terug, boeking_bij_naam_email (modus naam) niet.
  // Aan het eerste element aflezen in plaats van aan modus, want lijst blijft staan tot een nieuwe
  // zoekactie en volgt zo altijd de bron van de getoonde resultaten.
  const weetStand = lijst && lijst.length>0 && typeof lijst[0].gaat_door !== "undefined";

  async function zoekOpNaam(){
    if(!naam.trim() || !email.trim()) return;
    setBezig(true); setFout(null); setLijst(null);
    const r=await db.rpc("boeking_bij_naam_email",{p_naam:naam.trim(), p_email:email.trim()});
    setBezig(false);
    if(r.error){ setFout(foutTekst(r.error)); return; }
    const d=r.data||{gevonden:false};
    if(d.reden==="te_veel_pogingen"){
      setFout("Er is net te vaak gezocht. Wacht een paar minuten."); return; }
    setLijst(d.gevonden ? (d.reserveringen||[]) : []);
  }

  async function zoekOpNummer(){
    if(!ref.trim()) return;
    setBezig(true); setFout(null); setLijst(null);
    const r=await db.rpc("zoek_boeking",{p_referentie:ref.trim()});
    setBezig(false);
    if(r.error){ setFout(foutTekst(r.error)); return; }
    setLijst(r.data && r.data.gevonden ? [r.data] : []);
  }

  return (
    /* opzoek-blok toegevoegd, ronde 5, 24 augustus 2026: scoped klasse zodat de velden en labels
       in DIT blok naar Daniels vorm gaan (dikke navy rand, scherpe hoeken, hoofdletterlabels),
       zonder de kale label/input-regels aan te raken die ook door de Wachtlijst, MijnUp, Saldo
       checken en Verplaatsen-formulieren gebruikt worden. Zie stijl.css bij .opzoek-blok. */
    <div className="kaart opzoek-blok"><h2><Kop tekst="Heb je al gereserveerd"/></h2>

      {modus==="naam" && <>
        <div className="wl-tekst">Vul je naam in en het e-mailadres waarmee je hebt gereserveerd, dan zoeken we je reserveringen erbij. Ze moeten allebei kloppen.</div>
        <label className="wl-lab" htmlFor="zoeknaam">Naam</label>
        <input id="zoeknaam" className="wl-in" value={naam} autoComplete="off"
          placeholder="Voor- en achternaam"
          onChange={e=>setNaam(e.target.value)}
          onKeyDown={e=>e.key==="Enter"&&zoekOpNaam()}/>
        <label className="wl-lab" htmlFor="zoekemail">E-mailadres</label>
        <input id="zoekemail" className="wl-in" type="email" value={email} autoComplete="off"
          placeholder="jij@voorbeeld.nl"
          onChange={e=>setEmail(e.target.value)}
          onKeyDown={e=>e.key==="Enter"&&zoekOpNaam()}/>
        <button className="wl-knop" onClick={zoekOpNaam} disabled={bezig||!naam.trim()||!email.trim()}>
          {bezig?"Zoeken…":"Zoek mijn reserveringen"}</button>
        <button type="button" className="terug" onClick={()=>wissel("nummer")}>
          Ik heb mijn reserveringsnummer bij de hand.
        </button>
      </>}

      {modus==="nummer" && <>
        <div className="wl-tekst">Vul je reserveringsnummer in, dan zoeken we hem erbij. Hoofdletters en het streepje maken niet uit.</div>
        <label className="wl-lab">Reserveringsnummer</label>
        <div className="zoekrij">
          <input className="wl-in zoekveld" value={ref} onChange={e=>setRef(e.target.value)}
            placeholder="AB3K" onKeyDown={e=>e.key==="Enter"&&zoekOpNummer()}/>
          <button className="wl-knop" onClick={zoekOpNummer} disabled={bezig||!ref.trim()}>{bezig?"Zoeken…":"Zoeken"}</button>
        </div>
        <button type="button" className="terug" onClick={()=>wissel("naam")}>
          Zoek liever op naam en e-mailadres
        </button>
      </>}

      {fout && <div className="wl-fout">{fout}</div>}

      {lijst && lijst.length===0 && modus==="naam" &&
        <div className="niets">We vinden geen reservering bij deze naam en dit e-mailadres. Controleer allebei.</div>}
      {lijst && lijst.length===0 && modus==="nummer" &&
        <div className="niets">Geen reservering gevonden met dit nummer. Kijk het na in je bevestiging.</div>}

      {lijst && lijst.length>0 && <div>
        {lijst.length>1 && <div className="wl-tekst">We vinden {lijst.length} reserveringen.</div>}
        {/* onVerplaats gaat alleen mee in de reserveringsnummer-route: alleen zoek_boeking geeft
            gaat_door en groep terug, wat GevondenReservering nodig heeft om de knop terecht te tonen
            (zie de toelichting daar). In de naam-route zou de knop op een gok verschijnen. */}
        {lijst.map(r=><GevondenReservering key={r.referentie} r={r} onVerplaats={modus==="nummer"?onVerplaats:undefined} onTegoed={onVerplaats}/>)}
        {/* Hier stond tot 21 augustus 2026 achteraan: "Ben je kaarthouder, log dan in, dan zie en
            annuleer je alles zelf bij Mijn reserveringen." Die zin is weg. De inlogroute voor
            kaarthouders is er niet meer (besluit 6 van Roy, 14 augustus 2026), dus het scherm bood
            een weg aan die niet bestaat. De rest van de regel blijft: die zegt wat dit scherm wel en
            niet vertelt en waar je heen moet om te wijzigen. */}
        {/* Herzien op 22 augustus 2026, na het aansluiten van zoek_boeking op status/gaat_door. Hier
            stond tot dan onvoorwaardelijk "Twijfel je of je reservering doorgaat? Bel ons op "+TELEFOON+".": dat
            was een noodverband omdat het systeem het toen niet wist. Herzien op 22 augustus 2026,
            zelfde dag, tweede keer: toen dit voor het eerst herzien werd, gaf alleen zoek_boeking
            (het reserveringsnummer) de drie velden terug en boeking_bij_naam_email (naam plus
            e-mailadres) niet. Dat is nu ook achterhaald: boeking_bij_naam_email geeft ze sinds
            vandaag ook (staging, md5(prosrc) e49340a45dca0787a280f74b80e97e76, lengte 5012), dus
            weetStand hieronder staat in de praktijk voor beide routes op true. De voorwaarde zelf
            (aflezen aan het eerste element in plaats van aan de modus) blijft bewust staan als
            vangnet: een route die weer minder velden teruggeeft, laat dit scherm degraderen in
            plaats van iets beweren wat het niet weet. */}
        {weetStand
          ? <div className="let">Dit scherm toont nu de betaling en of je reservering nog doorgaat.</div>
          : <div className="let">Dit scherm toont de betaling. Twijfel je of je reservering doorgaat? Bel ons op {TELEFOON}.</div>}
      </div>}
    </div>
  );
}

// Herkent het antwoord dat PostgREST geeft als een aangeroepen functie niet bestaat (foutcode
// PGRST202, "Could not find the function ... in the schema cache"). VERPLAATS_FUNCTIE hieronder
// bestaat op 22 augustus 2026 nog NIET in de database: de bouwopdracht ligt klaar voor Enterprise
// (BOUWOPDRACHT-ENTERPRISE-verplaatsen-en-teller-20260822.md, hoofdstuk 2.1) maar is niet gebouwd.
// Dit scherm roept de functie toch al aan, zodat het werkt zodra hij er is, zonder dat de gast
// ondertussen een blanco scherm of een databasecode als "PGRST202" te zien krijgt.
function functieOntbreektFout(e){
  if(!e) return false;
  if(e.code === "PGRST202") return true;
  const m = ((e.message||"")+" "+(e.details||"")+" "+(e.hint||""));
  return m.indexOf("Could not find the function") >= 0;
}
// De naam van de nog te bouwen databasefunctie. Enterprise mag zelf een andere naam kiezen (de
// opdracht zegt letterlijk "kies zelf een passende naam"); als dat gebeurt is dit de ENE plek waar
// dat hersteld moet worden.
const VERPLAATS_FUNCTIE = "verplaats_boeking_gast";
// Ingevulde keuze van onze kant (Transparant Samenwerken), GEEN besluit van Daniel of Rene: zie
// hoofdstuk 2.2 van hetzelfde document. Letterlijk daaruit: "In het gesprek is voorgesteld: 'je kan
// op de dag zelf niet meer verplaatsen, dat is toch slecht weer' ... Er kwam geen tegenspraak op,
// maar ook geen hard besluit." De sleutelnaam hieronder is dezelfde die de databasekant gaat
// gebruiken in de tabel `instellingen`, zodat een wijziging daar dit scherm en de database in een
// keer allebei omzet. Bestaat de rij nog niet (Enterprise heeft hem nog niet aangemaakt), dan geldt
// de standaardwaarde uit hoofdstuk 2.2 hieronder: verplaatsen op de dag zelf mag dan niet.
const VERPLAATSEN_OP_DAG_ZELF_SLEUTEL = "verplaatsen_op_dag_zelf_toegestaan";
const VERPLAATSEN_OP_DAG_ZELF_STANDAARD = false;

// Punt 8 van het plan van 21 augustus 2026 (bouwronde 22 augustus, blok 2 punt 2): een gast
// verplaatst zijn eigen reservering zelf naar een andere datum en tijd, zonder inlog, met alleen
// zijn reserveringsnummer. Bereikbaar vanaf de reserveringsnummer-route in Opzoeken (zie de knop in
// GevondenReservering) en, zonder dat er al een bevestigingsmail met een link bestaat, ALVAST met een
// querystring-ingang (?verplaats=REFERENTIE) zodat een latere link op ditzelfde scherm uitkomt in
// plaats van dat er straks een tweede scherm gebouwd moet worden. Zie App() voor die ingang.
// ---- TEGOED-PATCH 20260928 (besluit 50) ------------------------------------------------------
// De gastroutes kennen het interne boeking-id niet (zoek_boeking geeft het niet). Daarom gaat de mail
// op reserveringsnummer; stuur-bevestiging zoekt het adres zelf op in de database, nooit uit dit verzoek.
async function stuurMailRef(referentie, soort){
  try{ await fetch(URL_+"/functions/v1/stuur-bevestiging",{method:"POST",
    headers:{"Content-Type":"application/json","Authorization":"Bearer "+KEY_},
    body:JSON.stringify({referentie:referentie, soort:soort})}); }catch(e){}
}
// Welke mail hoort bij de uitkomst van verhinderd melden of spelers afmelden:
//   tegoed                         -> tegoedmail, op het (eerste) tegoednummer
//   hele reservering, geen tegoed  -> annuleringsmail (strip terug, of alleen vrijgegeven)
//   losse spelers, geen tegoed     -> mail "spelers afgemeld" (strippen terug, voor hoeveel spelers de reservering nog staat); GEMENGD-PATCH 20260930, Roy
function mailNaAfmelden(j, ref){
  if(!j || !j.ok) return;
  const nrs = (j.tegoed_nummers && j.tegoed_nummers.length) ? j.tegoed_nummers : null;
  if(j.uitkomst==="tegoed"){ stuurMailRef(nrs ? nrs[0] : (j.referentie||ref), "tegoed"); return; }
  const losseSpelers = (j.afgemeld!==undefined) && !j.hele_groep;
  stuurMailRef(j.referentie||ref, losseSpelers ? "afgemeld" : "annulering");
}
// HEROPEN-PATCH 20260930: kreeg een kaart strippen terug en stond hij bij Recras dicht (leeggeraakt), dan gaat hij weer open. De server bepaalt zelf welke
// kaart open moet (alleen een kaart die wij dichtzetten en waar ons eigen saldo weer boven nul is); dit verzoek levert alleen het reserveringsnummer.
// Lukt het niet, dan zegt de gast dat eerlijk en krijgt de balie een regel in taaklog. Geeft een tekst als er iets is om te melden, anders null.
const HEROPEN_MELDING = "Let op: het vrijgeven van je kaart lukte nog niet. Bel ons op "+TELEFOON+".";
async function heropenKaart(referentie){
  try{ const r = await fetch(URL_+"/functions/v1/recras-kaart-heropen",{method:"POST",
    headers:{"Content-Type":"application/json","Authorization":"Bearer "+KEY_},
    body:JSON.stringify({referentie:referentie})}); return await r.json(); }catch(e){ return null; }
}
async function heropenNaTerug(j, ref){
  if(!j || !((j.strippen_terug>0) || (j.recras_strippen_terug>0))) return null;
  const nr = j.referentie || ref; if(!nr) return null;
  let h = await heropenKaart(nr);
  if(!h) h = await heropenKaart(nr);
  // status "balie": de kaart staat dicht en heeft al vijf mislukte pogingen gehad; de gast krijgt dezelfde melding (bel de balie), er komt geen nieuwe poging.
  if(h && h.status!=="balie" && (h.status==="niets_te_doen" || h.status==="uit" || (h.status==="ok" && !h.mislukt))) return null;
  return HEROPEN_MELDING;
}
const mailGeldig = s => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test((s||"").trim());
// TEGOED-PATCH v2 20260929: een tegoeddatum altijd met jaartal ("woensdag 29 september" leest als vandaag).
const metJaar = d => d ? (lang(d) + " " + String(d).slice(0,4)) : "";
// TEGOED-PATCH v2 20260929: de melding staat direct boven de knop die de gast indrukte, en scrolt erheen.
function FoutMelding({ tekst }){
  const plek = React.useRef(null);
  useEffect(()=>{ if(tekst && plek.current && plek.current.scrollIntoView) plek.current.scrollIntoView({block:"center", behavior:"smooth"}); },[tekst]);
  return tekst ? <div ref={plek} className="wl-fout">{tekst}</div> : null;
}

// Verhinderd melden (hele reservering) of losse spelers afmelden, met het e-mailadres als tweede
// sleutel. Eerst vraagt de database wat er gaat gebeuren (p_bevestigd false), de gast bevestigt, dan
// pas gebeurt het. Alle zinnen die de gast leest komen uit de database (goedgekeurd, UP-1069/1072/1074).
function VerhinderdBlok({ refNr, email, onStap }){
  const [stap,setStap]=useState(null);          // null | "spelers" | "vraag" | "klaar"
  const [soort,setSoort]=useState(null);        // "alles" | "spelers"
  const [vraag,setVraag]=useState(null);
  const [lijst,setLijst]=useState([]);
  const [keuze,setKeuze]=useState([]);
  const [bezig,setBezig]=useState(false);
  const [fout,setFout]=useState(null);
  const [tekst,setTekst]=useState(null);
  const ok = mailGeldig(email);
  useEffect(()=>{ if(onStap) onStap(stap!==null); },[stap]);
  async function roep(fn, args){
    setBezig(true); setFout(null);
    const r=await db.rpc(fn,args); setBezig(false);
    if(r.error){ setFout(foutTekst(r.error)); return null; }
    if(!r.data || r.data.ok===false){ setFout((r.data&&r.data.tekst)||("Dit lukt nu niet. Bel ons op "+TELEFOON+".")); return null; }
    return r.data;
  }
  async function vraagAlles(){
    const j=await roep("meld_verhinderd_gast",{p_referentie:refNr, p_email:email.trim(), p_bevestigd:false});
    if(j){ setSoort("alles"); setVraag(j.vraag); setStap("vraag"); }
  }
  async function laadSpelers(){
    const j=await roep("lijst_spelers_gast",{p_referentie:refNr, p_email:email.trim()});
    if(j){ setLijst(j.spelers||[]); setKeuze([]); setStap("spelers"); }
  }
  async function vraagSpelers(){
    const j=await roep("meld_spelers_af_gast",{p_referentie:refNr, p_email:email.trim(), p_deelnemer_ids:keuze, p_bevestigd:false});
    if(j){ setSoort("spelers"); setVraag(j.vraag); setStap("vraag"); }
  }
  async function bevestig(){
    const j = soort==="alles"
      ? await roep("meld_verhinderd_gast",{p_referentie:refNr, p_email:email.trim(), p_bevestigd:true})
      : await roep("meld_spelers_af_gast",{p_referentie:refNr, p_email:email.trim(), p_deelnemer_ids:keuze, p_bevestigd:true});
    if(!j) return;
    setTekst(j.tekst); setStap("klaar");
    haalUitAdres("verplaats"); haalUitAdres("dag");
    mailNaAfmelden(j, refNr);
    const heropenTekst = await heropenNaTerug(j, refNr);
    if(heropenTekst) setTekst(t=>(t||"")+" "+heropenTekst);
  }
  function wissel(id){ setKeuze(k => k.indexOf(id)>=0 ? k.filter(x=>x!==id) : k.concat([id])); }
  return (
    <div style={{marginTop:18}}>
      {stap===null && <>
        <FoutMelding tekst={fout}/>
        <button type="button" className="wl-knop nacht" onClick={vraagAlles} disabled={bezig||!ok}>{bezig?"Bezig…":"Ik ben verhinderd"}</button>
        <button type="button" className="wl-knop rood" style={{marginTop:8}} onClick={laadSpelers} disabled={bezig||!ok}>Spelers afmelden</button>
      </>}
      {stap==="spelers" && <>
        <div className="wl-lab">Wie meldt zich af?</div>
        {lijst.map(s=>(
          <label key={s.deelnemer_id} className="akkoordregel">
            <input type="checkbox" checked={keuze.indexOf(s.deelnemer_id)>=0} onChange={()=>wissel(s.deelnemer_id)}/>
            <span>{s.label}</span>
          </label>))}
        <FoutMelding tekst={fout}/>
        <button type="button" className="wl-knop vol" onClick={vraagSpelers} disabled={bezig||keuze.length===0}>{bezig?"Bezig…":"Afmelden"}</button>
        <button type="button" className="terug" onClick={()=>{ setStap(null); setFout(null); }}>Terug</button>
      </>}
      {stap==="vraag" && <>
        <div className="let">{vraag}</div>
        <FoutMelding tekst={fout}/>
        <button type="button" className="wl-knop vol" onClick={bevestig} disabled={bezig}>{bezig?"Bezig…":"Ja, bevestigen"}</button>
        <button type="button" className="terug" onClick={()=>{ setStap(null); setVraag(null); setFout(null); }}>Nee, terug</button>
      </>}
      {stap==="klaar" && <div className="let groen">{tekst}</div>}
    </div>
  );
}

// Een tegoed inwisselen voor een nieuwe starttijd. Ingang: ?tegoed=NUMMER (knop in de tegoedmail),
// of de knop in het verplaatsscherm als het nummer al een tegoed is. Zelfde kiezer als Verplaatsen.
function TegoedInwisselen({ referentieVooraf, onSluit, maxDag }){
  const [ref,setRef]=useState(referentieVooraf||"");
  const [email,setEmail]=useState("");
  const [bezig,setBezig]=useState(false);
  const [fout,setFout]=useState(null);
  const [boeking,setBoeking]=useState(null);
  const [spelers,setSpelers]=useState(null);
  const [baan,setBaan]=useState(null);
  const [datum,setDatum]=useState(null);
  const [sloten,setSloten]=useState([]);
  const [ladenSloten,setLadenSloten]=useState(false);
  const [gekozen,setGekozen]=useState(null);
  const [klaar,setKlaar]=useState(null);
  useEffect(()=>{ function opToets(e){ if(e.key==="Escape") onSluit(); }
    window.addEventListener("keydown", opToets); return ()=>window.removeEventListener("keydown", opToets); },[]);
  useEffect(()=>{ db.from("banen").select("*").eq("actief",true).order("naam").then(r=>{ if(r.data&&r.data.length) setBaan(r.data[0]); }); },[]);
  async function zoek(){
    if(!ref.trim()) return;
    setBezig(true); setFout(null); setBoeking(null); setKlaar(null); setGekozen(null);
    const r=await db.rpc("zoek_boeking",{p_referentie:ref.trim()});
    setBezig(false);
    if(r.error){ setFout(foutTekst(r.error)); return; }
    if(!r.data || !r.data.gevonden){ setBoeking({gevonden:false}); return; }
    setBoeking(r.data); setSpelers(r.data.tegoed_spelers||1);
    // VOORUIT-BOEKEN 20261001: ligt de speeldag van de reservering voorbij het boekingsvenster, dan begint de kiezer op de laatste dag die nog mag.
    const dag0 = r.data.datum && String(r.data.datum) >= iso(new Date()) ? String(r.data.datum) : iso(new Date());
    const dag = (maxDag && dag0 > maxDag) ? maxDag : dag0;
    setDatum(dag);
  }
  useEffect(()=>{ if(referentieVooraf) zoek(); },[]); // eslint-disable-line
  const open = !!(boeking && boeking.gevonden && boeking.tegoed_status==="open");
  useEffect(()=>{ if(!baan || !datum || !open) return; let weg=false; setLadenSloten(true);
    (async()=>{
      const r=await db.from("beschikbaarheid").select("*").eq("baan_id",baan.id).eq("datum",datum).eq("status","open").eq("sport",SPORT).order("starttijd");
      if(weg) return; setLadenSloten(false);
      const nu=new Date(); const vandaag=datum===iso(nu); const klok=nu.toTimeString().slice(0,8);
      setSloten((r.data||[]).filter(s=>s.vrij>=(spelers||1) && (!vandaag||s.starttijd>klok)));
    })();
    return ()=>{ weg=true; };
  },[baan,datum,open,spelers]);
  async function wissel(){
    if(!gekozen) return;
    setBezig(true); setFout(null);
    const r=await db.rpc("wissel_tegoed_in_gast",{p_referentie:ref.trim(), p_email:email.trim(), p_nieuw_tijdslot_id:gekozen.id, p_spelers:spelers});
    setBezig(false);
    if(r.error){ setFout(foutTekst(r.error)); return; }
    if(!r.data || r.data.ok===false){ setFout((r.data&&r.data.tekst)||("Dit lukt nu niet. Bel ons op "+TELEFOON+".")); return; }
    setKlaar(r.data);
    haalUitAdres("tegoed"); haalUitAdres("dag");
    stuurMailRef(r.data.referentie||ref.trim().toUpperCase(), "ingewisseld");
  }
  const ochtend=sloten.filter(s=>s.starttijd<"12:00:00"); const middag=sloten.filter(s=>s.starttijd>="12:00:00");
  function tegel(s){ return (
    <button key={s.id} type="button" className={"tijd"+(gekozen&&gekozen.id===s.id?" aan":"")} onClick={()=>setGekozen(s)}>
      <div className="t">{s.starttijd.slice(0,5)}</div><div className="v">{s.vrij} vrij</div></button>); }
  const aantalKeuzes=[]; for(let i=1;i<=((boeking&&boeking.tegoed_spelers)||1);i++) aantalKeuzes.push(i);
  return (
    <div className="scrim" onClick={e=>e.target===e.currentTarget&&onSluit()}>
      <div className="blad"><div className="greep"/>
        <h3>Kies een nieuwe starttijd</h3>
        {!referentieVooraf && !boeking && <>
          <label className="wl-lab" htmlFor="tegoedref">Reserveringsnummer</label>
          <input id="tegoedref" className="wl-in code-in" value={ref} autoComplete="off" spellCheck="false"
            placeholder="AB3K" onChange={e=>setRef(e.target.value)}
            onKeyDown={e=>{ if(e.key==="Enter"){ e.preventDefault(); zoek(); } }}/>
          <button className="wl-knop" onClick={zoek} disabled={bezig||!ref.trim()}>{bezig?"Zoeken…":"Zoeken"}</button>
        </>}
        {bezig && !boeking && <div className="wl-tekst">Zoeken…</div>}
        {!open && <FoutMelding tekst={fout}/>}
        {boeking && !boeking.gevonden &&
          <div className="niets">Geen reservering gevonden met dit nummer. Kijk het na in je bevestiging.</div>}
        {boeking && boeking.gevonden && !open && !klaar &&
          <div className="let">Op dit reserveringsnummer staat geen open tegoed. Bel ons op {TELEFOON} als dat niet klopt.</div>}
        {open && !klaar && <>
          <div className="code">{boeking.referentie}</div>
          <div className="details">
            <div><span>Tegoed geldig tot en met</span><span>{metJaar(boeking.tegoed_geldig_tot)}</span></div>
            <div><span>Spelers</span><span>{boeking.tegoed_spelers}</span></div>
            {boeking.paar && <div><span>Starttijden</span><span>2, dus 2x 9 holes</span></div>}
          </div>
          <label className="wl-lab" htmlFor="tegoedmail">E-mailadres van de reservering</label>
          <input id="tegoedmail" className="wl-in" type="email" value={email} autoComplete="email" onChange={e=>setEmail(e.target.value)}/>
          {aantalKeuzes.length>1 && <>
            <label className="wl-lab" htmlFor="tegoedspelers">Met hoeveel spelers?</label>
            <select id="tegoedspelers" className="wl-in" value={spelers||1} onChange={e=>{ setSpelers(parseInt(e.target.value,10)); setGekozen(null); }}>
              {aantalKeuzes.map(n=><option key={n} value={n}>{n}</option>)}
            </select>
            <div className="let">Met meer spelers? Boek de extra spelers apart, of bel de balie.</div>
          </>}
          {boeking.paar &&
            <div className="let">Dit zijn twee starttijden. Kies een nieuwe eerste starttijd; de tweede negen schuift automatisch mee. Past die niet meer, dan zeggen we dat en verschuift er niets.</div>}
          <label className="wl-lab" htmlFor="tegoeddag">Nieuwe datum</label>
          <input id="tegoeddag" className="wl-in" type="date" value={datum||""} min={iso(new Date())} max={maxDag||undefined}
            onChange={e=>{ const v=e.target.value; setDatum(maxDag && v>maxDag ? maxDag : v); setGekozen(null); }}/>
          {ladenSloten && <div className="wl-tekst">Tijden ophalen…</div>}
          {!ladenSloten && sloten.length===0 &&
            <div className="niets">Geen vrije {PROFIEL.naam.toLowerCase()}-tijden op deze dag. Kies een andere datum.</div>}
          {!ladenSloten && ochtend.length>0 && <div><div className="dagdeel">Ochtend</div><div className="raster">{ochtend.map(tegel)}</div></div>}
          {!ladenSloten && middag.length>0 && <div><div className="dagdeel">Middag</div><div className="raster">{middag.map(tegel)}</div></div>}
          {gekozen && <FoutMelding tekst={fout}/>}
          {gekozen &&
            <button className="wl-knop vol" onClick={wissel} disabled={bezig||!mailGeldig(email)}>{bezig?"Bezig…":"Kies een nieuwe starttijd"}</button>}
        </>}
        {klaar && <div className="let groen">{klaar.tekst}</div>}
        <button className="terug" onClick={onSluit}>Sluiten</button>
      </div>
    </div>
  );
}
// ---- einde TEGOED-PATCH 20260928 -----------------------------------------------------------------

function Verplaatsen({ referentieVooraf, onSluit, onTegoed, maxDag }){
  const [ref,setRef]=useState(referentieVooraf||"");
  const [bezig,setBezig]=useState(false);
  const [fout,setFout]=useState(null);
  const [boeking,setBoeking]=useState(null);           // resultaat van zoek_boeking, of {gevonden:false}
  const [dagZelfMag,setDagZelfMag]=useState(VERPLAATSEN_OP_DAG_ZELF_STANDAARD);
  const [baan,setBaan]=useState(null);
  const [datum,setDatum]=useState(null);
  const [sloten,setSloten]=useState([]);
  const [ladenSloten,setLadenSloten]=useState(false);
  const [gekozen,setGekozen]=useState(null);
  const [verplaatsBezig,setVerplaatsBezig]=useState(false);
  const [ontbreekt,setOntbreekt]=useState(false);
  const [klaar,setKlaar]=useState(null);                // { vanDatum, vanTijd, naarDatum, naarTijd }
  // TEGOED-PATCH 20260928: nieuwe verplaatsvorm, het e-mailadres van de reservering is de tweede sleutel.
  const [email,setEmail]=useState("");
  // TEGOED-PATCH v2 20260929: zolang de gast verhinderd meldt of spelers afmeldt, geen tijdenlijst.
  const [afmeldStap,setAfmeldStap]=useState(false);

  // Zelfde toetsenbordondersteuning als BevestigStap (de bevestigingsstap voor het betalen, bouwronde
  // van eerder vandaag): Escape sluit het venster.
  useEffect(()=>{
    function opToets(e){ if(e.key==="Escape") onSluit(); }
    window.addEventListener("keydown", opToets);
    return ()=>window.removeEventListener("keydown", opToets);
  },[]);

  useEffect(()=>{
    db.from("instellingen").select("waarde").eq("sleutel",VERPLAATSEN_OP_DAG_ZELF_SLEUTEL).single()
      .then(r=>{ if(r.data) setDagZelfMag(r.data.waarde==="true"); });
    db.from("banen").select("*").eq("actief",true).order("naam").then(r=>{ if(r.data&&r.data.length) setBaan(r.data[0]); });
  },[]);

  async function zoek(){
    if(!ref.trim()) return;
    setBezig(true); setFout(null); setBoeking(null); setKlaar(null);
    const r=await db.rpc("zoek_boeking",{p_referentie:ref.trim()});
    setBezig(false);
    if(r.error){ setFout(foutTekst(r.error)); return; }
    if(!r.data || !r.data.gevonden){ setBoeking({gevonden:false}); return; }
    setBoeking(r.data);
    // Startdag voor de kiezer. De "dag zelf"-grens hierboven gaat over de HUIDIGE starttijd van de
    // boeking (mag je vandaag nog verplaatsen), niet over welke dag je als nieuwe tijd mag kiezen.
    // Verplaatsen NAAR vandaag mag altijd, mits de tijd nog niet voorbij is (hoofdstuk 2.1.4,
    // "vóór aanvang" geldt voor het doelslot net zo goed als voor de bron).
    // Sinds 18 september begint de kiezer op de dag van de reservering zelf en niet op vandaag:
    // wie verplaatst wil meestal een andere tijd rond diezelfde dag, en dan is vandaag een vreemd
    // startpunt. Ligt die dag in het verleden, dan blijft het vandaag.
    // VOORUIT-BOEKEN 20261001: staat de reservering voorbij het boekingsvenster, dan begint de kiezer op de laatste dag die nog mag.
    const eigenDag0 = r.data.datum && String(r.data.datum) >= iso(new Date()) ? String(r.data.datum) : iso(new Date());
    const eigenDag = (maxDag && eigenDag0 > maxDag) ? maxDag : eigenDag0;
    setDatum(eigenDag);
  }
  // Automatisch zoeken als het scherm met een referentie erin geopend wordt (vanaf de knop in
  // GevondenReservering, of vanaf ?verplaats= in het adres). Alleen bij binnenkomst, niet bij elke
  // letter die de gast daarna zelf intypt.
  useEffect(()=>{ if(referentieVooraf) zoek(); },[]); // eslint-disable-line

  useEffect(()=>{ if(!baan || !datum) return; let weg=false; setLadenSloten(true);
    (async()=>{
      const r=await db.from("beschikbaarheid").select("*").eq("baan_id",baan.id).eq("datum",datum).eq("status","open").eq("sport",SPORT).order("starttijd");
      if(weg) return; setLadenSloten(false);
      const nu=new Date(); const vandaag=datum===iso(nu); const klok=nu.toTimeString().slice(0,8);
      setSloten((r.data||[]).filter(s=>s.vrij>0 && (!vandaag||s.starttijd>klok)));
    })();
    return ()=>{ weg=true; };
  },[baan,datum]);

  async function verplaats(){
    if(!gekozen) return;
    setVerplaatsBezig(true); setFout(null); setOntbreekt(false);
    const r=await db.rpc(VERPLAATS_FUNCTIE,{p_referentie:ref.trim(), p_email:email.trim(), p_nieuw_tijdslot_id:gekozen.id});
    setVerplaatsBezig(false);
    if(r.error){
      if(functieOntbreektFout(r.error)){
        console.warn("[upgolf] "+VERPLAATS_FUNCTIE+" bestaat nog niet in de database: "+(r.error.message||""));
        setOntbreekt(true); return;
      }
      setFout(foutTekst(r.error)); return;
    }
    // TEGOED-PATCH 20260928: de nieuwe vorm weigert met ok false en een tekst voor de gast.
    if(r.data && r.data.ok===false){ setFout(r.data.tekst||("Verplaatsen lukt nu niet. Bel ons op "+TELEFOON+".")); return; }
    // Punt A (18 september 2026): de functie geeft bij een paar allebei de nieuwe starttijden terug,
    // plus het aantal minuten ertussen. Die namen we niet over, dus de gast zag alleen de eerste en
    // dacht dat de tweede ronde was blijven staan. De database klopte; het scherm loog.
    // d kan leeg zijn als een oudere databaseversie draait; dan valt alles terug op wat we al hadden.
    const d = r.data || {};
    setKlaar({
      vanDatum: boeking.datum,
      vanTijd: String(boeking.starttijd).slice(0,5),
      naarDatum: d.nieuwe_datum || datum,
      naarTijd: String(d.nieuwe_starttijd || gekozen.starttijd).slice(0,5),
      paar: !!d.paar,
      vanTijd2: d.oude_starttijd_tweede ? String(d.oude_starttijd_tweede).slice(0,5) : null,
      naarTijd2: d.tweede_starttijd ? String(d.tweede_starttijd).slice(0,5) : null,
      minuten: d.minuten_tussen || null,
      referentie: d.referentie || ref.trim().toUpperCase(),
      referentie2: d.tweede_referentie || null,
    });
    // Uit de test van 18 september: na een geslaagde verplaatsing bleef ?verplaats= in de adresbalk
    // staan. Ververste de gast de pagina, dan opende het verplaatsscherm opnieuw met dezelfde
    // reservering en leek het alsof er niets gebeurd was, terwijl het wel gelukt was. Zodra het
    // gelukt is hoort die parameter weg. De &dag= is hier ook weg: die stond op de dag waarop de
    // site geopend werd, niet op de dag van de boeking, en klopte dus juist niet meer.
    haalUitAdres("verplaats");
    haalUitAdres("dag");
    // Punt B: de mail met de nieuwe tijd. Bewust NA setKlaar, zodat het scherm niet op de mail
    // wacht: het verplaatsen is op dit moment al gelukt en staat in de database.
    stuurVerplaatsMail(
      d.referentie || ref.trim().toUpperCase(),
      boeking.datum,
      String(boeking.starttijd).slice(0,5),
      d.oude_starttijd_tweede ? String(d.oude_starttijd_tweede).slice(0,5) : null);
  }

  // Mag deze boeking überhaupt verplaatst worden? Twee harde grenzen, allebei client-side al
  // gecontroleerd zodat de gast niet eerst een nieuwe tijd hoeft te kiezen om dan pas te horen dat
  // het sowieso niet kan. De database controleert dit straks nog een keer (hoofdstuk 2.1.4 voor
  // "vóór aanvang", 2.2 voor de dag-zelf-grens); dit is de vriendelijke variant ervoor, net als
  // stripTekort en codeTekort dat elders in dit bestand ook al zijn.
  const nu = new Date();
  const bronMoment = boeking && boeking.gevonden ? new Date(boeking.datum+"T"+boeking.starttijd) : null;
  const bronIsVandaag = boeking && boeking.gevonden && boeking.datum===iso(nu);
  const voorbij = bronMoment && bronMoment < nu;
  const dagZelfGeblokkeerd = bronIsVandaag && !dagZelfMag;
  // Bevinding 1: een paar telt hier NIET als groep; dat wordt gewoon verplaatst.
  const echteGroep = !!(boeking && boeking.groep && !boeking.paar);
  const magKiezen = boeking && boeking.gevonden && boeking.gaat_door && !echteGroep && !voorbij && !dagZelfGeblokkeerd;

  const ochtend=sloten.filter(s=>s.starttijd<"12:00:00"); const middag=sloten.filter(s=>s.starttijd>="12:00:00");
  function tegel(s){ return (
    <button key={s.id} type="button" className={"tijd"+(gekozen&&gekozen.id===s.id?" aan":"")} onClick={()=>setGekozen(s)}>
      <div className="t">{s.starttijd.slice(0,5)}</div><div className="v">{s.vrij} vrij</div></button>); }

  return (
    <div className="scrim" onClick={e=>e.target===e.currentTarget&&onSluit()}>
      <div className="blad"><div className="greep"/>
        <h3>Verplaatsen</h3>

        {!referentieVooraf && !boeking && <>
          <p className="bij">Vul je reserveringsnummer in, dan zoeken we je reservering erbij.</p>
          <label className="wl-lab" htmlFor="verplaatsref">Reserveringsnummer</label>
          <input id="verplaatsref" className="wl-in code-in" value={ref} autoComplete="off" spellCheck="false"
            placeholder="AB3K" onChange={e=>setRef(e.target.value)}
            onKeyDown={e=>{ if(e.key==="Enter"){ e.preventDefault(); zoek(); } }}/>
          <button className="wl-knop" onClick={zoek} disabled={bezig||!ref.trim()}>{bezig?"Zoeken…":"Zoeken"}</button>
        </>}

        {bezig && !boeking && <div className="wl-tekst">Zoeken…</div>}
        {/* TEGOED-PATCH v2 20260929: met een boeking staat de melding bij de knop, niet hier. */}
        {fout && !boeking && <div className="wl-fout">{fout}</div>}

        {boeking && !boeking.gevonden &&
          <div className="niets">Geen reservering gevonden met dit nummer. Kijk het na in je bevestiging.</div>}

        {boeking && boeking.gevonden && !klaar && <>
          <GevondenReservering r={boeking} zonderTegoed/>

          {!boeking.gaat_door && boeking.tegoed_status!=="open" &&
            <div className="let">Deze reservering gaat niet meer door, dus er is niets om te verplaatsen.</div>}
          {/* TEGOED-PATCH 20260928: een open tegoed wissel je in voor een nieuwe starttijd. */}
          {!boeking.gaat_door && boeking.tegoed_status==="open" && <>
            <div className="let">Dit reserveringsnummer is een tegoed tot en met {metJaar(boeking.tegoed_geldig_tot)}.</div>
            {onTegoed && <button type="button" className="wl-knop" onClick={()=>onTegoed(ref.trim())}>Kies een nieuwe starttijd</button>}
          </>}
          {boeking.gaat_door && !voorbij && <>
            <label className="wl-lab" htmlFor="verplaatsmail">E-mailadres van de reservering</label>
            <input id="verplaatsmail" className="wl-in" type="email" value={email} autoComplete="email" onChange={e=>setEmail(e.target.value)}/>
            <div className="let">Het e-mailadres waarop je de bevestiging kreeg.</div>
          </>}

          {boeking.gaat_door && echteGroep &&
            <div className="let">Deze boeking heeft meerdere starttijden na elkaar. Wil je deze starttijden verplaatsen? Bel ons op {TELEFOON}.</div>}

          {boeking.gaat_door && boeking.paar && !voorbij && !dagZelfGeblokkeerd &&
            <div className="let">Dit zijn twee starttijden. Kies een nieuwe eerste starttijd; de tweede negen schuift automatisch mee. Past die niet meer, dan zeggen we dat en verschuift er niets.</div>}

          {boeking.gaat_door && !echteGroep && voorbij &&
            <div className="let">Deze starttijd is al voorbij. Verplaatsen kan dan niet meer.</div>}

          {boeking.gaat_door && !echteGroep && !voorbij && dagZelfGeblokkeerd &&
            <div className="let">Deze reservering staat voor vandaag. Op de dag zelf kun je niet zelf verplaatsen.</div>}

          {/* GEMENGD-PATCH 20260930 (Roy): de knoppen bovenaan, daaronder "of kies een nieuwe tijd" en de tijden. */}
          {boeking.gaat_door && !voorbij && <VerhinderdBlok refNr={ref.trim()} email={email} onStap={setAfmeldStap}/>}
          {magKiezen && !afmeldStap && <>
            <div className="of-kies">of kies een nieuwe tijd</div>
            <p className="bij" style={{marginTop:16}}>Kies een nieuwe datum en tijd. Dit scherm toont alleen {PROFIEL.naam.toLowerCase()}-tijden.{footgolfZichtbaar() && <> Was je boeking voor de andere sport, verplaats hem dan via <a href={PROFIEL.andereIngang.href}>deze pagina</a>.</>}</p>
            <label className="wl-lab" htmlFor="verplaatsdag">Nieuwe datum</label>
            <input id="verplaatsdag" className="wl-in" type="date" value={datum||""} min={iso(new Date())} max={maxDag||undefined}
              onChange={e=>{ const v=e.target.value; setDatum(maxDag && v>maxDag ? maxDag : v); setGekozen(null); }}/>

            {ladenSloten && <div className="wl-tekst">Tijden ophalen…</div>}
            {!ladenSloten && sloten.length===0 &&
              <div className="niets">Geen vrije {PROFIEL.naam.toLowerCase()}-tijden op deze dag. Kies een andere datum.</div>}
            {!ladenSloten && ochtend.length>0 && <div><div className="dagdeel">Ochtend</div><div className="raster">{ochtend.map(tegel)}</div></div>}
            {!ladenSloten && middag.length>0 && <div><div className="dagdeel">Middag</div><div className="raster">{middag.map(tegel)}</div></div>}

            {gekozen && <>
              {/* Zelfde toon als de bevestigingsstap bij het boeken (BevestigStap): geen geld terug,
                  wel gewoon zelf verplaatsen, dat is precies wat hier gebeurt. */}
              <div className="details" style={{marginTop:12}}>
                <div><span>Van</span><span>{lang(boeking.datum)}, {String(boeking.starttijd).slice(0,5)}</span></div>
                <div><span>Naar</span><span>{lang(datum)}, {gekozen.starttijd.slice(0,5)}</span></div>
              </div>
              <div className="let">Je reservering blijft dezelfde boeking, alleen de starttijd verandert. Er komt geen geld bij of terug.</div>
              {ontbreekt &&
                <div className="fout">Verplaatsen kan op dit moment nog niet. Deze functie staat nog niet klaar.</div>}
              <FoutMelding tekst={fout}/>
              <button className="wl-knop vol" onClick={verplaats} disabled={verplaatsBezig||!mailGeldig(email)}>
                {verplaatsBezig?"Bezig…":"Verplaats mijn reservering"}</button>
            </>}
          </>}
        </>}

        {klaar && <>
          {klaar.paar && klaar.naarTijd2
            ? <>
                <div className="let groen">Gelukt. Allebei je starttijden zijn verzet naar {lang(klaar.naarDatum)}: de eerste negen om {klaar.naarTijd} en de tweede negen om {klaar.naarTijd2}.</div>
                <div className="details" style={{marginTop:8}}>
                  <div><span>Eerste starttijd</span><span>{klaar.naarTijd}{klaar.vanTijd?" (was "+klaar.vanTijd+")":""}</span></div>
                  <div><span>Tweede starttijd</span><span>{klaar.naarTijd2}{klaar.vanTijd2?" (was "+klaar.vanTijd2+")":""}</span></div>
                  {klaar.minuten && <div><span>Ertussen</span><span>{klaar.minuten} minuten</span></div>}
                </div>
                <div className="wl-tekst">Je reserveringsnummer blijft {klaar.referentie}. Het is en blijft een reservering.</div>
              </>
            : <>
                <div className="let groen">Gelukt. Je reservering staat nu op {lang(klaar.naarDatum)} om {klaar.naarTijd}, in plaats van {lang(klaar.vanDatum)} om {klaar.vanTijd}.</div>
                <div className="wl-tekst">Je reserveringsnummer blijft {klaar.referentie}.</div>
              </>}
        </>}

        <button className="terug" onClick={onSluit}>Sluiten</button>
      </div>
    </div>
  );
}

// VOORUIT-BOEKEN 20261001 (besluit 79): vandaag in Amsterdam als jjjj-mm-dd, en een dag optellen op de kalenderdatum zelf,
// zodat zomer- en wintertijd niets verschuiven. De database rekent ook met de Amsterdamse dag (boek_tijdslot, boek_groep).
function vandaagAms(){
  const p = new Intl.DateTimeFormat("en-CA",{timeZone:"Europe/Amsterdam",year:"numeric",month:"2-digit",day:"2-digit"}).formatToParts(new Date());
  const g = t => p.find(x=>x.type===t).value;
  return g("year")+"-"+g("month")+"-"+g("day");
}
function plusDagen(isoDatum, n){
  const [j,m,d] = isoDatum.split("-").map(Number);
  return new Date(Date.UTC(j,m-1,d+n)).toISOString().slice(0,10);
}
function laatsteDagTekst(isoDatum){ return new Date(isoDatum+"T12:00:00").toLocaleDateString("nl-NL",{weekday:"long",day:"numeric",month:"long"}); }

function App(){
  useBoekGrenzen();   // B9R1-20261007: footgolf in het menu en de groepsgroottes uit de database; tekent opnieuw als het antwoord er is
  const [sessie,setSessie]=useState(undefined); const [lid,setLid]=useState(null);
  useSiteTeksten();   // B9R2-TEKSTEN-20261007: teksten uit het beheer; tekent opnieuw als het antwoord er is
  const [mijnRes,setMijnRes]=useState([]); const [toonLogin,setToonLogin]=useState(false);
  const [baan,setBaan]=useState(null); const [types,setTypes]=useState([]); const [regels,setRegels]=useState([]);
  const [producten,setProducten]=useState([]);
  const [venster,setVenster]=useState(14);
  // held-dag-patch 25-09-2026: pas als de spelerstypes binnen zijn is het echte boekingsvenster bekend.
  const [vensterBekend,setVensterBekend]=useState(false); const [dicht,setDicht]=useState([]);
  // De dag komt uit het adres als die er staat, anders vandaag. Zo overleeft de keuze een verversing.
  const [datum,setDatum]=useState(()=>dagUitAdres()||iso(new Date())); const [sloten,setSloten]=useState([]);
  const [alleSloten,setAlleSloten]=useState([]); const [sportDagen,setSportDagen]=useState(null);
  const [laden,setLaden]=useState(true); const [gekozen,setGekozen]=useState(null);
  const [boeken,setBoeken]=useState(false); const [bon,setBon]=useState(null);
  const [mollie,setMollie]=useState(false); const [betaalBanner,setBetaalBanner]=useState(null);
  const [openUur,setOpenUur]=useState(null);
  // Ronde 6 (18 september 2026): 9 of 18 holes. Weesp heeft negen holes, dus 18 holes zijn twee
  // starttijden achter elkaar. De gast kiest hier 9 of 18; de rest van de stroom is dezelfde.
  // De schakelaar komt uit de database en staat standaard uit, zodat deze site zich zonder
  // schakelaar precies zo gedraagt als voor vandaag.
  const [holes,setHoles]=useState(9);
  const [holes18Aan,setHoles18Aan]=useState(false);
  useEffect(()=>{ achttienHolesAan().then(setHoles18Aan, ()=>setHoles18Aan(false)); },[]);
  // Toegevoegd 22 augustus 2026: MIJN UP-verzamelscherm (punt 5) en het losse saldo-scherm (punt 4).
  // Allebei gewoon een modal-toggle, net als toonLogin hierboven; geen sessie of route erachter.
  const [toonMijnUp,setToonMijnUp]=useState(false);
  const [toonSaldoCheck,setToonSaldoCheck]=useState(false);
  // Toegevoegd 22 augustus 2026, punt 8 van het plan: zelf verplaatsen. Geopend vanaf de knop in
  // GevondenReservering (via de reserveringsnummer-route) of vanaf ?verplaats=REFERENTIE in het
  // adres, zodat een link met de referentie erin (bijvoorbeeld later vanuit de bevestigingsmail) op
  // ditzelfde scherm uitkomt in plaats van dat daar een tweede scherm voor gebouwd moet worden.
  const [toonVerplaatsen,setToonVerplaatsen]=useState(false);
  const [verplaatsRef,setVerplaatsRef]=useState("");
  useEffect(()=>{ const p=new URLSearchParams(window.location.search).get("verplaats");
    if(p){ setVerplaatsRef(p); setToonVerplaatsen(true); } },[]);
  // TEGOED-PATCH 20260928: ?tegoed=NUMMER opent het inwisselscherm (knop in de tegoedmail).
  const [toonTegoed,setToonTegoed]=useState(false);
  const [tegoedRef,setTegoedRef]=useState("");
  useEffect(()=>{ const p=new URLSearchParams(window.location.search).get("tegoed");
    if(p){ setTegoedRef(p); setToonTegoed(true); } },[]);

  useEffect(()=>{ db.auth.getSession().then(r=>setSessie(r.data.session));
    const s=db.auth.onAuthStateChange((_e,ses)=>setSessie(ses)); return ()=>s.data.subscription.unsubscribe(); },[]);

  const laadLid=useCallback(async()=>{
    if(!sessie){ setLid(null); setMijnRes([]); return; }
    await db.rpc("koppel_mijn_lidmaatschap");
    const p=await db.rpc("mijn_lidprofiel"); setLid(p.data||null);
    const r=await db.rpc("mijn_reserveringen"); setMijnRes(r.data||[]);
  },[sessie]);
  useEffect(()=>{ laadLid(); },[laadLid]);

  useEffect(()=>{ db.from("instellingen").select("waarde").eq("sleutel","mollie_actief").single()
    .then(r=>{ if(r.data && r.data.waarde==="true") setMollie(true); }); },[]);

  useEffect(()=>{
    const p=new URLSearchParams(window.location.search);
    // ?recras= (bouwspec Recras-kaart 4.2 punt 8): terug van de Recras-betaling. Zelfde banners als
    // ?betaald=; per poging eerst recras-boekingstatus, die de stand uit Recras op onze boeking zet.
    const viaRecras = !p.get("betaald") && !!p.get("recras");
    const ref = p.get("betaald") || p.get("recras");
    const param = viaRecras ? "recras" : "betaald";
    if(!ref) return;
    // Mollie stuurt de gast terug zodra de betaling bevestigd is, maar de webhook naar de
    // database loopt daar PARALLEL aan. Eenmalig kijken levert daardoor vaak "in verwerking"
    // op terwijl het geld er een seconde later wel is, en de gast ziet dat nooit meer.
    // Daarom een paar keer opnieuw kijken voordat we "in verwerking" als eindstand tonen.
    // Gemeten op 8 augustus 2026: de webhook was binnen 20 seconden klaar, maar de pagina
    // had toen al zijn enige controle gedaan.
    let gestopt=false;
    (async()=>{
      // Gemeten 9 augustus 2026, avond: bij een MISLUKTE betaling komt de webhook pas MINUTEN
      // later binnen, niet seconden. Langer pollen lost dat dus niet op; dat laat de gast alleen
      // langer naar "Even geduld" kijken. Daarom blijft het venster kort en zijn de teksten
      // eerlijk gemaakt: de eindtekst hieronder belooft niets en zegt wat de gast wel kan doen.
      const POGINGEN=6, WACHT=2000;   // circa 10 seconden in totaal
      for(let i=0;i<POGINGEN && !gestopt;i++){
        if(viaRecras){ const rs=await recrasStatus(ref); if(rs.status==="fout") console.warn("[upgolf] recras-boekingstatus: "+rs.reden); }
        const r=await db.rpc("zoek_boeking",{p_referentie:ref});
        const st=r.data && r.data.gevonden && r.data.betaalstatus;
        // GEMENGD-VERVOLG V2 20260930: betaald, maar een kaart kon niet worden gebruikt. Dit is een eindstand, dus stoppen met kijken.
        if(r.data && r.data.gevonden && r.data.kaart_bel_balie){
          const kw=kaartBelWoorden(ref);
          setBetaalBanner({ok:false, tt:kw.tt, sl:kw.bij});
          haalUitAdres(param); if(viaRecras) haalUitAdres("wacht"); return;
        }
        if(st==="betaald"){
          // De datum, de tijd en het bedrag staan erbij. Zonder die drie eindigt een geslaagde
          // betaling in een balk die alleen een code noemt, en moet de gast zijn eigen reservering
          // opzoeken om te zien wanneer hij moet komen. Alles hieronder komt uit zoek_boeking zelf,
          // dus er wordt niets herhaald uit het geheugen van het vorige scherm.
          const d = r.data;
          const wanneer = d.datum ? (lang(d.datum) + " om " + String(d.starttijd).slice(0,5)) : null;
          setBetaalBanner({ok:true, tt:"Betaling geslaagd",
            sl:"Reservering " + ref + " is betaald en bevestigd." +
               (wanneer ? " Je speelt op " + wanneer + "." : "") +
               // Bij ?recras= is het bedrag op onze boeking de greenfee-rekensom en niet wat de gast via
               // Recras betaalde (gemeten 14 september: banner zei 42,00 bij 21,00 plus een strip). Dan weglaten.
               (d.bedrag_cent && !viaRecras ? " Bedrag: " + euro(d.bedrag_cent) + "." : "")});
          haalUitAdres(param); haalUitAdres("wacht"); return;
        }
        if(st==="mislukt"){
          // Mislukt is een eindstand: markeer_mislukt zet de boeking op geannuleerd, annuleert
          // de deelnemers en geeft de starttijd weer vrij. Opnieuw betalen kan dus niet meer.
          setBetaalBanner({ok:false,tt:"Betaling niet gelukt",
            sl:"Reservering "+ref+" is vervallen en de starttijd is weer vrij. Maak een nieuwe reservering."});
          haalUitAdres(param); if(viaRecras) haalUitAdres("wacht"); return;
        }
        // Nog open. Eerst laten weten dat we bezig zijn, dan opnieuw kijken.
        if(i===0) setBetaalBanner({ok:true,tt:"Even geduld",sl:"We controleren de betaling voor "+ref+"."});
        if(i<POGINGEN-1) await new Promise(res=>setTimeout(res,WACHT));
      }
      if(gestopt) return;
      // Na circa tien seconden nog steeds op open, of de referentie bestaat helemaal niet.
      // Hier stond een groen vinkje met "Je krijgt een bevestiging per mail". Dat was onwaar in
      // beide gevallen: bij een afgebroken of mislukte betaling is er niets betaald, en bij een
      // onbekende referentie bestaat er niets. Geen vinkje en geen mailbelofte meer.
      // Dit blijft de OPEN-tekst, en die belooft bewust GEEN tweede poging. De betaalfunctie
      // mollie-betaling weigert een tweede poging op dezelfde boeking met een 409 zolang er al een
      // mollie_id aan hangt ("voor deze boeking loopt al een betaling"). Bron: regel 282 van
      // mollie-betaling-voorstel-2026-08-08.ts, de versie die na de deploy van 8 augustus draait;
      // de gedeployde bron zelf is niet als artefact vastgelegd. Vandaar "wacht even en
      // ververs" en niet "probeer opnieuw": de status staat nog open omdat de melding van Mollie
      // pas minuten later binnenkomt, niet omdat de gast iets moet overdoen.
      setBetaalBanner({ok:false,tt:"Betaling nog niet afgerond",
        sl:"De betaling voor "+ref+" is nog niet bevestigd. Wacht even en ververs deze pagina, of bel ons op "+TELEFOON+"."});
      haalUitAdres(param);
    })();
    return ()=>{ gestopt=true; };
  },[]);

  // De gast die vanaf het betaalscherm terugkomt zonder te betalen. Zijn reserveringsnummer staat in
  // het adres als `wacht` (gezet vlak voor het vertrek, zie probeerBetaling). Eén keer kijken wat de
  // stand is en dat eerlijk melden. Bewust GEEN herhaalde controle zoals bij `betaald`: hier is niets
  // onderweg waar we op wachten, de gast is juist weggelopen bij de betaling.
  useEffect(()=>{
    const p=new URLSearchParams(window.location.search);
    if(p.get("betaald") || p.get("recras")) return;   // die routes doen hun eigen melding
    const ref=p.get("wacht");
    if(!ref) return;
    let weg=false;
    (async()=>{
      const r=await db.rpc("zoek_boeking",{p_referentie:ref});
      if(weg) return;
      const d=r.data;
      if(d && d.gevonden && d.kaart_bel_balie){
        const kw=kaartBelWoorden(ref);
        setBetaalBanner({ok:false,tt:kw.tt,sl:kw.bij});
      } else if(d && d.gevonden && d.betaalstatus==="betaald"){
        setBetaalBanner({ok:true,tt:"Betaling geslaagd",sl:"Reservering "+ref+" is betaald en bevestigd."});
      } else if(d && d.gevonden && d.betaalstatus==="mislukt"){
        setBetaalBanner({ok:false,tt:"Reservering vervallen",
          sl:"Reservering "+ref+" is komen te vervallen en de starttijd is weer vrij. Maak een nieuwe reservering."});
      } else if(d && d.gevonden){
        setBetaalBanner({ok:false,tt:"Nog niet betaald",
          sl:"Reservering "+ref+" staat op je naam, maar er is nog niet afgerekend. Schrijf dit nummer over en bel ons op "+TELEFOON+", ook als je wilt annuleren."});
      }
      haalUitAdres("wacht");
    })();
    return ()=>{ weg=true; };
  },[]);

  // 25 september 2026: tegelijk in plaats van na elkaar. Gemeten door de manager kwamen de gegevens
  // in rondes (banen, dan spelerstypes en producten, dan prijsregels en sluitingsdagen). Banen,
  // spelerstypes en producten hangen nergens van af en gaan nu in een keer de deur uit; prijsregels
  // en sluitingsdagen hebben het baan-id nodig en gaan samen zodra de baan er is. Deze gegevens zijn
  // voor golf en footgolf gelijk en worden bij het wisselen van sport NIET opnieuw opgehaald.
  useEffect(()=>{ (async()=>{
    // Promise.resolve maakt van elke vraag EEN belofte. Een supabase-vraag gaat bij elke .then()
    // opnieuw naar de server; zonder dit werden spelerstypes en producten twee keer opgehaald
    // (gemeten in de proefopstelling van 25 september).
    const qB=Promise.resolve(db.from("banen").select("*").eq("actief",true).order("naam"));
    const qT=Promise.resolve(db.from("spelerstypes").select("*").eq("actief",true).order("code"));
    const qP=Promise.resolve(db.from("producten").select("*").eq("actief",true));
    qP.then(pr=>setProducten(pr.data||[]));
    qT.then(t=>{ if(t.data){ setTypes(t.data); const g=t.data.filter(x=>!x.is_lid);
      if(g.length) setVenster(Math.max.apply(null,g.map(x=>x.boekingsvenster_dagen))); }
      setVensterBekend(true); }, ()=>setVensterBekend(true));
    const b=await qB;
    if(b.data&&b.data.length){ setBaan(b.data[0]);
      const [p,sd]=await Promise.all([
        db.from("prijsregels").select("*").eq("baan_id",b.data[0].id),
        db.from("sluitingsdagen").select("datum").eq("baan_id",b.data[0].id)]);
      setRegels(p.data||[]); setDicht((sd.data||[]).map(x=>x.datum)); }
  })(); },[]);

  // ---- Golf en footgolf in een pagina (25 september 2026) ----
  // De sport is state, zodat een wissel alleen opnieuw tekent. React, supabase-js en de vertaalde
  // code blijven staan: geen nieuwe paginalading en geen wit scherm meer. Wat per sport verschilt
  // (de tijden van de dag en de dagen met aanbod) haalt zichzelf opnieuw op, omdat `sport` in de
  // afhankelijkheden van die twee effecten staat. De rest blijft liggen.
  const [sport,setSport]=useState(SPORT);
  function wisselSport(naar, e){
    // Ctrl-, Cmd- of middenklik: de gast wil een nieuw tabblad, dan doet de browser de gewone link.
    if(e && (e.metaKey||e.ctrlKey||e.shiftKey||e.altKey||(typeof e.button==="number" && e.button>0))) return;
    if(e) e.preventDefault();
    if(naar===SPORT || naar!=="golf") return;
    if(!zetSport(naar)) return;
    zetSportInAdres(naar);
    setGekozen(null); setBoeken(false); setBon(null); setSportDagen(null); setHoles(9);
    setSport(naar);
  }

  const laad=useCallback(async()=>{
    if(!baan) return; setLaden(true); setGekozen(null);
    // Deze voorkant is de ingang voor ÉÉN sport (SPORT, uit data.js) en toont dus alleen sloten van
    // die sport. De sport komt uit de data, uit de
    // kolom `sport` op het tijdslot zelf. Bewust GEEN lijst met dagen en GEEN tijdvenster in de code:
    // zet de balie een slot in het beheersysteem op golf, dan verschijnt het hier vanzelf, en zetten ze
    // er voetgolf neer dan verdwijnt het vanzelf. Zo blijft het scherm gelijk aan wat de database
    // toestaat en hoeven wij niets te wijzigen als de bezetting verandert.
    // Ronde 6: bij 18 holes komt de lijst uit `beschikbaarheid_paren`. Die view zet de eerste en de
    // tweede starttijd al bij elkaar, met het aantal vrije plekken van allebei en het aantal minuten
    // ertussen. De voorkant rekent hier dus NIETS zelf uit; staat er geen paar in de view, dan is die
    // tijd voor 18 holes niet te boeken en hoort hij er ook niet te staan.
    const bron = holes===18 ? "beschikbaarheid_paren" : "beschikbaarheid";
    const r=await db.from(bron).select("*").eq("baan_id",baan.id).eq("datum",datum).eq("status","open").eq("sport",SPORT).order("starttijd");
    const nu=new Date(); const vandaag=datum===iso(nu); const klok=nu.toTimeString().slice(0,8);
    // Alles wat er voor deze sport op deze dag staat, ook wat vol of verstreken is. Zonder die
    // lijst kan het scherm de drie redenen voor "geen tijden" niet uit elkaar houden, en dan
    // stond er "Deze dag zit vol" op een maandag waarop er helemaal geen footgolf is.
    setAlleSloten(r.data||[]);
    // Bij 18 holes moet ook de tweede starttijd nog plek hebben, anders is het geen speelbaar paar.
    setSloten((r.data||[]).filter(s=>s.vrij>0 && (holes!==18 || s.tweede_vrij>0) && (!vandaag||s.starttijd>klok)));
    setLaden(false);
  },[baan,datum,holes,sport]);
  useEffect(()=>{ laad(); },[laad]);

  // Ronde 6: is er geen 18-holesproduct of staat de schakelaar uit, dan bestaat de keuze niet.
  // Liever geen knop dan een knop die de database daarna weigert.
  const product18 = productVoorHoles(producten, SPORT, 18);
  const keuze18Mogelijk = holes18Aan && SPORT==="golf" && !!product18;
  // Vangnet: valt de mogelijkheid weg terwijl de gast op 18 stond, dan terug naar 9 holes in
  // plaats van een lege lijst.
  useEffect(()=>{ if(!keuze18Mogelijk && holes!==9) setHoles(9); },[keuze18Mogelijk]);

  // Grens waarboven we per uur samenvouwen. Gemeten op staging: een gewone dag geeft 30 tot 31 vrije
  // tijden, en dat leest als een muur. Acht tijden passen op een telefoon nog in beeld, dus daaronder
  // zou een uurrij alleen een extra tik zijn zonder dat er iets ingeklapt wordt.
  const DIRECT_TONEN = 8;
  const compact = sloten.length > DIRECT_TONEN;
  // Het eerste uur met vrije tijden staat open bij binnenkomst, zodat het scherm niet leeg oogt en de
  // gast meteen ziet wat een uurrij doet. Bij een nieuwe dag schuift dat mee.
  useEffect(()=>{ setOpenUur(sloten.length ? sloten[0].starttijd.slice(0,2) : null); },[sloten]);
  useEffect(()=>{ if(!gekozen) return;
    const el=document.querySelector(".verder-paneel"); if(el && el.scrollIntoView) el.scrollIntoView({block:"nearest", behavior:"smooth"}); },[gekozen]);

  async function uitloggen(){ await db.auth.signOut(); setLid(null); }
  // TEGOED-PATCH 20260928, richting (b): eerst vraagt de database wat er gebeurt (tegoed, strip terug,
  // of gewoon annuleren), dan bevestigt het lid, dan de mail die bij de uitkomst hoort (veld mail).
  async function annuleerMijn(bId){
    const v=await db.rpc("annuleer_mijn_boeking",{p_boeking_id:bId, p_bevestigd:false});
    if(v.error){ alert(foutTekst(v.error)); return; }
    if(!window.confirm((v.data&&v.data.vraag)||"Deze reservering annuleren?")) return;
    const r=await db.rpc("annuleer_mijn_boeking",{p_boeking_id:bId, p_bevestigd:true});
    if(r.error){ alert(foutTekst(r.error)); return; }
    if(r.data && r.data.tekst) alert(r.data.tekst);
    stuurMail(bId,(r.data&&r.data.mail)||"annulering");
    const heropenTekst = await heropenNaTerug(r.data, null); if(heropenTekst) alert(heropenTekst);
    laadLid(); laad(); }

  // VOORUIT-BOEKEN 20261001 (besluit 79): hoe ver vooruit staat in de database, per spelerstype
  // (spelerstypes.boekingsvenster_dagen, in Beheer, Spelerstypes, "Dagen") en wordt daar ook afgedwongen. Sinds 1 oktober is het 14.
  // Een ingelogd lid krijgt het venster van zijn eigen type; de oude ondergrens van 30 dagen is weg.
  // De rij telt vanaf vandaag in Amsterdam; vandaag is dag 0, dus venster 14 geeft vijftien dagen in de rij.
  const vensterLid = types.filter(x=>x.is_lid).reduce((m,x)=>Math.max(m,x.boekingsvenster_dagen),0);
  const ledenVenster = lid ? Math.max(venster, vensterLid) : venster;
  const dagen=[]; for(let i=0;i<=ledenVenster;i++){ dagen.push(plusDagen(vandaagAms(), i)); }
  const maxDagGast = plusDagen(vandaagAms(), venster);

  // Op welke dagen in de strip staat deze sport überhaupt? Bij golf is dat elke dag, maar footgolf
  // staat er alleen op woensdag, zaterdag en zondag (gemeten op staging: 819 footgolfsloten, alle
  // tussen 15:00 en 17:00, alleen op die drie dagen). Zonder dit ziet een gast een strip van
  // vijftien gelijke dagen waarvan er elf leeg blijken zodra hij ze aantikt.
  // Eén vraag voor het hele venster, alleen de datumkolom.
  const eersteDag = dagen[0], laatsteDag = dagen[dagen.length-1];
  useEffect(()=>{ if(!baan) return; let weg=false; setSportDagen(null);
    (async()=>{ const r=await db.from("beschikbaarheid").select("datum")
      .eq("baan_id",baan.id).eq("status","open").eq("sport",SPORT)
      .gte("datum",eersteDag).lte("datum",laatsteDag);
      if(!weg) setSportDagen(r.data ? [...new Set(r.data.map(x=>x.datum))] : null); })();
    return ()=>{ weg=true; };
  },[baan, eersteDag, laatsteDag, sport]);

  // De dag in het adres bijhouden. De eerste keer VERVANGEN wij de invoer in de geschiedenis, want
  // anders staat er meteen een extra stap in en gaat de terugknop nergens heen. Daarna DUWEN wij er
  // een bij, zodat de terugknop naar de vorige dag gaat in plaats van de pagina te verlaten.
  const eersteAdres = React.useRef(true);
  useEffect(()=>{
    if(dagUitAdres() === datum && eersteAdres.current){ eersteAdres.current=false; return; }
    zetDagInAdres(datum, eersteAdres.current);
    eersteAdres.current = false;
  },[datum]);

  // De terugknop van de browser. Zonder dit blijft het adres wel veranderen maar het scherm niet.
  useEffect(()=>{
    // 25 september 2026: de terugknop zet ook de sport terug, want die staat nu ook in het adres.
    const terug = () => {
      const s = sportUitAdres();
      if(s !== SPORT && s==="golf" && zetSport(s)){ setGekozen(null); setBoeken(false); setBon(null); setSportDagen(null); setHoles(9); setSport(s); }
      setDatum(dagUitAdres() || iso(new Date()));
    };
    window.addEventListener("popstate", terug);
    return () => window.removeEventListener("popstate", terug);
  },[]);

  // Vangnet voor een dag die niet te kiezen is: in het verleden, voorbij het boekingsvenster, of
  // onzin die de controle in `dagUitAdres` wel doorkwam. Dan schuiven wij naar de eerstvolgende
  // bruikbare dag in plaats van een leeg scherm te tonen. Dit werkt ook als het venster later groeit
  // doordat er iemand inlogt, want dan draait deze controle opnieuw.
  // held-dag-patch 25-09-2026: gemeten dat dit vangnet bij het laden draaide met het startvenster van
  // 14 dagen, voordat het echte venster (90) binnen was. Een link naar ?dag= op dag 15 of later schoof
  // zo naar vandaag. Nu pas schuiven als het venster bekend is; een dag in het verleden of voorbij het
  // echte venster schuift daarna nog steeds naar de eerste bruikbare dag.
  useEffect(()=>{
    if(!vensterBekend) return;
    if(dagen.length && dagen.indexOf(datum) < 0) setDatum(dagen[0]);
  },[datum, ledenVenster, vensterBekend]);

  // De dagenstrip meeschuiven naar de gekozen dag. Zonder dit staat een dag die verderop ligt buiten
  // beeld en lijkt er niets gekozen. `block:"nearest"` zodat de PAGINA niet verspringt.
  useEffect(()=>{
    const el = document.querySelector(".strip .dag.aan");
    if(el && el.scrollIntoView) el.scrollIntoView({block:"nearest", inline:"center"});
  },[datum, dagen.length]);
  const gastType=types.filter(t=>!t.is_lid)[0];
  // Ronde 6: de prijs hangt aan het product, en dat product hangt aan de keuze 9 of 18 holes.
  const prijsBij=s=>{ if(!gastType) return null;
    const p=productVoorHoles(producten,s.sport,holes); return p?prijsVan(regels,gastType.id,datum,s.starttijd,p.id,s.baan_id):null; };
  const ochtend=sloten.filter(s=>s.starttijd<"12:00:00"); const middag=sloten.filter(s=>s.starttijd>="12:00:00");

  function tegel(s){ const p=prijsBij(s); const paar = holes===18 && s.tweede_starttijd; return (
    <button key={s.id} className={"tijd"+(gekozen&&gekozen.id===s.id?" aan":"")+(paar?" paar":"")} onClick={()=>setGekozen(s)}>
      <div className="t">{s.starttijd.slice(0,5)}</div>
      {/* Bij 18 holes staat de tweede starttijd op de tegel zelf. Anders kiest een gast een tijd
          zonder te zien dat er een tweede bij hoort, en dat is precies het misverstand dat we
          willen voorkomen. Beide getallen komen uit de view. */}
      {paar && <div className="t2">en {String(s.tweede_starttijd).slice(0,5)}</div>}
      <div className="p">{p===null?"balie":p===0?"gratis":euro(p)}</div>
      <div className="v">{paar ? Math.min(s.vrij, s.tweede_vrij) : s.vrij} vrij</div></button>); }

  // Op een drukke dag staan er dertig vrijwel gelijke tegels onder elkaar. Dat leest als een muur:
  // je moet aftellen in plaats van kiezen. Vanaf meer dan DIRECT_TONEN tijden vouwen we daarom per uur
  // samen, zodat de gast eerst een blok kiest en pas daarna een tijd. Onder die grens zou een
  // inklapping niets inklappen en alleen een extra tik kosten, dus dan tonen we gewoon alles.
  // 25 september 2026, mail Stefan: de knop Verder stond rechts in een witte balk onderaan een witte
  // pagina en viel weg. Er wordt altijd een flight tegelijk geboekt, dus een winkelmandje of een
  // vaste balk voegt niets toe. De knop staat nu direct onder de gekozen tijd, in hetzelfde blok.
  const heeftGekozen = l => !!gekozen && l.some(x=>x.id===gekozen.id);
  function verderPaneel(){
    if(!gekozen || boeken || bon) return null;
    const paar = holes===18 && gekozen.tweede_starttijd;
    const v = paar ? Math.min(gekozen.vrij, gekozen.tweede_vrij) : gekozen.vrij;
    return (<div className="verder-paneel" key="verder">
      <div className="info">
        <b>{gekozen.starttijd.slice(0,5)}{paar ? " en "+String(gekozen.tweede_starttijd).slice(0,5) : ""}</b>
        {v + " plek" + (v===1?"":"ken") + " vrij"}</div>
      <button className="knop verder-knop" style={knopBreek()} onClick={()=>setBoeken(true)}>{siteTekst("boek_knop_verder_tijd")}</button>
    </div>);
  }
  function blok(titel,lijst){
    if(!lijst.length) return null;
    if(!compact) return (<div><div className="dagdeel">{titel}</div><div className="raster">{lijst.map(tegel)}</div>
      {heeftGekozen(lijst) && verderPaneel()}</div>);
    const uren=[];
    lijst.forEach(s=>{ const u=s.starttijd.slice(0,2); const b=uren.find(x=>x.uur===u);
      if(b) b.sloten.push(s); else uren.push({uur:u,sloten:[s]}); });
    return (<div><div className="dagdeel">{titel}</div><div className="uurlijst">
      {uren.map(u=>{ const open=openUur===u.uur;
        // De prijs in de dichtgeklapte rij, zodat de gast niet hoeft open te klappen om te zien wat een
        // blok kost. Nagemeten over veertig uren op vijf dagen: binnen een uur is de prijs steeds gelijk.
        // Toch niet blind de eerste tegel nemen: zodra er ooit een prijsregel bijkomt die halverwege een
        // uur ingaat, zou dat een prijs tonen die niet voor alle tijden in dat uur geldt. Bij meer dan
        // een prijs binnen het uur laten we hem daarom weg en zegt de rij niets in plaats van iets fouts.
        const prijzen=[...new Set(u.sloten.map(s=>{ const p=prijsBij(s); return p===null?"balie":p===0?"gratis":p; }))];
        const prijsTekst = prijzen.length===1 ? (typeof prijzen[0]==="number"?euro(prijzen[0]):prijzen[0]) : null;
        return (
        <React.Fragment key={u.uur}>
          <button className={"uurrij"+(open?" open":"")} aria-expanded={open}
            onClick={()=>setOpenUur(open?null:u.uur)}>
            <div className="uurtijd">{u.uur}:00</div>
            <div className="uuraantal">{u.sloten.length} tijd{u.sloten.length===1?"":"en"} vrij</div>
            {prijsTekst && <div className="uurprijs">{prijsTekst}</div>}
          </button>
          {open && <div className="raster">{u.sloten.map(tegel)}</div>}
          {heeftGekozen(u.sloten) && verderPaneel()}
        </React.Fragment>); })}
    </div></div>); }

  const seizoen = lid && lid.seizoenskaart; const strip = lid && lid.kaarten && lid.kaarten[0];

  return (
    <div>
      {/* De kop is per sport anders. Het merk blijft UPgolf, want dat is het systeem waar een gast
          in zit; welke sport hij boekt staat als merkje ernaast en nog een keer voluit in de kop.
          Op de footgolfingang moet een gast die van upevents.nl/evenement/footgolf/ komt in één
          oogopslag zien dat hij goed zit, zonder eerst een tijd aan te klikken. */}
      <div className="hero"><div className="hero-in">
        {/* Lockup herbouwd, ronde 4, 24 augustus 2026, op aanwijzing van hoofd-activiteiten (nabouw
            van Daniels header, gelijk op alle drie de pagina's): groot rood UP, een scheidingslijn,
            dan twee regels ("GOLF WEESP" / "PREMIUM BOOKING"). Zie de uitgebreide toelichting bij
            .merk-up in stijl.css voor de gemeten verhouding en waarom deze twee tekstregels nieuw
            zijn. PROFIEL.merkStaart/de oude "UPgolf"-tekst wordt hier niet meer gebruikt: dat was
            de vorige, kortere vorm van deze lockup. */}
        <div className="hero-top"><div className="merk">
            <img className="merk-logo" src="up-logo-rood.png" width="33" height="52" alt="UP Events"/>
            <span className="merk-scheiding"></span>
            <span className="merk-tekst">
              <span className="merk-naam">UP Events Golf</span>
            </span>
          </div>
          {/* Het "Footgolf"-badgemerkje dat hier stond is WEG, ronde 5, 24 augustus 2026: de
              anti-ai-poort vond dat het alleen op footgolf.html stond en nergens op Daniels drie
              screenshots voorkomt, terwijl de opdracht een op elke pagina identieke header vraagt.
              Het signaal dat het gaf ("je zit hier goed") zit sindsdien in de hero-titel verderop:
              die toont nu letterlijk "UP FOOTGOLF" met FOOTGOLF in rood, een grotere en eerdere
              bevestiging dan een klein pilletje naast het logo ooit was. Geen vervangend element
              hier toegevoegd. */}
          {/* Nav toegevoegd, ronde 3, 24 augustus 2026 (nabouw Daniel, letterlijke opdracht van
              Roy). Alleen de drie schermen die op de gastvoorkant echt bestaan en werken: golf,
              footgolf, kaart kopen. Geen PRODUCTEN/TEE SHEET/ADMIN/TECHNIEK, die schermen zijn er
              hier niet en dat worden dus geen dode links (besluit van hoofd-activiteiten). Actieve
              pagina krijgt een kader (className "aan"), net als Daniels ADMIN-item in screenshot 03. */}
          <nav className="hoofdnav">
            <a className={PROFIEL.sleutel==="golf"?"aan":undefined} href="index.html" onClick={e=>wisselSport("golf",e)}>Golf</a>
            {footgolfZichtbaar() && <a href={FOOTGOLF_BOEKEN_URL}>Footgolf</a>}
            <a {...kaartKoopLink("abonnement",false)}>Kaart kopen</a>
          </nav>
          {/* De kaarthouders-inlogknop is eruit gehaald: besluit 6 van Roy, 14 augustus 2026,
              geen kaarthoudersdashboard, de inlogroute is definitief van tafel. Een kaart of
              abonnement gebruiken kan gewoon met de code bij het boeken, zonder inloggen.
              De onderliggende inlogcode (Login-component, sessie, lid) blijft staan: een
              kaarthouder-sessie leidt nog wel tot de welkomstregel en het kaarttarief in het
              boekingsblad, dus die logica raak ik niet aan, alleen de knop die naar de
              inlogroute leidt. */}
          {/* MIJN UP: toegevoegd 22 augustus 2026, punt 5 van het plan van 21 augustus. De rode knop
              komt uit Daniels nabouw (screenshot 01/02: rood, rechts in de navy header). Hij opent
              een verzamelscherm, geen inlog en geen route: zie MijnUp hierboven. */}
          <div className="header-acties">
            <button className="mijnup-knop" onClick={()=>setToonMijnUp(true)}>MIJN UP</button>
            {sessie && <button className="login-knop" onClick={uitloggen}>Uitloggen</button>}
          </div></div>
        {lid && <div className="welkom">Welkom terug, <b>{lid.naam}</b>.</div>}
        {/* CTA-knop en titel, HERZIEN in ronde 5, 24 augustus 2026, op letterlijke opdracht van Roy
            ("1 op 1 overnemen"): ronde 4 hergebruikte PROFIEL.kop op de knop, met een gemelde
            open vraag ("wat moet hier echt staan?") omdat de tekst dubbelop was met de titel.
            Die vraag is nu beantwoord door Daniels eigen screenshot zelf tegen te lezen: de knop en
            de titel zijn bij hem GEEN dubbele tekst, het zijn twee eigen teksten met een eigen rol
            (de knop is de uitnodiging, de titel is de merknaam). Beide nu 1 op 1 overgenomen:
            - Knop: "Boek je starttijd bij UP {naam}" (CSS zet dit om in hoofdletters, net als
              Daniels "BOEK JE STARTTIJD BIJ UP GOLF"). Zelf tegen screenshot 01 gecontroleerd.
            - Titel: "UP {naam}", waarbij Kop hieronder het LAATSTE woord ("Golf"/"Footgolf") rood
              kleurt en "UP" in de gewone kopkleur (wit) blijft, exact zoals Daniels "UP" wit /
              "GOLF" rood. Voor footgolf hetzelfde patroon met "Footgolf" in plaats van "Golf",
              zoals gevraagd.
            Klikken op de knop scrollt nog steeds naar het "Wanneer wil je spelen"-blok, geen
            route- of schermwijziging, puur een anker binnen dezelfde pagina. */}
        {/* held-dag-patch 25-09-2026: de knop "Boek je starttijd bij UP ..." is weg (Roy: overbodig; hij
            scrolde alleen naar het blok hieronder). Titel, sloganregel, onderregel en blokjes staan hieronder
            in lagen: de eigen sport zichtbaar, de andere sport onzichtbaar op dezelfde plek, zodat golf en
            footgolf even hoog zijn en de foto er hetzelfde uitgesneden bij staat. Zie heldLagen onderaan. */}
        <h1 className="hero-lagen"><span className="laag"><Kop tekst={PROFIEL.titel||("UP "+PROFIEL.naam)}/></span>
          {ANDERE_PROFIELEN().map(p=><span key={p.sleutel} className="laag laag-stil" aria-hidden="true"><Kop tekst={p.titel||("UP "+p.naam)}/></span>)}</h1>
        {/* Ondertitel/slogan, ronde 5, 24 augustus 2026: Daniels eigen tekst uit zijn mockup, per
            profiel in data.js (PROFIEL.heroSlogan), zie daar de toelichting waarom footgolf hem
            leeg laat. .hero-slogan (nieuw, was .hero p) draagt de hoofdletter- en tweeregel-vorm
            die eerder al op de onderkop stond. */}
        {Object.values(SPORTEN).some(p=>p.heroSlogan) &&
          <p className="hero-slogan hero-lagen"><span className="laag">{PROFIEL.heroSlogan||""}</span>
            {ANDERE_PROFIELEN().map(p=><span key={p.sleutel} className="laag laag-stil" aria-hidden="true">{p.heroSlogan||""}</span>)}</p>}
        {/* De functionele regel die hier eerder de enige onderkop was ("Golfbaan Weesp, kies een
            dag en een tijd, de rest regelen we aan de balie.") blijft gewoon bestaan: hij is niet
            versierting, hij zegt wat een gast hier moet doen. Alleen kleiner gezet en onder de
            nieuwe slogan geplaatst (.hero-info, nieuwe eigen klasse, GEEN hoofdletters), zodat hij
            niet meer met de slogan om aandacht concurreert maar er wel meteen onder blijft staan. */}
        {/* NAAM-20261008 (besluit 99 punt 8): de kleine regel onder de slogan is bij golf weg (PROFIEL.onderkop is leeg).
            De regel wordt alleen getekend als een zichtbare laag er tekst voor heeft. Een sport die niet in het menu
            staat telt niet mee voor de hoogte, anders blijft er een lege strook onder de slogan staan. Zodra footgolf
            weer in het menu komt en zijn eigen onderkop houdt, is hij weer even hoog als golf (de lagen-opbouw). */}
        {(()=>{ const lagen=[PROFIEL, ...ANDERE_PROFIELEN()].filter(p=>p.onderkop && (p===PROFIEL || footgolfZichtbaar()));
          return lagen.length>0 && <p className="hero-info hero-lagen">{lagen.map(p=>
            <span key={p.sleutel} className={"laag"+(p===PROFIEL?"":" laag-stil")} aria-hidden={p===PROFIEL?undefined:"true"}>{p.baanVoorop ? (baan?baan.naam:"Golfbaan Weesp")+", " : ""}{p.onderkop}</span>)}</p>; })()}
        {(() => { const van = p => p.feiten
            .filter(x => typeof x === "string" || !x.alleenMet18 || keuze18Mogelijk)
            .map(x => typeof x === "string" ? x : x.tekst);
          const lagen = [PROFIEL, ...ANDERE_PROFIELEN()].map(p => ({p, f: van(p)}));
          if(!lagen.some(l => l.f.length)) return null;
          return <div className="hero-lagen feiten-lagen">{lagen.map((l,i)=>
            <ul key={l.p.sleutel} className={"feiten laag"+(i?" laag-stil":"")} aria-hidden={i?"true":undefined}>{l.f.map(x=><li key={x}>{x}</li>)}</ul>)}</div>; })()}
        {/* Carrousel-indicator, ronde 5, 24 augustus 2026: drie liggende streepjes (rood/aqua/grijs)
            zoals Daniel ze onder zijn hero-titel toont. Puur decoratief: dit wordt GEEN werkende
            carrousel, wij hebben hier maar één hero-foto en de werking van dit scherm verandert
            niet. aria-hidden zodat een schermlezer dit overslaat: wie de streepjes niet ziet, mist
            functioneel niets. */}
        <div className="hero-indicator" aria-hidden="true"><span></span><span></span><span></span></div>
        {footgolfZichtbaar() && <a className="kruislink" href={FOOTGOLF_BOEKEN_URL}>Footgolf boek je bij UP Events</a>}
      </div></div>

      <div className="vel">
        {betaalBanner &&
          <div className={"banner "+(betaalBanner.ok?"ok":"fout")}>
            <div className="ic">{betaalBanner.ok?"✓":"!"}</div>
            <div><div className="tt">{betaalBanner.tt}</div><div>{betaalBanner.sl}</div></div>
            <button className="sl" onClick={()=>setBetaalBanner(null)}>×</button>
          </div>}

        {lid && (seizoen||strip) &&
          <div className="lidbalk">
            {seizoen && <div className="lidchip seiz"><div className="k">Seizoenskaart</div><div className="v">tot {kort(seizoen.geldig_tot)}</div></div>}
            {strip && <div className="lidchip kaart-chip"><div className="k">{strip.naam}</div><div className="v">{strip.saldo} strepen</div></div>}
          </div>}

        {lid && mijnRes.length>0 &&
          <div className="kaart"><h2><Kop tekst="Mijn reserveringen"/></h2>
            {mijnRes.map(r=>(
              <div className="mijnboeking" key={r.boeking_id}>
                <div><div className="dt">{kort(r.datum)} · {r.starttijd.slice(0,5)}</div>
                  <div className="sub">{r.spelers} speler{r.spelers===1?"":"s"} · {r.boekingsreferentie}</div></div>
                <button className="ann" onClick={()=>annuleerMijn(r.boeking_id)}>Annuleren</button>
              </div>))}
          </div>}

        {/* Twee kolommen op een breed scherm: links kiezen en boeken, rechts opzoeken. Op een smal
            scherm zet stijl.css ze onder elkaar en blijft de volgorde zoals hij was, want dat is de
            weergave waarop een gast boekt. De Verder-balk staat nu BINNEN de linkerkolom, onder de
            tijden, in plaats van los onderaan de pagina. */}
        <div className="reserveer-kolommen">
        <div className="kolom-boeken">

        <div className="kaart"><div className="vk-agenda-kopregel"><h2><Kop tekst={siteTekst("boek_kop_wanneer")}/></h2><VkAgendaknop datum={datum} dagen={dagen} onKies={d=>setDatum(d)}/></div>
          <div className="strip">{dagen.map(d=>{ const dt=new Date(d+"T12:00:00"); const g=dicht.indexOf(d)>=0;
            // Een dag waarop deze sport niet gespeeld wordt, staat er bleek bij. Zolang de lijst nog
            // niet binnen is markeren we niets, want een dag ten onrechte bleek tonen is erger dan
            // een halve seconde zonder markering.
            const geenSport = !g && sportDagen!==null && sportDagen.indexOf(d)<0;
            return (<button key={d} className={"dag"+(d===datum?" aan":"")+(g?" dicht":"")+(geenSport?" leeg":"")}
              title={geenSport?("Geen "+PROFIEL.naam.toLowerCase()+" op deze dag"):undefined} onClick={()=>setDatum(d)}>
              <div className="wd">{WD[dt.getDay()]}</div><div className="nr">{dt.getDate()}</div><div className="mnd">{MND[dt.getMonth()]}</div></button>); })}</div>
          {vensterBekend && dagen.length>0 && <div className="wl-tekst" style={{marginTop:10,marginBottom:0}}>Je kunt boeken tot en met {laatsteDagTekst(dagen[dagen.length-1])}.</div>}
        </div>

        <div className="kaart"><h2><Kop tekst={siteTekst("boek_kop_tijd")}/></h2>
          {/* Ronde 6: 9 of 18 holes. Twee knoppen in dezelfde vorm als de dagenstrip hierboven,
              zodat het leest als dezelfde soort keuze en niet als een instelling. Verschijnt
              alleen als de schakelaar aan staat en er een 18-holesproduct is. */}
          {keuze18Mogelijk &&
            <div className="holeskeuze">
              <button className={"holes"+(holes===9?" aan":"")} onClick={()=>{ setHoles(9); setGekozen(null); }}>
                <div className="hn">9 holes</div><div className="hu">Eén ronde</div></button>
              <button className={"holes"+(holes===18?" aan":"")} onClick={()=>{ setHoles(18); setGekozen(null); }}>
                <div className="hn">2x 9 holes</div><div className="hu">Twee starttijden</div></button>
            </div>}
          {/* EIS 5, eerste plek: de uitleg bij de tijdkeuze. De tekst staat in data.js en wacht op
              goedkeuring; zolang hij leeg is komt dit blok niet in beeld. */}
          {keuze18Mogelijk && holes===18 && gekozen && uitleg18(UITLEG_18_BIJ_TIJD, gekozen.starttijd, gekozen.tweede_starttijd) &&
            <div className="let">{uitleg18(UITLEG_18_BIJ_TIJD, gekozen.starttijd, gekozen.tweede_starttijd)}</div>}
          {laden && <div className="laden">Tijden ophalen…</div>}
          {/* Er zijn vier redenen waarom er geen tijden staan, en ze vragen om vier verschillende
              antwoorden. Hier stond één tekst plus altijd de wachtlijst, en dat leverde op een
              maandag "Deze dag zit vol. Wil je dat we je bellen zodra er een plek vrijkomt?" terwijl
              er die dag helemaal geen footgolf is. De wachtlijst hoort alleen bij een dag die
              werkelijk vol is; daar is bellen een antwoord, in de andere gevallen niet. */}
          {!laden && sloten.length===0 && (() => {
            const gesloten = dicht.indexOf(datum)>=0;
            const geenAanbod = !gesloten && alleSloten.length===0;
            const verstreken = !gesloten && !geenAanbod && alleSloten.every(s=>s.vrij>0);
            if(gesloten) return <div className="niets">De baan is deze dag gesloten.</div>;
            if(geenAanbod) return <div className="niets">{PROFIEL.geenAanbodTekst}</div>;
            if(verstreken) return <div className="niets">De starttijden van vandaag zijn geweest. Kies een andere dag.</div>;
            // Geen aparte regel "Deze dag is volgeboekt" erboven: de wachtlijst zegt dat zelf al,
            // en die twee stonden anders als bijna dezelfde zin onder elkaar.
            return baan ? <Wachtlijst baan={baan} datum={datum}/>
                        : <div className="niets">Deze dag is volgeboekt.</div>;
          })()}
          {!laden && blok("Ochtend",ochtend)}
          {!laden && blok("Middag",middag)}
        </div>

        {/* De vaste balk onderaan met Verder is weg (25 september 2026): de knop staat nu onder de
            gekozen tijd, zie verderPaneel. Twee knoppen voor hetzelfde zou weer zoeken betekenen. */}

        </div>
        <div className="kolom-opzoeken">
          <Opzoeken onVerplaats={ref=>{setVerplaatsRef(ref); setToonVerplaatsen(true);}}/>
          {/* Toegevoegd 21 augustus 2026, op verzoek van Roy: zonder dit blok was het koopscherm voor
              kaarten en abonnementen alleen bereikbaar door kaart.html met de hand in de adresbalk te
              typen. Roy vond dat zelf niet tijdens zijn oefenrun. Onder "Heb je al gereserveerd?",
              zelfde kaart-stijl, zodat het een vast onderdeel van de pagina lijkt en niet iets bijgeplakts.
              Alleen op golf: gescoperd op PROFIEL.kaarttarieven, dezelfde vlag die verderop al bepaalt
              of kaart- en seizoenstarieven getoond worden (die staat voor footgolf op false, want er
              is geen footgolfkaart, zie de toelichting bij SPORTEN.footgolf in data.js). Roy, gedicteerd
              21 augustus 2026: "Die knop hoeft niet bij foorgolf daar hebbben we nu nog geen abbo of
              strippenkaarten." Geen eigen nieuw kenmerk verzonnen, het bestaande onderscheid gevolgd. */}
          {/* NAAM-20261008 (besluit 99 punt 8, Lydia): de twee blokken "Kaart of abonnement kopen" en "Saldo checken" zijn
              één blok met de kop "Kaarten en abonnementen" en twee knoppen. De knoppen doen hetzelfde als eerst: de koopknop
              gaat via kaartKoopLink naar de UP-site (besluit 75, niet veranderd), de saldoknop opent SaldoCheck. De twee
              uitlegzinnen die onder de oude koppen stonden zijn niet meegenomen: de opdracht was een kop en twee knoppen.
              Alleen op golf (PROFIEL.kaarttarieven), net als de twee blokken eerder. */}
          {PROFIEL.kaarttarieven &&
          <div className="kaart">
            <h2><Kop tekst="Kaarten en abonnementen"/></h2>
            <a className="wl-knop vol" style={{display:"block", textAlign:"center", textDecoration:"none"}} {...kaartKoopLink("abonnement",false)}>Kaart of abonnement kopen</a>
            <button type="button" className="wl-knop vol" style={{marginTop:10}} onClick={()=>setToonSaldoCheck(true)}>Saldo checken</button>
          </div>}
        </div>
        </div>
      </div>

      {toonLogin && <Login onSluit={()=>setToonLogin(false)}/>}
      {boeken && gekozen && <Boeken slot={gekozen} datum={datum} types={types} regels={regels} producten={producten} lid={lid} mollie={mollie}
        holes={holes}
        onSluit={()=>setBoeken(false)} onKlaar={b=>{setBoeken(false); setBon(b); laadLid();}}/>}
      {bon && gekozen && <Klaar bon={bon} datum={datum} slot={gekozen} mollie={mollie} opnieuw={()=>{setBon(null); laad();}}/>}
      {toonMijnUp && <MijnUp onSluit={()=>setToonMijnUp(false)}/>}
      {toonSaldoCheck && <SaldoCheck onSluit={()=>setToonSaldoCheck(false)}/>}
      {toonVerplaatsen && <Verplaatsen maxDag={maxDagGast} referentieVooraf={verplaatsRef} onSluit={()=>{setToonVerplaatsen(false); setVerplaatsRef("");}}
        onTegoed={r=>{ setToonVerplaatsen(false); setVerplaatsRef(""); setTegoedRef(r); setToonTegoed(true); }}/>}
      {toonTegoed && <TegoedInwisselen maxDag={maxDagGast} referentieVooraf={tegoedRef} onSluit={()=>{ setToonTegoed(false); setTegoedRef(""); haalUitAdres("tegoed"); }}/>}
    </div>
  );
}
// Stijl van de knop Verder onder de gekozen tijd (25 september 2026). Hier in het bestand zodat
// stijl.css niet mee hoeft; UP-rood op een lichtblauw vlak, breed op een telefoon.
(function verderStijl(){
  if(document.getElementById("verder-stijl")) return;
  const st=document.createElement("style"); st.id="verder-stijl";
  st.textContent=`
.verder-paneel{display:flex;align-items:center;gap:12px;margin:12px 0 6px;padding:12px 14px;
  background:var(--up-brand-licht,#DAECF3);border:1px solid var(--up-navy,#002D41);border-radius:12px}
.verder-paneel .info{font-size:14px;color:#002D41;line-height:1.3}
.verder-paneel .info b{display:block;color:#002D41;font-size:17px;font-weight:700}
.verder-paneel .verder-knop{margin-left:auto;background:var(--rood-knop,#FE424D);color:var(--up-navy,#002D41);border:0;border-radius:10px;
  font-size:17px;font-weight:700;padding:14px 34px;min-height:50px;cursor:pointer}
.verder-paneel .verder-knop:hover{background:var(--up-navy,#002D41);color:var(--up-brand-rood,#FE424D)}
.verder-paneel .verder-knop:focus-visible{outline:3px solid #002D41;outline-offset:2px}
@media (max-width:560px){
  .verder-paneel{flex-direction:column;align-items:stretch;text-align:center}
  .verder-paneel .verder-knop{margin-left:0;width:100%}
}`;
  document.head.appendChild(st);
})();
// held-dag-patch 25-09-2026: de andere sportprofielen, voor de onzichtbare lagen in de kop.
function ANDERE_PROFIELEN(){ return Object.values(SPORTEN).filter(p => p.sleutel !== PROFIEL.sleutel); }
// Lagen: alle kinderen in dezelfde gridcel, dus de hoogte is die van het hoogste kind. De onzichtbare laag
// neemt ruimte in maar wordt niet getoond en niet voorgelezen (aria-hidden). stijl.css blijft ongewijzigd.
(function heldLagen(){
  if(document.getElementById("held-lagen-stijl")) return;
  const st=document.createElement("style"); st.id="held-lagen-stijl";
  st.textContent=`
.hero-lagen{display:grid}
.hero-lagen > .laag{grid-area:1 / 1; min-width:0}
.hero-lagen > .laag-stil{visibility:hidden; pointer-events:none; user-select:none}`;
  document.head.appendChild(st);
})();
ReactDOM.createRoot(document.getElementById("root")).render(<App/>);

// ===== VkAgenda (agendaknop voorkant, 25 sep 2026) =====
// Zelfde maandkalender als de knop AGENDA op balie.upgolf.nl (balie-app.jsx), in de huisstijl van de
// voorkant. De rij met dagen blijft de gewone keuze; de agenda is een snelle sprong naar een week of
// maand verderop. Te kiezen zijn alleen dagen die ook in de rij staan (vandaag tot en met de
// boekingshorizon); de rest staat bleek en doet niets. Kiezen = setDatum, en de bestaande code schuift
// de rij dan zelf naar die dag en haalt de starttijden op.
// Valkuil uit de balie: een absoluut paneel binnen een element met overflow wordt stil afgeknipt. De
// wikkel staat op relative zonder overflow; op een smal scherm staat het paneel vast midden in beeld.
// Geen const op het hoogste niveau: dit blok staat onderaan het bestand en wordt dan pas uitgevoerd.
function vkAgendaStijl(){
  if(document.getElementById("vk-agenda-stijl")) return;
  const s=document.createElement("style"); s.id="vk-agenda-stijl";
  s.textContent=`
.vk-agenda-kopregel{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px}
.vk-agenda-kopregel h2{margin-right:auto;margin-bottom:0}
.vk-agendawikkel{position:relative;flex:0 0 auto}
.vk-agendaknop{background:#002D41;color:#fff;border:0;border-radius:8px;padding:9px 14px;min-height:40px;
  font:inherit;font-weight:700;font-size:14px;cursor:pointer;display:inline-flex;align-items:center;gap:7px}
.vk-agendaknop{position:relative;top:-6px}
.vk-agendaknop:hover{background:var(--up-brand-rood,#FE424D);color:var(--up-navy,#002D41)}
.vk-agendaknop:focus-visible{outline:3px solid #FE424D;outline-offset:2px}
.vk-agenda-scrim{position:fixed;inset:0;z-index:60;background:transparent}
.vk-agenda{position:absolute;top:calc(100% + 8px);right:0;z-index:61;width:300px;background:#fff;
  border:1px solid var(--up-navy,#002D41);border-radius:12px;box-shadow:0 10px 30px rgba(0,45,65,.18);padding:12px;color:#002D41}
.vk-agenda-kop{display:flex;align-items:center;gap:6px;margin-bottom:8px}
.vk-agenda-maand{flex:1;text-align:center;font-weight:700;font-size:15px}
.vk-agenda-maand::first-letter{text-transform:uppercase}
.vk-agenda-pijl{width:36px;height:36px;border:1px solid #DAECF3;background:#fff;color:#002D41;border-radius:8px;
  font-size:20px;line-height:1;cursor:pointer}
.vk-agenda-pijl:disabled{opacity:.35;cursor:default}
.vk-agenda-weekdagen{display:grid;grid-template-columns:repeat(7,1fr);gap:2px;margin-bottom:4px}
.vk-agenda-weekdagen span{text-align:center;font-size:11px;font-weight:700;color:#002D41;text-transform:uppercase}
.vk-agenda-raster{display:grid;grid-template-columns:repeat(7,1fr);gap:3px}
.vk-agenda-dag{height:38px;border:1px solid transparent;background:none;color:#002D41;border-radius:8px;
  font:inherit;font-size:14px;cursor:pointer}
.vk-agenda-dag:hover{background:#DAECF3}
.vk-agenda-dag.buiten{opacity:.45}
.vk-agenda-dag.uit{color:#002D41;cursor:default;background:none;text-decoration:line-through;opacity:.5}
.vk-agenda-dag.vandaag{border-color:#FE424D;font-weight:700}
.vk-agenda-dag.gekozen{background:#002D41;color:#fff;font-weight:700;border-color:#002D41}
.vk-agenda-dag:focus-visible{outline:2px solid #FE424D;outline-offset:1px}
.vk-agenda-bij{margin:9px 0 0;font-size:12px;color:#002D41;text-align:center}
@media (max-width:560px){
  .vk-agenda-scrim{background:rgba(0,45,65,.35)}
  .vk-agenda{position:fixed;left:50%;top:50%;right:auto;transform:translate(-50%,-50%);
    width:min(340px,calc(100vw - 24px))}
  .vk-agenda-dag{height:44px}
}`;
  document.head.appendChild(s);
}

function VkAgenda({datum,dagen,onKies,onSluit}){
  const WEEKDAGEN=["ma","di","wo","do","vr","za","zo"];
  const eersteMag=dagen[0], laatsteMag=dagen[dagen.length-1];
  const [cursor,setCursor]=useState(datum||eersteMag);
  const raster=React.useRef(null);
  useEffect(()=>{ const el=raster.current&&raster.current.querySelector('[data-focus="ja"]'); if(el) el.focus(); },[cursor]);
  const c=new Date(cursor+"T12:00:00");
  const eerste=new Date(c.getFullYear(),c.getMonth(),1,12);
  const start=new Date(eerste); start.setDate(1-((eerste.getDay()+6)%7)); // week begint op maandag
  const cellen=[]; for(let i=0;i<42;i++){ const d=new Date(start); d.setDate(start.getDate()+i); cellen.push(d); }
  const vandaag=iso(new Date());
  const mag=s=>dagen.indexOf(s)>=0;
  const maandSleutel=s=>s.slice(0,7);
  const kanTerug=maandSleutel(cursor)>maandSleutel(eersteMag);
  const kanVerder=maandSleutel(cursor)<maandSleutel(laatsteMag);
  function binnen(s){ return s<eersteMag?eersteMag:(s>laatsteMag?laatsteMag:s); }
  function schuif(dagenErbij,maandenErbij){
    const d=new Date(cursor+"T12:00:00");
    if(maandenErbij){ d.setDate(1); d.setMonth(d.getMonth()+maandenErbij); } else d.setDate(d.getDate()+dagenErbij);
    setCursor(binnen(iso(d)));
  }
  function toets(e){
    if(e.key==="Escape"){ e.preventDefault(); onSluit(); return; }
    if(e.key==="ArrowLeft"){ e.preventDefault(); schuif(-1); return; }
    if(e.key==="ArrowRight"){ e.preventDefault(); schuif(1); return; }
    if(e.key==="ArrowUp"){ e.preventDefault(); schuif(-7); return; }
    if(e.key==="ArrowDown"){ e.preventDefault(); schuif(7); return; }
    if(e.key==="PageUp"){ e.preventDefault(); if(kanTerug) schuif(0,-1); return; }
    if(e.key==="PageDown"){ e.preventDefault(); if(kanVerder) schuif(0,1); return; }
  }
  const maandnaam=c.toLocaleDateString("nl-NL",{month:"long",year:"numeric"});
  return (<div className="vk-agenda" role="dialog" aria-label="Kies een dag" onKeyDown={toets}>
    <div className="vk-agenda-kop">
      <button type="button" className="vk-agenda-pijl" disabled={!kanTerug} onClick={()=>schuif(0,-1)} aria-label="Vorige maand">‹</button>
      <div className="vk-agenda-maand">{maandnaam}</div>
      <button type="button" className="vk-agenda-pijl" disabled={!kanVerder} onClick={()=>schuif(0,1)} aria-label="Volgende maand">›</button>
    </div>
    <div className="vk-agenda-weekdagen" aria-hidden="true">{WEEKDAGEN.map(d=><span key={d}>{d}</span>)}</div>
    <div className="vk-agenda-raster" ref={raster}>
      {cellen.map(d=>{ const s=iso(d); const buiten=d.getMonth()!==c.getMonth(); const ok=mag(s);
        const isCursor=s===cursor;
        return (<button key={s} type="button"
          className={"vk-agenda-dag"+(buiten?" buiten":"")+(ok?"":" uit")+(s===vandaag?" vandaag":"")+(s===datum?" gekozen":"")}
          data-dag={s} data-focus={isCursor?"ja":"nee"} tabIndex={isCursor?0:-1}
          aria-disabled={ok?undefined:"true"} aria-current={s===datum?"date":undefined}
          onFocus={()=>{ if(!isCursor) setCursor(s); }}
          onClick={()=>{ if(ok) onKies(s); }}>{d.getDate()}</button>);})}
    </div>
    <p className="vk-agenda-bij">Kies een dag. Doorgestreepte dagen zijn nog niet te boeken.</p>
  </div>);
}

function VkAgendaknop({datum,dagen,onKies}){
  const [open,setOpen]=useState(false);
  const knop=React.useRef(null);
  useEffect(()=>{ vkAgendaStijl(); },[]);
  function sluit(){ setOpen(false); if(knop.current) knop.current.focus(); }
  if(!dagen || !dagen.length) return null;
  return (<div className="vk-agendawikkel">
    <button ref={knop} type="button" className="vk-agendaknop" aria-haspopup="dialog" aria-expanded={open?"true":"false"}
      onClick={()=>setOpen(!open)}><span aria-hidden="true">📅</span>Agenda</button>
    {open && <div className="vk-agenda-scrim" onClick={sluit}/>}
    {open && <VkAgenda datum={datum} dagen={dagen} onKies={d=>{ onKies(d); sluit(); }} onSluit={sluit}/>}
  </div>);
}
// ========================================================================================
