Aerial view of ZALS Century City at dusk, with the canal, Table Mountain and city lights beyond Twilight over the park and boardwalk with a curved apartment building glowing in the distance The Century City canal at dusk, lined with palms and Mediterranean-style buildings Sunny aerial view of the canal, palm-lined streets and towers with Table Mountain beyond Modern residential and hotel towers in Century City under a clear blue sky Gated entrance to the On Park apartment development in Century City, Cape Town, under a clear blue sky Two apartment towers beside a landscaped water feature at sunset in Century City, Cape Town

Boutique serviced apartments in Century City, Cape Town

ZALS is a small collection of fully serviced one-, two- and three-bedroom apartments — and one penthouse — spread across Century City's best addresses, each a short walk from the canal and Canal Walk.

Find your stay

5
residences in the collection
1–3
bedrooms, plus a penthouse
Self-catering
full kitchen in every unit

A city planned around a canal

Century City was laid out from scratch as a city within a city — office towers and law firms above, a canal and the Intaka Island wetland reserve below, and one of Africa's largest shopping centres a short walk from both.

ZALS' apartments sit inside that plan: private front doors, full kitchens, and water closer than any hotel room in the district gets you.

Aerial view of Century City in Cape Town at sunny midday, showing the canal winding past palm-lined promenades, Mediterranean-style residential blocks and a modern high-rise, with Table Mountain and Lion's Head on the horizon
The canal, Century City

The collection

Five apartments, each different. All fully serviced and self-catering, all in Century City.

Tap a residence for photos, amenities and live availability — bookings are held automatically, no double-booking.

Rates shown are indicative starting points. Exact addresses and final rates are confirmed at booking.

What's included

Every ZALS residence is self-catering and fully serviced. A few things vary by unit — ask at enquiry if one's essential.

Inside the apartment

  • Full kitchen

    Fridge, oven, hob, kettle, toaster and all the cookware you'd want for a proper meal.

  • Fast Wi-Fi & workspace

    A dedicated spot to work, with wireless throughout the apartment.

  • Flat-screen TV

    Several residences include the full DStv bouquet.

  • Linen & towels

    Beds made up and towels provided before you arrive.

Comfort, service and access

  • Air conditioning

    Fitted in select residences — ask which ones at enquiry.

  • Housekeeping on request

    Daily servicing is available if you'd like it during your stay.

  • Free private parking

    On-site or secure underground parking at most residences.

  • 24-hour security

    Controlled access and on-site security across the precinct.

Check-in, check-out and cancellation

  1. 45 or more days before arrival

    Full refund on cancellation

    Most residences offer a full refund for cancellations made this far ahead. Exact terms are confirmed when you book.

  2. Arrival day

    Check-in from 2–3pm

    The time depends on the residence. Early arrival can usually be arranged.

  3. Departure day

    Check-out by 12 noon

    A later checkout can usually be arranged.

Getting around from here

Century City sits on the M5, roughly between the CBD and the airport, with most day-to-day essentials inside the precinct itself. A canal runs through the middle of it, with Intaka Island's wetland trails tucked in beside the towers.

~20 min
from Cape Town International Airport
<15 min
from the Cape Town CBD

Close by

  • Canal Walk Shopping CentreWalking distance
  • Intaka Island wetland reserveWalking distance
  • Virgin Active Century CityAround the corner

Across Cape Town

Approximate distances from Century City.

  • CTICC~10 km
  • Robben Island Ferry~11 km
  • V&A Waterfront~13 km
  • Kirstenbosch Gardens~17 km
Exact addresses are shared when you book. Open in Google Maps →

Airport shuttle

Cape Town International is roughly 20 minutes from Century City. Book a private transfer and have a driver waiting when you land, instead of queueing for a taxi.

  • Meet & greet

    Your driver meets you in arrivals with a name board and helps with luggage.

  • Flight tracking

    Send us your flight number and we keep the pickup in step with delays or early arrivals.

  • Straight to your door

    Dropped at your residence, with the access details already sorted.

How it works

  • RoutesAirport to residence, residence to airport
  • Journey timeAbout 20 minutes, traffic permitting
  • VehiclesSedan, SUV or minibus for larger groups
  • PricingFixed fare, quoted before you confirm
  • BookingTell us your flight and group size

Transfers can be added to any stay. We confirm the fare and driver details by email.

Car rental

Century City is a good base for the Peninsula, the Winelands and the West Coast, and a car makes all three easy. Tell us the dates and we arrange the rental so it's ready when you arrive.

  • Parking where you stay

    Most residences include parking, so the car lives at your door.

  • Collect on arrival

    We can arrange pickup at the airport or delivery to your residence.

  • Clear terms

    Insurance, deposit and mileage are explained up front, before you commit.

Choose a vehicle type

  • EconomyCity driving, 2 to 4 guests
  • Compact SUVPeninsula and Winelands day trips
  • Full-size SUVFamilies, extra luggage
  • People carrierGroups of 6 or more

Drivers need a valid licence (in English or with a certified translation, plus an international driving permit where required) and a credit card for the deposit. Rates and availability are confirmed when we quote.

Tours

Let us put a day together for you: private or small-group, with pickup from your residence. Here are the ones guests ask for most.

City

Table Mountain & city highlights

The cable car (weather permitting), Bo-Kaap and the V&A Waterfront, with time to wander.

Half or full day
Coast

Cape Peninsula & Cape Point

Chapman's Peak, the penguins at Boulders Beach and the reserve at Cape Point.

Full day
Wine

Winelands tasting

Stellenbosch, Franschhoek or Paarl, with estate tastings and lunch at a vineyard.

Full day
History

Robben Island

The ferry and guided island tour. Tickets sell out, so book well ahead.

About half a day
Nature

West Coast & wildlife

Langebaan lagoon, coastal reserves and, in season, the spring wildflowers.

Full day
Custom

Your own itinerary

Hiking, whale watching, a township visit or something else? Tell us what you like and we'll build it.

Flexible

Tour availability depends on season and weather. We confirm timing, price and inclusions by email before anything is booked.

How past guests have rated it

8.3out of 10

Very good

Two-bed, Nine Palms, across 62 stays

10out of 10

Exceptional

One-bed, Century City, recent guests

Ratings from third-party booking platforms, shown as published — not a ZALS-run survey.

Verified guest reviews

From guests who completed a paid stay with us.

Loading reviews…

Why book direct

Book straight with ZALS and you deal with the people who look after the apartments.

  • Live availability

    Every residence has its own calendar, so you see what’s actually free before you commit.

  • Dates held automatically

    Bookings are held the moment you reserve — no double-booking, no waiting on a reply.

  • Confirmation by email

    You get a reference and confirmation straight away, and can view or cancel from My bookings.

  • A real person on the other end

    Early arrival, a later check-out, or a question about a unit? Our reservations team replies within one business day.

Good to know

Quick answers to what guests ask most before booking.

Ask us something else
What are the check-in and check-out times?

Check-in is from 2–3pm, depending on the residence, and check-out is by 12 noon. Early arrival and a later check-out can usually be arranged — mention it when you book or enquire and we’ll confirm.

How does booking work?

Open any residence to see its live calendar, pick your dates and guests, and pay securely through PayFast. Your dates are held automatically, so there’s no double-booking, and a confirmation email with your reference follows within a couple of minutes.

Can I cancel, and will I get a refund?

Most residences offer a full refund if you cancel 45 or more days before arrival. The exact terms are confirmed when you book, and you can view or cancel a stay from My bookings.

How do I view or manage my booking?

Go to My bookings and enter the email you booked with. We’ll send you a sign-in link — there’s no password to remember.

Is everything provided for self-catering?

Yes. Every residence has a full kitchen (fridge, oven, hob, kettle, toaster and cookware), beds made up with linen and towels before you arrive, and Wi-Fi with a place to work.

Is parking included?

Most residences include free private parking, either on-site or in secure underground parking. Each residence’s page shows exactly what’s included.

Do the apartments have air conditioning?

Air conditioning is fitted in select residences. Open a residence to see whether it has it, or ask us at enquiry if it’s essential for your stay.

Is housekeeping available during my stay?

Yes — daily servicing is available on request if you’d like it.

How far are you from the airport and the city?

