// Villa Mesochori — owners' area extras: seasonal pricing calendar + photo manager. const axNS = window.VillaMesochoriDesignSystem_9e1a21; const { Button: AxButton, Input: AxInput, LucideIcon: AxIcon } = axNS; const AX_MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; function axISO(y, m, d) { return `${y}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`; } function axPriceFor(iso, draft) { let p = Number(draft.nightly) || 0; (draft.seasons || []).forEach((s) => { if (s.from && s.to && iso >= s.from && iso <= s.to) p = Number(s.price) || p; }); return p; } function axCellColor(p, base) { if (!base || p === base) return "var(--surface-sunken)"; const delta = Math.min(1, Math.abs(p - base) / base); const a = 0.25 + 0.6 * delta; return p > base ? `rgba(192, 94, 55, ${a.toFixed(2)})` : `rgba(78, 122, 90, ${a.toFixed(2)})`; } function AxMonth({ year, month, draft }) { const base = Number(draft.nightly) || 0; const startDow = (new Date(year, month, 1).getDay() + 6) % 7; // Monday first const days = new Date(year, month + 1, 0).getDate(); const cells = []; for (let i = 0; i < startDow; i++) cells.push(null); for (let d = 1; d <= days; d++) cells.push(d); return (
{AX_MONTHS[month]}
{cells.map((d, i) => { if (!d) return ; const iso = axISO(year, month, d); const p = axPriceFor(iso, draft); return ( ); })}
); } function SeasonPricing({ draft, setDraft }) { const [year, setYear] = React.useState(new Date().getFullYear()); const seasons = draft.seasons || []; const setSeason = (i, patch) => { const next = seasons.map((s, j) => (j === i ? { ...s, ...patch } : s)); setDraft((d) => ({ ...d, seasons: next })); }; const addSeason = () => setDraft((d) => ({ ...d, seasons: [...seasons, { label: "New season", from: `${year}-06-01`, to: `${year}-06-30`, price: Number(draft.nightly) || 0 }], })); const removeSeason = (i) => setDraft((d) => ({ ...d, seasons: seasons.filter((_, j) => j !== i) })); return (
{/* Season rules */}
{seasons.map((s, i) => (
setSeason(i, { label: e.target.value })} placeholder="e.g. High season" /> setSeason(i, { from: e.target.value })} /> setSeason(i, { to: e.target.value })} /> setSeason(i, { price: Number(e.target.value) })} />
))}
Add a season

Dates are inclusive. Where seasons overlap, the one lower in the list wins. Nights outside any season use the base rate ({draft.currency}{draft.nightly}).

{/* Annual calendar */}
setYear(year - 1)}>← {year} setYear(year + 1)}>→
base rate higher lower
{AX_MONTHS.map((_, m) => )}

