import React, { useState, useEffect, useMemo, useCallback } from "react"; import * as XLSX from "xlsx"; /* ============================================================ GRENSROCK — Sponsor Management Data model (persisted in window.storage under one key): sponsors[] editions[] (with formulas[]) deals[] ============================================================ */ const STORAGE_KEY = "grensrock:data:v1"; const DRINK_TICKET_VALUE = 27.5; const uid = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 7); const LANGS = { nl: "Nederlands", fr: "Français", en: "English" }; /* ---------- contacts ---------- */ const COMMS = { info: "Information", invoice: "Invoicing", vouchers: "Tickets & vouchers", }; const COMM_LABELS = { info: { nl: "Algemene informatie", fr: "Informations générales", en: "General information" }, invoice: { nl: "Facturatie", fr: "Facturation", en: "Invoicing" }, vouchers: { nl: "Tickets & bonnen", fr: "Tickets & bons", en: "Tickets & vouchers" }, }; const emptyContact = { firstName: "", lastName: "", email: "", phone: "", language: "nl", prefs: { info: true, invoice: false, vouchers: false }, }; const contactName = (c) => [c?.firstName, c?.lastName].filter(Boolean).join(" ").trim(); const sponsorContacts = (s) => (Array.isArray(s?.contacts) ? s.contacts : []); const primaryContact = (s) => sponsorContacts(s).find((c) => c.email) || sponsorContacts(s)[0] || null; /* Contacts who should receive a given kind of communication (falls back to the first contact) */ const contactsFor = (s, kind) => { const withMail = sponsorContacts(s).filter((c) => c.email); const picked = withMail.filter((c) => c.prefs?.[kind]); return picked.length ? picked : withMail.slice(0, 1); }; /* Old single-contact sponsors become a one-entry contact list */ const withContacts = (s) => { if (Array.isArray(s.contacts)) return s; const had = s.contactName || s.contactEmail || s.contactPhone; return { ...s, contacts: had ? [{ ...emptyContact, id: uid(), lastName: s.contactName || "", email: s.contactEmail || "", phone: s.contactPhone || "", language: s.contactLanguage || "nl", prefs: { info: true, invoice: true, vouchers: true }, }] : [], }; }; const WEBSITE_OPTS = { logo: "Logo", name: "Name only", none: "No mention" }; const POSTER_OPTS = { logo: "Logo", name: "Name only", none: "No mention" }; const FOLDER_OPTS = { none: "No mention", full: "Full page", half: "Half page", quarter: "1/4 page", eighth: "1/8 page" }; const LED_ZONES = { vip: "VIP", foodvillage: "Food village", stage: "Stage" }; /* Migrate older saved data: ledboarding was a single value, folderMention a yes/no */ const normLed = (v) => Array.isArray(v) ? v.filter((x) => LED_ZONES[x]) : (v && v !== "none" ? [v] : []); const normFolder = (v) => (typeof v === "boolean" ? (v ? "full" : "none") : (v && FOLDER_OPTS[v] ? v : "none")); const normBenefits = (o) => ({ ...o, ledboarding: normLed(o.ledboarding), folderMention: normFolder(o.folderMention), foodDrinkVouchers: Array.isArray(o.foodDrinkVouchers) ? o.foodDrinkVouchers : [], posterMention: POSTER_OPTS[o.posterMention] ? o.posterMention : "none", settlement: SETTLEMENTS[o.settlement] ? o.settlement : "invoice", inKindDescription: o.inKindDescription || "", inKindValue: o.inKindValue ?? "", refRequired: !!o.refRequired, invoiceReference: o.invoiceReference || "", }); /* How the sponsor settles their contribution */ const SETTLEMENTS = { invoice: "Invoice (money)", inkind: "In kind — no invoice", mixed: "Combination: part invoiced, part in kind", }; /* Amount that actually has to be invoiced */ const invoiceAmount = (d) => { const cost = Number(d.cost) || 0; if (d.settlement === "inkind") return 0; if (d.settlement === "mixed") return Math.max(0, cost - (Number(d.inKindValue) || 0)); return cost; }; const needsInvoice = (d) => invoiceAmount(d) > 0; /* A reference is required when the sponsor is flagged as such (or the agreement overrides it) */ const refRequiredFor = (d, sponsor) => !!(sponsor?.refRequired || d?.refRequired); const refMissing = (d, sponsor) => needsInvoice(d) && refRequiredFor(d, sponsor) && !String(d.invoiceReference || "").trim(); const migrate = (data) => withPortalCodes({ ...data, deals: (data.deals || []).map(normBenefits), editions: (data.editions || []).map((e) => ({ ...e, formulas: (e.formulas || []).map(normBenefits) })), }); const ledLabel = (arr) => normLed(arr).map((z) => LED_ZONES[z]).join(" + "); /* ---------- access ---------- */ /* Temporary shared password for everyone. Replace with real per-user authentication when the app moves to a backend (BillToBox phase). */ const SHARED_PASSWORD = "grensrock"; const sameEmail = (a, b) => normStr(a).toLowerCase() === normStr(b).toLowerCase(); const ROLES = { admin: "Admin — full access", user: "User — sponsors & agreements", }; const isAdmin = (u) => u?.role === "admin"; /* Team accounts use firstname@grensrock.be */ const TEAM_DOMAIN = "grensrock.be"; const teamEmailFor = (firstName) => { const slug = normStr(firstName).toLowerCase() .normalize("NFD").replace(/[\u0300-\u036f]/g, "") // strip accents .replace(/[^a-z0-9]/g, ""); return slug ? `${slug}@${TEAM_DOMAIN}` : ""; }; const userName = (u) => [u?.firstName, u?.lastName].filter(Boolean).join(" ").trim() || u?.name || ""; /* Name of the team member responsible for a sponsor (falls back to legacy free text) */ const responsibleName = (sponsor, users = []) => { const u = users.find((x) => x.id === sponsor?.responsibleId); return u ? userName(u) : (sponsor?.responsible || ""); }; /* Old accounts stored a single name field */ const withUserNames = (u) => { if (u.firstName || u.lastName) return u; const parts = normStr(u.name).split(/\s+/).filter(Boolean); return { ...u, firstName: parts[0] || "", lastName: parts.slice(1).join(" ") }; }; const withPortalCodes = (data) => { const users = (data.users || []).map((u) => withUserNames({ role: "user", ...u })); /* Old sponsors stored the responsible as free text — match it to an account where possible */ const linkResponsible = (s) => { if (s.responsibleId !== undefined) return s; const txt = normStr(s.responsible); const hit = txt && users.find((u) => userName(u).toLowerCase() === txt.toLowerCase() || normStr(u.firstName).toLowerCase() === txt.toLowerCase()); return { ...s, responsibleId: hit ? hit.id : "" }; }; return { ...data, settings: { tickoweb: { ...defaultTickoweb }, ...(data.settings || {}), tickoweb: { ...defaultTickoweb, ...(data.settings?.tickoweb || {}) } }, users, vouchers: data.vouchers || [], sponsors: (data.sponsors || []).map((s) => linkResponsible(withContacts(s))), }; }; /* ---------- logo storage (separate shared keys, so the main blob stays small) ---------- */ const logoKey = (sponsorId) => "grensrock:logo:" + sponsorId; async function fileToLogoDataUrl(file) { const dataUrl = await new Promise((res, rej) => { const r = new FileReader(); r.onload = () => res(r.result); r.onerror = () => rej(new Error("read failed")); r.readAsDataURL(file); }); // SVG or already small: keep as-is (preserves transparency/vector quality) if (file.type === "image/svg+xml" || dataUrl.length < 700000) return dataUrl; // Downscale large raster images so they fit comfortably in storage const img = await new Promise((res, rej) => { const i = new Image(); i.onload = () => res(i); i.onerror = () => rej(new Error("not an image")); i.src = dataUrl; }); const max = 1200; const scale = Math.min(1, max / Math.max(img.width, img.height)); const c = document.createElement("canvas"); c.width = Math.max(1, Math.round(img.width * scale)); c.height = Math.max(1, Math.round(img.height * scale)); c.getContext("2d").drawImage(img, 0, 0, c.width, c.height); const out = c.toDataURL("image/png"); if (out.length > 4500000) throw new Error("too large"); return out; } /* Reusable logo block: shows current logo, allows upload/replace/remove */ function LogoUploader({ sponsorId, compact }) { const [logo, setLogo] = useState(null); const [busy, setBusy] = useState(false); const [err, setErr] = useState(""); useEffect(() => { let live = true; (async () => { try { const r = await window.storage.get(logoKey(sponsorId), true); if (live && r && r.value) setLogo(r.value); } catch { /* no logo yet */ } })(); return () => { live = false; }; }, [sponsorId]); const onFile = async (e) => { const file = e.target.files && e.target.files[0]; if (!file) return; setErr(""); setBusy(true); try { const dataUrl = await fileToLogoDataUrl(file); await window.storage.set(logoKey(sponsorId), dataUrl, true); setLogo(dataUrl); } catch (ex) { setErr(ex.message === "too large" ? "This image is too large even after resizing. Please use a smaller file." : "Couldn't read this file. Use a PNG, JPG or SVG."); } setBusy(false); e.target.value = ""; }; const remove = async () => { try { await window.storage.delete(logoKey(sponsorId), true); } catch { /* already gone */ } setLogo(null); }; return (
{logo ? Sponsor logo : No logo yet}
{logo && } {err && {err}} {!err && !compact && PNG, JPG or SVG — used for folder, LED boarding, website…}
); } /* ---------- portal invitation text ---------- */ function buildInvitation(sponsor, contact, responsible) { const c = contact || primaryContact(sponsor) || {}; const lang = c.language || "nl"; const B = (nl, fr, en) => ({ nl, fr, en }[lang]); return B( `Beste ${contactName(c) || sponsor.companyName}, Via onze sponsortool kan u de gegevens van ${sponsor.companyName} voor Grensrock zelf nakijken en aanpassen, en uw logo opladen (voor de folder, ledboarding, website, ...). Link: [link naar de sponsortool] Meld u aan met uw e-mailadres (${c.email || "het adres dat wij van u hebben"}) en het wachtwoord: ${SHARED_PASSWORD} Alvast bedankt! ${responsible || "Het Grensrock-team"}`, `Cher/Chère ${contactName(c) || sponsor.companyName}, Via notre outil sponsors, vous pouvez vérifier et modifier vous-même les données de ${sponsor.companyName} pour Grensrock, et télécharger votre logo (pour le dépliant, le ledboarding, le site web, ...). Lien : [lien vers l'outil sponsors] Connectez-vous avec votre adresse e-mail (${c.email || "l'adresse que nous avons"}) et le mot de passe : ${SHARED_PASSWORD} Merci d'avance ! ${responsible || "L'équipe Grensrock"}`, `Dear ${contactName(c) || sponsor.companyName}, Through our sponsor tool you can review and update ${sponsor.companyName}'s details for Grensrock yourself, and upload your logo (for the folder, LED boarding, website, ...). Link: [link to the sponsor tool] Log in with your email address (${c.email || "the address we have on file"}) and the password: ${SHARED_PASSWORD} Thank you! ${responsible || "The Grensrock team"}`); } const eur = (n) => { const v = Number(n) || 0; return "€ " + v.toLocaleString("nl-BE", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); }; const emptySponsor = { companyName: "", address: "", vatNumber: "", entityType: "legal", contacts: [], responsibleId: "", refRequired: false, }; const emptyEdition = { name: "", startDate: "", endDate: "", location: "", active: true, formulas: [] }; const emptyFormula = { name: "", cost: "", vipFriday: 0, vipSaturday: 0, vipGuests: 0, drinkTickets: 0, foodDrinkValue: 0, folderMention: "none", websiteMention: "none", posterMention: "none", ledboarding: [], }; const emptyDeal = { sponsorId: "", editionId: "", formulaName: "", cost: "", vipFriday: 0, vipSaturday: 0, vipGuests: 0, drinkTickets: 0, foodDrinkValue: 0, foodDrinkVouchers: [], folderMention: "none", websiteMention: "none", posterMention: "none", ledboarding: [], notes: "", settlement: "invoice", inKindDescription: "", inKindValue: "", refRequired: false, invoiceReference: "", invoiceCreated: false, invoiceNumber: "", emailSentAt: null, createdAt: null, }; /* ---------- Email / contract templates ---------- */ function buildEmail(deal, sponsor, edition, contact) { const c = contact || primaryContact(sponsor) || {}; const lang = c.language || "nl"; const benefits = []; const B = (nl, fr, en) => ({ nl, fr, en }[lang]); if (Number(deal.vipFriday) > 0) benefits.push(B(`${deal.vipFriday} VIP-tickets vrijdag`, `${deal.vipFriday} tickets VIP vendredi`, `${deal.vipFriday} VIP tickets Friday`)); if (Number(deal.vipSaturday) > 0) benefits.push(B(`${deal.vipSaturday} VIP-tickets zaterdag`, `${deal.vipSaturday} tickets VIP samedi`, `${deal.vipSaturday} VIP tickets Saturday`)); if (Number(deal.vipGuests) > 0) benefits.push(B(`${deal.vipGuests} VIP-tickets voor genodigden`, `${deal.vipGuests} tickets VIP pour invités`, `${deal.vipGuests} VIP tickets for guests`)); if (Number(deal.drinkTickets) > 0) benefits.push(B( `${deal.drinkTickets} drankbonnen (waarde ${eur(DRINK_TICKET_VALUE)} per bon)`, `${deal.drinkTickets} tickets boissons (valeur ${eur(DRINK_TICKET_VALUE)} par ticket)`, `${deal.drinkTickets} drink tickets (value ${eur(DRINK_TICKET_VALUE)} each)`)); if (Number(deal.foodDrinkValue) > 0) { const vt = voucherText(deal.foodDrinkVouchers); const split = vt ? B(`, verdeeld in bonnen: ${vt}`, `, répartis en bons : ${vt}`, `, divided into vouchers: ${vt}`) : ""; benefits.push(B( `Bonnen eten & drinken ter waarde van ${eur(deal.foodDrinkValue)}${split}`, `Bons food & boissons d'une valeur de ${eur(deal.foodDrinkValue)}${split}`, `Food & drink vouchers worth ${eur(deal.foodDrinkValue)}${split}`)); } const folder = normFolder(deal.folderMention); if (folder !== "none") { const size = { full: B("volledige pagina", "page entière", "full page"), half: B("halve pagina", "demi-page", "half page"), quarter:B("1/4 pagina", "1/4 de page", "1/4 page"), eighth: B("1/8 pagina", "1/8 de page", "1/8 page"), }[folder]; benefits.push(B( `Vermelding in de festivalfolder (${size})`, `Mention dans le dépliant du festival (${size})`, `Mention in the festival folder (${size})`)); } if (deal.websiteMention !== "none") benefits.push(deal.websiteMention === "logo" ? B("Logo op de website", "Logo sur le site web", "Logo on the website") : B("Naamsvermelding op de website", "Mention du nom sur le site web", "Name mention on the website")); if (deal.posterMention && deal.posterMention !== "none") benefits.push(deal.posterMention === "logo" ? B("Logo op de festivalaffiche", "Logo sur l'affiche du festival", "Logo on the festival poster") : B("Naamsvermelding op de festivalaffiche", "Mention du nom sur l'affiche du festival", "Name mention on the festival poster")); const zones = normLed(deal.ledboarding); if (zones.length > 0) { const zoneList = zones.map((z) => LED_ZONES[z]).join(" + "); benefits.push(B(`Vermelding op ledboarding (${zoneList})`, `Mention sur le ledboarding (${zoneList})`, `Mention on LED boarding (${zoneList})`)); } const list = benefits.map((b) => " • " + b).join("\n"); const dates = edition.startDate ? ` (${edition.startDate}${edition.endDate ? " – " + edition.endDate : ""})` : ""; const notes = (deal.notes || "").trim(); /* Contribution + settlement wording */ const inKind = (deal.inKindDescription || "").trim(); const toInvoice = invoiceAmount(deal); const contribution = B( `${contribution}`, `Contribution : ${eur(deal.cost)}`, `Contribution: ${eur(deal.cost)}`); const ref = String(deal.invoiceReference || "").trim(); const refLine = refRequiredFor(deal, sponsor) ? (ref ? B(`\nReferentie op de factuur: ${ref}`, `\nRéférence sur la facture : ${ref}`, `\nReference on the invoice: ${ref}`) : B("\nGelieve ons de referentie te bezorgen die op de factuur moet komen.", "\nMerci de nous communiquer la référence à mentionner sur la facture.", "\nPlease let us know the reference to put on the invoice.")) : ""; let settlementBlock; if (deal.settlement === "inkind") { settlementBlock = B( `Deze sponsoring gebeurt in natura${inKind ? `:\n${inKind}` : "."} Er wordt hiervoor geen factuur opgemaakt.`, `Ce sponsoring se fait en nature${inKind ? ` :\n${inKind}` : "."} Aucune facture ne sera établie pour cet accord.`, `This sponsorship is provided in kind${inKind ? `:\n${inKind}` : "."} No invoice will be issued for this agreement.`); } else if (deal.settlement === "mixed") { settlementBlock = B( `Een deel van deze sponsoring gebeurt in natura${inKind ? `:\n${inKind}` : "."} Te factureren bedrag: ${eur(toInvoice)}. De facturatie volgt via e-invoicing (BillToBox).${refLine}`, `Une partie de ce sponsoring se fait en nature${inKind ? ` :\n${inKind}` : "."} Montant à facturer : ${eur(toInvoice)}. La facturation suivra par e-invoicing (BillToBox).${refLine}`, `Part of this sponsorship is provided in kind${inKind ? `:\n${inKind}` : "."} Amount to be invoiced: ${eur(toInvoice)}. Invoicing will follow via e-invoicing (BillToBox).${refLine}`); } else { settlementBlock = B( `De facturatie volgt via e-invoicing (BillToBox).${refLine}`, `La facturation suivra par e-invoicing (BillToBox).${refLine}`, `Invoicing will follow via e-invoicing (BillToBox).${refLine}`); } const confirm = B( 'Dit overzicht geldt als contract. Gelieve dit ter bevestiging te beantwoorden met "akkoord".', 'Cet aperçu fait office de contrat. Veuillez répondre « accord » pour confirmation.', 'This overview serves as the contract. Please reply "agreed" to confirm.'); const subject = B( `Bevestiging sponsorovereenkomst ${edition.name}`, `Confirmation de l'accord de sponsoring ${edition.name}`, `Confirmation of sponsorship agreement ${edition.name}`); const body = B( `Beste ${contactName(c) || sponsor.companyName}, Hartelijk dank voor uw steun aan ${edition.name}${dates}. Hieronder vindt u een overzicht van de afgesproken sponsorformule${deal.formulaName ? ` "${deal.formulaName}"` : ""}. Sponsor: ${sponsor.companyName} BTW-nummer: ${sponsor.vatNumber || "—"} ${contribution} Inbegrepen: ${list || " • (geen voordelen geselecteerd)"} ${notes ? "\nOpmerkingen:\n" + notes + "\n" : ""} ${settlementBlock} ${confirm} Met vriendelijke groeten, ${deal.responsible || "Het Grensrock-team"} Grensrock`, `Cher/Chère ${contactName(c) || sponsor.companyName}, Merci beaucoup pour votre soutien à ${edition.name}${dates}. Vous trouverez ci-dessous un aperçu de la formule de sponsoring convenue${deal.formulaName ? ` « ${deal.formulaName} »` : ""}. Sponsor : ${sponsor.companyName} Numéro de TVA : ${sponsor.vatNumber || "—"} ${contribution} Inclus : ${list || " • (aucun avantage sélectionné)"} ${notes ? "\nRemarques :\n" + notes + "\n" : ""} ${settlementBlock} ${confirm} Cordialement, ${deal.responsible || "L'équipe Grensrock"} Grensrock`, `Dear ${contactName(c) || sponsor.companyName}, Thank you very much for supporting ${edition.name}${dates}. Below you will find an overview of the agreed sponsorship formula${deal.formulaName ? ` "${deal.formulaName}"` : ""}. Sponsor: ${sponsor.companyName} VAT number: ${sponsor.vatNumber || "—"} ${contribution} Included: ${list || " • (no benefits selected)"} ${notes ? "\nNotes:\n" + notes + "\n" : ""} ${settlementBlock} ${confirm} Kind regards, ${deal.responsible || "The Grensrock team"} Grensrock`); return { subject, body }; } /* ============================================================ VOUCHERS + TICKOWEB ============================================================ The Tickoweb OpenAPI reference at https://openapi.tickoweb.be/v1 could not be read when this was written, so the exact endpoint paths, payload shape and auth header below are ASSUMPTIONS. Everything Tickoweb-specific lives in this one module: adjust ENDPOINTS/payloads here once you have the real spec and the rest of the app keeps working unchanged. Modes: - "manual" (default): nothing is called. The team creates the voucher in Tickoweb and pastes the code back in. Works today. - "live": calls the API. Needs a server-side proxy — see note in the settings screen about the API key and CORS. ============================================================ */ const VOUCHER_TYPES = { vipFriday: { label: "VIP ticket Friday", kind: "ticket" }, vipSaturday: { label: "VIP ticket Saturday", kind: "ticket" }, vipGuests: { label: "VIP ticket guest", kind: "ticket" }, drink: { label: "Drink voucher", kind: "voucher" }, food: { label: "Food & drink voucher", kind: "voucher" }, }; const VOUCHER_STATUS = { pending: { label: "To create", tone: "amber" }, created: { label: "Created", tone: "grey" }, sent: { label: "Sent", tone: "green" }, cancelled: { label: "Cancelled", tone: "grey" }, error: { label: "Failed", tone: "amber" }, }; /* What an agreement entitles the sponsor to, as individual vouchers */ function expectedVouchers(deal) { const out = []; const push = (type, value, n) => { for (let i = 0; i < Number(n || 0); i++) out.push({ type, value: Number(value) || 0 }); }; push("vipFriday", 0, deal.vipFriday); push("vipSaturday", 0, deal.vipSaturday); push("vipGuests", 0, deal.vipGuests); push("drink", DRINK_TICKET_VALUE, deal.drinkTickets); const rows = (deal.foodDrinkVouchers || []).filter((r) => Number(r.qty) > 0 && Number(r.value) > 0); if (rows.length) rows.forEach((r) => push("food", r.value, r.qty)); else if (Number(deal.foodDrinkValue) > 0) push("food", deal.foodDrinkValue, 1); // no split defined yet return out; } const defaultTickoweb = { mode: "manual", // "manual" | "live" baseUrl: "https://openapi.tickoweb.be/v1", proxyUrl: "", // your own server that holds the API key apiKey: "", eventId: "", productIds: {}, // voucher type -> Tickoweb product/ticket-type id }; /* --- The only place that talks to Tickoweb --- */ const tickoweb = { configured(cfg) { return cfg?.mode === "live" && !!(cfg.proxyUrl || (cfg.baseUrl && cfg.apiKey)); }, _url(cfg, path) { return (cfg.proxyUrl ? cfg.proxyUrl.replace(/\/$/, "") : cfg.baseUrl.replace(/\/$/, "")) + path; }, async _call(cfg, path, method, body) { const res = await fetch(this._url(cfg, path), { method, headers: { "Content-Type": "application/json", ...(cfg.apiKey && !cfg.proxyUrl ? { Authorization: `Bearer ${cfg.apiKey}` } : {}), }, ...(body ? { body: JSON.stringify(body) } : {}), }); if (!res.ok) throw new Error(`Tickoweb ${method} ${path} → ${res.status} ${res.statusText}`); return res.json(); }, /* Create a voucher and (optionally) let Tickoweb email it to the recipient */ async create(cfg, { voucher, sponsor, edition, recipient }) { if (!this.configured(cfg)) { // Manual mode: nothing is called; the team fills in the code afterwards return { manual: true }; } const data = await this._call(cfg, "/vouchers", "POST", { eventId: cfg.eventId || undefined, productId: cfg.productIds?.[voucher.type] || undefined, value: voucher.value || undefined, reference: `${edition?.name || ""} · ${sponsor?.companyName || ""}`.trim(), recipient: recipient?.email ? { name: recipient.name, email: recipient.email } : undefined, }); return { code: data.code || data.id || "", url: data.url || data.downloadUrl || "" }; }, async send(cfg, { voucher, recipient }) { if (!this.configured(cfg)) return { manual: true }; await this._call(cfg, `/vouchers/${encodeURIComponent(voucher.code)}/send`, "POST", { email: recipient?.email, name: recipient?.name, }); return {}; }, async cancel(cfg, { voucher }) { if (!this.configured(cfg)) return { manual: true }; await this._call(cfg, `/vouchers/${encodeURIComponent(voucher.code)}`, "DELETE"); return {}; }, }; /* ---------- Small UI atoms ---------- */ /* Grensrock plectrum logo, drawn to match the brand mark */ const PICK_PATH = "M50 2 C70 4 88 22 93 45 C98 68 84 90 58 96 C32 102 8 88 4 62 C0 36 18 8 50 2 Z"; const PickLogo = ({ size = 48 }) => ( GRENS ROCK ); /* House-style background: thin colored plectrum outlines on black */ const PickLines = () => { const picks = [ { c: "#4A90C4", x: -60, y: -40, s: 3.2, r: 12 }, { c: "#C9518D", x: 180, y: -80, s: 2.6, r: -25 }, { c: "#C9C463", x: 420, y: -30, s: 3.6, r: 40 }, { c: "#DE8140", x: 620, y: 60, s: 2.8, r: -10 }, { c: "#4FA05F", x: -40, y: 260, s: 2.9, r: 65 }, { c: "#C9518D", x: 300, y: 330, s: 3.4, r: -50 }, { c: "#4A90C4", x: 560, y: 300, s: 2.5, r: 20 }, { c: "#DE8140", x: 120, y: 120, s: 2.2, r: 95 }, { c: "#C9C463", x: 660, y: -90, s: 2.3, r: -70 }, ]; return ( ); }; const Field = ({ label, children, hint }) => ( ); const Modal = ({ title, onClose, children, wide }) => (
e.target === e.currentTarget && onClose()}>