Century City is about 20 minutes from Cape Town International Airport and under 15 minutes from the CBD. Canal Walk and the Intaka Island wetland reserve are within walking distance.

What about security?

There is 24-hour security with controlled access across the precinct.

Property partner

Own an apartment in Century City or Cape Town and want it to earn more? Let ZALS run it as a fully serviced short-term rental, so you earn from your property without the day-to-day work.

  1. Free estimate

    Tell us where the property is and its size. We come back with an income projection.

  2. Setup & onboarding

    A property visit, a list of what's needed to be guest-ready, then photography, inventory and key handover.

  3. We run it

    Listings, pricing, guest messages, check-ins, cleaning and maintenance are handled by our team.

  4. You get paid

    Clear statements for every booking and expense, and you can block dates when you want to use the place yourself.

  • High-touch hosting

    Guests are welcomed in person and the apartment is checked before every arrival.

  • Professional cleaning & linen

    Hotel-standard turnovers between every stay.

  • Safe & secure

    Guests are screened and the property is assessed at every check-in and check-out.

  • Flexible personal use

    Want the apartment for yourself or family? Just tell us the dates.

Get a free estimate

Thanks, we have your details

We'll be in touch within one business day with your estimate. Reference

General enquiry

Already picked a residence? Open it above to check live availability and book directly. Not sure yet? Tell us what you're after here.

About you
Your stay

Your enquiry goes straight to our reservations team — we reply within one business day.

Enquiry received

Thanks — we'll come back to you within one business day with availability and rates.

Reference

'; w.document.open(); w.document.write(html); w.document.close(); } function statusLabel(b){ if(b.status === 'cancelled') return 'Cancelled'; if(b.status === 'confirmed') return 'Confirmed'; if(b.payment_status === 'unpaid') return 'Awaiting payment'; return 'Requested'; } async function onCancelBooking(b, btn){ if(!window.confirm('Cancel this booking? This can\u2019t be undone.')) return; btn.disabled = true; btn.textContent = 'Cancelling\u2026'; try{ var res = await sb.rpc('cancel_my_booking', { p_booking_id: b.id, p_reason: 'Cancelled by guest' }); if(res.error){ throw res.error; } var info = res.data || {}; if(info.refund_amount > 0){ window.alert('Booking cancelled. A refund of R' + formatNum(info.refund_amount) + ' is due and will be processed to your original payment method.'); }else{ window.alert('Booking cancelled. No refund is due under our cancellation policy for these dates.'); } await renderAccountBookings(); }catch(err){ window.alert("Couldn't cancel that booking \u2014 please try again or email reservations@zals.co.za."); btn.disabled = false; btn.textContent = 'Cancel booking'; } } async function renderAccountBookings(){ var list = document.getElementById('accountBookingsList'); list.innerHTML = '

Loading your bookings\u2026

'; var res = await sb.from('bookings').select('*').order('checkin', { ascending:false }); if(res.error){ list.innerHTML = '

Could not load your bookings.

'; return; } var rows = res.data || []; if(!rows.length){ list.innerHTML = '

No bookings yet on this email.

'; return; } list.innerHTML = ''; var today = new Date(); today.setHours(0,0,0,0); rows.forEach(function(b){ var card = document.createElement('div'); card.className = 'account-booking'; var row = document.createElement('div'); row.className = 'account-booking__row'; var ref = document.createElement('strong'); ref.className = 'account-booking__ref'; ref.textContent = b.ref; var status = document.createElement('span'); status.className = 'account-booking__status'; status.textContent = statusLabel(b); row.appendChild(ref); row.appendChild(status); card.appendChild(row); var meta = document.createElement('p'); meta.style.color = 'var(--text-muted)'; meta.style.marginTop = '.35rem'; meta.textContent = b.residence_name + ' \u00b7 ' + b.checkin + ' to ' + b.checkout + ' \u00b7 ' + b.nights + ' nights \u00b7 R' + formatNum(b.total); card.appendChild(meta); if(b.payment_status === 'paid' || b.payment_status === 'refund_pending' || b.payment_status === 'refund_not_due' || b.payment_status === 'refunded'){ var rbtn = document.createElement('button'); rbtn.className = 'btn btn--outline btn--small'; rbtn.type = 'button'; rbtn.textContent = 'Download receipt'; rbtn.style.marginTop = '.6rem'; rbtn.addEventListener('click', function(){ openReceipt(b); }); card.appendChild(rbtn); } var checkinDate = new Date(b.checkin + 'T00:00:00'); var cancellable = (b.status === 'requested' || b.status === 'confirmed') && checkinDate > today; if(cancellable){ var actions = document.createElement('div'); actions.className = 'account-booking__actions'; var cancelBtn = document.createElement('button'); cancelBtn.className = 'btn btn--outline btn--small'; cancelBtn.type = 'button'; cancelBtn.textContent = 'Cancel booking'; cancelBtn.addEventListener('click', function(){ onCancelBooking(b, cancelBtn); }); actions.appendChild(cancelBtn); card.appendChild(actions); } if(b.status === 'cancelled' && Number(b.refund_amount) > 0){ var refundNote = document.createElement('p'); refundNote.style.color = 'var(--text-muted)'; refundNote.style.marginTop = '.35rem'; refundNote.style.fontSize = '.85rem'; refundNote.textContent = (b.payment_status === 'refunded' ? 'Refunded: ' : 'Refund due: ') + 'R' + formatNum(b.refund_amount); card.appendChild(refundNote); } list.appendChild(card); }); } async function renderAccountAuthState(){ if(!sb){ return; } var s = await sb.auth.getSession(); var signedIn = !!(s.data && s.data.session); document.getElementById('accountSignedOut').hidden = signedIn; document.getElementById('accountSignedIn').hidden = !signedIn; if(!signedIn){ return; } document.getElementById('accountEmailLabel').textContent = s.data.session.user.email; await renderAccountBookings(); } async function enterAccount(){ if(!accountWired){ wireAccountLogin(); accountWired = true; } await renderAccountAuthState(); } /* ================= Leave a review (#review= route) ================= */ var reviewWired = false; var reviewToken = null; var reviewRating = 0; function updateReviewStars(){ Array.prototype.forEach.call(document.querySelectorAll('#reviewStars button'), function(btn){ btn.classList.toggle('is-selected', Number(btn.getAttribute('data-star')) <= reviewRating); }); } async function onSubmitReview(e){ e.preventDefault(); var errEl = document.getElementById('reviewFormError'); errEl.hidden = true; var body = document.getElementById('reviewBody').value.trim(); if(!reviewRating){ errEl.textContent = 'Pick a rating from 1 to 5.'; errEl.hidden = false; return; } if(!body){ errEl.textContent = 'Add a few words about your stay.'; errEl.hidden = false; return; } if(!sb){ errEl.textContent = "Can't reach the booking service right now."; errEl.hidden = false; return; } var btn = document.getElementById('reviewSubmitBtn'); btn.disabled = true; btn.textContent = 'Submitting\u2026'; try{ var res = await sb.rpc('submit_review', { p_token: reviewToken, p_rating: reviewRating, p_body: body }); if(res.error){ throw res.error; } document.getElementById('reviewSubmitForm').hidden = true; document.getElementById('reviewSubmitDone').hidden = false; loadVerifiedReviews(); }catch(err){ errEl.textContent = (err && err.message) ? err.message : "Couldn't submit that review \u2014 the link may have expired."; errEl.hidden = false; btn.disabled = false; btn.textContent = 'Submit review'; return; } } function enterReviewSubmit(token){ reviewToken = token; reviewRating = 0; document.getElementById('reviewSubmitForm').hidden = false; document.getElementById('reviewSubmitDone').hidden = true; document.getElementById('reviewBody').value = ''; document.getElementById('reviewFormError').hidden = true; updateReviewStars(); if(!reviewWired){ Array.prototype.forEach.call(document.querySelectorAll('#reviewStars button'), function(btn){ btn.addEventListener('click', function(){ reviewRating = Number(btn.getAttribute('data-star')); updateReviewStars(); }); }); document.getElementById('reviewForm').addEventListener('submit', onSubmitReview); reviewWired = true; } } async function loadVerifiedReviews(){ var list = document.getElementById('verifiedReviewsList'); if(!list){ return; } if(!sb){ list.innerHTML = '

