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 ?
:
No logo yet }
{busy ? "Processing…" : logo ? "Replace logo" : "Upload logo"}
{logo && Remove }
{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 (
{picks.map((p, i) => (
))}
);
};
const Field = ({ label, children, hint }) => (
{label}
{children}
{hint && {hint} }
);
const Modal = ({ title, onClose, children, wide }) => (
e.target === e.currentTarget && onClose()}>
);
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 (
);
})}
+ {T("Contactpersoon toevoegen", "Ajouter un contact", "Add contact person")}
);
}
/* ---------- 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 (
Legal entity
Person
setS({ ...s, responsibleId: e.target.value })}>
— not assigned —
{teamMembers.map((u) => {userName(u)} )}
setS({ ...s, refRequired: e.target.checked })} />
This sponsor requires a reference on contract & invoice (PO number, order reference, cost centre…)
Contact persons
setS({ ...s, contacts })} />
Cancel
onSave({
...s,
vatNumber: vatToStore(s.vatNumber),
contacts: (s.contacts || []).map((c) => ({ ...c, id: c.id || uid(), email: emailToStore(c.email) })),
})}>Save sponsor
);
}
/* ---------- 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}>
set("folderMention", e.target.value)}>
{Object.entries(FOLDER_OPTS).map(([k, l]) => {l} )}
set("posterMention", e.target.value)}>
{Object.entries(POSTER_OPTS).map(([k, l]) => {l} )}
set("websiteMention", e.target.value)}>
{Object.entries(WEBSITE_OPTS).map(([k, l]) => {l} )}
{Object.entries(LED_ZONES).map(([k, l]) => {
const zones = normLed(v.ledboarding);
const on = zones.includes(k);
return (
set("ledboarding", e.target.checked ? [...zones, k] : zones.filter((z) => z !== k))} />
{l}
);
})}
>
);
}
/* ---------- 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) => (
addRow(v)}>+ €{v}
))}
addRow("")}>+ Other
{(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))}
onChange(rows.filter((_, x) => x !== i))}>✕
))
)}
{(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 (
);
}
/* ---------- 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 && (
{ onChange(""); setQ(""); setOpen(true); }}>Change
)}
);
}
return (
{ setQ(e.target.value); setOpen(true); }}
onFocus={() => setOpen(true)}
/>
{open && (
{matches.map((s) => (
{ onChange(s.id); setOpen(false); }}>
{s.companyName}
{[s.vatNumber, contactName(primaryContact(s))].filter(Boolean).join(" · ") || "\u00A0"}
))}
{matches.length === 0 && (
No sponsor found for "{q}"
)}
+ Add new sponsor{q.trim() ? ` "${q.trim()}"` : ""}
)}
);
}
/* ---------- 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 (
setNewSponsorDraft(null)}>← Back to agreement
{
const created = onAddSponsor(s);
set("sponsorId", created.id);
setNewSponsorDraft(null);
}}
onCancel={() => setNewSponsorDraft(null)}
/>
);
}
return (
set("sponsorId", id)}
locked={!!initial?.lockSponsor || !!d.id}
onCreateNew={() => setNewSponsorDraft({})}
/>
{ set("editionId", e.target.value); }}>
— select —
{activeEditions.map((e) => {e.name} )}
{d.editionId && (
applyFormula(e.target.value)}>
— select —
{formulas.map((f) => {f.name} ({eur(f.cost)}) )}
{OP_MAAT} (custom)
set("cost", e.target.value)} />
)}
{d.editionId &&
}
{d.editionId && Number(d.foodDrinkValue) > 0 && (
set("foodDrinkVouchers", rows)}
/>
)}
{d.editionId && (
set("settlement", e.target.value)}>
{Object.entries(SETTLEMENTS).map(([k, l]) => {l} )}
{d.settlement !== "invoice" && (
<>
{d.settlement === "mixed" && (
set("inKindValue", e.target.value)} />
)}
{invoiceAmount(d) === 0
? "No invoice will be created for this agreement ✓"
: <>To invoice: {eur(invoiceAmount(d))} of {eur(d.cost)} — the rest is settled in kind>}
>
)}
{needsInvoice(d) && (() => {
const sp = sponsors.find((x) => x.id === d.sponsorId);
const required = refRequiredFor(d, sp);
return (
<>
{sp?.refRequired ? (
{sp.companyName} requires a reference on contract & invoice (set on the sponsor).
) : (
set("refRequired", e.target.checked)} />
A reference is required on the invoice for this agreement only
)}
{required && (
set("invoiceReference", e.target.value)}
placeholder="e.g. PO-2026-0481"
style={!String(d.invoiceReference || "").trim() ? { borderColor: "var(--orange)" } : undefined} />
)}
>
);
})()}
)}
Cancel
onSave(d)}>Save agreement
);
}
/* ---------- Sponsor import (xlsx / csv) ---------- */
const IMPORT_FIELDS = [
["companyName", "Company / name *"],
["address", "Address"],
["vatNumber", "VAT number"],
["entityType", "Type (legal/person)"],
["contactFirstName", "Contact first name"],
["contactLastName", "Contact last name"],
["contactPhone", "Contact phone"],
["contactEmail", "Contact email"],
["contactLanguage", "Language (NL/FR/EN)"],
];
const HEADER_SYNONYMS = {
contactFirstName: ["voornaam", "first name", "prénom", "prenom", "firstname"],
contactLastName: ["achternaam", "contactpersoon", "contact person", "contact name", "contactnaam", "personne de contact", "last name", "familienaam", "naam contact", "contact"],
contactPhone: ["phone", "telefoon", "tel", "gsm", "mobile", "téléphone", "telephone"],
contactEmail: ["e-mail", "email", "mail", "e-mailadres", "courriel"],
contactLanguage: ["taal", "language", "langue", "lang"],
vatNumber: ["btw", "vat", "tva", "ondernemingsnummer", "btw-nummer", "vat number"],
companyName: ["bedrijfsnaam", "company name", "company", "bedrijf", "firma", "sponsor", "organisatie", "société", "naam", "name"],
address: ["adres", "address", "adresse", "straat"],
entityType: ["rechtsvorm", "entity", "type"],
};
const normStr = (v) => String(v ?? "").trim();
const normVatKey = (v) => normStr(v).replace(/[^0-9a-z]/gi, "").toLowerCase();
const normNameKey = (v) => normStr(v).toLowerCase().replace(/\s+/g, " ");
const normLang = (v) => {
const t = normStr(v).toLowerCase();
if (/^fr|fran|frans/.test(t)) return "fr";
if (/^en|eng/.test(t)) return "en";
return "nl";
};
const normEntity = (v) => (/pers|natuur|individu|physique/.test(normStr(v).toLowerCase()) ? "person" : "legal");
/* ---------- VAT validation ---------- */
const cleanVat = (raw) => normStr(raw).toUpperCase().replace(/[\s.\-]/g, "");
function validateVat(raw) {
let v = cleanVat(raw);
if (!v) return null; // empty is allowed (e.g. persons)
if (/^\d{9,10}$/.test(v)) v = "BE" + (v.length === 9 ? "0" + v : v); // bare number: assume Belgian
if (!/^[A-Z]{2}/.test(v)) return { valid: false, code: "format" };
const cc = v.slice(0, 2);
if (cc === "BE") {
const d = v.slice(2);
if (!/^\d{10}$/.test(d) || !/^[01]/.test(d)) return { valid: false, code: "be_format" };
const check = 97 - (Number(d.slice(0, 8)) % 97);
if (check !== Number(d.slice(8))) return { valid: false, code: "be_check" };
return { valid: true, formatted: `BE ${d.slice(0, 4)}.${d.slice(4, 7)}.${d.slice(7)}` };
}
if (cc === "FR") {
if (!/^FR[0-9A-Z]{2}\d{9}$/.test(v)) return { valid: false, code: "fr_format" };
return { valid: true, formatted: `FR ${v.slice(2, 4)} ${v.slice(4, 7)} ${v.slice(7, 10)} ${v.slice(10)}` };
}
if (cc === "NL") {
if (!/^NL\d{9}B\d{2}$/.test(v)) return { valid: false, code: "nl_format" };
return { valid: true, formatted: v };
}
if (!/^[A-Z]{2}[0-9A-Z]{2,12}$/.test(v)) return { valid: false, code: "format" };
return { valid: true, formatted: v };
}
const VAT_MSG = {
format: { nl: "Dit lijkt geen geldig BTW-nummer.", fr: "Ceci ne semble pas être un numéro de TVA valide.", en: "This doesn't look like a valid VAT number." },
be_format: { nl: "Een Belgisch BTW-nummer heeft de vorm BE 0123.456.789.", fr: "Un numéro de TVA belge a la forme BE 0123.456.789.", en: "A Belgian VAT number looks like BE 0123.456.789." },
be_check: { nl: "De controlecijfers van dit Belgische BTW-nummer kloppen niet — kijk het even na.", fr: "Les chiffres de contrôle de ce numéro de TVA belge ne correspondent pas — vérifiez-le.", en: "The check digits of this Belgian VAT number don't match — please verify it." },
fr_format: { nl: "Een Frans BTW-nummer heeft de vorm FR XX 123456789.", fr: "Un numéro de TVA français a la forme FR XX 123456789.", en: "A French VAT number looks like FR XX 123456789." },
nl_format: { nl: "Een Nederlands BTW-nummer heeft de vorm NL123456789B01.", fr: "Un numéro de TVA néerlandais a la forme NL123456789B01.", en: "A Dutch VAT number looks like NL123456789B01." },
};
const vatMessage = (code, lang = "en") => (VAT_MSG[code] || VAT_MSG.format)[lang] || VAT_MSG.format.en;
/* Shared VAT input with live validation feedback */
function VatInput({ value, onChange, lang = "en", placeholder }) {
const res = validateVat(value);
return (
<>
{res && !res.valid && {vatMessage(res.code, lang)} }
{res && res.valid && ✓ {res.formatted} }
>
);
}
/* Returns the value to store: formatted when valid, as-typed otherwise */
const vatToStore = (raw) => {
const res = validateVat(raw);
return res && res.valid ? res.formatted : normStr(raw);
};
/* ---------- Email validation ---------- */
/* Pragmatic check: one @, a sensible local part, a dotted domain with a real TLD. */
const EMAIL_RE = /^[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\.)+[A-Za-z]{2,}$/;
function validateEmail(raw) {
const v = normStr(raw);
if (!v) return null; // empty is allowed
if (/\s/.test(v)) return { valid: false, code: "space" };
if ((v.match(/@/g) || []).length !== 1) return { valid: false, code: "at" };
if (!EMAIL_RE.test(v)) return { valid: false, code: "format" };
return { valid: true, formatted: v.toLowerCase() };
}
const MAIL_MSG = {
at: { nl: "Een e-mailadres bevat één @-teken.", fr: "Une adresse e-mail contient un seul @.", en: "An email address contains exactly one @." },
space: { nl: "Een e-mailadres mag geen spaties bevatten.", fr: "Une adresse e-mail ne peut pas contenir d'espaces.", en: "An email address can't contain spaces." },
format: { nl: "Dit lijkt geen geldig e-mailadres (bv. naam@bedrijf.be).", fr: "Ceci ne semble pas être une adresse e-mail valide (p.ex. nom@societe.be).", en: "This doesn't look like a valid email address (e.g. name@company.be)." },
};
const mailMessage = (code, lang = "en") => (MAIL_MSG[code] || MAIL_MSG.format)[lang] || MAIL_MSG.format.en;
/* Shared email input with live validation feedback */
function EmailInput({ value, onChange, lang = "en", placeholder }) {
const res = validateEmail(value);
return (
<>
{res && !res.valid && {mailMessage(res.code, lang)} }
>
);
}
const emailToStore = (raw) => {
const res = validateEmail(raw);
return res && res.valid ? res.formatted : normStr(raw);
};
function autoMap(headers) {
const map = {};
const used = new Set();
// contact* fields first so "contact naam" doesn't get claimed by companyName's "naam"
const order = ["contactFirstName", "contactLastName", "contactPhone", "contactEmail", "contactLanguage", "vatNumber", "address", "entityType", "companyName"];
for (const field of order) {
const syns = HEADER_SYNONYMS[field];
let hit = -1;
for (const syn of syns) {
hit = headers.findIndex((h, i) => !used.has(i) && normStr(h).toLowerCase().includes(syn));
if (hit >= 0) break;
}
map[field] = hit;
if (hit >= 0) used.add(hit);
}
return map;
}
function ImportModal({ sponsors, onImport, onClose }) {
const [headers, setHeaders] = useState(null);
const [rows, setRows] = useState([]);
const [map, setMap] = useState({});
const [err, setErr] = useState("");
const [result, setResult] = useState(null); // {createdCount, skipped:[]}
const onFile = async (e) => {
const file = e.target.files && e.target.files[0];
if (!file) return;
setErr("");
try {
const buf = await file.arrayBuffer();
const wb = XLSX.read(buf);
const ws = wb.Sheets[wb.SheetNames[0]];
const all = XLSX.utils.sheet_to_json(ws, { header: 1, defval: "" })
.filter((r) => r.some((c) => normStr(c) !== ""));
if (all.length < 2) { setErr("The file needs a header row plus at least one sponsor row."); return; }
const hdr = all[0].map(normStr);
setHeaders(hdr);
setRows(all.slice(1));
setMap(autoMap(hdr));
} catch {
setErr("Couldn't read this file. Save it as .xlsx or .csv and try again.");
}
};
/* Build candidates + duplicate analysis, live */
const analysis = useMemo(() => {
if (!headers || map.companyName == null || map.companyName < 0) return null;
const get = (row, f) => (map[f] >= 0 ? normStr(row[map[f]]) : "");
const existingVat = new Set(sponsors.map((s) => normVatKey(s.vatNumber)).filter(Boolean));
const existingName = new Set(sponsors.map((s) => normNameKey(s.companyName)));
const seenVat = new Set(), seenName = new Set();
const create = [], skipped = [];
rows.forEach((row, i) => {
const companyName = get(row, "companyName");
if (!companyName) { skipped.push({ line: i + 2, name: "(empty)", reason: "no company name" }); return; }
const vatKey = normVatKey(get(row, "vatNumber"));
const nameKey = normNameKey(companyName);
if ((vatKey && existingVat.has(vatKey)) || existingName.has(nameKey)) {
skipped.push({ line: i + 2, name: companyName, reason: "already in database" }); return;
}
if ((vatKey && seenVat.has(vatKey)) || seenName.has(nameKey)) {
skipped.push({ line: i + 2, name: companyName, reason: "duplicate inside file" }); return;
}
if (vatKey) seenVat.add(vatKey);
seenName.add(nameKey);
const rawVat = get(row, "vatNumber");
const vatRes = validateVat(rawVat);
const rawMail = get(row, "contactEmail");
const mailRes = validateEmail(rawMail);
create.push({
companyName,
address: get(row, "address"),
vatNumber: vatToStore(rawVat),
vatInvalid: !!(vatRes && !vatRes.valid),
entityType: map.entityType >= 0 ? normEntity(get(row, "entityType")) : "legal",
contacts: (get(row, "contactFirstName") || get(row, "contactLastName") || rawMail || get(row, "contactPhone"))
? [{
...emptyContact, id: uid(),
firstName: get(row, "contactFirstName"),
lastName: get(row, "contactLastName"),
email: emailToStore(rawMail),
phone: get(row, "contactPhone"),
language: map.contactLanguage >= 0 ? normLang(get(row, "contactLanguage")) : "nl",
prefs: { info: true, invoice: true, vouchers: true },
}]
: [],
contactPreview: [get(row, "contactFirstName"), get(row, "contactLastName")].filter(Boolean).join(" "),
emailPreview: emailToStore(rawMail),
langPreview: (map.contactLanguage >= 0 ? normLang(get(row, "contactLanguage")) : "nl"),
mailInvalid: !!(mailRes && !mailRes.valid),
});
});
return { create, skipped };
}, [headers, rows, map, sponsors]);
const doImport = () => {
onImport(analysis.create);
setResult({ createdCount: analysis.create.length, skipped: analysis.skipped });
};
/* ----- result screen ----- */
if (result) {
return (
{result.createdCount} new sponsor(s) created.
{result.skipped.length > 0 && <> {result.skipped.length} row(s) skipped.>}
{result.skipped.length > 0 && (
Row Name Reason
{result.skipped.map((s, i) => (
{s.line} {s.name} {s.reason}
))}
)}
Done
);
}
return (
{!headers ? (
<>
Upload your sponsor list as an Excel (.xlsx) or CSV file. The first row must contain
column headers (e.g. Bedrijfsnaam, BTW-nummer, Contactpersoon, E-mail, Taal…). Existing sponsors are
recognised by VAT number or company name and won't be created twice.
{err && {err}
}
>
) : (
<>
Column mapping
Columns were detected automatically — check them and adjust if needed.
{IMPORT_FIELDS.map(([field, label]) => (
setMap({ ...map, [field]: Number(e.target.value) })}>
— skip —
{headers.map((h, i) => {h || `Column ${i + 1}`} )}
))}
{map.companyName < 0 && Select which column contains the company name.
}
{analysis && (
<>
Preview
{analysis.create.length} new {" "}
{analysis.skipped.length} skipped (duplicates / empty)
{analysis.create.length > 0 && (
Company VAT Contact Email Lang
{analysis.create.slice(0, 6).map((c, i) => (
{c.companyName}
{c.vatNumber
? (c.vatInvalid ? ⚠ {c.vatNumber} : c.vatNumber)
: "—"}
{c.contactPreview || "—"}
{c.emailPreview
? (c.mailInvalid ? ⚠ {c.emailPreview} : c.emailPreview)
: "—"}
{(c.langPreview || "nl").toUpperCase()}
))}
{analysis.create.length > 6 && (
…and {analysis.create.length - 6} more
)}
)}
>
)}
{ setHeaders(null); setRows([]); setErr(""); }}>Choose another file
Import {analysis ? analysis.create.length : 0} sponsor(s)
>
)}
);
}
/* ---------- Email preview modal ---------- */
function EmailModal({ deal, sponsor, edition, onClose, onMarkSent }) {
/* The contract is invoicing-related: send it to the invoicing contacts,
falling back to whoever receives general information. */
const invoiceContacts = contactsFor(sponsor, "invoice");
const infoContacts = contactsFor(sponsor, "info");
const recipients = (invoiceContacts.length ? invoiceContacts : infoContacts)
.filter((c) => validateEmail(c.email)?.valid);
const [idx, setIdx] = useState(0);
const [copied, setCopied] = useState(false);
const main = recipients[idx] || recipients[0] || null;
const { subject, body } = buildEmail(deal, sponsor, edition, main);
const to = recipients.map((c) => c.email).join(",");
const mailto = `mailto:${encodeURIComponent(to)}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
const copy = async () => {
try {
await navigator.clipboard.writeText(`To: ${to}\nSubject: ${subject}\n\n${body}`);
setCopied(true); setTimeout(() => setCopied(false), 2000);
} catch { /* clipboard unavailable */ }
};
const mixedLangs = new Set(recipients.map((c) => c.language || "nl")).size > 1;
return (
{recipients.length === 0 ? (
No contact with a valid email address for invoicing or information. Add one on the sponsor first,
otherwise this contract can't be delivered.
) : (
<>
To: {recipients.map((c) => `${contactName(c) || c.email}`).join(", ")}
{" · "}Language: {LANGS[main?.language || "nl"]}
{recipients.length > 1 && (
setIdx(Number(e.target.value))}>
{recipients.map((c, i) => (
{contactName(c) || c.email} ({(c.language || "nl").toUpperCase()})
))}
)}
>
)}
{copied ? "Copied ✓" : "Copy email"}
{recipients.length > 0 &&
Open in mail client }
Mark as sent
);
}
/* ---------- Sponsor detail (history) ---------- */
function SponsorDetail({ sponsor, deals, editions, users = [], vouchers = [], canDelete,
onBack, onEdit, onDelete, onAddDeal, onEditDeal, onEmail }) {
const [sub, setSub] = useState("info");
const history = deals
.filter((d) => d.sponsorId === sponsor.id)
.map((d) => ({ ...d, edition: editions.find((e) => e.id === d.editionId) }))
.sort((a, b) => (b.edition?.startDate || "").localeCompare(a.edition?.startDate || ""));
const totalCost = history.reduce((s, d) => s + (Number(d.cost) || 0), 0);
const myVouchers = vouchers.filter((v) => v.sponsorId === sponsor.id);
const contacts = sponsorContacts(sponsor);
const TABS = [
["info", "Information"],
["contacts", `Contact persons (${contacts.length})`],
["sponsoring", `Sponsoring (${history.length})`],
["vouchers", `Vouchers (${myVouchers.filter((v) => v.status !== "cancelled").length})`],
];
return (
← All sponsors
{sponsor.companyName}
{sponsor.entityType === "legal" ? "Legal entity" : "Person"}
{sponsor.vatNumber && <> · VAT {sponsor.vatNumber}>}
{responsibleName(sponsor, users) && <> · Managed by {responsibleName(sponsor, users)}>}
{sponsor.refRequired &&
reference required on contract & invoice }
Edit
{canDelete && Delete }
+ Agreement
{TABS.map(([k, l]) => (
setSub(k)}>{l}
))}
{/* ---- Information ---- */}
{sub === "info" && (
Company details
Company {sponsor.companyName}
Type {sponsor.entityType === "legal" ? "Legal entity" : "Person"}
VAT number {sponsor.vatNumber || "—"}
Address {sponsor.address || "—"}
Responsible {responsibleName(sponsor, users) || "— not assigned —"}
Invoice reference
{sponsor.refRequired ? "Required on contract & invoice" : "Not required"}
Logo (artwork: folder, LED, …)
)}
{/* ---- Contact persons ---- */}
{sub === "contacts" && (
{contacts.length === 0 ? (
No contact persons yet — add one via Edit. They log in with their email address.
) : contacts.map((c, i) => (
{contactName(c) || no name }
· {(c.language || "nl").toUpperCase()}
{c.email || "no email"}{c.phone ? ` · ${c.phone}` : ""}
{Object.keys(COMMS).filter((k) => c.prefs?.[k]).map((k) => {COMMS[k]} )}
{c.email &&
}
))}
)}
{/* ---- Sponsoring ---- */}
{sub === "sponsoring" && (
<>
{history.length} editions sponsored
{eur(totalCost)} total contributed
{history.length === 0 ? (
No agreements yet. Add one for an active edition to get started.
) : (
Edition Formula Cost
VIP fr/sa/guest Drinks Food&drink
Folder Website Poster LED Invoice Email
{history.map((d) => (
onEditDeal(d)}>
{d.edition ? d.edition.name : "?"} {d.edition && !d.edition.active && archived }
{d.formulaName || custom }
{eur(d.cost)}
{d.settlement === "inkind" && in kind
}
{d.settlement === "mixed" && {eur(invoiceAmount(d))} billed
}
{d.vipFriday || 0} / {d.vipSaturday || 0} / {d.vipGuests || 0}
{d.drinkTickets || 0}
{Number(d.foodDrinkValue) > 0 ? (
<>
{eur(d.foodDrinkValue)}
{voucherText(d.foodDrinkVouchers) && (
{voucherText(d.foodDrinkVouchers)}
)}
>
) : "—"}
{normFolder(d.folderMention) !== "none" ? FOLDER_OPTS[normFolder(d.folderMention)] : "—"}
{d.websiteMention !== "none" ? WEBSITE_OPTS[d.websiteMention] : "—"}
{d.posterMention && d.posterMention !== "none" ? POSTER_OPTS[d.posterMention] : "—"}
{normLed(d.ledboarding).length > 0 ? ledLabel(d.ledboarding) : "—"}
{!needsInvoice(d)
? in kind
: refMissing(d, sponsor)
? ref. missing
: d.invoiceCreated
? {d.invoiceNumber || "created"}
: pending }
{d.emailSentAt ? sent : not sent }
{ e.stopPropagation(); onEmail(d); }}>Email
))}
)}
>
)}
{/* ---- Vouchers ---- */}
{sub === "vouchers" && (
myVouchers.length === 0 ? (
No vouchers yet. Generate them per edition in the Vouchers tab.
) : (
Edition Type Value
Status Code Assigned to
{myVouchers.map((v) => {
const ed = editions.find((e) => e.id === v.editionId);
const st = VOUCHER_STATUS[v.status] || VOUCHER_STATUS.pending;
return (
{ed ? ed.name : "—"}
{VOUCHER_TYPES[v.type]?.label || v.type}
{v.value > 0 ? eur(v.value) : "—"}
{st.label}
{v.code || — }
{v.recipientName || v.recipientEmail
? <>{v.recipientName}{v.recipientEmail}
>
: sponsor }
{v.url && View }
);
})}
)
)}
);
}
function CopyInviteButton({ sponsor, contact, responsible }) {
const [copied, setCopied] = useState(false);
const copy = async () => {
try {
await navigator.clipboard.writeText(buildInvitation(sponsor, contact, responsible));
setCopied(true); setTimeout(() => setCopied(false), 2000);
} catch { /* clipboard unavailable */ }
};
return (
{copied ? "Copied ✓" : "Invite (" + ((contact?.language || "nl").toUpperCase()) + ")"}
);
}
/* ---------- Team account form ---------- */
const emptyUser = { firstName: "", lastName: "", email: "", role: "user" };
function UserForm({ initial, onSave, onCancel, isOnlyAdmin, existing = [] }) {
const [u, setU] = useState({ ...emptyUser, ...initial });
/* Email follows firstname@grensrock.be unless it was edited by hand */
const [customEmail, setCustomEmail] = useState(!!initial?.email && initial.email !== teamEmailFor(initial?.firstName));
const email = customEmail ? u.email : teamEmailFor(u.firstName);
const mailRes = validateEmail(email);
const dupe = !!email && existing.some((x) => x.id !== u.id && sameEmail(x.email, email));
const valid = u.firstName.trim().length > 0 && !!email.trim() && (!mailRes || mailRes.valid) && !dupe;
return (
);
}
function buildUserInvite(user) {
return `Hi ${userName(user)},
You have access to the Grensrock sponsor tool.
Link: [link to the tool]
Log in with your email address (${user.email}) and the password: ${SHARED_PASSWORD}
Your role: ${user.role === "admin" ? "admin (full access)" : "user (sponsors & agreements)"}
Grensrock`;
}
function CopyUserInvite({ user }) {
const [copied, setCopied] = useState(false);
return (
{
try {
await navigator.clipboard.writeText(buildUserInvite(user));
setCopied(true); setTimeout(() => setCopied(false), 2000);
} catch { /* clipboard unavailable */ }
}}>{copied ? "Copied ✓" : "Copy invite"}
);
}
/* ---------- Tickoweb settings ---------- */
function TickowebSettings({ value, onSave, onCancel }) {
const [c, setC] = useState({ ...defaultTickoweb, ...value });
const set = (k) => (e) => setC({ ...c, [k]: e.target.value });
return (
);
}
/* ---------- Landing (choose team vs sponsor access) ---------- */
function Landing({ users, sponsors, onTeam, onPortal }) {
const [side, setSide] = useState(null); // null | 'team' | 'sponsor'
const [email, setEmail] = useState("");
const [pwd, setPwd] = useState("");
const [err, setErr] = useState("");
const noAccounts = users.length === 0;
const check = (who) => {
if (pwd !== SHARED_PASSWORD) { setErr("Wrong password."); return; }
if (who === "team") {
const u = users.find((x) => sameEmail(x.email, email));
if (u) { onTeam(u.id); return; }
setErr("No team account with that email address. Ask an admin to add you.");
} else {
let found = null;
for (const sp of sponsors) {
const c = sponsorContacts(sp).find((x) => x.email && sameEmail(x.email, email));
if (c) { found = { sponsorId: sp.id, contactId: c.id }; break; }
}
if (found) { onPortal(found.sponsorId, found.contactId); return; }
setErr("We don't know that email address. Use the address we have on file for you.");
}
};
const form = (who, label) => (
<>
{ setEmail(e.target.value); setErr(""); }}
onKeyDown={(e) => e.key === "Enter" && check(who)} autoFocus />
{ setPwd(e.target.value); setErr(""); }}
onKeyDown={(e) => e.key === "Enter" && check(who)} />
check(who)}>{label}
{ setSide(null); setEmail(""); setPwd(""); setErr(""); }}>Back
>
);
return (
Sponsor management
Organisation
{noAccounts ? (
<>
No team accounts yet — set up the first admin.
onTeam(null)}>Set up
>
) : side === "team" ? form("team", "Log in") : (
<>
Manage sponsors, editions, agreements and invoicing.
{ setErr(""); setSide("team"); }}>Log in as team member
>
)}
Sponsor
{side === "sponsor" ? form("sponsor", "Open my page") : (
<>
Update your company details and upload your logo.
{ setErr(""); setSide("sponsor"); }}>Log in as sponsor
>
)}
{err &&
{err}
}
);
}
/* ---------- Sponsor self-service portal ---------- */
function SponsorPortal({ sponsor, asContact, deals, editions, vouchers = [], onSaveSponsor, onAssignVoucher, onExit }) {
const [sub, setSub] = useState("info");
const [assigning, setAssigning] = useState(null);
const [assignDraft, setAssignDraft] = useState({ name: "", email: "" });
const myVouchers = vouchers
.filter((v) => v.sponsorId === sponsor.id)
.sort((a, b) => (a.type || "").localeCompare(b.type || ""));
useEffect(() => {
if (assigning) setAssignDraft({ name: assigning.recipientName || "", email: assigning.recipientEmail || "" });
}, [assigning]);
const [s, setS] = useState({ ...sponsor });
const [saved, setSaved] = useState(false);
const set = (k) => (e) => { setS({ ...s, [k]: e.target.value }); setSaved(false); };
/* Portal language follows the contact who logged in */
const lang = asContact?.language || primaryContact(sponsor)?.language || "nl";
const T = (nl, fr, en) => ({ nl, fr, en }[lang]);
const history = deals
.filter((d) => d.sponsorId === sponsor.id)
.map((d) => ({ ...d, edition: editions.find((e) => e.id === d.editionId) }))
.sort((a, b) => (b.edition?.startDate || "").localeCompare(a.edition?.startDate || ""));
const vatRes = validateVat(s.vatNumber);
const contactsOk = (s.contacts || []).every((c) => {
const r = validateEmail(c.email);
return !r || r.valid;
});
const save = () => {
if ((vatRes && !vatRes.valid) || !contactsOk) return; // button is disabled, extra guard
// Sponsors can edit their own info, never team-side fields
const { responsible, id, ...rest } = s;
onSaveSponsor(sponsor.id, {
...rest,
vatNumber: vatToStore(s.vatNumber),
contacts: (s.contacts || []).map((c) => ({ ...c, id: c.id || uid(), email: emailToStore(c.email) })),
});
setSaved(true);
};
const TABS = [
["info", T("Gegevens", "Données", "Information")],
["contacts", `${T("Contactpersonen", "Contacts", "Contact persons")} (${(s.contacts || []).length})`],
["sponsoring", `${T("Sponsoring", "Sponsoring", "Sponsorship")} (${history.length})`],
["vouchers", `${T("Tickets & bonnen", "Tickets & bons", "Tickets & vouchers")} (${myVouchers.filter((v) => v.status !== "cancelled").length})`],
];
const saveBar = (
{T("Gegevens opslaan", "Enregistrer", "Save details")}
{saved && {T("Opgeslagen ✓", "Enregistré ✓", "Saved ✓")} }
);
return (
{T("Sponsorportaal", "Portail sponsors", "Sponsor portal")}
{T("Afsluiten", "Quitter", "Exit")}
{sponsor.companyName}
{T(
"Kijk uw gegevens na, pas ze aan waar nodig en laad uw logo op. Bedankt voor uw steun aan Grensrock!",
"Vérifiez vos données, modifiez-les si nécessaire et téléchargez votre logo. Merci pour votre soutien à Grensrock !",
"Review your details, update them where needed and upload your logo. Thank you for supporting Grensrock!")}
{TABS.map(([k, l]) => (
setSub(k)}>{l}
))}
{/* ---- Information ---- */}
{sub === "info" && (
<>
Logo
{T(
"Uw logo gebruiken we voor de folder, ledboarding, website, ... Laad bij voorkeur een versie met transparante achtergrond op (PNG of SVG).",
"Votre logo sera utilisé pour le dépliant, le ledboarding, le site web, ... Téléchargez de préférence une version à fond transparent (PNG ou SVG).",
"Your logo is used for the folder, LED boarding, website, ... Preferably upload a version with a transparent background (PNG or SVG).")}
{T("Bedrijfsgegevens", "Données de l'entreprise", "Company details")}
{ setS({ ...s, refRequired: e.target.checked }); setSaved(false); }} />
{T("Wij hebben een referentie (bestelbon, PO-nummer, kostenplaats) nodig op de factuur",
"Nous avons besoin d'une référence (bon de commande, numéro PO, centre de coûts) sur la facture",
"We need a reference (purchase order, PO number, cost centre) on the invoice")}
{saveBar}
>
)}
{/* ---- Contact persons ---- */}
{sub === "contacts" && (
<>
{T(
"Voeg gerust collega's toe en duid aan welke communicatie ieder van hen wil ontvangen.",
"N'hésitez pas à ajouter des collègues et à indiquer quelles communications chacun souhaite recevoir.",
"Feel free to add colleagues and indicate which communications each of them wants to receive.")}
{ setS({ ...s, contacts }); setSaved(false); }} />
{saveBar}
>
)}
{/* ---- Sponsorship ---- */}
{sub === "sponsoring" && (
history.length === 0 ? (
{T("Nog geen sponsoring geregistreerd.", "Aucun sponsoring enregistré.", "No sponsorship registered yet.")}
) : (
{T("Editie", "Édition", "Edition")}
{T("Formule", "Formule", "Formula")}
{T("Bijdrage", "Contribution", "Contribution")}
{T("Zichtbaarheid", "Visibilité", "Visibility")}
{history.map((d) => {
const vis = [];
if (normFolder(d.folderMention) !== "none") vis.push(`${T("Folder", "Dépliant", "Folder")}: ${FOLDER_OPTS[normFolder(d.folderMention)]}`);
if (d.posterMention && d.posterMention !== "none") vis.push(`${T("Affiche", "Affiche", "Poster")}: ${POSTER_OPTS[d.posterMention]}`);
if (d.websiteMention !== "none") vis.push(`Website: ${WEBSITE_OPTS[d.websiteMention]}`);
if (normLed(d.ledboarding).length) vis.push(`LED: ${ledLabel(d.ledboarding)}`);
return (
{d.edition ? d.edition.name : "?"}
{d.formulaName || "—"}
{eur(d.cost)}
{d.settlement === "inkind" && {T("in natura", "en nature", "in kind")}
}
{vis.join(" · ") || "—"}
);
})}
)
)}
{/* ---- Vouchers ---- */}
{sub === "vouchers" && (
myVouchers.length === 0 ? (
{T(
"Er zijn nog geen tickets of bonnen toegekend.",
"Aucun ticket ou bon ne vous a encore été attribué.",
"No tickets or vouchers have been assigned yet.")}
) : (
<>
{T(
"Wil u een ticket doorgeven aan een collega of klant? Vul zijn naam en e-mailadres in — wij sturen het rechtstreeks naar hem door.",
"Vous souhaitez transmettre un ticket à un collègue ou un client ? Indiquez son nom et son e-mail — nous le lui envoyons directement.",
"Want to pass a ticket on to a colleague or client? Fill in their name and email — we'll send it straight to them.")}
{T("Editie", "Édition", "Edition")}
{T("Type", "Type", "Type")}
{T("Waarde", "Valeur", "Value")}
{T("Status", "Statut", "Status")}
{T("Op naam van", "Au nom de", "Assigned to")}
{myVouchers.map((v) => {
const ed = editions.find((e) => e.id === v.editionId);
const st = VOUCHER_STATUS[v.status] || VOUCHER_STATUS.pending;
return (
{ed ? ed.name : "—"}
{VOUCHER_TYPES[v.type]?.label || v.type}
{v.value > 0 ? eur(v.value) : "—"}
{st.label}
{v.recipientName || v.recipientEmail
? <>{v.recipientName}{v.recipientEmail}
>
: — }
{v.url && {T("Bekijk", "Voir", "View")} }
{v.status !== "cancelled" && (
setAssigning(v)}>
{v.recipientEmail ? T("Wijzig", "Modifier", "Change") : T("Doorgeven", "Transmettre", "Pass on")}
)}
);
})}
>
)
)}
{assigning && (
setAssigning(null)}>
{VOUCHER_TYPES[assigning.type]?.label}{assigning.value > 0 ? ` · ${eur(assigning.value)}` : ""}
setAssignDraft({ ...assignDraft, name: e.target.value })} />
setAssignDraft({ ...assignDraft, email: e.target.value })} />
{T(
"Wij sturen het ticket op naam naar dit adres. U kan dit later nog wijzigen.",
"Nous enverrons le ticket nominatif à cette adresse. Vous pouvez le modifier plus tard.",
"We'll send the personalised ticket to this address. You can change this later.")}
{assigning.recipientEmail && (
{
onAssignVoucher(assigning.id, { recipientName: "", recipientEmail: "" });
setAssigning(null);
}}>{T("Terugnemen", "Reprendre", "Take back")}
)}
setAssigning(null)}>{T("Annuleren", "Annuler", "Cancel")}
{
onAssignVoucher(assigning.id, {
recipientName: assignDraft.name.trim(),
recipientEmail: emailToStore(assignDraft.email),
});
setAssigning(null);
}}>{T("Doorgeven", "Transmettre", "Pass on")}
)}
);
}
/* ============================================================
Main app
============================================================ */
export default function GrensrockApp() {
const [data, setData] = useState({ sponsors: [], editions: [], deals: [], users: [], settings: { teamCode: "" } });
const [loaded, setLoaded] = useState(false);
const [saveErr, setSaveErr] = useState(false);
const [mode, setMode] = useState({ view: "landing" }); // landing | team | portal
const [userId, setUserId] = useState(null);
const [tab, setTab] = useState("overview");
const [modal, setModal] = useState(null); // {type, payload}
const [query, setQuery] = useState("");
const [openSponsorId, setOpenSponsorId] = useState(null);
const [invEdition, setInvEdition] = useState("");
const [voEdition, setVoEdition] = useState("");
const [voSponsor, setVoSponsor] = useState("");
const [voStatus, setVoStatus] = useState("");
const [voSel, setVoSel] = useState([]);
const [voBusy, setVoBusy] = useState(false);
/* ----- load & save (shared storage: whole team + sponsors work on the same data) ----- */
useEffect(() => {
(async () => {
let d = null;
try {
const r = await window.storage.get(STORAGE_KEY, true);
if (r && r.value) d = JSON.parse(r.value);
} catch { /* no shared data yet */ }
if (!d) {
// One-time migration from the old per-user storage
try {
const old = await window.storage.get(STORAGE_KEY);
if (old && old.value) {
d = JSON.parse(old.value);
await window.storage.set(STORAGE_KEY, JSON.stringify(migrate(d)), true);
}
} catch { /* nothing to migrate */ }
}
if (d) {
const migrated = migrate(d);
setData(migrated);
// Persist newly generated codes/keys so they stay stable across sessions
if (JSON.stringify(migrated) !== JSON.stringify(d)) {
try { await window.storage.set(STORAGE_KEY, JSON.stringify(migrated), true); } catch { /* retried on next save */ }
}
}
setLoaded(true);
})();
}, []);
const persist = useCallback(async (next) => {
setData(next);
try {
await window.storage.set(STORAGE_KEY, JSON.stringify(next), true);
setSaveErr(false);
} catch { setSaveErr(true); }
}, []);
/* ----- derived ----- */
const { sponsors, editions, deals } = data;
const users = data.users || [];
const vouchers = data.vouchers || [];
const me = users.find((u) => u.id === userId) || null;
const admin = users.length === 0 ? true : isAdmin(me); // bootstrap: first entry is admin
const activeEditions = editions.filter((e) => e.active);
const sortedEditions = [...editions].sort((a, b) => (b.startDate || "").localeCompare(a.startDate || ""));
const filteredSponsors = useMemo(() => {
const q = query.trim().toLowerCase();
const list = [...sponsors].sort((a, b) => a.companyName.localeCompare(b.companyName));
if (!q) return list;
return list.filter((s) =>
[s.companyName, s.vatNumber, responsibleName(s, users), s.address,
...sponsorContacts(s).flatMap((c) => [contactName(c), c.email])]
.some((f) => (f || "").toLowerCase().includes(q)));
}, [sponsors, query]);
const openSponsor = sponsors.find((s) => s.id === openSponsorId);
/* ----- mutations ----- */
const saveUser = (u) => {
const bootstrap = users.length === 0;
const created = { ...u, id: uid() };
const next = u.id
? { ...data, users: users.map((x) => (x.id === u.id ? { ...x, ...u } : x)) }
: { ...data, users: [...users, bootstrap ? { ...created, role: "admin" } : created] };
persist(next); setModal(null);
if (bootstrap) setUserId(created.id); // stay logged in as the new admin
};
const deleteUser = (id) => {
const u = users.find((x) => x.id === id);
const admins = users.filter(isAdmin);
if (isAdmin(u) && admins.length <= 1) { window.alert("You can't remove the last admin."); return; }
if (!window.confirm(`Remove the account of ${userName(u)}? They lose access immediately.`)) return;
persist({ ...data, users: users.filter((x) => x.id !== id) });
if (id === userId) { setUserId(null); setMode({ view: "landing" }); }
};
/* ----- vouchers ----- */
const updateVoucher = (id, patch) =>
persist({ ...data, vouchers: vouchers.map((v) => (v.id === id ? { ...v, ...patch } : v)) });
/* Create voucher records for everything the agreements of an edition entitle to.
Existing (incl. cancelled) records are kept; only the shortfall is added. */
const generateVouchers = (editionId) => {
const edDeals = deals.filter((d) => d.editionId === editionId);
const added = [];
edDeals.forEach((d) => {
const wanted = expectedVouchers(d);
const have = vouchers.filter((v) => v.dealId === d.id && v.status !== "cancelled");
const countBy = (list, t, val) => list.filter((x) => x.type === t && Number(x.value) === Number(val)).length;
const seen = new Set();
wanted.forEach((w) => {
const key = `${w.type}|${w.value}`;
if (seen.has(key)) return;
seen.add(key);
const need = countBy(wanted, w.type, w.value) - countBy(have, w.type, w.value);
for (let i = 0; i < need; i++) {
added.push({
id: uid(), sponsorId: d.sponsorId, editionId, dealId: d.id,
type: w.type, value: w.value, status: "pending",
code: "", url: "", recipientName: "", recipientEmail: "",
createdAt: null, sentAt: null, cancelledAt: null, error: "",
});
}
});
});
if (added.length === 0) { window.alert("Everything is already generated for this edition."); return; }
persist({ ...data, vouchers: [...vouchers, ...added] });
window.alert(`${added.length} voucher(s) added as "to create".`);
};
/* Run a Tickoweb action over a selection, one by one so a failure doesn't stop the rest */
const runVoucherAction = async (action, ids) => {
const cfg = data.settings?.tickoweb || defaultTickoweb;
if (action === "cancel" && !window.confirm(`Cancel ${ids.length} voucher(s)?`)) return;
setVoBusy(true);
let next = vouchers;
for (const id of ids) {
const v = next.find((x) => x.id === id);
if (!v) continue;
const sponsor = sponsors.find((s) => s.id === v.sponsorId);
const edition = editions.find((e) => e.id === v.editionId);
const target = v.recipientEmail
? { name: v.recipientName, email: v.recipientEmail }
: (() => {
const c = contactsFor(sponsor, "vouchers")[0];
return c ? { name: contactName(c), email: c.email } : null;
})();
try {
let patch = {};
if (action === "createSend") {
if (v.status === "pending") {
const r = await tickoweb.create(cfg, { voucher: v, sponsor, edition, recipient: target });
patch = { ...patch, code: r.code || v.code, url: r.url || v.url, createdAt: new Date().toISOString(), status: "created", error: "" };
}
const sendable = { ...v, ...patch };
if (sendable.code || cfg.mode !== "live") {
await tickoweb.send(cfg, { voucher: sendable, recipient: target });
patch = { ...patch, status: "sent", sentAt: new Date().toISOString() };
}
} else if (action === "resend") {
await tickoweb.send(cfg, { voucher: v, recipient: target });
patch = { status: "sent", sentAt: new Date().toISOString(), error: "" };
} else if (action === "cancel") {
if (v.code) await tickoweb.cancel(cfg, { voucher: v });
patch = { status: "cancelled", cancelledAt: new Date().toISOString(), error: "" };
}
next = next.map((x) => (x.id === id ? { ...x, ...patch } : x));
} catch (ex) {
next = next.map((x) => (x.id === id ? { ...x, status: "error", error: String(ex.message || ex) } : x));
}
}
await persist({ ...data, vouchers: next });
setVoSel([]);
setVoBusy(false);
};
const saveSponsor = (s) => {
const next = s.id
? { ...data, sponsors: sponsors.map((x) => (x.id === s.id ? s : x)) }
: { ...data, sponsors: [...sponsors, { ...s, id: uid() }] };
persist(next); setModal(null);
};
const updateSponsorInfo = (id, patch) =>
persist({ ...data, sponsors: sponsors.map((s) => (s.id === id ? { ...s, ...patch } : s)) });
const addSponsorInline = (s) => {
const created = { ...emptySponsor, ...s, id: uid() };
persist({ ...data, sponsors: [...sponsors, created] });
return created;
};
const importSponsors = (list) => {
const created = list.map(({ vatInvalid, mailInvalid, contactPreview, emailPreview, langPreview, ...s }) =>
({ ...emptySponsor, ...s, id: uid() }));
persist({ ...data, sponsors: [...sponsors, ...created] });
};
const deleteSponsor = (id) => {
if (!window.confirm("Delete this sponsor and all their agreements?")) return;
persist({ ...data, sponsors: sponsors.filter((s) => s.id !== id), deals: deals.filter((d) => d.sponsorId !== id) });
window.storage.delete(logoKey(id), true).catch(() => { /* no logo stored */ });
setOpenSponsorId(null);
};
const saveEdition = (ed) => {
const next = ed.id
? { ...data, editions: editions.map((x) => (x.id === ed.id ? { ...x, ...ed } : x)) }
: { ...data, editions: [...editions, { ...ed, id: uid(), formulas: ed.formulas || [] }] };
persist(next); setModal(null);
};
const toggleEdition = (id) =>
persist({ ...data, editions: editions.map((e) => (e.id === id ? { ...e, active: !e.active } : e)) });
const deleteEdition = (id) => {
const n = deals.filter((d) => d.editionId === id).length;
if (!window.confirm(n ? `This edition has ${n} agreement(s). Delete edition and agreements?` : "Delete this edition?")) return;
persist({ ...data, editions: editions.filter((e) => e.id !== id), deals: deals.filter((d) => d.editionId !== id) });
};
const saveFormula = (editionId, f, originalName) => {
persist({
...data,
editions: editions.map((e) => {
if (e.id !== editionId) return e;
const fs = e.formulas || [];
return {
...e,
formulas: originalName
? fs.map((x) => (x.name === originalName ? f : x))
: [...fs, f],
};
}),
});
setModal(null);
};
const deleteFormula = (editionId, name) =>
persist({ ...data, editions: editions.map((e) => e.id === editionId ? { ...e, formulas: (e.formulas || []).filter((f) => f.name !== name) } : e) });
const saveDeal = (d) => {
const next = d.id
? { ...data, deals: deals.map((x) => (x.id === d.id ? { ...x, ...d } : x)) }
: { ...data, deals: [...deals, { ...d, id: uid(), createdAt: new Date().toISOString(), responsible: d.responsible || userName(me) || "" }] };
persist(next);
setModal(null);
// Offer the email right after creating a new agreement
if (!d.id) {
const created = next.deals[next.deals.length - 1];
setModal({ type: "email", payload: created });
}
};
const markEmailSent = (dealId) => {
persist({ ...data, deals: deals.map((d) => d.id === dealId ? { ...d, emailSentAt: new Date().toISOString() } : d) });
setModal(null);
};
const setInvoice = (dealId, patch) =>
persist({
...data,
deals: deals.map((d) => {
if (d.id !== dealId) return d;
const next = { ...d, ...patch };
// Never allow "invoice created" while a required reference is still missing
if (refMissing(next, sponsors.find((x) => x.id === next.sponsorId))) next.invoiceCreated = false;
return next;
}),
});
/* ----- overview stats ----- */
const overviewEdition = activeEditions[0] || sortedEditions[0];
const ovDeals = overviewEdition ? deals.filter((d) => d.editionId === overviewEdition.id) : [];
const ovTotal = ovDeals.reduce((s, d) => s + (Number(d.cost) || 0), 0);
const ovCash = ovDeals.reduce((s, d) => s + invoiceAmount(d), 0);
const ovKind = ovTotal - ovCash;
const ovBillable = ovDeals.filter(needsInvoice);
const ovInvoiced = ovBillable.filter((d) => d.invoiceCreated).length;
if (!loaded) return ;
/* ----- access modes ----- */
if (mode.view === "landing") {
return (
{ setUserId(uid2); setMode({ view: "team" }); setTab("overview"); }}
onPortal={(sponsorId, contactId) => setMode({ view: "portal", sponsorId, contactId })}
/>
);
}
if (mode.view === "portal") {
const ps = sponsors.find((s) => s.id === mode.sponsorId);
if (!ps) {
return (
This sponsor page is no longer available.
setMode({ view: "landing" })}>Back to start
);
}
return (
c.id === mode.contactId)}
deals={deals} editions={editions} vouchers={vouchers}
onSaveSponsor={updateSponsorInfo}
onAssignVoucher={(id, patch) => updateVoucher(id, patch)}
onExit={() => setMode({ view: "landing" })}
/>
);
}
const invEd = editions.find((e) => e.id === (invEdition || overviewEdition?.id));
const invDeals = invEd ? deals.filter((d) => d.editionId === invEd.id) : [];
return (
{[["overview", "Overview"], ["sponsors", "Sponsors"], ["editions", "Editions & formulas"], ["vouchers", "Vouchers"],
...(admin ? [["invoicing", "Invoicing"], ["team", "Team"]] : [])].map(([k, l]) => (
{ setTab(k); setOpenSponsorId(null); }}>{l}
))}
{me ? <>{userName(me)} {admin ? "admin" : "user"} > : "setup"}
{ setUserId(null); setMode({ view: "landing" }); }} title="Log out">Log out
{saveErr &&
Couldn't save the last change — check your connection and try again.
}
{users.length === 0 && (
No team accounts yet — anyone with the link can get in.
setModal({ type: "user", payload: { role: "admin" } })}>Create the first admin
)}
{/* ---------- OVERVIEW ---------- */}
{tab === "overview" && (
{editions.length === 0 && sponsors.length === 0 ? (
Welcome 👋
Set up in three steps:
Create an edition (e.g. Grensrock 2026) and define its sponsor formulas.
Add your sponsors with their contact details.
Register agreements — pick a formula, adjust, and send the confirmation email.
setModal({ type: "edition" })}>Create first edition
setModal({ type: "sponsor" })}>Add a sponsor
) : (
<>
{overviewEdition ? overviewEdition.name : "No edition yet"}
{overviewEdition && (
{overviewEdition.startDate}{overviewEdition.endDate ? " – " + overviewEdition.endDate : ""}
{overviewEdition.location && " · " + overviewEdition.location}
{" · "}{overviewEdition.active ? active : inactive }
)}
{ovDeals.length} sponsors this edition
{eur(ovTotal)} total sponsorship value
{eur(ovCash)} to invoice{ovKind > 0 ? ` · ${eur(ovKind)} in kind` : ""}
{ovInvoiced} / {ovBillable.length} invoiced
setModal({ type: "deal" })}>+ New agreement
setModal({ type: "sponsor" })}>+ New sponsor
>
)}
)}
{/* ---------- SPONSORS ---------- */}
{tab === "sponsors" && !openSponsor && (
)}
{tab === "sponsors" && openSponsor && (
setOpenSponsorId(null)}
onEdit={() => setModal({ type: "sponsor", payload: openSponsor })}
canDelete={admin}
onDelete={() => deleteSponsor(openSponsor.id)}
onAddDeal={() => setModal({ type: "deal", payload: { sponsorId: openSponsor.id, lockSponsor: true } })}
onEditDeal={(d) => setModal({ type: "deal", payload: d })}
onEmail={(d) => setModal({ type: "email", payload: d })}
/>
)}
{/* ---------- EDITIONS ---------- */}
{tab === "editions" && (
Festival editions
{admin && setModal({ type: "edition" })}>+ New edition }
{sortedEditions.length === 0 && No editions yet.
}
{sortedEditions.map((ed) => {
const edDeals = deals.filter((d) => d.editionId === ed.id);
const tot = edDeals.reduce((s, d) => s + (Number(d.cost) || 0), 0);
return (
{ed.name} {ed.active ? active : inactive }
{ed.startDate}{ed.endDate ? " – " + ed.endDate : ""}{ed.location && " · " + ed.location}
{" · "}{edDeals.length} sponsor(s) · {eur(tot)}
{admin && <> setModal({ type: "edition", payload: ed })}>Edit
toggleEdition(ed.id)}>{ed.active ? "Deactivate" : "Activate"}
deleteEdition(ed.id)}>Delete >}
Formulas
{admin && setModal({ type: "formula", payload: { editionId: ed.id } })}>+ Formula }
{(ed.formulas || []).length === 0 ? (
No formulas yet for this edition.
) : (
Name Cost VIP fr/sa/guest Drinks Food&drink Folder Website Poster LED
{(ed.formulas || []).map((f) => (
admin && setModal({ type: "formula", payload: { editionId: ed.id, formula: f } })}>
{f.name}
{eur(f.cost)}
{f.vipFriday || 0} / {f.vipSaturday || 0} / {f.vipGuests || 0}
{f.drinkTickets || 0}
{Number(f.foodDrinkValue) > 0 ? eur(f.foodDrinkValue) : "—"}
{normFolder(f.folderMention) !== "none" ? FOLDER_OPTS[normFolder(f.folderMention)] : "—"}
{f.websiteMention !== "none" ? WEBSITE_OPTS[f.websiteMention] : "—"}
{f.posterMention && f.posterMention !== "none" ? POSTER_OPTS[f.posterMention] : "—"}
{normLed(f.ledboarding).length > 0 ? ledLabel(f.ledboarding) : "—"}
{admin && { e.stopPropagation(); deleteFormula(ed.id, f.name); }}>✕ }
))}
)}
);
})}
)}
{/* ---------- VOUCHERS ---------- */}
{tab === "vouchers" && (() => {
const ved = editions.find((e) => e.id === (voEdition || overviewEdition?.id));
const all = ved ? vouchers.filter((v) => v.editionId === ved.id) : [];
const shown = all.filter((v) =>
(voSponsor ? v.sponsorId === voSponsor : true) &&
(voStatus ? v.status === voStatus : true));
const counts = Object.keys(VOUCHER_STATUS).reduce((a, k) => ({ ...a, [k]: all.filter((v) => v.status === k).length }), {});
const selectable = shown.filter((v) => v.status !== "cancelled");
const cfg = data.settings?.tickoweb || defaultTickoweb;
return (
Vouchers & tickets
{admin && setModal({ type: "tickoweb" })}>Tickoweb settings }
{ved && generateVouchers(ved.id)}>Generate from agreements }
{ setVoEdition(e.target.value); setVoSel([]); }}>
{sortedEditions.map((e) => {e.name} )}
setVoSponsor(e.target.value)}>
All sponsors
{[...new Set(all.map((v) => v.sponsorId))].map((id) => {
const sp = sponsors.find((x) => x.id === id);
return {sp ? sp.companyName : "?"} ;
})}
setVoStatus(e.target.value)}>
All statuses
{Object.entries(VOUCHER_STATUS).map(([k, v]) => {v.label} )}
{cfg.mode !== "live" && (
Manual mode — nothing is sent to Tickoweb automatically. Create the voucher there and paste the code back here.
)}
{Object.entries(VOUCHER_STATUS).map(([k, v]) => (
{counts[k] || 0} {v.label.toLowerCase()}
))}
{shown.length === 0 ? (
{all.length === 0
? "No vouchers yet for this edition — use \"Generate from agreements\"."
: "No vouchers match these filters."}
) : (
<>
setVoSel(selectable.map((v) => v.id))}>Select all shown
setVoSel([])}>Clear
runVoucherAction("createSend", voSel)}>
{voBusy ? "Working…" : `Create & send (${voSel.length})`}
runVoucherAction("resend", voSel)}>Resend
runVoucherAction("cancel", voSel)}>Cancel
>
)}
);
})()}
{/* ---------- TEAM ---------- */}
{tab === "team" && admin && (
Team accounts
setModal({ type: "user" })}>+ New account
{users.length === 0 ? (
No accounts yet. Create the first admin account — after that, everyone logs in with their email address.
) : (
Name Email (login) Role
{users.map((u) => (
setModal({ type: "user", payload: u })}>
{userName(u)} {u.id === userId && · you }
{u.email
? u.email
: ⚠ no email — can't log in }
{isAdmin(u) ? admin : user }
e.stopPropagation()}>
deleteUser(u.id)}>✕
))}
)}
Admin — everything: editions, formulas, invoicing and team accounts.
User — can view and update all sponsors and their agreements.
Everyone logs in with their email address and the shared password
{SHARED_PASSWORD}
Temporary arrangement — personal passwords come with the move to a real backend.
)}
{/* ---------- INVOICING ---------- */}
{tab === "invoicing" && admin && (
Invoicing (BillToBox — manual)
setInvEdition(e.target.value)} className="gr-search" style={{ maxWidth: 260 }}>
{sortedEditions.map((e) => {e.name} )}
{!invEd ? Create an edition first.
: invDeals.length === 0 ? (
No agreements for {invEd.name} yet.
) : (() => {
const billable = invDeals.filter(needsInvoice);
const inKind = invDeals.filter((d) => !needsInvoice(d));
const blockedCount = billable.filter((d) => refMissing(d, sponsors.find((x) => x.id === d.sponsorId))).length;
return (
<>
{blockedCount > 0 && (
{blockedCount} agreement{blockedCount > 1 ? "s" : ""} can't be invoiced yet — a required reference is still missing.
)}
{billable.length > 0 && (
)}
{inKind.length > 0 && (
<>
Not invoiced — settled in kind
Sponsor Value Provides
{inKind.map((d) => {
const s = sponsors.find((x) => x.id === d.sponsorId);
return (
{s ? s.companyName : "?"}
{eur(d.cost)}
{d.inKindDescription || — }
);
})}
>
)}
>
);
})()}
)}
{/* ---------- modals ---------- */}
{modal?.type === "sponsor" && (
setModal(null)}>
setModal(null)} />
)}
{modal?.type === "tickoweb" && (
setModal(null)} wide>
{
persist({ ...data, settings: { ...(data.settings || {}), tickoweb: cfg } });
setModal(null);
}}
onCancel={() => setModal(null)} />
)}
{modal?.type === "user" && (
setModal(null)}>
setModal(null)} />
)}
{modal?.type === "import" && (
setModal(null)} />
)}
{modal?.type === "edition" && (
setModal(null)}>
setModal(null)} />
)}
{modal?.type === "formula" && (
setModal(null)} wide>
saveFormula(modal.payload.editionId, f, modal.payload.formula?.name)}
onCancel={() => setModal(null)} />
)}
{modal?.type === "deal" && (
setModal(null)} wide>
setModal(null)} onAddSponsor={addSponsorInline} />
)}
{modal?.type === "email" && (() => {
const d = deals.find((x) => x.id === modal.payload.id) || modal.payload;
const s = sponsors.find((x) => x.id === d.sponsorId);
const e = editions.find((x) => x.id === d.editionId);
if (!s || !e) return null;
return setModal(null)} onMarkSent={() => markEmailSent(d.id)} />;
})()}
);
}
/* ============================================================
Styles — Grensrock house style: black chrome, the plectrum
mark, thin multicolour pick outlines and the five brand
colours (blue, green, yellow, orange, pink) as accents.
============================================================ */
const CSS = `
@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@800;900&family=IBM+Plex+Sans:wght@400;500;600&display=swap');
.gr-root { --stage:#0A0A0A; --paper:#FCFBF8; --ink:#1C1A1C; --line:#E4E1DA;
--pink:#C9518D; --pink-deep:#A0356C; --blue:#4A90C4; --blue-deep:#2F6E9C;
--orange:#DE8140; --orange-deep:#AD5A1D; --yellow:#C9C463;
--green:#2E7D4F; --red:#B3362B; --mut:#6E6A73;
--rainbow:linear-gradient(90deg,#4A90C4,#4FA05F,#C9C463,#DE8140,#C9518D);
font-family:'IBM Plex Sans',system-ui,sans-serif; color:var(--ink); background:var(--paper);
min-height:100vh; }
.gr-root * { box-sizing:border-box; }
.gr-header { background:var(--stage); color:#fff; padding:14px 28px;
display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:12px; }
.gr-brand { display:flex; align-items:center; gap:14px; }
.gr-brandsub { display:block; font-size:11px; letter-spacing:2.5px; text-transform:uppercase; color:#B9B4C0; }
.gr-nav { display:flex; gap:4px; flex-wrap:wrap; align-self:flex-end; }
.gr-nav button { background:none; border:none; color:#CFCAD6; font:600 14px 'IBM Plex Sans',sans-serif;
padding:9px 14px; cursor:pointer; border-radius:6px 6px 0 0; }
.gr-nav button:hover { color:#fff; }
.gr-nav button.on { background:var(--paper); color:var(--ink); }
.gr-nav button:focus-visible, .gr-root button:focus-visible, .gr-root a:focus-visible,
.gr-root input:focus-visible, .gr-root select:focus-visible, .gr-root textarea:focus-visible
{ outline:2px solid var(--blue-deep); outline-offset:1px; }
.gr-borderline { height:4px; background:var(--rainbow); }
.gr-main { max-width:1080px; margin:0 auto; padding:26px 28px 60px; }
.gr-h2 { font-family:'Montserrat',sans-serif; font-weight:800; font-size:24px; margin:0 0 4px; }
.gr-h3 { font-family:'Montserrat',sans-serif; font-weight:800; font-size:17px; margin:0; }
.gr-subhead { font:600 13px 'IBM Plex Sans',sans-serif; text-transform:uppercase; letter-spacing:1.5px;
color:var(--mut); border-top:1px dashed var(--line); padding-top:14px; margin:16px 0 6px; }
.gr-muted { color:var(--mut); font-size:13.5px; margin:2px 0; }
.gr-loading { padding:40px; color:var(--mut); }
.gr-warn { background:#FBEAE7; color:var(--red); padding:8px 28px; font-size:13px; }
.gr-onboard { max-width:560px; }
.gr-onboard ol { padding-left:20px; line-height:1.9; }
.gr-statrow { display:flex; gap:14px; flex-wrap:wrap; margin:18px 0; }
.gr-stat { border:1px solid var(--line); border-left:4px solid var(--blue); background:#fff;
border-radius:8px; padding:12px 18px 10px; font-size:12px; color:var(--mut); min-width:140px; }
.gr-stat:nth-child(2) { border-left-color:var(--pink); }
.gr-stat:nth-child(3) { border-left-color:var(--orange); }
.gr-stat:nth-child(4) { border-left-color:var(--yellow); }
.gr-stat span { display:block; font-family:'Montserrat',sans-serif; font-weight:800; font-size:22px; color:var(--ink); }
.gr-toolbar { display:flex; gap:12px; align-items:center; justify-content:space-between; margin-bottom:16px; flex-wrap:wrap; }
.gr-search { flex:1; min-width:220px; padding:10px 14px; border:1px solid var(--line); border-radius:8px;
font:14px 'IBM Plex Sans',sans-serif; background:#fff; }
.gr-btn { background:var(--stage); color:#fff; border:none; border-radius:8px; padding:10px 18px;
font:600 14px 'IBM Plex Sans',sans-serif; cursor:pointer; }
.gr-btn:hover { background:#2A2731; }
.gr-btn:disabled { opacity:.4; cursor:not-allowed; }
.gr-btn-ghost { background:#fff; color:var(--ink); border:1px solid var(--line); border-radius:8px;
padding:10px 16px; font:600 14px 'IBM Plex Sans',sans-serif; cursor:pointer; text-decoration:none; display:inline-block; }
.gr-btn-ghost:hover { border-color:var(--ink); }
.gr-mini { background:#fff; border:1px solid var(--line); border-radius:6px; padding:4px 10px;
font:600 12px 'IBM Plex Sans',sans-serif; cursor:pointer; }
.gr-mini:hover { border-color:var(--ink); }
.gr-danger { color:var(--red); }
.gr-back { background:none; border:none; color:var(--mut); font:600 13px 'IBM Plex Sans',sans-serif;
cursor:pointer; padding:0; margin-bottom:12px; }
.gr-back:hover { color:var(--ink); }
.gr-tablewrap { overflow-x:auto; border:1px solid var(--line); border-radius:8px; background:#fff; }
.gr-table { width:100%; border-collapse:collapse; font-size:13.5px; }
.gr-table th { text-align:left; font:600 11px 'IBM Plex Sans',sans-serif; text-transform:uppercase;
letter-spacing:1px; color:var(--mut); padding:10px 12px; border-bottom:2px solid var(--line); white-space:nowrap; }
.gr-table td { padding:10px 12px; border-bottom:1px solid var(--line); vertical-align:middle; }
.gr-table tr:last-child td { border-bottom:none; }
.gr-table .gr-r { text-align:right; }
.gr-clickable tbody tr { cursor:pointer; }
.gr-clickable tbody tr:hover { background:#FBF3F7; }
.gr-clickable input, .gr-clickable button { cursor:auto; }
.gr-compact th, .gr-compact td { padding:7px 10px; }
.gr-rowbtns { white-space:nowrap; } .gr-rowbtns .gr-mini { margin-left:4px; }
.gr-inline-input { border:1px solid var(--line); border-radius:6px; padding:5px 8px; font:13px 'IBM Plex Sans',sans-serif; width:130px; }
.gr-tag { display:inline-block; font:600 11px 'IBM Plex Sans',sans-serif; padding:2px 8px; border-radius:20px; }
.gr-tag-grey { background:#EFEDE8; color:var(--mut); }
.gr-tag-green { background:#E2F1E7; color:var(--green); }
.gr-tag-amber { background:#FBEBDD; color:var(--orange-deep); }
.gr-card { border:1px solid var(--line); border-radius:10px; background:#fff; padding:16px 18px; margin-bottom:16px; }
.gr-cardhead { display:flex; justify-content:space-between; gap:12px; flex-wrap:wrap; }
.gr-formulas { margin-top:12px; border-top:1px dashed var(--line); padding-top:10px; }
.gr-formulahead { display:flex; justify-content:space-between; align-items:center; margin-bottom:6px; }
.gr-detail-head { display:flex; justify-content:space-between; gap:16px; flex-wrap:wrap; }
.gr-detail-btns { display:flex; gap:8px; align-items:flex-start; flex-wrap:wrap; }
.gr-empty { color:var(--mut); border:1px dashed var(--line); border-radius:8px; padding:22px; text-align:center; }
.gr-field { display:block; margin-bottom:12px; }
.gr-label { display:block; font:600 11px 'IBM Plex Sans',sans-serif; text-transform:uppercase;
letter-spacing:1px; color:var(--mut); margin-bottom:4px; }
.gr-hint { display:block; font-size:12px; color:var(--pink-deep); margin-top:3px; }
.gr-field input, .gr-field select, .gr-field textarea { width:100%; padding:9px 12px;
border:1px solid var(--line); border-radius:8px; font:14px 'IBM Plex Sans',sans-serif; background:#fff; }
.gr-grid2 { display:grid; grid-template-columns:1fr 1fr; gap:0 14px; }
.gr-grid3 { display:grid; grid-template-columns:1fr 1fr 1fr; gap:0 14px; }
@media (max-width:640px){ .gr-grid2,.gr-grid3 { grid-template-columns:1fr; } .gr-main{padding:20px 16px 50px;} .gr-header{padding:16px;} }
.gr-actions { display:flex; gap:10px; justify-content:flex-end; margin-top:14px; flex-wrap:wrap; }
.gr-overlay { position:fixed; inset:0; background:rgba(23,21,27,.55); display:flex;
align-items:flex-start; justify-content:center; padding:5vh 16px; z-index:50; overflow-y:auto; }
.gr-modal { background:var(--paper); border-radius:12px; width:100%; max-width:520px;
border-top:5px solid transparent; border-image:var(--rainbow) 1; overflow:hidden; }
.gr-modal-wide { max-width:680px; }
.gr-modal-head { display:flex; justify-content:space-between; align-items:center; padding:16px 20px 0; }
.gr-modal-head h3 { font-family:'Montserrat',sans-serif; font-weight:800; margin:0; font-size:18px; }
.gr-x { background:none; border:none; font-size:15px; cursor:pointer; color:var(--mut); }
.gr-modal-body { padding:14px 20px 20px; }
.gr-whoami { display:flex; align-items:center; gap:6px; color:#CFCAD6; font-size:13px;
padding:9px 10px 9px 16px; border-left:1px solid #2A2731; margin-left:8px; }
.gr-setupbar { background:#FBEBDD; color:var(--orange-deep); padding:9px 28px; font-size:13.5px;
font-weight:600; display:flex; align-items:center; gap:12px; flex-wrap:wrap; }
.gr-linkbtn { background:none; border:none; color:var(--mut); font:600 13px 'IBM Plex Sans',sans-serif;
cursor:pointer; padding:0; text-decoration:underline; align-self:flex-start; }
.gr-linkbtn:hover { color:var(--ink); }
.gr-contactcard { border:1px solid var(--line); border-left:4px solid var(--blue); border-radius:10px;
background:#fff; padding:12px 14px 2px; margin-bottom:10px; }
.gr-contacthead { display:flex; justify-content:space-between; align-items:center; margin-bottom:6px; }
.gr-contactline { display:flex; justify-content:space-between; align-items:flex-start; gap:10px;
padding:8px 0; border-bottom:1px dashed var(--line); }
.gr-contactline:last-child { border-bottom:none; }
.gr-commtags { display:flex; gap:4px; flex-wrap:wrap; margin-top:4px; }
.gr-check-sm { padding:5px 10px; font-size:12.5px; }
.gr-subnav { display:flex; gap:2px; flex-wrap:wrap; border-bottom:2px solid var(--line); margin:18px 0 18px; }
.gr-subnav button { background:none; border:none; border-bottom:3px solid transparent; margin-bottom:-2px;
padding:9px 14px; font:600 13.5px 'IBM Plex Sans',sans-serif; color:var(--mut); cursor:pointer; }
.gr-subnav button:hover { color:var(--ink); }
.gr-subnav button.on { color:var(--ink); border-bottom-color:var(--pink); }
.gr-dl { display:grid; grid-template-columns:auto 1fr; gap:6px 16px; margin:0; font-size:13.5px; }
.gr-dl dt { color:var(--mut); font-weight:600; }
.gr-dl dd { margin:0; }
.gr-cancelled { opacity:.55; text-decoration:line-through; }
.gr-cancelled input, .gr-cancelled a { text-decoration:none; }
.gr-blocked { background:#FEF6F5; }
.gr-blockbanner { background:#FBEAE7; color:var(--red); border-radius:8px; padding:10px 14px;
font-size:13.5px; font-weight:600; margin:0 0 14px; }
.gr-settle { border:1px dashed var(--line); border-radius:10px; padding:12px 14px; margin-bottom:12px; background:#F8FBFD; }
.gr-settle .gr-field:last-of-type { margin-bottom:6px; }
.gr-vouchers { border:1px dashed var(--line); border-radius:10px; padding:12px 14px; margin-bottom:12px; background:#FFFBFD; }
.gr-voucherquick { display:flex; gap:6px; flex-wrap:wrap; }
.gr-voucherrow { display:flex; align-items:center; gap:8px; margin:6px 0; flex-wrap:wrap; }
.gr-voucherrow input { width:90px; padding:7px 10px; border:1px solid var(--line); border-radius:8px;
font:14px 'IBM Plex Sans',sans-serif; background:#fff; }
.gr-voucherline { min-width:86px; text-align:right; font-weight:600; font-size:13.5px; }
.gr-voucherstatus { font-size:13px; color:var(--pink-deep); margin:6px 0 0; }
.gr-voucherstatus.ok { color:var(--green); }
.gr-landing { position:relative; min-height:100vh; display:flex; flex-direction:column; align-items:center;
justify-content:center; padding:40px 20px; background:#000; text-align:center; overflow:hidden; }
.gr-picklines { position:absolute; inset:0; width:100%; height:100%; opacity:.85; pointer-events:none; }
.gr-landing > * { position:relative; z-index:1; }
.gr-landcards { display:flex; gap:18px; margin-top:34px; flex-wrap:wrap; justify-content:center; }
.gr-landcard { background:var(--paper); border-radius:14px; border-top:5px solid transparent;
border-image:var(--rainbow) 1;
padding:22px 24px; width:290px; text-align:left; display:flex; flex-direction:column; gap:10px; }
.gr-landcard input { padding:10px 12px; border:1px solid var(--line); border-radius:8px;
font:14px 'IBM Plex Sans',sans-serif; }
.gr-landing > p { color:#F3B7B0; }
.gr-logoblock { display:flex; gap:14px; align-items:flex-start; flex-wrap:wrap; }
.gr-logoframe { width:150px; height:100px; border:1px solid var(--line); border-radius:10px;
background:repeating-conic-gradient(#F1EFE9 0% 25%, #fff 0% 50%) 0 0 / 20px 20px;
display:flex; align-items:center; justify-content:center; overflow:hidden; }
.gr-logoframe img { max-width:100%; max-height:100%; object-fit:contain; }
.gr-logoframe .gr-muted { background:#fff; padding:2px 6px; border-radius:4px; font-size:12px; }
.gr-logobtns { display:flex; flex-direction:column; gap:8px; align-items:flex-start; max-width:220px; }
.gr-filebtn { display:inline-block; }
.gr-portalbox { border:1px solid var(--line); border-radius:10px; background:#fff; padding:14px 16px; font-size:13.5px; }
.gr-code { font-family:'Montserrat',sans-serif; font-weight:800; letter-spacing:1px; color:var(--pink-deep);
background:#FAE7F0; padding:2px 8px; border-radius:6px; }
.gr-exit { align-self:center; }
.gr-file { display:block; width:100%; padding:22px; border:2px dashed var(--line); border-radius:10px;
background:#fff; font:14px 'IBM Plex Sans',sans-serif; cursor:pointer; margin:8px 0; }
.gr-file:hover { border-color:var(--pink-deep); }
.gr-checkrow { display:flex; gap:8px; flex-wrap:wrap; }
.gr-check { display:flex; align-items:center; gap:7px; border:1px solid var(--line); border-radius:8px;
background:#fff; padding:8px 14px; font:500 13.5px 'IBM Plex Sans',sans-serif; cursor:pointer; }
.gr-check:hover { border-color:var(--ink); }
.gr-check.on { border-color:var(--pink-deep); background:#FAE7F0; }
.gr-check input { accent-color:var(--pink-deep); }
.gr-picker { position:relative; }
.gr-picker input { width:100%; padding:9px 12px; border:1px solid var(--line); border-radius:8px;
font:14px 'IBM Plex Sans',sans-serif; background:#fff; }
.gr-picker-list { border:1px solid var(--line); border-radius:8px; background:#fff; margin-top:6px;
max-height:260px; overflow-y:auto; box-shadow:0 6px 18px rgba(23,21,27,.08); }
.gr-picker-item { display:block; width:100%; text-align:left; background:none; border:none;
border-bottom:1px solid var(--line); padding:9px 12px; cursor:pointer; font:13.5px 'IBM Plex Sans',sans-serif; }
.gr-picker-item:hover { background:#FBF3F7; }
.gr-picker-item strong { display:block; }
.gr-picker-item .gr-muted { font-size:12px; }
.gr-picker-item:last-child { border-bottom:none; }
.gr-picker-new { color:var(--pink-deep); font-weight:600; }
.gr-picker-none { padding:10px 12px; color:var(--mut); font-size:13px; border-bottom:1px solid var(--line); }
.gr-picked { display:flex; justify-content:space-between; align-items:center; gap:10px;
border:1px solid var(--line); border-left:4px solid var(--blue); border-radius:8px;
background:#fff; padding:9px 12px; font-size:14px; }
.gr-emailpreview { border:1px solid var(--line); border-radius:8px; background:#fff; margin-top:8px; }
.gr-email-subject { font-weight:600; padding:10px 14px; border-bottom:1px solid var(--line); }
.gr-emailpreview pre { margin:0; padding:14px; font:13px/1.55 'IBM Plex Sans',sans-serif;
white-space:pre-wrap; max-height:340px; overflow-y:auto; }
@media (prefers-reduced-motion: no-preference) {
.gr-modal { animation:grpop .18s ease-out; }
@keyframes grpop { from { transform:translateY(8px); opacity:0; } to { transform:none; opacity:1; } }
}
`;