// 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) })} />
removeSeason(i)}
title="Remove this season"
style={{ appearance: "none", background: "none", border: "1px solid var(--border-strong)", borderRadius: "var(--radius-md)", height: 40, cursor: "pointer", color: "var(--text-muted)", display: "inline-flex", alignItems: "center", justifyContent: "center" }}
>
))}
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}
{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
Name Email Check-in Nights Guests
{list.map((g, i) => (
{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" />
setSandbox(e.target.checked)} />
Sandbox (testing) mode — tick while trying it out with a sandbox 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 (
);
}
function AxArea({ label, value, onChange, rows }) {
return (
{label}
);
}
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 };