{title}

{children}
); const Tag = ({ tone = "grey", children }) => ( {children} ); /* ---------- Contacts editor (shared by team form and sponsor portal) ---------- */ function ContactsEditor({ contacts, onChange, lang = "en" }) { const T = (nl, fr, en) => ({ nl, fr, en }[lang] || en); const list = contacts || []; const upd = (i, patch) => onChange(list.map((c, x) => (x === i ? { ...c, ...patch } : c))); const add = () => onChange([...list, { ...emptyContact, id: uid(), prefs: { ...emptyContact.prefs } }]); return (
{list.length === 0 && (

{T("Nog geen contactpersonen.", "Aucune personne de contact.", "No contact persons yet.")}

)} {list.map((c, i) => { const mailRes = validateEmail(c.email); return (
{contactName(c) || T("Nieuwe contactpersoon", "Nouveau contact", "New contact")}
upd(i, { firstName: e.target.value })} /> upd(i, { lastName: e.target.value })} />
upd(i, { email: e.target.value })} lang={lang} /> upd(i, { phone: e.target.value })} />
{Object.keys(COMMS).map((k) => { const on = !!c.prefs?.[k]; return ( ); })}
{mailRes && !mailRes.valid && (

{T("Dit e-mailadres is niet geldig.", "Cette adresse e-mail n'est pas valide.", "This email address isn't valid.")}

)}
); })}
); } /* ---------- Sponsor form ---------- */ function SponsorForm({ initial, onSave, onCancel, teamMembers = [] }) { const [s, setS] = useState({ ...emptySponsor, ...initial }); const set = (k) => (e) => setS({ ...s, [k]: e.target.value }); const vatRes = validateVat(s.vatNumber); const contactsOk = (s.contacts || []).every((c) => { const r = validateEmail(c.email); return !r || r.valid; }); const valid = s.companyName.trim().length > 0 && (!vatRes || vatRes.valid) && contactsOk; return (

Contact persons

setS({ ...s, contacts })} />
); } /* ---------- Edition form ---------- */ function EditionForm({ initial, onSave, onCancel }) { const [ed, setEd] = useState({ ...emptyEdition, ...initial }); const set = (k) => (e) => setEd({ ...ed, [k]: e.target.value }); const valid = ed.name.trim().length > 0; return (
); } /* ---------- Benefit fields (shared by formula + deal forms) ---------- */ function BenefitFields({ v, set }) { const num = (k) => (e) => set(k, e.target.value === "" ? "" : Math.max(0, Number(e.target.value))); return ( <>
0 ? "Value: " + eur(Number(v.drinkTickets) * DRINK_TICKET_VALUE) : null}>
{Object.entries(LED_ZONES).map(([k, l]) => { const zones = normLed(v.ledboarding); const on = zones.includes(k); return ( ); })}
); } /* ---------- Voucher breakdown editor (food & drinks) ---------- */ const voucherTotal = (rows) => (rows || []).reduce((s, r) => s + (Number(r.value) || 0) * (Number(r.qty) || 0), 0); const voucherText = (rows) => (rows || []) .filter((r) => Number(r.qty) > 0 && Number(r.value) > 0) .map((r) => `${r.qty} × ${eur(r.value)}`).join(", "); function VoucherEditor({ amount, rows, onChange }) { const target = Number(amount) || 0; const allocated = voucherTotal(rows); const diff = target - allocated; const upd = (i, k, val) => onChange(rows.map((r, x) => (x === i ? { ...r, [k]: val } : r))); const addRow = (value = "") => onChange([...(rows || []), { value, qty: "" }]); return (
Divide {eur(target)} into vouchers
{[5, 10, 25].map((v) => ( ))}
{(rows || []).length === 0 ? (

No breakdown yet — add voucher types above, or leave empty to decide later.

) : ( (rows || []).map((r, i) => (
Voucher of upd(i, "value", e.target.value)} /> × upd(i, "qty", e.target.value)} /> {eur((Number(r.value) || 0) * (Number(r.qty) || 0))}
)) )} {(rows || []).length > 0 && (

Allocated: {eur(allocated)} of {eur(target)} {diff > 0 && <> — {eur(diff)} still to divide} {diff < 0 && <> — {eur(-diff)} over the purchased amount} {diff === 0 && <> ✓}

)}
); } /* ---------- Formula form ---------- */ function FormulaForm({ initial, onSave, onCancel }) { const [f, setF] = useState({ ...emptyFormula, ...initial }); const set = (k, val) => setF((p) => ({ ...p, [k]: val })); return (
set("name", e.target.value)} placeholder="Gold / Silver / Bronze…" /> set("cost", e.target.value)} />
); } /* ---------- Sponsor picker (search + inline create) ---------- */ function SponsorPicker({ sponsors, value, onChange, onCreateNew, locked }) { const [q, setQ] = useState(""); const [open, setOpen] = useState(false); const selected = sponsors.find((s) => s.id === value); const matches = useMemo(() => { const t = q.trim().toLowerCase(); const list = [...sponsors].sort((a, b) => a.companyName.localeCompare(b.companyName)); if (!t) return list.slice(0, 8); return list.filter((s) => [s.companyName, s.vatNumber, ...sponsorContacts(s).flatMap((c) => [contactName(c), c.email])] .some((f) => (f || "").toLowerCase().includes(t))).slice(0, 8); }, [sponsors, q]); if (selected) { return (
{selected.companyName} {selected.contactName && · {selected.contactName}}
{!locked && ( )}
); } return (
{ setQ(e.target.value); setOpen(true); }} onFocus={() => setOpen(true)} /> {open && (
{matches.map((s) => ( ))} {matches.length === 0 && (
No sponsor found for "{q}"
)}
)}
); } /* ---------- Deal (agreement) form ---------- */ const OP_MAAT = "Op maat"; function DealForm({ initial, sponsors, editions, teamMembers = [], onSave, onCancel, onAddSponsor }) { const [d, setD] = useState({ ...emptyDeal, ...initial }); const [newSponsorDraft, setNewSponsorDraft] = useState(null); // null | prefill object const set = (k, val) => setD((p) => ({ ...p, [k]: val })); const activeEditions = editions.filter((e) => e.active); const edition = editions.find((e) => e.id === d.editionId); const formulas = edition ? edition.formulas || [] : []; const applyFormula = (name) => { if (name === OP_MAAT) { // Fully custom agreement: keep current values, everything editable set("formulaName", OP_MAAT); return; } const f = formulas.find((x) => x.name === name); if (!f) { set("formulaName", ""); return; } setD((p) => ({ ...p, formulaName: f.name, cost: f.cost, vipFriday: f.vipFriday, vipSaturday: f.vipSaturday, vipGuests: f.vipGuests, drinkTickets: f.drinkTickets, foodDrinkValue: f.foodDrinkValue, folderMention: f.folderMention, websiteMention: f.websiteMention, posterMention: f.posterMention || "none", ledboarding: f.ledboarding, })); }; const valid = d.sponsorId && d.editionId && d.cost !== ""; /* Inline "new sponsor" sub-form */ if (newSponsorDraft !== null) { return (
{ const created = onAddSponsor(s); set("sponsorId", created.id); setNewSponsorDraft(null); }} onCancel={() => setNewSponsorDraft(null)} />
); } return (
set("sponsorId", id)} locked={!!initial?.lockSponsor || !!d.id} onCreateNew={() => setNewSponsorDraft({})} /> {d.editionId && (
set("cost", e.target.value)} />
)} {d.editionId && } {d.editionId && Number(d.foodDrinkValue) > 0 && ( set("foodDrinkVouchers", rows)} /> )} {d.editionId && (
{d.settlement !== "invoice" && ( <>