Studio control

Manage what customers see and can book.

JT
Connected to API. Changes are live.
Classes0
Total capacity0
Available spots0

Classes

Scheduled classes with real-time availability.

ClassTypeDate & timeCoachPriceSpotsActions
Total bookings0
Confirmed0
Unique customers0

Bookings

Everyone who has booked a class.

CustomerClassDate & timeCoachBooked onStatusActions

Memberships

Monthly recurring plans.

PlanPriceBillingClass allowanceStatusActions

Class packages

Prepaid class packs.

PackagePriceCreditsStatusActions

Seasons

Limited-time campaigns.

SeasonDatesStatusActions

Promotions

Discounts and offers.

PromotionDiscountTargetDatesStatusActions
Saved

Studio control

Manage what customers see and can book.

JT
Connected to API. Changes are live.
Classes0
Total capacity0
Available spots0

Classes

Scheduled classes with real-time availability.

ClassTypeDate & timeCoachPriceSpotsActions
Total bookings0
Confirmed0
Unique customers0

Bookings

Everyone who has booked a class.

CustomerClassDate & timeCoachBooked onStatusActions

Memberships

Monthly recurring plans.

PlanPriceBillingClass allowanceStatusActions

Class packages

Prepaid class packs.

PackagePriceCreditsStatusActions

Seasons

Limited-time campaigns.

SeasonDatesStatusActions

Promotions

Discounts and offers.

