import React, { useEffect, useMemo, useState } from "react"; const PRODUCTS = [ { id: "fruit", name: "Fruit", icon: "🍎", cat: "fruit", price: 5, cost: 3, life: 2, demand: 12, unlock: 1 }, { id: "bread", name: "Brood", icon: "πŸ₯–", cat: "brood", price: 6, cost: 3, life: 1, demand: 12, unlock: 1 }, { id: "milk", name: "Melk", icon: "πŸ₯›", cat: "zuivel", price: 7, cost: 4, life: 3, demand: 10, unlock: 1 }, { id: "snack", name: "Snacks", icon: "🍫", cat: "snack", price: 9, cost: 5, life: 5, demand: 8, unlock: 1 }, { id: "cheese", name: "Kaas", icon: "πŸ§€", cat: "zuivel", price: 12, cost: 7, life: 4, demand: 7, unlock: 2 }, { id: "coffee", name: "Koffie", icon: "β˜•", cat: "snack", price: 15, cost: 9, life: 8, demand: 6, unlock: 2 }, { id: "meat", name: "Vlees", icon: "πŸ₯©", cat: "vlees", price: 20, cost: 12, life: 2, demand: 5, unlock: 3 }, { id: "frozen", name: "Diepvries", icon: "🧊", cat: "vlees", price: 23, cost: 14, life: 10, demand: 4, unlock: 3 }, { id: "flowers", name: "Bloemen", icon: "πŸ’", cat: "fruit", price: 28, cost: 16, life: 2, demand: 4, unlock: 4 }, { id: "cake", name: "Gebak", icon: "🍰", cat: "brood", price: 32, cost: 18, life: 2, demand: 4, unlock: 4 }, { id: "audio", name: "Elektronica", icon: "🎧", cat: "luxe", price: 58, cost: 36, life: 30, demand: 3, unlock: 5 }, { id: "luxury", name: "Luxe pakket", icon: "🎁", cat: "luxe", price: 78, cost: 48, life: 14, demand: 2, unlock: 5 } ]; const SPECS = [ { role: "Fruitmedewerker", cats: ["fruit"], bonus: 2, speed: 0.25, wage: 16 }, { role: "Broodbakker", cats: ["brood"], bonus: 2, speed: 0.2, wage: 16 }, { role: "Zuivelexpert", cats: ["zuivel"], bonus: 2, speed: 0.2, wage: 17 }, { role: "Slager", cats: ["vlees"], bonus: 3, speed: 0.15, wage: 22 }, { role: "Luxe verkoper", cats: ["luxe"], bonus: 5, speed: 0.1, wage: 30 }, { role: "Allrounder", cats: ["all"], bonus: 1, speed: 0.55, wage: 24 } ]; const DAYS = ["Ma", "Di", "Wo", "Do", "Vr", "Za", "Zo"]; const NAMES = ["Sam", "Noor", "Mila", "Daan", "Bo", "Aya", "Levi", "Tess", "Mo", "Iris", "Luca", "Saar"]; let NEXT_ID = 1; const clamp = (n, min, max) => Math.max(min, Math.min(max, n)); const euro = (n) => "€" + Math.floor(Number(n) || 0); const product = (id) => PRODUCTS.find((p) => p.id === id) || PRODUCTS[0]; const dayName = (day) => DAYS[(day - 1) % 7]; const isSunday = (day) => dayName(day) === "Zo"; const isWeekend = (day) => dayName(day) === "Za" || dayName(day) === "Zo"; const isFriday = (day) => dayName(day) === "Vr"; const shopName = (level) => ["", "Buurtsuper", "Grotere Supermarkt", "Superstore", "Hypermarkt", "Megamarkt"][level] || "Megamarkt"; const shopCost = (level) => [0, 1800, 6500, 25000, 100000, 999999][level] || 999999; const shopScore = (level) => [0, 1200, 5000, 15000, 50000, 999999][level] || 999999; const rentFor = (level) => [0, 18, 90, 240, 650, 1600][level] || 1600; const dayLength = (level) => 45 + (level - 1) * 10; const safeTarget = (day, level) => 180 + Math.floor((day - 1) / 7) * 240 + (level - 1) * 190; const minReputation = 50; const visibleProducts = (level) => PRODUCTS.filter((p) => p.unlock <= level); const bulkRate = (q) => (q >= 100 ? 0.18 : q >= 10 ? 0.13 : q >= 5 ? 0.08 : 0); const buyCost = (p, q) => Math.floor(p.cost * q * (1 - bulkRate(q))); const salePrice = (p, discount) => Math.max(1, p.price - (discount ? Math.ceil(p.price * 0.25) : 0)); const openShelfCost = (p, level) => 450 + p.unlock * 350 + level * 80; const shelfUpgradeCost = (p, cap, day, level) => 60 + day * 6 + level * 20 + p.unlock * 18 + Math.max(0, Math.floor((cap - 18) / 8)) * 45; const recruitCost = (time, level) => (25 + (level - 1) * 15) * (time < dayLength(level) / 2 ? 2 : 1); const scoreOf = (cash, safe, rep, served, lost, spoiled) => Math.floor(cash + safe + rep * 2 + served * 10 - lost * 14 - spoiled * 4); const emptyStats = () => ({ visitors: 0, served: 0, revenue: 0, profit: 0, walked: 0, missed: 0, spoiled: 0, golden: 0, sold: 0 }); const addStats = (a, b) => { const out = emptyStats(); Object.keys(out).forEach((k) => { out[k] = (a[k] || 0) + (b[k] || 0); }); return out; }; function makeDiscounts() { return Object.fromEntries(PRODUCTS.map((p) => [p.id, false])); } function makeStock() { return Object.fromEntries(PRODUCTS.map((p) => [p.id, p.unlock === 1 ? [{ qty: 6, age: 0 }] : []])); } function makeCap() { return Object.fromEntries(PRODUCTS.map((p) => [p.id, p.unlock === 1 ? 18 : 0])); } function stockOf(stock, id) { return (stock[id] || []).reduce((sum, b) => sum + b.qty, 0); } function cloneStock(stock) { return Object.fromEntries(PRODUCTS.map((p) => [p.id, (stock[p.id] || []).map((b) => ({ ...b }))])); } function addStock(stock, id, qty) { const next = cloneStock(stock); next[id].push({ qty, age: 0 }); return next; } function removeOne(stock, id) { const next = cloneStock(stock); let removed = false; next[id] = (next[id] || []).slice().sort((a, b) => b.age - a.age).map((b) => { if (removed) return b; removed = true; return { ...b, qty: b.qty - 1 }; }).filter((b) => b.qty > 0); return next; } function sellBasket(stock, basket) { return basket.reduce((cur, id) => removeOne(cur, id), stock); } function ageStock(stock) { const next = {}; let spoiled = 0; PRODUCTS.forEach((p) => { next[p.id] = []; (stock[p.id] || []).forEach((b) => { const age = b.age + 1; if (age > p.life) spoiled += b.qty; else next[p.id].push({ qty: b.qty, age }); }); }); return { stock: next, spoiled, waste: spoiled * 2 }; } function basketValue(basket, discounts = makeDiscounts()) { return basket.reduce((sum, id) => sum + salePrice(product(id), Boolean(discounts[id])), 0); } function basketGroups(basket) { const counts = {}; basket.forEach((id) => { counts[id] = (counts[id] || 0) + 1; }); return Object.keys(counts).map((id) => ({ id, qty: counts[id] })); } function missingProducts(stock, basket) { const need = {}; basket.forEach((id) => { need[id] = (need[id] || 0) + 1; }); return Object.keys(need).filter((id) => stockOf(stock, id) < need[id]); } function nearExpiry(stock, id) { const p = product(id); return (stock[id] || []).filter((b) => p.life - b.age <= 1).reduce((sum, b) => sum + b.qty, 0); } function waitPenalty(c) { const ratio = c.startPatience ? c.patience / c.startPatience : 1; return ratio < 0.35 ? 0.18 : ratio < 0.65 ? 0.08 : 0; } function repGain(rep) { return rep < 50 ? 0.7 : rep < 70 ? 0.5 : rep < 85 ? 0.32 : rep < 95 ? 0.18 : 0.08; } function adPower(ads, days) { const decay = days <= 0 ? 1 : days === 1 ? 0.99 : days === 2 ? 0.95 : clamp(0.95 - (days - 2) * 0.06, 0.35, 1); return clamp(ads * decay, 0, 1); } function isTrainingNow(s, day) { return Boolean(s.trainFrom && s.trainUntil && day >= s.trainFrom && day <= s.trainUntil); } function hasTrainingPlanned(s, day) { return Boolean(s.trainFrom && s.trainUntil && s.trainUntil >= day); } function trainingDuration(s, day) { return clamp(1 + (s.trainCount || 0) + Math.floor((day - 1) / 18), 1, 5); } function activeStaff(team, day) { return team.filter((s) => s.from <= day && !isTrainingNow(s, day)); } function ownerWageFor(team) { return 12 + team.filter((s) => s.id !== "owner").length * 2; } function normalizeOwnerWage(team) { const wage = ownerWageFor(team); return team.map((s) => (s.id === "owner" ? { ...s, wage } : s)); } function staffWage(team, day) { return normalizeOwnerWage(team).filter((s) => s.from <= day).reduce((sum, s) => sum + (s.wage || 0), 0); } function staffSpeed(team, day) { return 1 + activeStaff(team, day).reduce((sum, s) => sum + (s.speed || 0), 0); } function staffPotentialBonus(team, day) { return activeStaff(team, day).reduce((sum, s) => sum + (s.bonus || 0), 0); } function staffProductBonus(team, day, basket) { return Math.floor(activeStaff(team, day).reduce((sum, s) => { const cats = s.cats || []; const hit = cats.includes("all") || basket.some((id) => cats.includes(product(id).cat)); return sum + (hit ? (s.bonus || 0) : 0); }, 0)); } function monthlyRaiseAmount(s) { if (s.id === "owner") return 0; return (s.cats || []).includes("all") ? 5 : clamp(s.bonus || 2, 2, 6); } function isMonthlyRaiseDay(day) { return dayName(day) === "Ma" && day > 1 && Math.floor((day - 1) / 7) % 4 === 0; } function dayCosts(day, team, ads, sundayOpen, level, firedWages) { const base = staffWage(team, day) + firedWages; const wages = isSunday(day) && !sundayOpen ? 0 : base * (isSunday(day) && sundayOpen ? 2 : 1); const adverts = ads * 8; return { rent: rentFor(level), wages, adverts, total: rentFor(level) + wages + adverts }; } function activeProducts(level, cap) { return visibleProducts(level).filter((p) => (cap[p.id] || 0) > 0); } function customerChance(day, ads, rep, bonus, daysSinceAd, level) { if (rep < minReputation) return 0; return clamp(0.045 + day * 0.012 + adPower(ads, daysSinceAd) * 0.08 + rep * 0.0016 + bonus * 0.002 + (level - 1) * 0.04, 0.04, 0.72); } function makeCustomer(day, rep, discounts, ads, daysSinceAd, level, cap) { const items = activeProducts(level, cap); const pool = []; (items.length ? items : visibleProducts(1)).forEach((p) => { const demand = Math.max(1, Math.round(p.demand * (discounts[p.id] ? 2.5 : 1))); for (let i = 0; i < demand; i += 1) pool.push(p.id); }); const golden = Math.random() < clamp(0.025 + rep * 0.00025 + adPower(ads, daysSinceAd) * 0.025 + level * 0.004, 0.025, 0.13); const bulkBuyer = level >= 5 && !golden && Math.random() < clamp(0.04 + day * 0.002 + adPower(ads, daysSinceAd) * 0.03, 0.04, 0.16); const maxSize = clamp(4 + Math.floor((day - 1) / 4) + level, 4, 14); const size = bulkBuyer ? 20 + Math.floor(Math.random() * 81) : golden ? clamp(6 + Math.floor(day / 5) + level, 6, 15) : clamp(1 + Math.floor(Math.random() * maxSize) + Math.floor(day / 10), 1, maxSize); const basket = Array.from({ length: size }, () => pool[Math.floor(Math.random() * pool.length)]); const patience = clamp((golden ? 28 : bulkBuyer ? 34 : 22) + rep * 0.08 - day * 0.45 + level, 10, 46); return { id: "c" + NEXT_ID++, basket, patience, startPatience: patience, golden, bulkBuyer, tip: golden ? 18 + day * 4 + level * 8 : 0 }; } function makeApplicants(day, level) { const available = SPECS.filter((s) => s.cats.includes("all") || s.cats.some((cat) => PRODUCTS.some((p) => p.cat === cat && p.unlock <= level))); return [0, 1, 2].map(() => { const base = available[Math.floor(Math.random() * available.length)]; const boost = Math.random() < clamp(0.12 + level * 0.04, 0.12, 0.35) ? 1 : 0; return { id: "s" + NEXT_ID++, name: NAMES[Math.floor(Math.random() * NAMES.length)], role: (boost ? "Ervaren " : "") + base.role, cats: base.cats, speed: Math.round((base.speed + boost * 0.14) * 100) / 100, bonus: base.bonus + boost, wage: base.wage + Math.floor(day * 1.1) + (level - 1) * 8 + boost * 7, xp: 0, from: day + 1, trainCount: 0 }; }); } const shouldShowApplicantsAfterGoal = (goal, recruit, applicants) => Boolean(goal && !goal.failed && recruit && !applicants); const cashUpgradeAllowed = (cash, safe, cost, score, neededScore) => cash >= cost && score >= neededScore; const dismissalPayout = (s) => Math.max(5, Math.floor((s.wage || 0) * 0.35)); const upgradeCost = (day) => 80 + day * 22; const adCost = (ads) => 75 + ads * 45; function runTests() { console.assert(cashUpgradeAllowed(1800, 9999, 1800, 1200, 1200), "cash upgrade ok"); console.assert(!cashUpgradeAllowed(1000, 900, 1800, 1200, 1200), "safe cannot upgrade"); console.assert(staffProductBonus([{ from: 1, cats: ["brood"], bonus: 2 }], 1, ["bread", "bread"]) === 2, "bonus per klant"); console.assert(staffWage([{ id: "owner", from: 1, wage: 12 }, { id: "s1", from: 1, wage: 20, trainFrom: 2, trainUntil: 3 }], 2) === 34, "wage while training"); console.assert(trainingDuration({ trainCount: 2 }, 1) === 3, "training gets longer"); console.assert(recruitCost(dayLength(1), 1) === 25 && recruitCost(1, 1) === 50, "recruit cost halves"); console.assert(shouldShowApplicantsAfterGoal({ failed: false }, { fee: 25 }, null), "applicants after goal"); console.assert(addStats(emptyStats(), { missed: 7 }).missed === 7, "missed stats"); console.assert(makeCap().fruit === 18 && makeCap().cheese === 0, "locked shelves"); console.assert(adCost(0) === 75 && adCost(2) === 165, "ad cost scales"); } runTests(); function Box({ children, className = "" }) { return
{children}
; } function Button({ children, onClick, disabled = false, className = "" }) { const style = disabled ? "bg-slate-200 text-slate-400" : "bg-emerald-600 text-white hover:bg-emerald-700"; return ; } function Mini({ children, onClick, disabled = false, className = "" }) { const style = disabled ? "bg-slate-200 text-slate-400" : "bg-white text-slate-900 ring-1 ring-slate-200 hover:bg-slate-50"; return ; } function Row({ label, value, danger = false }) { return
{label}{value}
; } function Stat({ label, value }) { return
{label}
{value}
; } function Modal({ children }) { return
{children}
; } function GoalBar({ title, ok, value, pct }) { return
{title}{value}
; } function Help({ onClose }) { const lines = ["Scan klanten op tijd.", "Hogere reputatie zorgt voor meer klanten.", "Onder 50 reputatie is game over.", "Na sluitingstijd blijven klanten wachten en verliezen ze sneller geduld.", "Training duurt langer als hetzelfde personeelslid al vaker getraind is.", "Geld in de kluis telt niet mee voor winkelupgrade.", "Nieuwe producten verschijnen grijs; koop eerst het schap.", "Na vrijdag komt de doelcheck."]; return

❓ Uitleg

{lines.map((line) =>

β€’ {line}

)}
; } function EndDay({ data, onNext }) { return

πŸ“Š Dag {data.day} klaar

; } function GoalModal({ data, onNext }) { return

{data.passed ? "πŸ† Doel gehaald" : "πŸ’Έ Doel gemist"}

Dag {data.from} t/m {data.day}

; } function RaiseModal({ data, onNext }) { return

πŸ“ˆ Nieuwe maand: loonsverhoging

{data.rows.length ? data.rows.map((r) =>
{r.name}
{euro(r.oldWage)} β†’ {euro(r.newWage)} (+{euro(r.raise)})
) :

Geen personeel kreeg verhoging.

}
Eigen loon
{euro(data.ownerOld)} β†’ {euro(data.ownerNew)}
; } function Applicants({ list, cash, hire, skip, recruit }) { return

πŸ‘₯ Sollicitanten

Wervingskosten {euro(recruit?.fee || 0)}

{list.map((a) =>

{a.name}

{a.role}

)}
; } function StatsModal({ dayStats, weekStats, allStats, bestDayRevenue, bestWeekRevenue, bestDayProfit, onClose }) { const Block = ({ title, data, topRev, topProfit }) =>

{title}

0} /> 0} />{topRev !== null && }{topProfit !== null && }
; return