Reviews aren\u2019t connected in this preview.

'; return; } try{ var res = await sb.from('reviews').select('*').eq('published', true).order('created_at', { ascending:false }).limit(9); if(res.error){ throw res.error; } var rows = res.data || []; if(!rows.length){ list.innerHTML = '

No verified reviews yet \u2014 be the first to stay and tell us about it.

'; return; } list.innerHTML = ''; rows.forEach(function(rv){ var card = document.createElement('div'); card.className = 'review-card'; var rating = document.createElement('span'); rating.className = 'review-card__rating'; rating.textContent = rv.rating + '/5'; card.appendChild(rating); var name = document.createElement('p'); name.className = 'review-card__name'; name.textContent = rv.guest_name; card.appendChild(name); var body = document.createElement('p'); body.className = 'review-card__body'; body.textContent = rv.body; card.appendChild(body); list.appendChild(card); }); }catch(e){ list.innerHTML = '

Could not load reviews right now.

'; } } /* ================= Returning from PayFast ================= */ function parseHashRoute(){ var h = location.hash.charAt(0) === '#' ? location.hash.slice(1) : location.hash; var qIdx = h.indexOf('?'); var base = qIdx === -1 ? h : h.slice(0, qIdx); var params = {}; if(qIdx !== -1){ h.slice(qIdx + 1).split('&').forEach(function(pair){ var eq = pair.indexOf('='); if(eq === -1) return; params[decodeURIComponent(pair.slice(0, eq))] = decodeURIComponent(pair.slice(eq + 1)); }); } return { base: base, params: params }; } function enterBookingReturn(kind, params){ document.getElementById('bookingReturnSuccess').hidden = kind !== 'booking-success'; document.getElementById('bookingReturnCancel').hidden = kind !== 'booking-cancelled'; if(params.ref){ document.getElementById('bookingReturnRef').textContent = params.ref; } } /* ================= Magic-link sign-in callback ================= */ async function consumeAuthCallback(){ var h = location.hash; if(h.indexOf('access_token=') === -1) return; var raw = h.charAt(0) === '#' ? h.slice(1) : h; var params = {}; raw.split('&').forEach(function(pair){ var eq = pair.indexOf('='); if(eq === -1) return; params[decodeURIComponent(pair.slice(0, eq))] = decodeURIComponent(pair.slice(eq + 1)); }); if(!params.access_token || !sb) return; try{ await sb.auth.setSession({ access_token: params.access_token, refresh_token: params.refresh_token }); }catch(e){} history.replaceState(null, '', location.pathname + location.search + '#account'); } var EXTRA_SECTION_IDS = ['account', 'reviewSubmit', 'bookingReturn']; function hideExtraSections(){ EXTRA_SECTION_IDS.forEach(function(id){ var el = document.getElementById(id); if(el){ el.hidden = true; } }); } function route(){ var h = location.hash; if(h === '#admin'){ enterAdmin(); return; } exitAdmin(); var parsed = parseHashRoute(); if(parsed.base === 'account'){ hideExtraSections(); document.getElementById('account').hidden = false; enterAccount(); document.getElementById('account').scrollIntoView({ behavior:'smooth', block:'start' }); return; } if(parsed.base.indexOf('review=') === 0){ hideExtraSections(); document.getElementById('reviewSubmit').hidden = false; enterReviewSubmit(parsed.base.slice('review='.length)); document.getElementById('reviewSubmit').scrollIntoView({ behavior:'smooth', block:'start' }); return; } if(parsed.base === 'booking-success' || parsed.base === 'booking-cancelled'){ hideExtraSections(); document.getElementById('bookingReturn').hidden = false; enterBookingReturn(parsed.base, parsed.params); document.getElementById('bookingReturn').scrollIntoView({ behavior:'smooth', block:'start' }); return; } hideExtraSections(); } window.addEventListener('hashchange', route); function initResidenceFilter(forceRebuild){ var sel = document.getElementById('filterResidence'); if(sel.options.length <= 1 || forceRebuild){ var current = sel.value; sel.innerHTML = ''; ADMIN_RESIDENCES.forEach(function(r){ var o = document.createElement('option'); o.value=r.slug; o.textContent=r.name + (r.active===false ? ' (hidden)' : ''); sel.appendChild(o); }); if(current && ADMIN_RESIDENCES.some(function(r){ return r.slug===current; })){ sel.value = current; } } } /* ---------- house rules / policies (site_policies table, edited in admin > Policies) ---------- */ var POLICIES = []; function renderPublicPolicies(){ var items = POLICIES.filter(function(p){ return p.body && String(p.body).trim(); }); var list = document.getElementById('houseRulesList'), box = document.getElementById('houseRules'); if(list && box){ list.innerHTML = ''; items.forEach(function(p){ var d = document.createElement('div'), dt = document.createElement('dt'), dd = document.createElement('dd'); dt.textContent = p.label; dd.textContent = p.body; dd.style.whiteSpace = 'pre-line'; d.appendChild(dt); d.appendChild(dd); list.appendChild(d); }); box.hidden = !items.length; } var faq = document.querySelector('#faq .faq__list'); if(faq){ faq.querySelectorAll('[data-policy-faq]').forEach(function(n){ n.remove(); }); items.forEach(function(p){ var det = document.createElement('details'); det.className = 'faq__item'; det.setAttribute('data-policy-faq',''); var sm = document.createElement('summary'); sm.textContent = p.question || p.label; var pp = document.createElement('p'); pp.textContent = p.body; pp.style.whiteSpace = 'pre-line'; det.appendChild(sm); det.appendChild(pp); faq.appendChild(det); }); } } async function loadPublicPolicies(){ var res = await sb.from('site_policies').select('key,label,question,body,position').order('position', { ascending:true }); if(res.error){ throw res.error; } POLICIES = res.data || []; renderPublicPolicies(); } function loadResSettings(){ var wrap = document.getElementById('resSettingsList'); if(!wrap){ return; } wrap.innerHTML = ''; ADMIN_RESIDENCES.forEach(function(r){ var box = document.createElement('div'); box.setAttribute('data-res-slug', r.slug); var h = document.createElement('strong'); h.textContent = r.name; box.appendChild(h); var mn = document.createElement('div'); mn.className = 'field'; mn.style.margin = '.5rem 0'; mn.innerHTML = ''; mn.querySelector('input').value = r.minNights || 1; box.appendChild(mn); var grid = document.createElement('div'); grid.style.cssText = 'display:grid;grid-template-columns:repeat(auto-fill,minmax(11rem,1fr));gap:.4rem .8rem;'; Object.keys(AMENITY_META).forEach(function(k){ var l = document.createElement('label'); l.style.cssText = 'display:flex;gap:.45rem;align-items:center;font-size:.9rem;'; var cb = document.createElement('input'); cb.type = 'checkbox'; cb.value = k; cb.setAttribute('data-res-fac',''); cb.checked = (r.amenities || []).indexOf(k) > -1; l.appendChild(cb); l.appendChild(document.createTextNode(AMENITY_META[k].label)); grid.appendChild(l); }); box.appendChild(grid); wrap.appendChild(box); }); document.getElementById('resSettingsSaveBtn').onclick = async function(){ var note = document.getElementById('resSettingsSaveNote'); note.textContent = 'Saving\u2026'; try{ var results = await Promise.all(Array.prototype.map.call(wrap.querySelectorAll('[data-res-slug]'), function(box){ var amen = Array.prototype.map.call(box.querySelectorAll('[data-res-fac]:checked'), function(c){ return c.value; }); var mn = Math.min(60, Math.max(1, Math.round(Number(box.querySelector('[data-res-minn]').value) || 1))); return sb.from('residences').update({ amenities: amen, min_nights: mn }).eq('slug', box.getAttribute('data-res-slug')).select('slug'); })); var bad = results.find(function(x){ return x.error || !x.data || !x.data.length; }); if(bad){ throw bad.error || new Error('not saved'); } await loadResidences(); note.textContent = 'Saved \u2014 live on the site now.'; setTimeout(function(){ note.textContent=''; }, 3000); }catch(e){ note.textContent = "Couldn't save \u2014 check you're signed in and try again."; } }; } async function loadArrivalInfo(){ var wrap = document.getElementById('arrivalList'); if(!wrap){ return; } wrap.innerHTML = ''; var res = await sb.from('residence_arrival_info').select('*'); var byId = {}; (res.data || []).forEach(function(x){ byId[x.residence_id] = x; }); var defs = [['checkin_instructions','How to get in (door code, lift, who meets them)','textarea'],['wifi_name','Wi-Fi network name','input'],['wifi_password','Wi-Fi password','input'],['parking_info','Parking (bay number, entrance, height limit)','textarea']]; ADMIN_RESIDENCES.forEach(function(r){ var box = document.createElement('div'); box.setAttribute('data-arr-slug', r.slug); var h = document.createElement('strong'); h.textContent = r.name; box.appendChild(h); defs.forEach(function(d){ var f = document.createElement('div'); f.className = 'field'; f.style.marginTop = '.5rem'; var l = document.createElement('label'); l.textContent = d[1]; var el = document.createElement(d[2]); el.setAttribute('data-arr-field', d[0]); if(d[2]==='textarea'){ el.rows = 2; } el.maxLength = d[2]==='textarea' ? 1000 : 100; el.value = (byId[r.slug] && byId[r.slug][d[0]]) || ''; f.appendChild(l); f.appendChild(el); box.appendChild(f); }); wrap.appendChild(box); }); document.getElementById('arrivalSaveBtn').onclick = async function(){ var note = document.getElementById('arrivalSaveNote'); note.textContent = 'Saving\u2026'; try{ var rows = Array.prototype.map.call(wrap.querySelectorAll('[data-arr-slug]'), function(box){ var o = { residence_id: box.getAttribute('data-arr-slug'), updated_at: new Date().toISOString() }; box.querySelectorAll('[data-arr-field]').forEach(function(el){ o[el.getAttribute('data-arr-field')] = el.value.trim(); }); return o; }); var up = await sb.from('residence_arrival_info').upsert(rows, { onConflict:'residence_id' }); if(up.error){ throw up.error; } note.textContent = 'Saved.'; setTimeout(function(){ note.textContent=''; }, 3000); }catch(e){ note.textContent = "Couldn't save \u2014 check you're signed in and try again."; } }; } async function loadBusinessPanel(){ var wrap = document.getElementById('businessFields'); if(!wrap){ return; } try{ await loadSiteSettings(); }catch(e){ wrap.textContent = "Couldn't load business details."; return; } var S = SITE_SETTINGS || {}; var defs = [['business_name','Business / trading name','input'],['address','Address','input'],['email','Contact email','input'],['phone','Phone','input'], ['reg_number','Company registration number (optional)','input'],['vat_number','VAT number (only if registered)','input'],['vat_rate','VAT rate % (e.g. 15; 0 if not registered)','input'],['receipt_footer','Extra line at the bottom of receipts (optional)','input']]; wrap.innerHTML = ''; defs.forEach(function(d){ var f = document.createElement('div'); f.className = 'field'; var l = document.createElement('label'); l.textContent = d[1]; var el = document.createElement('input'); el.setAttribute('data-biz', d[0]); el.maxLength = 400; el.value = S[d[0]] == null ? '' : S[d[0]]; if(d[0] === 'vat_rate'){ el.type = 'number'; el.min = '0'; el.max = '30'; el.step = '0.5'; } f.appendChild(l); f.appendChild(el); wrap.appendChild(f); }); document.getElementById('businessSaveBtn').onclick = async function(){ var note = document.getElementById('businessSaveNote'); note.textContent = 'Saving\u2026'; var o = { id:1, updated_at:new Date().toISOString() }; wrap.querySelectorAll('[data-biz]').forEach(function(el){ var k = el.getAttribute('data-biz'); o[k] = k === 'vat_rate' ? (Number(el.value) || 0) : el.value.trim(); }); if(o.vat_rate > 0 && !o.vat_number){ note.textContent = 'Add your VAT number, or set the VAT rate to 0.'; return; } try{ var up = await sb.from('site_settings').upsert(o, { onConflict:'id' }).select('id'); if(up.error || !up.data || !up.data.length){ throw up.error || new Error('not saved'); } SITE_SETTINGS = Object.assign({}, SITE_SETTINGS, o); note.textContent = 'Saved \u2014 new receipts use these details.'; setTimeout(function(){ note.textContent=''; }, 3500); }catch(e){ note.textContent = "Couldn't save \u2014 check you're signed in and try again."; } }; } async function loadConflicts(){ var box = document.getElementById('conflictBanner'); if(!box){ return; } var res = await sb.from('calendar_conflicts').select('*').eq('resolved', false).order('detected_at', { ascending:false }); var rows = res.data || []; if(res.error || !rows.length){ box.hidden = true; box.innerHTML = ''; return; } var bk = {}; try{ (allBookings || []).forEach(function(x){ bk[x._id] = x; }); }catch(e){} box.innerHTML = 'Possible double booking' + (rows.length > 1 ? 's' : '') + '' + '