PromotionDiscountTargetDatesStatusActions
Saved
``` // ===== RENDER ===== function render() { const visible = data.classes; document.querySelector('#classCount').textContent = visible.length; document.querySelector('#capacityCount').textContent = visible.reduce((n, x) => n + Number(x.capacity), 0); document.querySelector('#spotsCount').textContent = visible.reduce((n, x) => n + Number(x.spots), 0); document.querySelector('#classesRows').innerHTML = visible.map(x => { const rowBookings = data.bookings.filter(b => b.classId === x.id); return `${esc(x.name)}${x.booked} booking${x.booked === 1 ? '' : 's'}${esc(x.service)}${esc(x.date)}${esc(x.time)}${esc(x.coach)}${money(x.price)}${x.spots} / ${x.capacity}`; }).join('') || '
No classes yetUse Add class to create one.
'; document.querySelector('#bookingCount').textContent = data.bookings.length; document.querySelector('#confirmedCount').textContent = data.bookings.filter(b => b.status === 'confirmed').length; document.querySelector('#customerCount').textContent = new Set(data.bookings.map(b => b.email || b.name)).size; document.querySelector('#bookingRows').innerHTML = data.bookings.map(b => `${esc(b.name || '—')}${esc(b.email || '—')}${esc(b.className)}${esc(b.service || '')}${esc(b.date)}${esc(b.time)}${esc(b.coach || '—')}${b.bookedAt ? new Date(b.bookedAt).toLocaleString('en-GB', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }) : '—'}${esc(b.status)}`).join('') || '
No bookings yetBookings appear when customers book classes.
'; document.querySelector('#membershipRows').innerHTML = data.memberships.map(x => `${esc(x.name)}${money(x.price)}${x.billing_period || 'Monthly'}${x.class_allowance || 'Unlimited'}${esc(x.status)}`).join('') || '
No memberships yetAdd one to get started.
'; document.querySelector('#packageRows').innerHTML = data.packages.map(x => `${esc(x.name)}${money(x.price)}${x.class_allowance || 0} classes${esc(x.status)}`).join('') || '
No packages yetAdd one to get started.
'; document.querySelector('#seasonRows').innerHTML = data.seasons.map(s => `${esc(s.name)}${esc(s.description || '')}${esc(s.start_date)} to ${esc(s.end_date)}${esc(s.status)}`).join('') || '
No seasons yetCreate a season to organize classes.
'; document.querySelector('#promotionRows').innerHTML = data.promotions.map(p => `${esc(p.title)}${esc(p.description || '')}${Number(p.discount_percent)}%${esc(p.target)}${esc(p.start_date)} to ${esc(p.end_date)}${esc(p.status)}`).join('') || '
No promotions yetCreate a promotion to boost bookings.
'; } // ===== MODAL & FORMS ===== const schemas = { class: [ ['name', 'Class name', 'text'], ['service', 'Class type', 'text'], ['date', 'Date', 'date'], ['time', 'Start time (24-hour)', 'time'], ['coach', 'Coach', 'text'], ['duration', 'Duration (minutes)', 'number'], ['capacity', 'Capacity', 'number'], ['price', 'Price (AED)', 'number'], ], membership: [ ['name', 'Plan name', 'text'], ['price', 'Monthly price (AED)', 'number'], ['class_allowance', 'Class allowance', 'text'], ['status', 'Status', 'select:Active,Draft'], ], package: [ ['name', 'Package name', 'text'], ['price', 'Price (AED)', 'number'], ['class_allowance', 'Class credits', 'number'], ['status', 'Status', 'select:Active,Draft'], ], season: [ ['name', 'Season name', 'text'], ['description', 'Description', 'text'], ['start_date', 'Start date', 'date'], ['end_date', 'End date', 'date'], ['status', 'Status', 'select:draft,active,inactive'], ], promotion: [ ['title', 'Promotion title', 'text'], ['description', 'Description', 'text'], ['target', 'Target', 'select:classes,memberships,packages,season'], ['discount_percent', 'Discount %', 'number'], ['start_date', 'Start date', 'date'], ['end_date', 'End date', 'date'], ['status', 'Status', 'select:draft,active,inactive'], ], }; function openEditor(type, id = null) { mode = type; editId = id; const item = id ? data[type === 'membership' || type === 'package' ? (type === 'membership' ? 'memberships' : 'packages') : type + 's'].find(x => x.id === id) || {} : {}; document.querySelector('#modalKicker').textContent = id ? 'Update item' : 'New item'; document.querySelector('#modalTitle').textContent = `${id ? 'Edit' : 'Add'} ${type}`; fields.innerHTML = (schemas[type] || []).map(([name, label, kind]) => { const val = item[name] ?? ''; if (kind === 'date') return `
`; if (kind === 'time') return `
`; if (kind.startsWith('select:')) return `
`; return `
`; }).join(''); modal.showModal(); } document.querySelector('#addClassBtn').onclick = () => openEditor('class'); document.querySelector('#addMembershipBtn').onclick = () => openEditor('membership'); document.querySelector('#addPackageBtn').onclick = () => openEditor('package'); document.querySelector('#addSeasonBtn').onclick = () => openEditor('season'); document.querySelector('#addPromotionBtn').onclick = () => openEditor('promotion'); document.querySelector('.close').onclick = () => modal.close(); modal.onclick = e => { if (e.target === modal) modal.close(); }; // ===== FORM SUBMIT ===== document.querySelector('#editorForm').onsubmit = async (e) => { e.preventDefault(); const obj = Object.fromEntries(new FormData(e.target)); try { if (mode === 'class') { const [year, month, day] = obj.date.split('-'); const [hour, min] = obj.time.split(':'); const startsAt = new Date(year, month - 1, day, hour, min).toISOString(); const payload = { name: obj.name, class_type: obj.service, starts_at: startsAt, coach_name: obj.coach, capacity: parseInt(obj.capacity), price: parseFloat(obj.price), duration_minutes: parseInt(obj.duration || 60), status: 'scheduled', }; if (editId) { await FitnessAPI.updateClass(editId, payload); } else { await FitnessAPI.createClass(payload); } } else if (mode === 'membership' || mode === 'package') { const payload = { type: mode, name: obj.name, price: parseFloat(obj.price), status: obj.status || 'active', class_allowance: mode === 'membership' ? null : parseInt(obj.class_allowance || 0), validity: '90 days', }; if (editId) { await FitnessAPI.adminUpdate('plans', editId, payload); } else { await FitnessAPI.adminCreate('plans', payload); } } else if (mode === 'season') { const payload = { name: obj.name, description: obj.description, start_date: obj.start_date, end_date: obj.end_date, status: obj.status, }; if (editId) { await FitnessAPI.adminUpdate('seasons', editId, payload); } else { await FitnessAPI.adminCreate('seasons', payload); } } else if (mode === 'promotion') { const payload = { title: obj.title, description: obj.description, target: obj.target, discount_percent: parseFloat(obj.discount_percent), start_date: obj.start_date, end_date: obj.end_date, status: obj.status, }; if (editId) { await FitnessAPI.adminUpdate('promotions', editId, payload); } else { await FitnessAPI.adminCreate('promotions', payload); } } modal.close(); fetchAllData(); showToast(editId ? 'Changes saved' : 'Item added'); } catch (err) { showToast('Error: ' + err.message); } }; // ===== TAB SWITCHING ===== document.querySelectorAll('[data-tab]').forEach(btn => { btn.onclick = () => { document.querySelectorAll('[data-tab]').forEach(x => x.classList.remove('active')); document.querySelectorAll('.panel').forEach(x => x.classList.remove('active')); btn.classList.add('active'); document.querySelector('#' + btn.dataset.tab).classList.add('active'); }; }); // ===== ACTIONS ===== document.body.addEventListener('click', async (e) => { const edit = e.target.closest('[data-edit]'); const del = e.target.closest('[data-delete]'); const cancel = e.target.closest('[data-cancel-booking]'); if (edit) { openEditor(edit.dataset.edit, +edit.dataset.id); } if (del) { if (confirm('Remove this item?')) { try { const type = del.dataset.delete; if (type === 'class') { await FitnessAPI.updateClass(+del.dataset.id, { status: 'cancelled' }); } else { await FitnessAPI.adminArchive(type + 's', +del.dataset.id); } fetchAllData(); showToast('Item removed'); } catch (err) { showToast('Error: ' + err.message); } } } if (cancel) { if (confirm('Cancel this booking?')) { try { await FitnessAPI.bookingAction(+cancel.dataset.cancelBooking, 'cancel', { reason: 'Cancelled by admin', }); fetchAllData(); showToast('Booking cancelled'); } catch (err) { showToast('Error: ' + err.message); } } } }); // ===== INIT ===== fetchAllData(); //setInterval(fetchAllData, 3000);