πŸ“Š Statistieken

; } function ProductCard({ p, stock, cap, orderQty, discounts, onBuy, onOpenShelf, onDiscount, paused, cash, level }) { const opened = (cap[p.id] || 0) > 0; if (!opened) return
{p.icon}
{p.name}

Nieuw schap beschikbaar, nog niet gekocht.

; const free = Math.max(0, cap[p.id] - stockOf(stock, p.id)); const qty = Math.min(orderQty === "max" ? free : Number(orderQty), free); const pct = Math.min(100, (stockOf(stock, p.id) / cap[p.id]) * 100); const old = nearExpiry(stock, p.id); return ; } function CustomerCard({ customer, stock, onScan, paused }) { const miss = missingProducts(stock, customer.basket); const card = customer.golden ? "bg-amber-100 ring-2 ring-amber-300" : miss.length ? "bg-rose-50 ring-2 ring-rose-200" : customer.bulkBuyer ? "bg-indigo-50 ring-2 ring-indigo-200" : ""; const bar = customer.patience <= 7 ? "bg-rose-500" : customer.patience <= 13 ? "bg-amber-400" : "bg-emerald-500"; return
{customer.golden ? "🌟 🧍 πŸ›’" : customer.bulkBuyer ? "πŸ“¦ 🧍 πŸ›’" : "🧍 πŸ›’"}
{basketGroups(customer.basket).map((g) =>
{product(g.id).icon} {g.qty > 1 ? g.qty + "x" : ""}
)}
{miss.length > 0 &&

Mist: {miss.map((id) => product(id).name).join(", ")}

}{waitPenalty(customer) > 0 &&

Wachtkorting -{Math.round(waitPenalty(customer) * 100)}%

}
; } function StaffCard({ member, day, cash, paused, onUpgrade, onTrain, onFire }) { const planned = hasTrainingPlanned(member, day); const now = isTrainingNow(member, day); const hasXp = (member.xp || 0) > 0; const canTrain = !paused && member.id !== "owner" && member.from <= day && !planned; return
{member.name}

{member.role}. XP {member.xp || 0}. Trainingen {member.trainCount || 0}{member.from > day ? ". Start dag " + member.from : ""}{planned ? now ? ". Op training t/m dag " + member.trainUntil : ". Training gepland" : ""}

onUpgrade(member.id, "speed")}>Snelheid {hasXp ? "XP" : euro(upgradeCost(day))} onUpgrade(member.id, "bonus")}>Bonus {hasXp ? "XP" : euro(upgradeCost(day))}{member.id !== "owner" && onTrain(member.id)} className="col-span-2">Train morgen}{member.id !== "owner" && onFire(member.id)} className="col-span-2 text-rose-700">Ontsla Β· krijg {euro(dismissalPayout(member))}}
; } export default function SupermarketGame() { const [screen, setScreen] = useState("menu"); const [owner, setOwner] = useState("Jij"); const [day, setDay] = useState(1); const [time, setTime] = useState(dayLength(1)); const [cash, setCash] = useState(95); const [safe, setSafe] = useState(0); const [rep, setRep] = useState(70); const [level, setLevel] = useState(1); const [stock, setStock] = useState(makeStock()); const [cap, setCap] = useState(makeCap()); const [discounts, setDiscounts] = useState(makeDiscounts()); const [queue, setQueue] = useState([]); const [staff, setStaff] = useState([{ id: "owner", name: "Jij", role: "Eigenaar", cats: ["all"], wage: 12, speed: 0, bonus: 0, from: 1, xp: 0, trainCount: 0 }]); const [ads, setAds] = useState(0); const [daysSinceAd, setDaysSinceAd] = useState(0); const [orderQty, setOrderQty] = useState(5); const [served, setServed] = useState(0); const [lost, setLost] = useState(0); const [spoiledTotal, setSpoiledTotal] = useState(0); const [stats, setStats] = useState(emptyStats()); const [week, setWeek] = useState(emptyStats()); const [allTime, setAllTime] = useState(emptyStats()); const [period, setPeriod] = useState(emptyStats()); const [bestDayRevenue, setBestDayRevenue] = useState(0); const [bestDayProfit, setBestDayProfit] = useState(0); const [bestWeekRevenue, setBestWeekRevenue] = useState(0); const [log, setLog] = useState(["Welkom bij je supermarkt."]); const [help, setHelp] = useState(false); const [showStats, setShowStats] = useState(false); const [paused, setPaused] = useState(false); const [summary, setSummary] = useState(null); const [goal, setGoal] = useState(null); const [raiseInfo, setRaiseInfo] = useState(null); const [recruit, setRecruit] = useState(null); const [applicants, setApplicants] = useState(null); const [sundayOpen, setSundayOpen] = useState(false); const [safeInput, setSafeInput] = useState("100"); const [firedWages, setFiredWages] = useState(0); const products = visibleProducts(level); const open = !isSunday(day) || sundayOpen; const normalizedStaff = useMemo(() => normalizeOwnerWage(staff), [staff]); const costs = useMemo(() => dayCosts(day, normalizedStaff, ads, sundayOpen, level, firedWages), [day, normalizedStaff, ads, sundayOpen, level, firedWages]); const speed = useMemo(() => staffSpeed(normalizedStaff, day), [normalizedStaff, day]); const potentialBonus = useMemo(() => staffPotentialBonus(normalizedStaff, day), [normalizedStaff, day]); const chance = customerChance(day, ads, rep, potentialBonus, daysSinceAd, level); const score = scoreOf(cash, safe, rep, served, lost, spoiledTotal); const safeOk = safe >= safeTarget(day, level); const storeReady = level < 5 && score >= shopScore(level) && cash >= shopCost(level); const actionLocked = paused && time > 0 && !summary && !goal && !applicants && !raiseInfo; const activeCount = activeStaff(normalizedStaff, day).length; const orderOptions = level >= 5 ? [1, 5, 10, 100, "max"] : [1, 5, 10, "max"]; const maxFreeSpace = products.reduce((m, p) => Math.max(m, Math.max(0, (cap[p.id] || 0) - stockOf(stock, p.id))), 0); const currentRecruitCost = recruitCost(time, level); const safeNeedNow = Math.min(cash, Math.max(0, safeTarget(day, level) - safe)); const goalWarning = dayName(day) === "Vr" && !safeOk; const goalWarningHard = goalWarning && safe + cash < safeTarget(day, level); function note(text) { setLog((old) => [text, ...old].slice(0, 9)); } function patchStats(patch) { setStats((s) => addStats(s, patch)); setWeek((s) => addStats(s, patch)); setAllTime((s) => addStats(s, patch)); if (!isWeekend(day)) setPeriod((s) => addStats(s, patch)); } function reset() { const name = (owner || "Jij").trim() || "Jij"; NEXT_ID = 1; setOwner(name); setScreen("play"); setDay(1); setTime(dayLength(1)); setCash(95); setSafe(0); setRep(70); setLevel(1); setStock(makeStock()); setCap(makeCap()); setDiscounts(makeDiscounts()); setQueue([]); setStaff([{ id: "owner", name, role: "Eigenaar", cats: ["all"], wage: 12, speed: 0, bonus: 0, from: 1, xp: 0, trainCount: 0 }]); setAds(0); setDaysSinceAd(0); setOrderQty(5); setServed(0); setLost(0); setSpoiledTotal(0); setStats(emptyStats()); setWeek(emptyStats()); setAllTime(emptyStats()); setPeriod(emptyStats()); setBestDayRevenue(0); setBestDayProfit(0); setBestWeekRevenue(0); setLog(["πŸͺ Dag 1 gestart. Succes, " + name + "!"]); setPaused(false); setSummary(null); setGoal(null); setRaiseInfo(null); setRecruit(null); setApplicants(null); setSundayOpen(false); setFiredWages(0); } function buy(id) { if (actionLocked) return note("Gepauzeerd."); if (!open) return note("Zondag dicht."); const p = product(id); if ((cap[id] || 0) <= 0) return note("Koop eerst het schap."); const free = Math.max(0, cap[id] - stockOf(stock, id)); const qty = Math.min(orderQty === "max" ? free : Number(orderQty), free); const cost = buyCost(p, qty); if (qty <= 0) return note(p.name + " is vol."); if (cash < cost) return note("Te weinig geld."); setCash(cash - cost); setStock(addStock(stock, id, qty)); note(qty + "x " + p.name + " gekocht voor " + euro(cost) + "."); } function openShelf(id) { const p = product(id); const cost = openShelfCost(p, level); if (actionLocked) return note("Gepauzeerd."); if (cash < cost) return note("Te weinig geld voor schap."); setCash(cash - cost); setCap({ ...cap, [id]: 18 }); note("Schap geopend: " + p.name + "."); } function scanCustomer(id) { if (actionLocked) return note("Gepauzeerd."); const c = queue.find((x) => x.id === id); if (!c) return; const missing = missingProducts(stock, c.basket); if (missing.length) { setQueue(queue.filter((x) => x.id !== id)); setRep(clamp(rep - 8, 0, 100)); setLost(lost + 1); patchStats({ walked: 1, missed: basketValue(c.basket, discounts) }); note("Niet op voorraad: " + product(missing[0]).name + "."); return; } const normal = basketValue(c.basket, makeDiscounts()); const staffBonus = staffProductBonus(normalizedStaff, day, c.basket); const before = basketValue(c.basket, discounts); const reduced = Math.max(1, Math.floor(before * (1 - waitPenalty(c)))); const waitDiscount = before - reduced; const total = reduced + staffBonus + (c.tip || 0); const repChange = repGain(rep) - (waitPenalty(c) >= 0.18 ? 2 : waitPenalty(c) >= 0.08 ? 1 : 0); setStock(sellBasket(stock, c.basket)); setCash(cash + total); setQueue(queue.filter((x) => x.id !== id)); setRep(clamp(rep + repChange, 0, 100)); setServed(served + 1); patchStats({ served: 1, revenue: total, missed: waitDiscount, sold: c.basket.length, golden: c.golden ? 1 : 0 }); note((c.golden ? "🌟 " : "") + "Opbrengst " + euro(total) + ": producten " + euro(reduced) + ", personeelsbonus " + euro(staffBonus) + (c.tip ? ", golden bonus " + euro(c.tip) : "") + (waitDiscount ? ", wachtkorting -" + euro(waitDiscount) : "") + (normal - before ? ", productkorting -" + euro(normal - before) : "") + "."); } function finishDay() { if (time > 0) return note("Einde dag kan pas als de timer op 0 staat."); const aged = ageStock(stock); const closing = { walked: queue.length, missed: queue.reduce((sum, c) => sum + basketValue(c.basket, discounts), 0), spoiled: aged.spoiled }; const dayStats = addStats(stats, closing); const after = cash - costs.total - aged.waste; const profit = dayStats.revenue - costs.total - aged.waste; const fullDay = { ...dayStats, profit }; const periodStats = isWeekend(day) ? period : { ...addStats(period, closing), profit: (period.profit || 0) + profit }; const decay = clamp(0.2 + Math.floor(day / 7) * 0.25, 0.2, 2.2); const flawless = dayStats.served >= 5 && dayStats.walked === 0 && dayStats.spoiled === 0 ? 1.2 : 0; const nextRep = clamp(rep - decay - closing.walked * 5 - aged.spoiled * 0.2 + flawless, 0, 100); setBestDayRevenue((v) => Math.max(v, dayStats.revenue)); setBestDayProfit((v) => Math.max(v, profit)); setStats(fullDay); setWeek((s) => ({ ...addStats(s, closing), profit: (s.profit || 0) + profit })); setAllTime((s) => ({ ...addStats(s, closing), profit: (s.profit || 0) + profit })); if (!isWeekend(day)) setPeriod(periodStats); setStock(aged.stock); setCash(after); setRep(nextRep); setLost(lost + closing.walked); setSpoiledTotal(spoiledTotal + aged.spoiled); setQueue([]); setSummary({ day, ...fullDay, after, nextRep, period: periodStats }); setPaused(true); setStaff((team) => normalizeOwnerWage(team.map((s) => (s.trainUntil && s.trainUntil <= day ? { ...s, trainFrom: null, trainUntil: null, xp: (s.xp || 0) + 1, trainCount: (s.trainCount || 0) + 1 } : s)))); } function applyMonthlyRaiseIfNeeded(nextDay) { if (!isMonthlyRaiseDay(nextDay)) return false; let rows = []; let ownerOld = 0; let ownerNew = 0; setStaff((team) => { const before = normalizeOwnerWage(team); ownerOld = before.find((s) => s.id === "owner")?.wage || 12; const raised = before.map((s) => { if (s.id === "owner" || s.from > nextDay) return s; const raise = monthlyRaiseAmount(s); rows.push({ id: s.id, name: s.name, oldWage: s.wage, newWage: s.wage + raise, raise }); return { ...s, wage: s.wage + raise }; }); const after = normalizeOwnerWage(raised); ownerNew = after.find((s) => s.id === "owner")?.wage || ownerOld; return after; }); setRaiseInfo({ rows, ownerOld, ownerNew }); note("Nieuwe maand: loonsverhoging verwerkt."); return true; } function advanceDay() { const next = day + 1; setDay(next); setTime(isSunday(next) ? 0 : dayLength(level)); setStats(emptyStats()); if (dayName(next) === "Ma") { setBestWeekRevenue((v) => Math.max(v, week.revenue)); setWeek(emptyStats()); setPeriod(emptyStats()); } setSummary(null); setGoal(null); setRecruit(null); setApplicants(null); setSundayOpen(false); setDaysSinceAd(daysSinceAd + 1); setPaused(isSunday(next)); setFiredWages(0); applyMonthlyRaiseIfNeeded(next); } function nextDay() { if (raiseInfo) { setRaiseInfo(null); return; } if (goal) { if (goal.failed) { setScreen("lost"); return; } if (shouldShowApplicantsAfterGoal(goal, recruit, applicants)) { setApplicants(makeApplicants(day, level)); setGoal(null); setSummary(null); return; } advanceDay(); return; } if (summary && summary.after < 0) { setScreen("lost"); return; } if (summary && isFriday(day)) { setGoal({ day, from: day - 4, period: summary.period, safe, safeNeed: safeTarget(day, level), safeOk, passed: safeOk, failed: !safeOk }); setSummary(null); return; } if (recruit && !applicants) { setApplicants(makeApplicants(day, level)); setSummary(null); return; } advanceDay(); } function startRecruit() { if (actionLocked) return note("Gepauzeerd."); if (!open || time <= 0) return note("Werven kan alleen als de winkel open is."); const fee = currentRecruitCost; if (cash < fee) return note("Te weinig geld."); setCash(cash - fee); setRecruit({ fee }); note("Werving gestart voor " + euro(fee) + ". Kandidaten komen einde dag."); } function hire(a) { const fee = a.wage * 2; if (cash < fee) return note("Te weinig geld."); setCash(cash - fee); setStaff(normalizeOwnerWage([...staff, a])); setApplicants(null); note(a.name + " start morgen. Je eigen loon stijgt met €2."); nextDay(); } function moveSafe(dir, amount) { if (actionLocked) return note("Gepauzeerd."); const requested = Math.max(0, Math.floor(Number(String(amount).replace(/[^0-9]/g, "")) || 0)); const moved = Math.min(dir === "in" ? cash : safe, requested); if (!moved) return; setCash(dir === "in" ? cash - moved : cash + moved); setSafe(dir === "in" ? safe + moved : safe - moved); } function upgradeStore() { const cost = shopCost(level); if (actionLocked) return note("Gepauzeerd."); if (score < shopScore(level)) return note("Score te laag."); if (cash < cost) return note("Te weinig geld in kas. Kluisgeld telt niet mee."); setCash(cash - cost); setLevel(level + 1); note("Upgrade naar " + shopName(level + 1) + ". Nieuwe schappen zijn nu te koop."); } function train(id) { const s = staff.find((x) => x.id === id); if (actionLocked || !s || s.id === "owner") return; if (s.from > day) return note(s.name + " is nog niet begonnen."); if (hasTrainingPlanned(s, day)) return note(s.name + " heeft al training gepland."); const duration = trainingDuration(s, day); setStaff(staff.map((x) => (x.id === id ? { ...x, trainFrom: day + 1, trainUntil: day + duration } : x))); note(s.name + " gaat " + duration + " dag" + (duration === 1 ? "" : "en") + " op training."); } function fireStaff(id) { const s = staff.find((x) => x.id === id); if (actionLocked || !s || s.id === "owner") return; setStaff(normalizeOwnerWage(staff.filter((x) => x.id !== id))); setCash(cash + dismissalPayout(s)); if (s.from <= day) setFiredWages((v) => v + s.wage); note(s.name + " ontslagen. Je krijgt " + euro(dismissalPayout(s)) + "."); } function upgradeStaff(id, skill) { const s = staff.find((x) => x.id === id); if (actionLocked || !s) return; const free = (s.xp || 0) > 0; const cost = free ? 0 : upgradeCost(day); if (cash < cost) return note("Te weinig geld."); if (cost > 0) setCash(cash - cost); setStaff(normalizeOwnerWage(staff.map((x) => { if (x.id !== id) return x; const xp = free ? (x.xp || 0) - 1 : (x.xp || 0); const wage = x.id === "owner" ? x.wage : x.wage + (skill === "speed" ? 2 : 3); return skill === "speed" ? { ...x, xp, wage, speed: Math.round(((x.speed || 0) + 0.12) * 100) / 100 } : { ...x, xp, wage, bonus: (x.bonus || 0) + 1 }; }))); } useEffect(() => { if (screen !== "play" || help || showStats || summary || goal || applicants || raiseInfo || !open) return undefined; const closedWithQueue = time === 0 && queue.length > 0; if (paused && !closedWithQueue) return undefined; const timer = setInterval(() => { setTime((t) => (t <= 1 ? 0 : t - 1)); setQueue((old) => { const afterClosing = time === 0; const loss = afterClosing ? 1.6 : 1 / speed; let next = old.map((c) => ({ ...c, patience: c.patience - loss })); const gone = next.filter((c) => c.patience <= 0); if (gone.length) { setLost((v) => v + gone.length); setRep((r) => clamp(r - gone.length * 10, 0, 100)); patchStats({ walked: gone.length, missed: gone.reduce((sum, c) => sum + basketValue(c.basket, discounts), 0) }); } next = next.filter((c) => c.patience > 0); if (!afterClosing && next.length < 3 + Math.floor(speed) + level && Math.random() < chance) { next.push(makeCustomer(day, rep, discounts, ads, daysSinceAd, level, cap)); patchStats({ visitors: 1 }); } return next; }); }, 1000); return () => clearInterval(timer); }, [screen, help, showStats, paused, summary, goal, applicants, raiseInfo, open, speed, chance, day, rep, discounts, ads, daysSinceAd, level, cap, time, queue.length]); useEffect(() => { if (time === 0) setPaused(true); }, [time]); useEffect(() => { if (cash < 0 && screen === "play" && !summary) setScreen("lost"); }, [cash, screen, summary]); useEffect(() => { if (rep < minReputation && screen === "play") setScreen("lost"); }, [rep, screen]); if (screen === "menu") { return
{help && setHelp(false)} />}
πŸͺ

Supermarkt Tycoon

setOwner(e.target.value)} className="mt-6 rounded-2xl border p-4 text-center font-black" placeholder="Naam eigenaar" />
; } if (screen === "lost") { return
πŸ’Έ

Game over

{owner} moest sluiten.

; } return
{help && setHelp(false)} />} {showStats && setShowStats(false)} />} {summary && } {goal && } {raiseInfo && setRaiseInfo(null)} />} {applicants && }
Dag {day} - {dayName(day)} - {shopName(level)}