A Booking.com reservation overlaps a direct booking. Move or refund one guest. If Booking.com is just echoing your own booking back, mark it resolved.

'; rows.forEach(function(c){ var b = bk[c.booking_id]; var row = document.createElement('div'); row.style.cssText = 'display:flex;gap:.75rem;align-items:center;justify-content:space-between;flex-wrap:wrap;padding:.5rem 0;border-top:1px solid var(--border);font-size:.9rem;'; var t = document.createElement('span'); t.textContent = c.residence_id + ': Booking.com ' + c.block_checkin + ' to ' + c.block_checkout + (b ? ' clashes with ' + b.ref + ' (' + b.name + ', ' + b.checkin + ' to ' + b.checkout + ')' : ' clashes with a direct booking'); var btn = document.createElement('button'); btn.type = 'button'; btn.className = 'btn btn--outline btn--small'; btn.textContent = 'Mark resolved'; btn.addEventListener('click', async function(){ btn.disabled = true; var up = await sb.from('calendar_conflicts').update({ resolved:true }).eq('id', c.id).select('id'); if(up.error || !up.data || !up.data.length){ btn.disabled = false; return; } loadConflicts(); }); row.appendChild(t); row.appendChild(btn); box.appendChild(row); }); box.hidden = false; } async function loadPoliciesPanel(){ loadConflicts().catch(function(){}); loadBusinessPanel().catch(function(){}); loadResSettings(); loadArrivalInfo().catch(function(){}); var wrap = document.getElementById('policiesList'); if(!wrap){ return; } wrap.innerHTML = ''; try{ await loadPublicPolicies(); }catch(e){ wrap.textContent = "Couldn't load policies \u2014 check you're signed in and try again."; return; } POLICIES.forEach(function(p){ var f = document.createElement('div'); f.className = 'field'; var lab = document.createElement('label'); lab.textContent = p.label; lab.setAttribute('for','pol-'+p.key); var ta = document.createElement('textarea'); ta.id = 'pol-'+p.key; ta.rows = 2; ta.maxLength = 2000; ta.value = p.body || ''; ta.setAttribute('data-policy-key', p.key); f.appendChild(lab); f.appendChild(ta); wrap.appendChild(f); }); document.getElementById('policiesSaveBtn').onclick = async function(){ var note = document.getElementById('policiesSaveNote'); var changes = []; wrap.querySelectorAll('textarea[data-policy-key]').forEach(function(t){ var key = t.getAttribute('data-policy-key'), val = t.value.trim(); var cur = POLICIES.find(function(p){ return p.key === key; }); if(cur && (cur.body || '') !== val){ changes.push({ key:key, body:val }); } }); if(!changes.length){ note.textContent = 'No changes to save.'; setTimeout(function(){ note.textContent=''; }, 2500); return; } note.textContent = 'Saving\u2026'; try{ var results = await Promise.all(changes.map(function(c){ return sb.from('site_policies').update({ body:c.body, updated_at:new Date().toISOString() }).eq('key', c.key).select('key'); })); var bad = results.find(function(x){ return x.error || !x.data || !x.data.length; }); if(bad){ throw bad.error || new Error('not saved'); } changes.forEach(function(c){ var cur = POLICIES.find(function(p){ return p.key === c.key; }); if(cur){ cur.body = c.body; } }); renderPublicPolicies(); note.textContent = 'Saved \u2014 live on the site now.'; setTimeout(function(){ note.textContent=''; }, 3000); }catch(e){ note.textContent = "Couldn't save \u2014 check you're signed in and try again."; } }; } async function loadRatesPanel(){ var table = document.getElementById('ratesTable'); table.innerHTML = ''; try{ await loadRates(); }catch(e){} ADMIN_RESIDENCES.forEach(function(r){ var rate = (ratesDoc && ratesDoc[r.slug]) || r.defaultRate; var tr = document.createElement('tr'); var tdName = document.createElement('td'); tdName.textContent = r.name + (r.active===false ? ' (hidden)' : ''); var tdInput = document.createElement('td'); var input = document.createElement('input'); input.type = 'number'; input.min = '0'; input.setAttribute('data-rate-slug', r.slug); input.value = rate; tdInput.appendChild(input); tr.appendChild(tdName); tr.appendChild(tdInput); table.appendChild(tr); }); document.getElementById('ratesSaveBtn').onclick = async function(){ var note = document.getElementById('ratesSaveNote'); var changes = []; table.querySelectorAll('input[data-rate-slug]').forEach(function(input){ var slug = input.getAttribute('data-rate-slug'); var val = Math.max(0, Math.round(Number(input.value) || 0)); if(!ratesDoc || ratesDoc[slug] !== val){ changes.push({ slug:slug, rate:val }); } }); if(!changes.length){ note.textContent = 'No changes to save.'; setTimeout(function(){ note.textContent=''; }, 2500); return; } note.textContent = 'Saving\u2026'; try{ var results = await Promise.all(changes.map(function(c){ return sb.from('residences').update({ nightly_rate: c.rate }).eq('slug', c.slug).select('slug'); })); var bad = results.find(function(x){ return x.error || !x.data || !x.data.length; }); if(bad){ throw bad.error || new Error('not saved'); } var next = Object.assign({}, ratesDoc); changes.forEach(function(c){ next[c.slug] = c.rate; }); ratesDoc = next; updateCardRatesFromDb(); note.textContent = 'Saved \u2014 live on the site now.'; setTimeout(function(){ note.textContent=''; }, 3000); }catch(e){ note.textContent = "Couldn't save \u2014 check you're signed in and try again."; } }; } function otaExportUrl(slug){ return SUPABASE_URL + '/functions/v1/ical-export?slug=' + encodeURIComponent(slug); } async function loadOtaPanel(){ var wrap = document.getElementById('otaList'); wrap.innerHTML = ''; var res = await sb.rpc('admin_get_ical'); var rows = res.data || []; var bySlug = {}; rows.forEach(function(r){ bySlug[r.slug] = r.ota_ical_url || ''; }); var blockCounts = {}; try{ var bRes = await sb.from('external_blocks').select('residence_id'); (bRes.data || []).forEach(function(x){ blockCounts[x.residence_id] = (blockCounts[x.residence_id] || 0) + 1; }); }catch(e){} ADMIN_RESIDENCES.forEach(function(r){ var item = document.createElement('div'); item.className = 'ota-item'; var exportUrl = otaExportUrl(r.slug); var count = blockCounts[r.slug] || 0; item.innerHTML = '

