import { useState, useRef, useCallback } from "react"; import Papa from "papaparse"; const CAMP_DAYS = [ { key: "26th May", label: "Monday 26th May", short: "Mon 26" }, { key: "27th May", label: "Tuesday 27th May", short: "Tue 27" }, { key: "28th May", label: "Wednesday 28th May", short: "Wed 28" }, ]; const FIELD_MAP = { "ORDER ID": ["order id", "order_id", "orderid", "id"], "ORDER DATE": ["order date", "order_date", "date"], "CHILD'S NAME": ["child's name", "childs name", "child name", "child"], CLASS: ["class"], SCHOOL: ["school"], PAYMENT: ["payment", "payment status", "payment_status"], BOOKING: ["booking", "booking status", "booking_status"], "FIRST NAME": ["first name", "first_name", "firstname", "billing first name"], "LAST NAME": ["last name", "last_name", "lastname", "billing last name"], EMAIL: ["email", "billing email"], PHONE: ["phone", "billing phone", "telephone"], NOTES: ["notes", "order notes", "customer note"], }; function normalise(str) { return (str || "").toLowerCase().trim(); } function findColumn(headers, candidates) { for (const h of headers) { if (candidates.some((c) => normalise(h).includes(c))) return h; } return null; } function findDayColumn(headers, dayKey) { // e.g. "26th May" — look for partial match const target = normalise(dayKey); return headers.find((h) => normalise(h).includes(target.split(" ")[0]) && normalise(h).includes(target.split(" ")[1])) || null; } function mapRow(row, colMap) { const mapped = {}; for (const [field, col] of Object.entries(colMap)) { mapped[field] = col ? (row[col] || "").trim() : ""; } return mapped; } function buildColMap(headers) { const colMap = {}; for (const [field, candidates] of Object.entries(FIELD_MAP)) { colMap[field] = findColumn(headers, candidates); } for (const day of CAMP_DAYS) { colMap[day.key] = findDayColumn(headers, day.key); } return colMap; } function isAttending(val) { const v = normalise(val); return v === "yes" || v === "true" || v === "1" || v === "attending" || (v !== "" && v !== "no" && v !== "false" && v !== "0"); } const styles = ` @import url('https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,600;0,9..144,700;1,9..144,400&family=DM+Sans:wght@300;400;500;600&display=swap'); *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } :root { --sun: #F5A623; --sky: #2D7DD2; --grass: #3BB273; --sand: #FDF6EC; --bark: #2C1A0E; --mist: #F0EBE3; --cloud: #FFFFFF; --ink: #1A1208; --mid: #6B5744; --border: #DDD0C0; --day1: #FF6B6B; --day2: #4ECDC4; --day3: #45B7D1; --radius: 12px; --shadow: 0 4px 24px rgba(44,26,14,0.1); } body { font-family: 'DM Sans', sans-serif; background: var(--sand); color: var(--ink); min-height: 100vh; } .app { max-width: 1400px; margin: 0 auto; padding: 24px 20px; } /* HEADER */ .header { display: flex; align-items: center; gap: 16px; margin-bottom: 36px; padding-bottom: 24px; border-bottom: 2px solid var(--border); } .header-icon { width: 56px; height: 56px; border-radius: 16px; background: linear-gradient(135deg, var(--sun), #E8890A); display: flex; align-items: center; justify-content: center; font-size: 28px; flex-shrink: 0; box-shadow: 0 4px 16px rgba(245,166,35,0.35); } .header h1 { font-family: 'Fraunces', serif; font-size: 28px; font-weight: 700; color: var(--ink); line-height: 1.1; } .header p { font-size: 14px; color: var(--mid); margin-top: 3px; } /* TABS */ .tabs { display: flex; gap: 4px; margin-bottom: 28px; background: var(--mist); border-radius: var(--radius); padding: 4px; width: fit-content; } .tab { padding: 8px 20px; border-radius: 9px; border: none; font-family: 'DM Sans', sans-serif; font-size: 14px; font-weight: 500; cursor: pointer; transition: all .2s; color: var(--mid); background: transparent; } .tab.active { background: var(--cloud); color: var(--ink); box-shadow: 0 2px 8px rgba(44,26,14,0.12); } /* UPLOAD ZONE */ .upload-zone { border: 2px dashed var(--border); border-radius: 20px; padding: 60px 40px; text-align: center; transition: all .25s; cursor: pointer; background: var(--cloud); position: relative; } .upload-zone.dragging { border-color: var(--sky); background: #EEF5FC; } .upload-zone input { position: absolute; inset: 0; opacity: 0; cursor: pointer; } .upload-icon { font-size: 48px; margin-bottom: 16px; } .upload-zone h3 { font-family: 'Fraunces', serif; font-size: 22px; margin-bottom: 8px; } .upload-zone p { color: var(--mid); font-size: 14px; } .upload-btn { margin-top: 20px; padding: 10px 28px; background: var(--sky); color: white; border: none; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; font-family: 'DM Sans', sans-serif; transition: opacity .2s; } .upload-btn:hover { opacity: .88; } /* STATS BAR */ .stats-bar { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 16px; margin-bottom: 28px; } .stat-card { background: var(--cloud); border-radius: var(--radius); padding: 16px 20px; box-shadow: var(--shadow); border-left: 4px solid var(--sun); } .stat-card:nth-child(2) { border-color: var(--day1); } .stat-card:nth-child(3) { border-color: var(--day2); } .stat-card:nth-child(4) { border-color: var(--day3); } .stat-card:nth-child(5) { border-color: var(--grass); } .stat-num { font-family: 'Fraunces', serif; font-size: 32px; font-weight: 700; line-height: 1; } .stat-label { font-size: 12px; color: var(--mid); margin-top: 4px; font-weight: 500; text-transform: uppercase; letter-spacing: .04em; } /* TOOLBAR */ .toolbar { display: flex; gap: 12px; align-items: center; margin-bottom: 20px; flex-wrap: wrap; } .search-box { flex: 1; min-width: 200px; padding: 9px 14px; border: 1.5px solid var(--border); border-radius: 8px; font-family: 'DM Sans', sans-serif; font-size: 14px; background: var(--cloud); color: var(--ink); transition: border-color .2s; } .search-box:focus { outline: none; border-color: var(--sky); } .filter-select { padding: 9px 14px; border: 1.5px solid var(--border); border-radius: 8px; font-family: 'DM Sans', sans-serif; font-size: 14px; background: var(--cloud); color: var(--ink); cursor: pointer; } .action-btn { padding: 9px 18px; border-radius: 8px; border: none; font-family: 'DM Sans', sans-serif; font-size: 14px; font-weight: 500; cursor: pointer; transition: all .2s; display: flex; align-items: center; gap: 6px; } .btn-primary { background: var(--sky); color: white; } .btn-primary:hover { opacity: .88; } .btn-success { background: var(--grass); color: white; } .btn-success:hover { opacity: .88; } .btn-ghost { background: var(--mist); color: var(--ink); border: 1.5px solid var(--border); } .btn-ghost:hover { background: var(--border); } .btn-danger { background: #FF6B6B; color: white; } /* TABLE */ .table-wrap { overflow-x: auto; border-radius: var(--radius); box-shadow: var(--shadow); } table { width: 100%; border-collapse: collapse; background: var(--cloud); font-size: 13px; } thead { background: var(--bark); color: white; } th { padding: 12px 14px; text-align: left; font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: .06em; white-space: nowrap; } td { padding: 11px 14px; border-bottom: 1px solid var(--mist); color: var(--ink); vertical-align: middle; } tr:hover td { background: #FDFAF5; } tr:last-child td { border-bottom: none; } .day-chip { display: inline-block; padding: 3px 10px; border-radius: 20px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; } .day-chip.yes { background: #D4EDDA; color: #155724; } .day-chip.no { background: var(--mist); color: var(--mid); } .payment-chip { display: inline-block; padding: 3px 10px; border-radius: 20px; font-size: 11px; font-weight: 600; } .payment-chip.paid { background: #D4EDDA; color: #155724; } .payment-chip.pending { background: #FFF3CD; color: #856404; } .payment-chip.failed { background: #F8D7DA; color: #721C24; } /* REGISTER VIEW */ .register-header { background: var(--cloud); border-radius: var(--radius); padding: 24px 28px; margin-bottom: 20px; box-shadow: var(--shadow); display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px; } .register-title { font-family: 'Fraunces', serif; font-size: 26px; font-weight: 700; } .register-meta { font-size: 13px; color: var(--mid); margin-top: 4px; } .day-tabs { display: flex; gap: 8px; flex-wrap: wrap; } .day-tab { padding: 10px 20px; border-radius: 10px; border: 2px solid transparent; font-family: 'DM Sans', sans-serif; font-size: 14px; font-weight: 600; cursor: pointer; transition: all .2s; background: var(--mist); color: var(--mid); } .day-tab.d0.active { background: #FFEAEA; color: var(--day1); border-color: var(--day1); } .day-tab.d1.active { background: #E8FAFA; color: #2AA39C; border-color: var(--day2); } .day-tab.d2.active { background: #E8F4FB; color: #2589AC; border-color: var(--day3); } /* REGISTER TABLE */ .reg-table th:first-child { width: 40px; text-align: center; } .reg-table .attendance-cell { text-align: center; } .tick-box { width: 22px; height: 22px; border: 2px solid var(--border); border-radius: 5px; display: inline-block; cursor: pointer; transition: all .15s; } .tick-box.checked { background: var(--grass); border-color: var(--grass); } .school-badge { display: inline-block; padding: 2px 8px; border-radius: 4px; background: var(--mist); font-size: 11px; color: var(--mid); } /* EMPTY STATE */ .empty { text-align: center; padding: 80px 20px; color: var(--mid); } .empty-icon { font-size: 56px; margin-bottom: 16px; } .empty h3 { font-family: 'Fraunces', serif; font-size: 22px; color: var(--ink); margin-bottom: 8px; } /* ERROR */ .error-box { background: #FFF0F0; border: 1.5px solid #FFB3B3; border-radius: 10px; padding: 14px 18px; margin-bottom: 20px; font-size: 13px; color: #B00020; } /* PRINT */ @media print { .no-print { display: none !important; } .app { padding: 0; } .register-header { box-shadow: none; border: 1px solid #ccc; } .table-wrap { box-shadow: none; } table { font-size: 11px; } td, th { padding: 8px 10px; } } `; export default function CampBookingSystem() { const [bookings, setBookings] = useState([]); const [headers, setHeaders] = useState([]); const [colMap, setColMap] = useState({}); const [tab, setTab] = useState("upload"); const [search, setSearch] = useState(""); const [schoolFilter, setSchoolFilter] = useState("all"); const [dragging, setDragging] = useState(false); const [error, setError] = useState(""); const [selectedDay, setSelectedDay] = useState(0); const [attendance, setAttendance] = useState({}); const fileRef = useRef(); const processCSV = useCallback((file) => { setError(""); Papa.parse(file, { header: true, skipEmptyLines: true, complete: ({ data, meta }) => { if (!data.length) { setError("No data found in the file."); return; } const hdrs = meta.fields || []; const map = buildColMap(hdrs); const rows = data.map((r) => mapRow(r, map)); setHeaders(hdrs); setColMap(map); setBookings(rows); setAttendance({}); setTab("bookings"); }, error: (e) => setError("Could not parse file: " + e.message), }); }, []); const handleDrop = useCallback((e) => { e.preventDefault(); setDragging(false); const file = e.dataTransfer.files[0]; if (file) processCSV(file); }, [processCSV]); const schools = [...new Set(bookings.map((b) => b["SCHOOL"]).filter(Boolean))].sort(); const filtered = bookings.filter((b) => { const q = search.toLowerCase(); const matchSearch = !q || Object.values(b).some((v) => v.toLowerCase().includes(q)); const matchSchool = schoolFilter === "all" || b["SCHOOL"] === schoolFilter; return matchSearch && matchSchool; }); const dayCount = (dayKey) => bookings.filter((b) => isAttending(b[dayKey])).length; const toggleAttendance = (id, dayIdx) => { const k = `${id}-${dayIdx}`; setAttendance((prev) => ({ ...prev, [k]: !prev[k] })); }; const registerBookings = bookings.filter((b) => isAttending(b[CAMP_DAYS[selectedDay].key])); const paymentClass = (val) => { const v = normalise(val); if (v.includes("paid") || v.includes("complete")) return "paid"; if (v.includes("pending") || v.includes("processing")) return "pending"; return "failed"; }; const exportRegisterCSV = () => { const day = CAMP_DAYS[selectedDay]; const rows = [ ["#", "Child's Name", "Class", "School", "Parent Name", "Phone", "Email", "Notes", "Present"], ...registerBookings.map((b, i) => [ i + 1, b["CHILD'S NAME"], b["CLASS"], b["SCHOOL"], `${b["FIRST NAME"]} ${b["LAST NAME"]}`.trim(), b["PHONE"], b["EMAIL"], b["NOTES"], attendance[`${b["ORDER ID"]}-${selectedDay}`] ? "✓" : "", ]), ]; const csv = Papa.unparse(rows); const blob = new Blob([csv], { type: "text/csv" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = `register-${day.key.replace(" ", "-")}.csv`; a.click(); }; return ( <>
{/* HEADER */}

Camp Booking System

May Half-Term Camp · 26, 27 & 28 May · Import from WooCommerce

{/* TABS */} {bookings.length > 0 && (
{[["upload", "📂 Import"], ["bookings", "📋 All Bookings"], ["register", "📝 Daily Register"]].map(([id, label]) => ( ))}
)} {error &&
⚠️ {error}
} {/* UPLOAD TAB */} {(tab === "upload" || !bookings.length) && (
{ e.preventDefault(); setDragging(true); }} onDragLeave={() => setDragging(false)} onDrop={handleDrop} > e.target.files[0] && processCSV(e.target.files[0])} />
📊

Drop your WooCommerce CSV export here

The file should include order details, child info, parent contact, and per-day booking columns

)} {/* BOOKINGS TAB */} {tab === "bookings" && bookings.length > 0 && ( <>
{bookings.length}
Total Bookings
{CAMP_DAYS.map((d) => (
{dayCount(d.key)}
{d.short}
))}
{schools.length}
Schools
setSearch(e.target.value)} />
{filtered.length === 0 ? (
🔍

No results found

Try adjusting your search or filter.

) : (
{CAMP_DAYS.map((d) => )} {filtered.map((b, i) => ( {CAMP_DAYS.map((d) => ( ))} ))}
Order ID Order Date Child's Name Class School Payment{d.short}Parent Phone Notes
{b["ORDER ID"]} {b["ORDER DATE"]} {b["CHILD'S NAME"]} {b["CLASS"]} {b["SCHOOL"]} {b["PAYMENT"] || "—"} {isAttending(b[d.key]) ? "✓ Yes" : "No"} {b["FIRST NAME"]} {b["LAST NAME"]} {b["PHONE"]} {b["NOTES"]}
)} )} {/* REGISTER TAB */} {tab === "register" && bookings.length > 0 && ( <>
📝 Daily Register
{registerBookings.length} children attending · {CAMP_DAYS[selectedDay].label}
{CAMP_DAYS.map((d, i) => ( ))}
{/* School filter for register */}
setSearch(e.target.value)} />
{registerBookings.length === 0 ? (
🏕️

No bookings for this day

No children are booked in for {CAMP_DAYS[selectedDay].label}.

) : (
{registerBookings .filter((b) => { const q = search.toLowerCase(); const matchSearch = !q || b["CHILD'S NAME"].toLowerCase().includes(q) || b["SCHOOL"].toLowerCase().includes(q); const matchSchool = schoolFilter === "all" || b["SCHOOL"] === schoolFilter; return matchSearch && matchSchool; }) .sort((a, b) => a["CHILD'S NAME"].localeCompare(b["CHILD'S NAME"])) .map((b, i) => { const checked = attendance[`${b["ORDER ID"]}-${selectedDay}`] || false; return ( ); })}
# Child's Name Class School Parent Name Phone Email Order ID Payment Notes Present
{i + 1} {b["CHILD'S NAME"]} {b["CLASS"]} {b["SCHOOL"]} {b["FIRST NAME"]} {b["LAST NAME"]} {b["PHONE"]} {b["EMAIL"]} {b["ORDER ID"]} {b["PAYMENT"] || "—"} {b["NOTES"]} toggleAttendance(b["ORDER ID"], selectedDay)}> {checked && }
)} )}
); }