Supermarkt Tycoon

Eigenaar {owner}. Score {score}.

πŸ† Doelvoortgang
{isWeekend(day) ? "Nieuw doel start maandag" : "Doelcheck na vrijdag"}

Zet genoeg geld in de kluis. Winkelupgrades gebruiken alleen kasgeld.

{safeOk ? "βœ… Veilig" : "⚠️ Nog niet veilig"}
{cash - costs.total < 20 &&
Let op: na dagkosten hou je weinig geld over.
} {goalWarning &&
{goalWarningHard ? "⚠️ Vandaag is de laatste dag voor de doelcheck en zelfs met al je kasgeld haal je het kluisdoel nog niet." : "⚠️ Vandaag is de laatste dag voor de doelcheck. Zet nog geld in de kluis om je doel te halen."}
} {actionLocked &&
⏸️ Pauze: je kunt nu niet inkopen, scannen, upgraden of geld verplaatsen.
}

🧺 Schappen

πŸ“¦ Inkoopaantal
{orderOptions.map((n) => )}
{products.map((p) => setDiscounts({ ...discounts, [id]: !discounts[id] })} paused={actionLocked} cash={cash} level={level} />)}

πŸ“ˆ Investeren

{products.filter((p) => (cap[p.id] || 0) > 0).map((p) => { const c = shelfUpgradeCost(p, cap[p.id], day, level); return ; })}

🧾 Kassa en wachtrij

{isSunday(day) && !sundayOpen &&

Zondag: dicht of open met dubbel loon.

Blijf dicht
}
{queue.map((c) => )}{queue.length === 0 && Geen klanten in de rij.}

πŸ“˜ Logboek

{log.map((line, i) =>

{line}

)}

πŸ“‹ Managerbord

πŸ’Έ Over na huur, loon en reclame
{euro(cash - costs.total)}

πŸ‘₯ Personeel

{staff.length} totaal Β· {activeCount} actief vandaag

{normalizedStaff.map((m) => )}

πŸ—οΈ Winkel uitbreiden

{level < 5 ?

Alleen kasgeld telt mee. Kluisgeld blijft veilig voor je doel.

:

Maximale winkel bereikt.

}

πŸ”’ Kluis

moveSafe("in", 50)}>Zet €50 weg moveSafe("out", 50)}>Haal €50 terug moveSafe("in", safeNeedNow)} className="col-span-2">Zet {euro(safeNeedNow)} weg
setSafeInput(e.target.value)} disabled={actionLocked} className="mt-3 w-full rounded-xl border p-2" />
moveSafe("in", safeInput)}>Zet weg moveSafe("out", safeInput)}>Haal terug
; }