'+escapeHtml(r.name)+(r.active===false ? ' (hidden)' : '')+'

'+ ''+ ''+ ''+ '
'+escapeHtml(exportUrl)+'
'+ '

'+(bySlug[r.slug] ? (count + ' night'+(count===1?'':'s')+' currently blocked from Booking.com') : 'Not connected yet')+'

'; wrap.appendChild(item); }); wrap.querySelectorAll('[data-copy-url]').forEach(function(btn){ btn.addEventListener('click', function(){ var val = btn.getAttribute('data-copy-url'); var label = btn.textContent; if(navigator.clipboard && navigator.clipboard.writeText){ navigator.clipboard.writeText(val).then(function(){ btn.textContent = 'Copied'; setTimeout(function(){ btn.textContent = label; }, 1500); }).catch(function(){}); } }); }); document.getElementById('otaSaveBtn').onclick = async function(){ var note = document.getElementById('otaSaveNote'); var changes = []; wrap.querySelectorAll('input[data-ota-slug]').forEach(function(input){ var slug = input.getAttribute('data-ota-slug'); var val = input.value.trim(); if((bySlug[slug]||'') !== val){ changes.push({ slug:slug, url: val || null }); } }); if(!changes.length){ note.textContent = 'No changes to save.'; setTimeout(function(){ note.textContent=''; }, 2500); return; } note.textContent = 'Saving\u2026'; try{ var results = await Promise.all(changes.map(function(c){ return sb.from('residences').update({ ota_ical_url: c.url }).eq('slug', c.slug).select('slug'); })); var bad = results.find(function(x){ return x.error || !x.data || !x.data.length; }); if(bad){ throw bad.error || new Error('not saved'); } note.textContent = 'Saved.'; setTimeout(function(){ note.textContent=''; }, 3000); loadOtaPanel(); }catch(e){ note.textContent = "Couldn't save \u2014 check you're signed in and try again."; } }; document.getElementById('otaSyncBtn').onclick = async function(){ var note = document.getElementById('otaSaveNote'); note.textContent = 'Syncing with Booking.com\u2026'; try{ var res2 = await sb.functions.invoke('ical-import', { body:{} }); if(res2.error){ throw res2.error; } var out = res2.data || {}; var errored = (out.results || []).filter(function(x){ return !x.ok; }); note.textContent = errored.length ? (errored.length+' residence(s) failed to sync \u2014 check the URL.') : 'Synced.'; setTimeout(function(){ note.textContent=''; }, 4000); loadOtaPanel(); }catch(e){ note.textContent = "Couldn't sync \u2014 try again shortly."; } }; } /* ---------- admin: add / hide / delete residences ---------- */ function slugify(s){ return String(s || '').toLowerCase().trim() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 60); } var resSlugDirty = false; var resFormWired = false; function wireResidenceAddForm(){ if(resFormWired){ return; } resFormWired = true; var amenityGrid = document.getElementById('resAmenityGrid'); Object.keys(AMENITY_META).forEach(function(key){ var label = document.createElement('label'); var cb = document.createElement('input'); cb.type = 'checkbox'; cb.value = key; cb.setAttribute('data-res-amenity', key); label.appendChild(cb); label.appendChild(document.createTextNode(' ' + AMENITY_META[key].label)); amenityGrid.appendChild(label); }); var toggleBtn = document.getElementById('resAddToggleBtn'); var form = document.getElementById('resAddForm'); toggleBtn.addEventListener('click', function(){ form.hidden = !form.hidden; toggleBtn.textContent = form.hidden ? '+ Add a residence' : 'Hide add-residence form'; if(!form.hidden){ document.getElementById('resName').focus(); } }); document.getElementById('resAddCancelBtn').addEventListener('click', function(){ form.reset(); form.hidden = true; toggleBtn.textContent = '+ Add a residence'; document.getElementById('resAddError').hidden = true; resSlugDirty = false; }); document.getElementById('resName').addEventListener('input', function(){ if(!resSlugDirty){ document.getElementById('resSlug').value = slugify(this.value); } }); document.getElementById('resSlug').addEventListener('input', function(){ resSlugDirty = true; }); form.addEventListener('submit', async function(e){ e.preventDefault(); var errEl = document.getElementById('resAddError'); errEl.hidden = true; var name = document.getElementById('resName').value.trim(); var slug = slugify(document.getElementById('resSlug').value); var beds = parseInt(document.getElementById('resBeds').value, 10); var baths = parseInt(document.getElementById('resBaths').value, 10); var sleeps = parseInt(document.getElementById('resSleeps').value, 10); var rate = parseInt(document.getElementById('resRate').value, 10); var tag = document.getElementById('resTag').value.trim(); var blurb = document.getElementById('resBlurb').value.trim(); var description = document.getElementById('resDescription').value.trim(); var featured = document.getElementById('resFeatured').checked; var amenities = Array.prototype.slice.call(document.querySelectorAll('[data-res-amenity]:checked')).map(function(cb){ return cb.value; }); if(!name || !slug || !beds || !baths || !sleeps || !rate || !blurb){ errEl.textContent = 'Please fill in name, slug, beds, baths, sleeps, rate and a blurb.'; errEl.hidden = false; return; } if(ADMIN_RESIDENCES.some(function(r){ return r.slug === slug; })){ errEl.textContent = 'That slug is already used by another residence \u2014 choose a different one.'; errEl.hidden = false; return; } var submitBtn = document.getElementById('resAddSubmitBtn'); submitBtn.disabled = true; submitBtn.textContent = 'Adding\u2026'; try{ var nextSort = ADMIN_RESIDENCES.reduce(function(m, r){ return Math.max(m, r.sortOrder || 0); }, 0) + 1; var ins = await sb.from('residences').insert({ slug: slug, name: name, short: name, tag: tag, beds: beds, baths: baths, sleeps: sleeps, nightly_rate: rate, featured: featured, blurb: blurb, description: description || blurb, amenities: amenities, gallery: ['Living space','Bedroom','View','Kitchen'], active: true, sort_order: nextSort }).select('slug'); if(ins.error){ throw ins.error; } form.reset(); form.hidden = true; toggleBtn.textContent = '+ Add a residence'; resSlugDirty = false; await refreshAfterResidenceChange(); }catch(err){ errEl.textContent = (err && err.code === '23505') ? 'That slug is already in use \u2014 choose a different one.' : "Couldn't add that residence \u2014 try again."; errEl.hidden = false; } submitBtn.disabled = false; submitBtn.textContent = 'Add residence'; }); } async function toggleResidenceActive(slug, makeActive){ try{ var res = await sb.from('residences').update({ active: makeActive }).eq('slug', slug).select('slug'); if(res.error || !res.data || !res.data.length){ throw res.error || new Error('not updated'); } await refreshAfterResidenceChange(); }catch(e){ alert("Couldn't update that residence \u2014 try again."); } } async function deleteResidence(slug, name){ if(!confirm('Delete "'+name+'" completely? This can\'t be undone. If it has any booking history, deleting will be blocked \u2014 hide it instead.')){ return; } try{ var res = await sb.from('residences').delete().eq('slug', slug).select('slug'); if(res.error){ throw res.error; } if(!res.data || !res.data.length){ throw new Error('not deleted'); } await refreshAfterResidenceChange(); }catch(e){ if(e && e.code === '23503'){ alert('"'+name+'" has booking history, so it can\'t be deleted. Use "Hide from site" instead.'); }else{ alert("Couldn't delete that residence \u2014 try again."); } } } function loadResidenceManagePanel(){ wireResidenceAddForm(); var list = document.getElementById('resList'); list.innerHTML = ''; ADMIN_RESIDENCES.forEach(function(r){ var item = document.createElement('div'); item.className = 'res-item' + (r.active === false ? ' is-hidden' : ''); var main = document.createElement('div'); main.className = 'res-item__main'; main.innerHTML = '
'+escapeHtml(r.name)+(r.active === false ? ' hidden' : '')+'
' + '
'+r.beds+' bed · '+r.baths+' bath · sleeps '+r.sleeps+' · R'+formatNum(r.defaultRate)+' / night
'; var actions = document.createElement('div'); actions.className = 'res-item__actions'; var toggleBtn = document.createElement('button'); toggleBtn.type = 'button'; toggleBtn.className = 'btn btn--outline btn--small'; toggleBtn.textContent = r.active === false ? 'Show on site' : 'Hide from site'; toggleBtn.addEventListener('click', function(){ toggleResidenceActive(r.slug, r.active === false); }); var delBtn = document.createElement('button'); delBtn.type = 'button'; delBtn.className = 'btn btn--outline btn--small'; delBtn.textContent = 'Delete'; delBtn.addEventListener('click', function(){ deleteResidence(r.slug, r.name); }); actions.appendChild(toggleBtn); actions.appendChild(delBtn); item.appendChild(main); item.appendChild(actions); list.appendChild(item); }); } /* stop the live feeds (leaving the admin view, or signing out) */ function stopLive(){ if(adminUnsub){ adminUnsub(); adminUnsub = null; } if(enquiriesUnsub){ enquiriesUnsub(); enquiriesUnsub = null; } if(reviewsUnsub){ reviewsUnsub(); reviewsUnsub = null; } } /* refetch whenever a row in this table changes; returns a function that stops listening */ function watchTable(table, refetch){ var channel = sb.channel('admin-' + table) .on('postgres_changes', { event:'*', schema:'public', table:table }, function(){ refetch().catch(function(){}); }) .subscribe(); return function(){ try{ sb.removeChannel(channel); }catch(e){} }; } async function fetchBookings(){ var res = await sb.from('bookings').select('*').order('checkin', { ascending:true }); if(res.error){ throw res.error; } allBookings = (res.data || []).map(function(b){ return { _id:b.id, ref:b.ref, residenceId:b.residence_id, residenceName:b.residence_name, name:b.name, email:b.email, phone:b.phone, guests:b.guests, children:b.children||0, childAges:b.child_ages||'', arrival:b.arrival_time||'', requests:b.special_requests||'', checkin:b.checkin, checkout:b.checkout, nights:b.nights, rate:b.rate, total:b.total, status:b.status, createdAt:b.created_at, paymentStatus:b.payment_status, refundAmount:b.refund_amount, paidAt:b.paid_at, invoiceNo:b.invoice_no, pfPaymentId:b.pf_payment_id }; }); renderStats(); renderBookingsTable(); renderPerformance(); } function subscribeBookings(){ if(adminUnsub){ adminUnsub(); adminUnsub = null; } fetchBookings().catch(function(){ var tbody = document.getElementById('bookingsBody'); tbody.innerHTML = 'Bookings are unavailable right now.'; }); adminUnsub = watchTable('bookings', fetchBookings); } function renderStats(){ var wrap = document.getElementById('adminStats'); var total = allBookings.length; var newEnquiries = allEnquiries.filter(function(q){ return q.status !== 'replied'; }).length; var requested = allBookings.filter(function(b){ return b.status==='requested'; }).length; var confirmed = allBookings.filter(function(b){ return b.status==='confirmed'; }).length; var today = new Date(); today.setHours(0,0,0,0); var weekOut = new Date(today.getTime() + 7*86400000); var upcoming = allBookings.filter(function(b){ if(b.status==='cancelled' || !b.checkin) return false; var ci = parseISO(b.checkin); return ci >= today && ci <= weekOut; }).length; var pendingReviews = allReviews.filter(function(rv){ return !rv.published; }).length; wrap.innerHTML = '
'+total+'Total bookings
'+ '
'+requested+'Awaiting payment
'+ '
'+confirmed+'Confirmed
'+ '
'+upcoming+'Check-ins, next 7 days
'+ ''+ ''; renderOverviewExtras(); } function renderBookingsTable(){ var statusFilter = document.getElementById('filterStatus').value; var residenceFilter = document.getElementById('filterResidence').value; var tbody = document.getElementById('bookingsBody'); var emptyEl = document.getElementById('bookingsEmpty'); var rows = allBookings.filter(function(b){ if(statusFilter!=='all' && b.status!==statusFilter) return false; if(residenceFilter!=='all' && b.residenceId!==residenceFilter) return false; return true; }); tbody.innerHTML = ''; if(!rows.length){ emptyEl.hidden = false; return; } emptyEl.hidden = true; rows.forEach(function(b){ var tr = document.createElement('tr'); tr.innerHTML = ''+escapeHtml(b.ref)+''+ ''+escapeHtml(b.residenceName||b.residenceId)+''+ ''+escapeHtml(b.name)+'
'+escapeHtml(b.email)+''+ ''+escapeHtml(b.checkin)+''+ ''+escapeHtml(b.checkout)+''+ ''+(b.nights||'')+''+ ''+(b.guests||'')+(b.children ? '
'+b.children+' child'+(b.children===1?'':'ren')+(b.childAges ? ' ('+escapeHtml(b.childAges)+')' : '')+'' : '')+(b.arrival||b.requests ? '
'+(b.arrival ? 'Arrives '+escapeHtml(b.arrival) : '')+(b.arrival&&b.requests ? ' \u00b7 ' : '')+escapeHtml(b.requests||'')+'' : '')+''+ 'R'+formatNum(b.total||0)+''+ ''+escapeHtml(b.status)+''+ ''+escapeHtml(b.paymentStatus||'unpaid')+(b.refundAmount>0 ? '
R'+formatNum(b.refundAmount)+' refund' : '')+''+ ''; var actionsCell = tr.querySelector('.row-actions'); /* No manual "Confirm" button: payment via PayFast confirms bookings automatically. Requested-but-unpaid bookings auto-cancel after 2 hours (see the send-booking-email setup notes) so there's nothing for a staff member to approve here. */ if(['paid','refund_pending','refund_not_due','refunded'].indexOf(b.paymentStatus) > -1){ var recBtn = document.createElement('button'); recBtn.type = 'button'; recBtn.textContent = 'Receipt'; recBtn.addEventListener('click', function(){ openReceipt({ ref:b.ref, residence_name:b.residenceName||b.residenceId, name:b.name, email:b.email, checkin:b.checkin, checkout:b.checkout, nights:b.nights, guests:b.guests, rate:b.rate, total:b.total, status:b.status, payment_status:b.paymentStatus, refund_amount:b.refundAmount, paid_at:b.paidAt, invoice_no:b.invoiceNo, pf_payment_id:b.pfPaymentId }); }); actionsCell.appendChild(recBtn); } if(b.status!=='cancelled'){ var cancelBtn = document.createElement('button'); cancelBtn.type='button'; cancelBtn.textContent='Cancel'; cancelBtn.addEventListener('click', function(){ setBookingStatus(b._id, 'cancelled', [cancelBtn]); }); actionsCell.appendChild(cancelBtn); } tbody.appendChild(tr); }); } async function setBookingStatus(id, status, btns){ btns.forEach(function(b){ b.disabled = true; }); try{ var res = await sb.from('bookings').update({ status: status }).eq('id', id).select('id'); if(res.error || !res.data || !res.data.length){ throw res.error || new Error('not updated'); } await fetchBookings(); }catch(e){ btns.forEach(function(b){ b.disabled = false; }); alert("Couldn't update that booking \u2014 try again."); } } /* ---------- bookings: CSV export (respects the current filters) ---------- */ function csvCell(v){ var s = v==null ? '' : String(v); if(/[",\r\n]/.test(s)){ s = '"' + s.replace(/"/g,'""') + '"'; } return s; } function bookingsToCsv(rows){ var headers = ['Ref','Residence','Guest','Email','Phone','Check-in','Check-out','Nights','Guests','Rate','Total','Status','Payment status','Refund']; var lines = [headers.map(csvCell).join(',')]; rows.forEach(function(b){ lines.push([b.ref, b.residenceName||b.residenceId, b.name, b.email, b.phone, b.checkin, b.checkout, b.nights, b.guests, b.rate, b.total, b.status, b.paymentStatus||'unpaid', b.refundAmount||0].map(csvCell).join(',')); }); return lines.join('\r\n'); } document.getElementById('bookingsExportBtn').addEventListener('click', function(){ var statusFilter = document.getElementById('filterStatus').value; var residenceFilter = document.getElementById('filterResidence').value; var rows = allBookings.filter(function(b){ if(statusFilter!=='all' && b.status!==statusFilter) return false; if(residenceFilter!=='all' && b.residenceId!==residenceFilter) return false; return true; }); if(!rows.length){ alert('No bookings match the current filters.'); return; } var blob = new Blob([bookingsToCsv(rows)], { type:'text/csv;charset=utf-8;' }); var url = URL.createObjectURL(blob); var a = document.createElement('a'); a.href = url; a.download = 'zals-bookings-' + new Date().toISOString().slice(0,10) + '.csv'; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(function(){ URL.revokeObjectURL(url); }, 1000); }); /* ---------- performance / reporting (admin) ---------- */ /* All figures come from this site's own `bookings` table (status === 'confirmed'). Booking.com reservations only ever create a row in `external_blocks` to hold the dates, so they have no fare/guest data here and are intentionally left out of these totals. */ function computePerformance(periodParam){ var confirmed = allBookings.filter(function(b){ return b.status === 'confirmed' && b.checkin && b.checkout; }); var today = new Date(); today.setHours(0,0,0,0); var periodEnd = new Date(today.getTime() + 86400000); var periodStart; if(periodParam === 'all'){ var earliest = null; confirmed.forEach(function(b){ var d = parseISO(b.checkin); if(!earliest || d < earliest){ earliest = d; } }); periodStart = earliest || today; } else { periodStart = new Date(today.getTime() - Number(periodParam)*86400000); } var periodNights = Math.max(1, Math.round((periodEnd - periodStart)/86400000)); var byResidence = {}; ADMIN_RESIDENCES.forEach(function(r){ byResidence[r.slug] = { name:r.name, bookings:0, nights:0, revenue:0 }; }); confirmed.forEach(function(b){ var entry = byResidence[b.residenceId]; if(!entry) return; var ci = parseISO(b.checkin), co = parseISO(b.checkout); if(co <= periodStart || ci >= periodEnd) return; var overlapStart = ci > periodStart ? ci : periodStart; var overlapEnd = co < periodEnd ? co : periodEnd; var overlapNights = Math.round((overlapEnd - overlapStart)/86400000); if(overlapNights <= 0) return; var totalNights = b.nights || Math.round((co-ci)/86400000) || 1; entry.bookings += 1; entry.nights += overlapNights; entry.revenue += (b.total||0) * (overlapNights/totalNights); }); return { periodStart:periodStart, periodEnd:periodEnd, periodNights:periodNights, byResidence:byResidence }; } function computeMonthlyRevenue(){ var months = []; var base = new Date(); base.setDate(1); base.setHours(0,0,0,0); for(var i=5;i>=0;i--){ var d = new Date(base.getFullYear(), base.getMonth()-i, 1); months.push({ key:d.getFullYear()+'-'+(d.getMonth()+1), label:d.toLocaleDateString('en-ZA',{month:'short',year:'2-digit'}), revenue:0 }); } var byKey = {}; months.forEach(function(m){ byKey[m.key]=m; }); allBookings.forEach(function(b){ if(b.status!=='confirmed' || !b.checkin) return; var d = parseISO(b.checkin); var key = d.getFullYear()+'-'+(d.getMonth()+1); if(byKey[key]){ byKey[key].revenue += (b.total||0); } }); return months; } function renderPerformance(){ var periodEl = document.getElementById('perfPeriod'); if(!periodEl) return; var perf = computePerformance(periodEl.value); var slugs = Object.keys(perf.byResidence); var totalNights = 0, totalRevenue = 0, totalBookings = 0; slugs.forEach(function(s){ var e = perf.byResidence[s]; totalNights += e.nights; totalRevenue += e.revenue; totalBookings += e.bookings; }); var maxOccupancyNights = perf.periodNights * slugs.length; var occupancyPct = maxOccupancyNights ? Math.round((totalNights/maxOccupancyNights)*100) : 0; var avgRate = totalNights ? Math.round(totalRevenue/totalNights) : 0; document.getElementById('perfStats').innerHTML = '
R'+formatNum(Math.round(totalRevenue))+'Revenue, this period
'+ '
'+occupancyPct+'%Occupancy, this period
'+ '
'+totalBookings+'Bookings, this period
'+ '
R'+formatNum(avgRate)+'Avg. rate per night sold
'; var rows = slugs.map(function(s){ return perf.byResidence[s]; }).sort(function(a,b){ return b.revenue - a.revenue; }); var body = document.getElementById('perfResidenceBody'); body.innerHTML = ''; rows.forEach(function(r){ var occ = perf.periodNights ? Math.round((r.nights/perf.periodNights)*100) : 0; var tr = document.createElement('tr'); tr.innerHTML = ''+escapeHtml(r.name)+''+ ''+r.bookings+''+ ''+r.nights+''+ ''+occ+'%'+ 'R'+formatNum(Math.round(r.revenue))+''; body.appendChild(tr); }); var months = computeMonthlyRevenue(); var maxRevenue = Math.max.apply(null, months.map(function(m){ return m.revenue; }).concat([1])); var wrap = document.getElementById('perfMonthly'); wrap.innerHTML = ''; months.forEach(function(m){ var row = document.createElement('div'); row.className = 'perf-bar-row'; var label = document.createElement('span'); label.className = 'perf-bar-label'; label.textContent = m.label; var track = document.createElement('div'); track.className = 'perf-bar-track'; var fill = document.createElement('div'); fill.className = 'perf-bar-fill'; fill.style.width = Math.round((m.revenue/maxRevenue)*100) + '%'; track.appendChild(fill); var value = document.createElement('span'); value.className = 'perf-bar-value'; value.textContent = 'R'+formatNum(Math.round(m.revenue)); row.appendChild(label); row.appendChild(track); row.appendChild(value); wrap.appendChild(row); }); } document.getElementById('perfPeriod').addEventListener('change', renderPerformance); /* ---------- enquiries (admin) ---------- */ async function fetchEnquiries(){ var res = await sb.from('enquiries').select('*').order('created_at', { ascending:false }); if(res.error){ throw res.error; } allEnquiries = (res.data || []).map(function(q){ return { _id:q.id, ref:q.ref, name:q.name, email:q.email, checkin:q.checkin, checkout:q.checkout, residence:q.residence, guests:q.guests, message:q.message, status:q.status, createdAt:q.created_at }; }); renderStats(); renderEnquiries(); } function subscribeEnquiries(){ if(enquiriesUnsub){ enquiriesUnsub(); enquiriesUnsub = null; } fetchEnquiries().catch(function(){ var list = document.getElementById('enquiriesList'); list.innerHTML = '
  • Enquiries are unavailable right now.
  • '; }); enquiriesUnsub = watchTable('enquiries', fetchEnquiries); } function mailtoFor(email){ return 'mailto:' + encodeURIComponent(email).replace(/%40/g, '@'); } function fmtStamp(iso){ try{ return new Date(iso).toLocaleString('en-ZA', {day:'numeric', month:'short', year:'numeric', hour:'2-digit', minute:'2-digit'}); } catch(e){ return iso || ''; } } function mk(tag, cls, text){ var n = document.createElement(tag); if(cls){ n.className = cls; } if(text != null){ n.textContent = text; } return n; } function renderEnquiries(){ var filter = document.getElementById('filterEnquiryStatus').value; var list = document.getElementById('enquiriesList'); var emptyEl = document.getElementById('enquiriesEmpty'); var rows = allEnquiries.filter(function(q){ var isReplied = q.status === 'replied'; return filter === 'all' || (filter === 'replied' ? isReplied : !isReplied); }); list.innerHTML = ''; emptyEl.textContent = allEnquiries.length ? 'No enquiries match that filter.' : 'No enquiries yet.'; emptyEl.hidden = rows.length > 0; rows.forEach(function(q){ var replied = q.status === 'replied'; var li = mk('li', 'enq' + (replied ? '' : ' enq--new')); var head = mk('div', 'enq__head'); var who = mk('div', 'enq__who'); who.appendChild(mk('strong', 'enq__name', q.name || 'Unnamed')); if(q.email){ var mail = mk('a', 'enq__email', q.email); mail.href = mailtoFor(q.email); who.appendChild(mail); } var meta = mk('div', 'enq__meta'); meta.appendChild(mk('span', 'status-badge status-' + (replied ? 'replied' : 'new'), replied ? 'replied' : 'new')); meta.appendChild(mk('span', null, fmtStamp(q.createdAt))); if(q.ref){ meta.appendChild(mk('span', null, q.ref)); } head.appendChild(who); head.appendChild(meta); li.appendChild(head); var facts = mk('div', 'enq__facts'); if(q.checkin || q.checkout){ facts.appendChild(mk('span', null, 'Stay: ' + (q.checkin || '?') + ' to ' + (q.checkout || '?'))); } if(q.residence){ facts.appendChild(mk('span', null, q.residence)); } if(q.guests){ facts.appendChild(mk('span', null, q.guests + (q.guests === 1 ? ' guest' : ' guests'))); } if(facts.childNodes.length){ li.appendChild(facts); } if(q.message){ li.appendChild(mk('p', 'enq__msg', q.message)); } var actions = mk('div', 'enq__actions'); var toggle = mk('button', null, replied ? 'Reopen' : 'Mark replied'); toggle.type = 'button'; toggle.addEventListener('click', function(){ setEnquiryStatus(q._id, replied ? 'new' : 'replied', toggle); }); actions.appendChild(toggle); if(q.email){ var reply = mk('a', 'enq__reply', 'Reply by email'); reply.href = mailtoFor(q.email) + '?subject=' + encodeURIComponent('Your ZALS enquiry' + (q.ref ? ' (' + q.ref + ')' : '')); actions.appendChild(reply); } li.appendChild(actions); list.appendChild(li); }); } async function setEnquiryStatus(id, status, btn){ btn.disabled = true; try{ var res = await sb.from('enquiries').update({ status: status }).eq('id', id).select('id'); if(res.error || !res.data || !res.data.length){ throw res.error || new Error('not updated'); } await fetchEnquiries(); }catch(e){ btn.disabled = false; alert("Couldn't update that enquiry \u2014 try again."); } } /* ---------- reviews (admin: moderate what's already collected by submit_review) ---------- */ async function fetchReviews(){ var res = await sb.from('reviews').select('*').order('created_at', { ascending:false }); if(res.error){ throw res.error; } allReviews = (res.data || []).map(function(rv){ return { _id:rv.id, name:rv.guest_name, rating:rv.rating, body:rv.body, published:!!rv.published, createdAt:rv.created_at }; }); renderStats(); renderReviews(); } function subscribeReviews(){ if(reviewsUnsub){ reviewsUnsub(); reviewsUnsub = null; } fetchReviews().catch(function(){ var list = document.getElementById('reviewsList'); list.innerHTML = '
  • Reviews are unavailable right now.
  • '; }); reviewsUnsub = watchTable('reviews', fetchReviews); } function renderReviews(){ var filter = document.getElementById('filterReviewStatus').value; var list = document.getElementById('reviewsList'); var emptyEl = document.getElementById('reviewsEmpty'); var rows = allReviews.filter(function(rv){ return filter === 'all' || (filter === 'published' ? rv.published : !rv.published); }); list.innerHTML = ''; emptyEl.textContent = allReviews.length ? 'No reviews match that filter.' : 'No reviews yet.'; emptyEl.hidden = rows.length > 0; rows.forEach(function(rv){ var li = mk('li', 'enq' + (rv.published ? '' : ' enq--new')); var head = mk('div', 'enq__head'); var who = mk('div', 'enq__who'); who.appendChild(mk('strong', 'enq__name', rv.name || 'Guest')); who.appendChild(mk('span', null, (rv.rating||0) + '/5')); var meta = mk('div', 'enq__meta'); meta.appendChild(mk('span', 'status-badge status-' + (rv.published ? 'confirmed' : 'requested'), rv.published ? 'published' : 'pending')); meta.appendChild(mk('span', null, fmtStamp(rv.createdAt))); head.appendChild(who); head.appendChild(meta); li.appendChild(head); if(rv.body){ li.appendChild(mk('p', 'enq__msg', rv.body)); } var actions = mk('div', 'enq__actions'); var toggle = mk('button', null, rv.published ? 'Unpublish' : 'Publish'); toggle.type = 'button'; toggle.addEventListener('click', function(){ setReviewPublished(rv._id, !rv.published, toggle); }); actions.appendChild(toggle); li.appendChild(actions); list.appendChild(li); }); } async function setReviewPublished(id, published, btn){ btn.disabled = true; try{ var res = await sb.from('reviews').update({ published: published }).eq('id', id).select('id'); if(res.error || !res.data || !res.data.length){ throw res.error || new Error('not updated'); } await fetchReviews(); }catch(e){ btn.disabled = false; alert("Couldn't update that review \u2014 try again."); } } document.getElementById('filterReviewStatus').addEventListener('change', renderReviews); document.getElementById('filterEnquiryStatus').addEventListener('change', renderEnquiries); document.getElementById('adminStats').addEventListener('click', function(e){ var jump = e.target.closest('[data-jump]'); if(!jump){ return; } var target = document.getElementById(jump.getAttribute('data-jump')); if(target){ showAdminPanel(panelOf(target) || 'overview'); } }); document.getElementById('filterStatus').addEventListener('change', renderBookingsTable); document.getElementById('filterResidence').addEventListener('change', renderBookingsTable); consumeAuthCallback().then(route); })();