Hover any day to see its nightly rate.

); } // ---- Photo manager ---- const AX_SLOTS = [ ["imgHero", "Home — hero (the big view photo)"], ["imgHouse", "Home — the house / living room"], ["imgBedDouble", "Home — the double bedroom"], ["imgBedTwin", "Home — the twin bedroom"], ["imgView", "Home — 'The view' arch photo"], ["imgVillage", "Home — the village"], ]; function axResize(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onerror = reject; reader.onload = () => { const img = new Image(); img.onerror = reject; img.onload = () => { const max = 1600; const scale = Math.min(1, max / Math.max(img.width, img.height)); const cv = document.createElement("canvas"); cv.width = Math.round(img.width * scale); cv.height = Math.round(img.height * scale); cv.getContext("2d").drawImage(img, 0, 0, cv.width, cv.height); resolve(cv.toDataURL("image/jpeg", 0.82)); }; img.src = reader.result; }; reader.readAsDataURL(file); }); } function AxPhotoRow({ k, label, draft, setDraft }) { const fileRef = React.useRef(null); const [busy, setBusy] = React.useState(false); const [err, setErr] = React.useState(false); const isDefault = draft[k] === window.VMContent.defaults[k]; const onFile = (e) => { const f = e.target.files && e.target.files[0]; e.target.value = ""; if (!f) return; setBusy(true); setErr(false); axResize(f) .then((dataUrl) => window.VMContent.uploadImage(k, dataUrl)) .then((url) => { setDraft((d) => ({ ...d, [k]: url })); setBusy(false); }) .catch(() => { setErr(true); setBusy(false); }); }; return (
{label}
{label}
{err ? "Upload failed — try a smaller photo" : busy ? "Uploading…" : isDefault ? "Original photo" : "Your photo"}
fileRef.current.click()} disabled={busy}>Replace {!isDefault && ( setDraft((d) => ({ ...d, [k]: window.VMContent.defaults[k] }))}>Restore original )}
); } function PhotoManager({ draft, setDraft }) { return (
{AX_SLOTS.map(([k, label]) => )}

Photos are resized automatically before upload. Remember to press Save changes below — that's when the new photo goes live.

); } function GuestBook() { const [guests, setGuests] = React.useState(null); const [err, setErr] = React.useState(false); React.useEffect(() => { window.VMContent.getGuests().then(setGuests).catch(() => setErr(true)); }, []); const downloadCsv = () => { const esc = (v) => `"${String(v == null ? "" : v).replace(/"/g, '""')}"`; const rows = [["Requested", "Name", "Email", "Check-in", "Nights", "Guests", "Notes", "Quoted total"]] .concat(guests.map((g) => [g.when, g.name, g.email, g.checkin, g.nights, g.guests, g.notes, g.total])); const blob = new Blob([rows.map((r) => r.map(esc).join(",")).join("\n")], { type: "text/csv" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "villa-mesochori-guests.csv"; a.click(); URL.revokeObjectURL(a.href); }; if (err) return

Couldn't load the guest book — try signing out and in again.

; if (!guests) return

Loading…

; if (!guests.length) return

No booking requests yet — every request made on the site will be captured here with the guest's name, email, dates and notes.

; const list = guests.slice().reverse(); return (
{guests.length} request{guests.length === 1 ? "" : "s"} captured Download CSV for email lists
NameEmailCheck-inNightsGuests
{list.map((g, i) => (
{g.name || "—"} {g.email || "—"} {g.checkin} {g.nights} {g.guests}
{g.notes ?
“{g.notes}” · quoted {g.total}
:
quoted {g.total}
}
))}
); } function SquareSecrets() { const [token, setToken] = React.useState(""); const [sandbox, setSandbox] = React.useState(false); const [state, setState] = React.useState("idle"); // idle | saving | saved | error const serverMode = window.VMContent.serverMode(); const save = () => { setState("saving"); window.VMContent.saveSecret({ squareAccessToken: token, squareSandbox: sandbox }) .then(() => { setState("saved"); setToken(""); setTimeout(() => setState("idle"), 2500); }) .catch(() => setState("error")); }; if (!serverMode) { return

The Square access token can only be set on the live site (it's stored on the server, never in the browser).

; } return (
setToken(e.target.value)} hint="Stored on the server only — developer.squareup.com → your app → Production → Access token" />
{state === "saving" ? "Saving…" : "Save token to server"} {state === "saved" && Saved securely} {state === "error" && Couldn't save — try again}
); } function CalendarSync() { const [url, setUrl] = React.useState(""); const [state, setState] = React.useState("idle"); // idle | saving | saved | error const [status, setStatus] = React.useState(null); // {configured, count} const serverMode = window.VMContent.serverMode(); const refresh = () => window.VMContent.getAvailability().then((a) => setStatus({ configured: a.configured, count: a.busy.length })); React.useEffect(() => { if (serverMode) refresh(); }, []); const save = () => { setState("saving"); window.VMContent.saveSecret({ icalUrl: url.trim() }) .then(() => { setState("saved"); setUrl(""); refresh(); setTimeout(() => setState("idle"), 2500); }) .catch(() => setState("error")); }; if (!serverMode) { return

Calendar sync runs on the live site — the calendar address is stored on the server, never in the browser.

; } return (

In Google Calendar: Settings → your availability calendar → “Integrate calendar” → copy the Secret address in iCal format and paste it here. Any event on that calendar blocks those dates on the booking page (re-synced every 15 minutes).

setUrl(e.target.value)} placeholder="https://calendar.google.com/calendar/ical/…/basic.ics" hint="Stored on the server only. Paste an empty value to turn sync off." />
{state === "saving" ? "Saving…" : "Save & sync now"} {state === "saved" && Saved} {state === "error" && Couldn't save — check the address} {status && ( {status.configured ? `Sync is on — ${status.count} night${status.count === 1 ? "" : "s"} currently blocked` : "Sync is off — all dates show as available"} )}
); } // Generic card-list editor — used for house details and places to go function AxCardShell({ title, onRemove, children }) { return (
{title}
{children}
); } function AxArea({ label, value, onChange, rows }) { return ( ); } function HouseCardsEditor({ draft, setDraft }) { const cards = draft.houseCards || []; const set = (i, patch) => setDraft((d) => ({ ...d, houseCards: cards.map((c, j) => (j === i ? { ...c, ...patch } : c)) })); return (

These cards appear on the home page under “The house” — one card per area (the balcony, the kitchen, the garden…). Add as many as you like.

{cards.map((card, i) => ( setDraft((d) => ({ ...d, houseCards: cards.filter((_, j) => j !== i) }))}> set(i, { title: e.target.value })} placeholder="e.g. The balcony" /> set(i, { body: e.target.value })} /> ))}
setDraft((d) => ({ ...d, houseCards: [...cards, { title: "", body: "" }] }))}>Add an area
); } function AxPlaceImg({ place, onUploaded }) { const fileRef = React.useRef(null); const [busy, setBusy] = React.useState(false); const onFile = (e) => { const f = e.target.files && e.target.files[0]; e.target.value = ""; if (!f) return; setBusy(true); axResize(f) .then((dataUrl) => window.VMContent.uploadImage("place", dataUrl)) .then((url) => { onUploaded(url); setBusy(false); }) .catch(() => setBusy(false)); }; return (
{place.img ? ( ) : (
)} fileRef.current.click()} disabled={busy}>{busy ? "Uploading…" : place.img ? "Replace photo" : "Add photo"}
); } // Pull coordinates out of anything Google Maps gives you: a share link with @lat,lng, // a ?q=lat,lng link, or a plain "36.5104, 23.0662" paste from right-click → coordinates. function axParseCoords(str) { const s = String(str || ""); let m = s.match(/@(-?\d{1,2}\.\d+),(-?\d{1,3}\.\d+)/) || s.match(/[?&](?:q|query|ll|center)=(-?\d{1,2}\.\d+)\s*,\s*(-?\d{1,3}\.\d+)/) || s.match(/^\s*(-?\d{1,2}\.\d+)\s*,\s*(-?\d{1,3}\.\d+)\s*$/) || s.match(/!3d(-?\d{1,2}\.\d+)!4d(-?\d{1,3}\.\d+)/); if (!m) return null; const lat = Number(m[1]), lng = Number(m[2]); if (Number.isNaN(lat) || Number.isNaN(lng) || Math.abs(lat) > 90 || Math.abs(lng) > 180) return null; return { lat, lng }; } function AxLocationField({ place, onCoords }) { const [text, setText] = React.useState(""); const [state, setState] = React.useState("idle"); // idle | ok | bad const apply = (v) => { setText(v); const c = axParseCoords(v); if (c) { onCoords(c); setState("ok"); } else setState(v.trim() ? "bad" : "idle"); }; return (
apply(e.target.value)} placeholder="Paste a Google Maps share link, or e.g. 36.5104, 23.0662" hint={ state === "ok" ? `Pin set — ${place.lat.toFixed(4)}, ${place.lng.toFixed(4)}` : state === "bad" ? "Couldn't find coordinates in that — in Google Maps, right-click the spot and click the coordinates to copy them" : `Current pin: ${(place.lat ?? 0).toFixed(4)}, ${(place.lng ?? 0).toFixed(4)}` } />
); } function PlacesEditor({ draft, setDraft }) { const places = draft.places || []; const set = (i, patch) => setDraft((d) => ({ ...d, places: places.map((p, j) => (j === i ? { ...p, ...patch } : p)) })); return (

The cards on the “Places to go” page. Category groups the filter tags — type an existing one (Beaches, Day trips, Tavernas, Nature, Neapoli) or a new one and a tag appears automatically. Drop in a Google Maps link to pin the place on the map.

{places.map((p, i) => ( setDraft((d) => ({ ...d, places: places.filter((_, j) => j !== i) }))}>
set(i, { name: e.target.value })} /> set(i, { cat: e.target.value })} placeholder="Neapoli / Beaches / …" />
set(i, { dist: e.target.value })} placeholder="e.g. 10 min drive" /> set(i, coords)} />
set(i, { tip: e.target.value })} rows={2} /> set(i, { img: url })} />
))}
setDraft((d) => ({ ...d, places: [...places, { cat: "", name: "", dist: "", tip: "", img: "", lat: 36.5359, lng: 23.0799 }] }))}>Add a place
); } function ArrivalEditor({ draft, setDraft }) { const steps = draft.arriveSteps || []; const set = (i, patch) => setDraft((d) => ({ ...d, arriveSteps: steps.map((s, j) => (j === i ? { ...s, ...patch } : s)) })); return (
setDraft((d) => ({ ...d, houseLat: coords.lat, houseLng: coords.lng }))} />

The direction steps below appear as numbered photo cards — add one card per landmark on the way to the house (4–6 works well).

{steps.map((s, i) => ( setDraft((d) => ({ ...d, arriveSteps: steps.filter((_, j) => j !== i) }))}> set(i, { title: e.target.value })} placeholder="e.g. The church square" /> set(i, { body: e.target.value })} rows={2} /> set(i, coords)} /> set(i, { img: url })} /> ))}
setDraft((d) => ({ ...d, arriveSteps: [...steps, { title: "", body: "", img: "" }] }))}>Add a step
); } function GalleryEditor({ draft, setDraft }) { const items = draft.gallery || []; const set = (i, patch) => setDraft((d) => ({ ...d, gallery: items.map((g, j) => (j === i ? { ...g, ...patch } : g)) })); return (

The expandable “More photos” gallery on the home page — kitchen, bathrooms, garden and anything else guests should see. Cards without a photo show a “coming soon” placeholder until you upload one.

{items.map((g, i) => ( setDraft((d) => ({ ...d, gallery: items.filter((_, j) => j !== i) }))}> set(i, { label: e.target.value })} placeholder="e.g. The kitchen & dining table" /> set(i, { img: url })} /> ))}
setDraft((d) => ({ ...d, gallery: [...items, { label: "", img: "" }] }))}>Add a photo card
); } window.VMAdminExtras = { SeasonPricing, PhotoManager, GuestBook, SquareSecrets, CalendarSync, HouseCardsEditor, PlacesEditor, ArrivalEditor, GalleryEditor };