Chunk 001: wynajem_motorowek
assets/booking.css (lines 1–241)
- Repository
- wynajem_motorowek
- Path
assets/booking.css
.booking-widget {
margin-top: 16px;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 16px;
background: rgba(0, 0, 0, 0.32);
padding: 14px;
color: rgba(255, 255, 255, 0.9);
}
.booking-widget h3 {
margin: 0 0 12px;
font-size: 18px;
}
.booking-top {
display: flex;
align-items: center;
gap: 10px;
justify-content: space-between;
margin-bottom: 10px;
}
.booking-top-controls {
align-items: flex-end;
justify-content: flex-start;
flex-wrap: wrap;
}
.booking-inline-field {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 120px;
}
.booking-inline-field span {
font-size: 12px;
color: rgba(255, 255, 255, 0.75);
}
.booking-inline-field input {
height: 34px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.24);
background: rgba(0, 0, 0, 0.3);
color: rgba(255, 255, 255, 0.92);
padding: 6px 10px;
}
.booking-inline-field select {
height: 34px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.24);
background: rgba(0, 0, 0, 0.3);
color: rgba(255, 255, 255, 0.92);
padding: 6px 10px;
}
.booking-inline-check {
display: inline-flex;
align-items: center;
gap: 6px;
height: 34px;
}
.booking-nav {
display: flex;
align-items: center;
gap: 8px;
}
.booking-btn {
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.9);
border-radius: 10px;
height: 34px;
padding: 0 12px;
cursor: pointer;
}
.booking-btn-primary {
border: 1px solid #ff4a4a;
background: #d51919;
color: #ffffff;
}
.booking-tabs {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin: 10px 0;
}
.booking-tab {
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.9);
border-radius: 999px;
padding: 6px 12px;
cursor: pointer;
}
.booking-tab.active {
background: rgba(255, 255, 255, 0.18);
}
.booking-timeline {
position: relative;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 10px;
height: 420px;
overflow: hidden;
background: rgba(255, 255, 255, 0.02);
}
.booking-grid-line {
position: absolute;
left: 0;
right: 0;
border-top: 1px dashed rgba(255, 255, 255, 0.12);
}
.booking-grid-label {
position: absolute;
left: 8px;
top: -10px;
font-size: 11px;
color: rgba(255, 255, 255, 0.58);
}
.booking-busy,
.booking-selection {
position: absolute;
left: 72px;
right: 12px;
border-radius: 8px;
}
.booking-busy {
background: rgba(255, 120, 120, 0.45);
}
.booking-selection {
background: rgba(120, 200, 255, 0.35);
border: 1px solid rgba(120, 200, 255, 0.8);
}
.booking-form-row {
margin-top: 12px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.booking-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.booking-field input,
.booking-field select,
.booking-field textarea {
height: 36px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.24);
background: rgba(0, 0, 0, 0.3);
color: rgba(255, 255, 255, 0.92);
padding: 6px 10px;
}
.booking-field textarea {
height: 72px;
resize: vertical;
}
.booking-field.full {
grid-column: 1 / -1;
}
.booking-actions {
margin-top: 12px;
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.booking-msg {
margin-top: 10px;
font-size: 14px;
color: rgba(255, 255, 255, 0.85);
}
.booking-msg.error {
color: rgba(255, 170, 170, 0.95);
}
.booking-msg.ok {
color: rgba(170, 255, 190, 0.95);
}
.booking-service {
position: absolute;
left: 72px;
right: 12px;
border-radius: 8px;
background: rgba(210, 225, 255, 0.22);
border: 1px solid rgba(210, 225, 255, 0.35);
}
.booking-countdown {
margin-top: 10px;
font-weight: 700;
}
.booking-inline-note {
color: rgba(255, 255, 255, 0.72);
font-size: 12px;
min-height: 34px;
display: inline-flex;
align-items: center;
}
.hide {
display: none;
}
@media (max-width: 900px) {
.booking-top-controls {
align-items: stretch;
}
.booking-inline-field {
min-width: 100%;
}
.booking-form-row {
grid-template-columns: 1fr;
}
}
assets/booking.js (lines 1–753)
- Repository
- wynajem_motorowek
- Path
assets/booking.js
(function () {
const API_BASE = window.BOOKING_API_BASE || window.location.origin;
const DAY_START_MIN = 8 * 60;
const DAY_END_MIN = 20 * 60;
const DAY_DURATION_MIN = DAY_END_MIN - DAY_START_MIN;
let state = {
mountedEl: null,
date: todayKey(),
resources: [],
resourceMeta: {},
selectedResource: "",
peopleCount: 1,
startMin: 10 * 60,
endMin: 12 * 60,
blocks: [],
holdId: null,
expiresAt: null,
wantInvoice: false,
paymentMethod: "ONLINE",
countdownTimer: null,
step: "select",
message: "",
messageType: "",
customerEmail: "",
};
function todayKey() {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function roundUpToQuarter(min) {
return Math.ceil(min / 15) * 15;
}
function isPastDateKey(dateKey) {
return String(dateKey || "") < todayKey();
}
function minSelectableStartMin() {
if (isPastDateKey(state.date)) return DAY_END_MIN;
if (state.date !== todayKey()) return DAY_START_MIN;
const now = new Date();
const currentMin = now.getHours() * 60 + now.getMinutes();
return Math.max(DAY_START_MIN, Math.min(DAY_END_MIN, roundUpToQuarter(currentMin)));
}
function normalizeSelectionToAllowedWindow() {
const minStart = minSelectableStartMin();
if (minStart >= DAY_END_MIN) {
state.startMin = DAY_END_MIN - 15;
state.endMin = DAY_END_MIN;
return;
}
if (state.startMin < minStart) {
const duration = Math.max(15, selectionDuration());
state.startMin = minStart;
state.endMin = Math.min(DAY_END_MIN, state.startMin + duration);
}
if (state.endMin <= state.startMin) {
state.endMin = Math.min(DAY_END_MIN, state.startMin + 15);
}
}
function qs(sel, root = document) {
return root.querySelector(sel);
}
function minsToText(min) {
const h = String(Math.floor(min / 60)).padStart(2, "0");
const m = String(min % 60).padStart(2, "0");
return `${h}:${m}`;
}
function timeTextToMin(value) {
if (!/^\d{2}:\d{2}$/.test(value || "")) return null;
const [h, m] = value.split(":").map(Number);
if (!Number.isInteger(h) || !Number.isInteger(m)) return null;
return h * 60 + m;
}
function normalizeNip(value) {
return String(value || "").replace(/\D/g, "");
}
function minToY(min, height) {
return ((min - DAY_START_MIN) / DAY_DURATION_MIN) * height;
}
function yToMin(y, height) {
const ratio = Math.max(0, Math.min(1, y / height));
const raw = DAY_START_MIN + ratio * DAY_DURATION_MIN;
return Math.round(raw / 15) * 15;
}
async function api(path, method = "GET", body) {
const res = await fetch(`${API_BASE}${path}`, {
method,
headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const err = new Error(data.reason || "API_ERROR");
err.payload = data;
throw err;
}
return data;
}
async function loadResources() {
const data = await api("/api/resources");
const items = Array.isArray(data.items) ? data.items : [];
state.resources = Array.isArray(data.resources) ? data.resources : items.map((item) => item.name);
state.resourceMeta = Object.fromEntries(
items.map((item) => [String(item.name), { capacity: Number(item.capacity || 6) }])
);
if (!state.selectedResource && state.resources.length) {
state.selectedResource = state.resources[0];
}
const capacity = resourceCapacity();
if (!Number.isInteger(state.peopleCount) || state.peopleCount <= 0) state.peopleCount = 1;
if (state.peopleCount > capacity) state.peopleCount = capacity;
}
async function loadAvailability() {
if (!state.date) return;
const data = await api(`/api/availability?date=${encodeURIComponent(state.date)}`);
state.blocks = Array.isArray(data.blocks) ? data.blocks : [];
normalizeSelectionToAllowedWindow();
}
function blocksForCurrentResource() {
return state.blocks
.filter((b) => b.resourceName === state.selectedResource)
.map((b) => ({
startMin: new Date(b.start).getHours() * 60 + new Date(b.start).getMinutes(),
endMin: new Date(b.end).getHours() * 60 + new Date(b.end).getMinutes(),
type: b.type || "BUSY",
}));
}
function resourceCapacity() {
return Number(state.resourceMeta?.[state.selectedResource]?.capacity || 6);
}
function selectionDuration() {
return Math.max(15, Math.min(DAY_END_MIN, state.endMin) - Math.max(DAY_START_MIN, state.startMin));
}
function selectionOverlapsBlocked(startMin, endMin, blocks) {
return blocks.some((block) => startMin < block.endMin && endMin > block.startMin);
}
function findNextFreeRange(startMin, endMin, blocks) {
const duration = Math.max(15, endMin - startMin);
const normalized = blocks
.filter((item) => item.endMin > DAY_START_MIN && item.startMin < DAY_END_MIN)
.map((item) => ({
startMin: Math.max(DAY_START_MIN, item.startMin),
endMin: Math.min(DAY_END_MIN, item.endMin),
}))
.sort((a, b) => a.startMin - b.startMin);
let candidate = Math.max(DAY_START_MIN, startMin);
for (const block of normalized) {
if (candidate + duration <= block.startMin) {
return { startMin: candidate, endMin: candidate + duration };
}
if (candidate < block.endMin) {
candidate = block.endMin;
}
}
if (candidate + duration <= DAY_END_MIN) {
return { startMin: candidate, endMin: candidate + duration };
}
return null;
}
function snapSelectionToFree() {
const from = Math.max(DAY_START_MIN, Math.min(state.startMin, state.endMin));
const to = Math.min(DAY_END_MIN, Math.max(state.startMin, state.endMin));
if (to <= from) return;
const blocks = blocksForCurrentResource();
if (!selectionOverlapsBlocked(from, to, blocks)) return;
const range = findNextFreeRange(from, to, blocks);
if (range) {
state.startMin = range.startMin;
state.endMin = range.endMin;
}
}
function setMessage(text, type = "") {
state.message = text || "";
state.messageType = type;
rerender();
}
function clearCountdown() {
if (state.countdownTimer) {
clearInterval(state.countdownTimer);
state.countdownTimer = null;
}
}
function startCountdown() {
clearCountdown();
state.countdownTimer = setInterval(() => {
if (!state.expiresAt) return;
if (new Date(state.expiresAt).getTime() <= Date.now()) {
clearCountdown();
state.step = "select";
state.holdId = null;
state.expiresAt = null;
setMessage("Za długo – rezerwacja wygasła", "error");
loadAvailability().then(() => rerender()).catch(() => rerender());
return;
}
const countdownEl = qs(".booking-countdown", state.mountedEl);
if (countdownEl) countdownEl.textContent = countdownText();
}, 1000);
}
function countdownText() {
if (!state.expiresAt) return "";
const diff = Math.max(0, new Date(state.expiresAt).getTime() - Date.now());
const totalSec = Math.floor(diff / 1000);
const mm = String(Math.floor(totalSec / 60)).padStart(2, "0");
const ss = String(totalSec % 60).padStart(2, "0");
return `Masz ${mm}:${ss} na dokończenie płatności`;
}
function renderTimelineBlocks(host, height) {
const blocks = blocksForCurrentResource();
blocks.forEach((block) => {
const el = document.createElement("div");
el.className = block.type === "SERVICE" ? "booking-service" : "booking-busy";
const top = minToY(block.startMin, height);
const bottom = minToY(block.endMin, height);
el.style.top = `${top}px`;
el.style.height = `${Math.max(8, bottom - top)}px`;
host.appendChild(el);
});
const sel = document.createElement("div");
sel.className = "booking-selection";
const from = Math.min(state.startMin, state.endMin);
const to = Math.max(state.startMin, state.endMin);
sel.style.top = `${minToY(from, height)}px`;
sel.style.height = `${Math.max(8, minToY(to, height) - minToY(from, height))}px`;
host.appendChild(sel);
}
function buildSelectStepHtml() {
const dateMin = todayKey();
const startMinAllowed = minSelectableStartMin();
const startMinText = minsToText(Math.min(DAY_END_MIN - 15, Math.max(DAY_START_MIN, startMinAllowed)));
return `
<div class="booking-top booking-top-controls">
<div class="booking-nav">
<button class="booking-btn" data-action="prev-day">◀</button>
<strong>${state.date}</strong>
<button class="booking-btn" data-action="next-day">▶</button>
</div>
<label class="booking-inline-field">
<span>Kalendarz</span>
<input id="bookingDate" type="date" value="${state.date}" min="${dateMin}" />
</label>
<label class="booking-inline-field">
<span>Od</span>
<input id="bookingStart" type="time" value="${minsToText(state.startMin)}" min="${startMinText}" max="20:00" step="900" />
</label>
<label class="booking-inline-field">
<span>Do</span>
<input id="bookingEnd" type="time" value="${minsToText(state.endMin)}" min="08:00" max="20:00" step="900" />
</label>
<label class="booking-inline-field">
<span>Liczba osób</span>
<input id="bookingPeople" type="number" value="${state.peopleCount}" min="1" max="${resourceCapacity()}" />
</label>
<div class="booking-inline-note">maksymalnie ${resourceCapacity()} osób</div>
<label class="booking-inline-field">
<span>Płatność</span>
<select id="bookingPaymentMethod">
<option value="ONLINE" ${state.paymentMethod === "ONLINE" ? "selected" : ""}>Online</option>
<option value="CASH" ${state.paymentMethod === "CASH" ? "selected" : ""}>Gotówka</option>
</select>
</label>
<button class="booking-btn booking-btn-primary" data-action="check">Rezerwuje</button>
</div>
<div class="booking-tabs">
${state.resources
.map(
(name) =>
`<button class="booking-tab ${name === state.selectedResource ? "active" : ""}" data-resource="${name}">${name}</button>`
)
.join("")}
</div>
<div class="booking-timeline" id="bookingTimeline"></div>
`;
}
function buildDetailsStepHtml() {
return `
<div class="booking-countdown">${countdownText()}</div>
<div class="booking-form-row">
<label class="booking-field"><span>Imię i nazwisko</span><input id="dName" type="text" /></label>
<label class="booking-field"><span>Email</span><input id="dEmail" type="email" /></label>
<label class="booking-field"><span>Telefon</span><input id="dPhone" type="text" /></label>
<label class="booking-field"><span>Liczba osób</span><input id="dPeople" type="number" min="1" max="${resourceCapacity()}" value="${state.peopleCount}" /></label>
</div>
<div class="booking-actions">
<label class="booking-inline-check"><input type="checkbox" id="wantInvoice" ${state.wantInvoice ? "checked" : ""} /> Chcę fakturę</label>
</div>
<div class="booking-msg">maksymalnie ${resourceCapacity()} osób</div>
<div id="invoiceFields" class="${state.wantInvoice ? "" : "hide"}">
<div class="booking-form-row">
<label class="booking-field"><span>Nazwa firmy</span><input id="iCompany" type="text" /></label>
<label class="booking-field"><span>NIP</span><input id="iNip" type="text" /></label>
<label class="booking-field full"><span>Adres</span><input id="iAddress" type="text" /></label>
<label class="booking-field full"><span>Email</span><input id="iEmail" type="email" /></label>
</div>
</div>
<div class="booking-actions">
<button class="booking-btn" data-action="send-details">Przejdź do płatności (test)</button>
<button class="booking-btn" data-action="cancel-process">Anuluj i wróć do kalendarza</button>
</div>
`;
}
function buildPaymentStepHtml() {
const methodLabel = state.paymentMethod === "CASH" ? "gotówka" : "online";
return `
<div class="booking-countdown">${countdownText()}</div>
<div class="booking-msg">Płatność (${methodLabel}): wybierz wynik symulacji.</div>
<div class="booking-actions">
<button class="booking-btn" data-action="pay-confirm">Potwierdź</button>
<button class="booking-btn" data-action="pay-fail">Anuluj</button>
<button class="booking-btn" data-action="pay-cash-confirm">Potwierdź gotówkę</button>
<button class="booking-btn" data-action="cancel-process">Wróć do kalendarza</button>
</div>
`;
}
function rerender(rebind = true) {
const root = state.mountedEl;
if (!root) return;
let stepHtml = "";
if (state.step === "select") stepHtml = buildSelectStepHtml();
if (state.step === "details") stepHtml = buildDetailsStepHtml();
if (state.step === "payment") stepHtml = buildPaymentStepHtml();
root.innerHTML = `
<div class="booking-widget">
<h3>Rezerwacje</h3>
${stepHtml}
${state.message ? `<div class="booking-msg ${state.messageType}">${state.message}</div>` : ""}
</div>
`;
if (state.step === "select") {
const timeline = qs("#bookingTimeline", root);
const height = timeline.clientHeight || 420;
for (let h = 8; h <= 20; h += 1) {
const min = h * 60;
const y = minToY(min, height);
const line = document.createElement("div");
line.className = "booking-grid-line";
line.style.top = `${y}px`;
const label = document.createElement("div");
label.className = "booking-grid-label";
label.textContent = `${String(h).padStart(2, "0")}:00`;
line.appendChild(label);
timeline.appendChild(line);
}
renderTimelineBlocks(timeline, height);
let dragStart = null;
timeline.onmousedown = (evt) => {
dragStart = Math.max(minSelectableStartMin(), yToMin(evt.offsetY, height));
state.startMin = dragStart;
state.endMin = dragStart + 60;
normalizeSelectionToAllowedWindow();
rerender();
};
timeline.onmousemove = (evt) => {
if (dragStart === null) return;
const current = Math.max(minSelectableStartMin(), yToMin(evt.offsetY, height));
state.startMin = Math.max(DAY_START_MIN, Math.min(dragStart, current));
state.endMin = Math.min(DAY_END_MIN, Math.max(dragStart, current));
if (state.endMin <= state.startMin) state.endMin = state.startMin + 15;
normalizeSelectionToAllowedWindow();
rerender();
};
window.onmouseup = () => {
if (dragStart !== null) {
snapSelectionToFree();
rerender();
}
dragStart = null;
};
}
if (rebind) bindEvents();
}
async function handleCheckAvailability() {
try {
if (isPastDateKey(state.date)) {
setMessage("Nie można rezerwować w przeszłości.", "error");
return;
}
if (state.date === todayKey() && state.startMin < minSelectableStartMin()) {
normalizeSelectionToAllowedWindow();
setMessage("Nie można rezerwować w przeszłości.", "error");
rerender();
return;
}
const payload = {
resourceName: state.selectedResource,
date: state.date,
start: minsToText(state.startMin),
end: minsToText(state.endMin),
peopleCount: state.peopleCount,
wantInvoice: state.wantInvoice,
paymentMethod: state.paymentMethod,
};
const data = await api("/api/holds", "POST", payload);
if (!data.ok && data.reason === "BUSY") {
state.step = "select";
state.holdId = null;
state.expiresAt = null;
let msg = "Termin zajęty";
if (data.suggestion) {
const s = timeTextToMin(data.suggestion.start);
const e = timeTextToMin(data.suggestion.end);
if (s !== null && e !== null) {
state.startMin = s;
state.endMin = e;
msg = `Termin zajęty. Ustawiono sugerowany termin: ${data.suggestion.start}-${data.suggestion.end}`;
}
}
setMessage(msg, "error");
return;
}
state.holdId = data.holdId;
state.expiresAt = data.expiresAt;
state.step = "details";
startCountdown();
setMessage("Termin dostępny. Uzupełnij dane.", "ok");
} catch (err) {
const reason = err.payload?.reason || err.message;
if (reason === "CAPACITY_EXCEEDED") {
const max = Number(err.payload?.maxCapacity || resourceCapacity());
if (state.peopleCount > max) state.peopleCount = max;
setMessage(`Za dużo osób. Maksymalnie ${max} osób dla tej łódki.`, "error");
rerender();
return;
}
setMessage(`Błąd: ${reason}`, "error");
}
}
async function handleSendDetails() {
try {
const customerEmail = (qs("#dEmail", state.mountedEl)?.value || "").trim();
const invoiceNipRaw = qs("#iNip", state.mountedEl)?.value || "";
const invoiceNip = normalizeNip(invoiceNipRaw);
if (state.wantInvoice && invoiceNip.length !== 10) {
setMessage("NIP musi mieć dokładnie 10 cyfr.", "error");
return;
}
const payload = {
name: (qs("#dName", state.mountedEl)?.value || "").trim(),
email: customerEmail,
phone: String(qs("#dPhone", state.mountedEl)?.value || "").trim(),
peopleCount: Number(qs("#dPeople", state.mountedEl)?.value || 1),
wantInvoice: state.wantInvoice,
invoice: state.wantInvoice
? {
companyName: qs("#iCompany", state.mountedEl)?.value || "",
nip: invoiceNip,
address: qs("#iAddress", state.mountedEl)?.value || "",
email: qs("#iEmail", state.mountedEl)?.value || "",
}
: null,
};
await api(`/api/holds/${encodeURIComponent(state.holdId)}/details`, "POST", payload);
state.peopleCount = payload.peopleCount;
state.customerEmail = payload.email;
state.step = "payment";
setMessage("Dane zapisane. Dokończ płatność testową.", "ok");
} catch (err) {
const reason = err.payload?.reason || err.message;
if (reason === "EXPIRED") {
state.step = "select";
state.holdId = null;
state.expiresAt = null;
clearCountdown();
setMessage("Za długo – rezerwacja wygasła", "error");
return;
}
if (reason === "CAPACITY_EXCEEDED") {
const max = Number(err.payload?.maxCapacity || resourceCapacity());
setMessage(`Za dużo osób. Maksymalnie ${max} osób dla tej łódki.`, "error");
return;
}
setMessage(`Błąd: ${reason}`, "error");
}
}
async function handlePayment(decision) {
try {
const path =
decision === "confirm"
? "/api/payments/test/confirm"
: decision === "cash"
? "/api/payments/cash/confirm"
: "/api/payments/test/fail";
const result = await api(path, "POST", { holdId: state.holdId });
clearCountdown();
state.holdId = null;
state.expiresAt = null;
if (result?.ok && decision !== "fail") {
const email = encodeURIComponent(state.customerEmail || "");
window.location.href = `/rezerwacja_dziekujemy.html?email=${email}`;
return;
}
state.step = "select";
await loadAvailability();
const successMsg = result?.orderNumber
? `Rezerwacja zakończona. Numer zamówienia: ${result.orderNumber}`
: "Rezerwacja zakończona.";
setMessage(result.ok ? successMsg : "Nie udało się zakończyć płatności.", result.ok ? "ok" : "error");
} catch (err) {
const reason = err.payload?.reason || err.message;
if (reason === "EXPIRED") {
clearCountdown();
state.holdId = null;
state.expiresAt = null;
state.step = "select";
setMessage("Za długo – rezerwacja wygasła", "error");
return;
}
setMessage(`Błąd: ${reason}`, "error");
}
}
async function handleCancelProcess() {
const currentHoldId = state.holdId;
clearCountdown();
state.holdId = null;
state.expiresAt = null;
state.step = "select";
try {
if (currentHoldId) {
await api("/api/payments/test/fail", "POST", { holdId: currentHoldId });
}
} catch (_err) {}
await loadAvailability();
setMessage("Proces anulowany.", "");
}
function bindEvents() {
const root = state.mountedEl;
if (!root) return;
root.querySelectorAll("[data-resource]").forEach((btn) => {
btn.onclick = async () => {
state.selectedResource = btn.getAttribute("data-resource") || "";
const cap = resourceCapacity();
if (state.peopleCount > cap) state.peopleCount = cap;
await loadAvailability();
snapSelectionToFree();
rerender();
};
});
root.querySelectorAll("[data-action='prev-day']").forEach((btn) => {
btn.onclick = async () => {
const d = new Date(`${state.date}T00:00:00`);
d.setDate(d.getDate() - 1);
const nextDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
if (isPastDateKey(nextDate)) {
setMessage("Nie można rezerwować w przeszłości.", "error");
return;
}
state.date = nextDate;
await loadAvailability();
snapSelectionToFree();
rerender();
};
});
root.querySelectorAll("[data-action='next-day']").forEach((btn) => {
btn.onclick = async () => {
const d = new Date(`${state.date}T00:00:00`);
d.setDate(d.getDate() + 1);
state.date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
await loadAvailability();
snapSelectionToFree();
rerender();
};
});
const start = qs("#bookingStart", root);
const end = qs("#bookingEnd", root);
const people = qs("#bookingPeople", root);
const paymentMethod = qs("#bookingPaymentMethod", root);
const dateInput = qs("#bookingDate", root);
const wantInvoice = qs("#wantInvoice", root);
const invoiceFields = qs("#invoiceFields", root);
if (start) {
start.onchange = () => {
const val = timeTextToMin(start.value);
if (val !== null) {
state.startMin = Math.max(minSelectableStartMin(), val);
if (state.endMin <= state.startMin) {
state.endMin = Math.min(DAY_END_MIN, state.startMin + Math.max(15, selectionDuration()));
}
snapSelectionToFree();
rerender();
}
};
}
if (end) {
end.onchange = () => {
const val = timeTextToMin(end.value);
if (val !== null) {
state.endMin = Math.max(minSelectableStartMin() + 15, val);
if (state.endMin <= state.startMin) state.endMin = Math.min(DAY_END_MIN, state.startMin + 15);
snapSelectionToFree();
rerender();
}
};
}
if (people) {
people.onchange = () => {
const cap = resourceCapacity();
const val = Number(people.value || 1);
state.peopleCount = Math.max(1, Math.min(cap, Number.isFinite(val) ? Math.floor(val) : 1));
rerender();
};
}
if (paymentMethod) {
paymentMethod.onchange = () => {
const value = String(paymentMethod.value || "ONLINE").toUpperCase();
state.paymentMethod = value === "CASH" ? "CASH" : "ONLINE";
};
}
if (wantInvoice) {
wantInvoice.onchange = () => {
state.wantInvoice = wantInvoice.checked;
if (invoiceFields) invoiceFields.classList.toggle("hide", !state.wantInvoice);
};
}
if (dateInput) {
dateInput.onchange = async () => {
if (!dateInput.value) return;
if (isPastDateKey(dateInput.value)) {
dateInput.value = state.date;
setMessage("Nie można rezerwować w przeszłości.", "error");
return;
}
state.date = dateInput.value;
await loadAvailability();
snapSelectionToFree();
rerender();
};
}
root.querySelectorAll("[data-action='check']").forEach((btn) => {
btn.onclick = handleCheckAvailability;
});
root.querySelectorAll("[data-action='send-details']").forEach((btn) => {
btn.onclick = handleSendDetails;
});
root.querySelectorAll("[data-action='pay-confirm']").forEach((btn) => {
btn.onclick = () => handlePayment("confirm");
});
root.querySelectorAll("[data-action='pay-fail']").forEach((btn) => {
btn.onclick = () => handlePayment("fail");
});
root.querySelectorAll("[data-action='pay-cash-confirm']").forEach((btn) => {
btn.onclick = () => handlePayment("cash");
});
root.querySelectorAll("[data-action='cancel-process']").forEach((btn) => {
btn.onclick = handleCancelProcess;
});
}
async function mount(root) {
state.mountedEl = root;
try {
normalizeSelectionToAllowedWindow();
await loadResources();
await loadAvailability();
rerender();
} catch (err) {
root.innerHTML = `<div class="booking-widget"><h3>Rezerwacje</h3><div class="booking-msg error">Nie udało się połączyć z backendem rezerwacji.</div></div>`;
}
}
function mountIfPresent() {
const root = document.getElementById("booking-root");
if (!root) return;
if (root.dataset.bookingMounted === "1") return;
root.dataset.bookingMounted = "1";
mount(root);
}
const observer = new MutationObserver(() => mountIfPresent());
observer.observe(document.documentElement, { childList: true, subtree: true });
window.addEventListener("DOMContentLoaded", mountIfPresent);
window.addEventListener("hashchange", () => {
setTimeout(mountIfPresent, 0);
});
})();
assets/style.css (lines 1–482)
- Repository
- wynajem_motorowek
- Path
assets/style.css
:root{
--bg: #070A10;
--text: rgba(255,255,255,.92);
--muted: rgba(255,255,255,.70);
--line: rgba(255,255,255,.14);
--card: rgba(0,0,0,.35);
--card2: rgba(255,255,255,.06);
--accent: #7ab8ff;
--shadow: 0 16px 50px rgba(0,0,0,.55);
--radius: 22px;
--tile-h: 92px;
}
*{ box-sizing:border-box; }
html, body{ height:100%; }
body{
margin:0;
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
color: var(--text);
background: var(--bg);
}
a{ color: inherit; }
.bg{
min-height:100%;
background:
radial-gradient(1000px 600px at 20% 0%, rgba(120,184,255,.18), transparent 60%),
radial-gradient(900px 500px at 80% 10%, rgba(255,170,120,.12), transparent 55%),
linear-gradient(180deg, rgba(0,0,0,.50), rgba(0,0,0,.70)),
url("../tlo1.jpg") center/cover no-repeat fixed;
padding: 26px 0 40px;
}
.shell{
width: min(1200px, calc(100% - 30px));
margin: 0 auto;
}
.top{
display:flex;
align-items:center;
justify-content:space-between;
gap:16px;
padding: 12px 4px 16px;
}
.brand{
display:flex;
flex-direction:column;
gap:6px;
}
.brand h1{
margin:0;
font-size: 34px;
letter-spacing: .2px;
}
.brand .tag{
margin:0;
color: var(--muted);
font-size: 14px;
}
.nav{
display:flex;
gap:10px;
flex-wrap:wrap;
}
.nav button{
border:1px solid var(--line);
background: rgba(255,255,255,.05);
color: var(--text);
padding: 10px 14px;
border-radius: 14px;
cursor:pointer;
transition:.12s ease;
}
.nav button:hover{
background: rgba(255,255,255,.08);
border-color: rgba(255,255,255,.24);
}
.nav button.active{
border-color: rgba(122,184,255,.55);
background: rgba(122,184,255,.12);
}
/* DESKTOP: 2 kolumny */
.panel{
display:grid;
grid-template-columns: 175px 1fr;
gap: 16px;
align-items:start;
}
/* ✅ krytyczne: w grid/flex potomkowie mogą rozpychać – to temu zapobiega */
.panel > *{ min-width: 0; }
/* ====== SIDEBAR ====== */
.sidebar{
border: 1px solid var(--line);
border-radius: var(--radius);
background: rgba(0,0,0,.35);
box-shadow: var(--shadow);
overflow:hidden;
}
.sidebar h3{
margin:0;
padding: 14px 14px 10px;
font-size: 16px;
color: var(--muted);
border-bottom: 1px solid var(--line);
}
/* ====== MENU (kafelki) ====== */
.menu{ padding: 10px; }
.tiles{
display:flex;
flex-direction:column;
gap: 10px;
}
.tile{
width:100%;
height: var(--tile-h);
padding: 0;
border-radius: 16px;
overflow:hidden;
cursor:pointer;
border: 1px solid rgba(255,255,255,.14);
background: rgba(255,255,255,.04);
transition:.12s ease;
}
.tile:hover{
background: rgba(255,255,255,.07);
border-color: rgba(255,255,255,.22);
}
.tile.active{
border-color: rgba(122,184,255,.60);
background: rgba(122,184,255,.10);
box-shadow: 0 0 0 3px rgba(122,184,255,.12);
}
.tile img{
width:100%;
height:100%;
display:block;
object-fit: cover;
object-position:center;
opacity:.96;
filter: saturate(1.03) contrast(1.03);
}
/* ====== RAMKA (panel treści) ====== */
.frame{
position: relative;
border-radius: var(--radius);
border: 1px solid var(--line);
overflow:hidden;
box-shadow: var(--shadow);
min-height: 520px;
background: rgba(0,0,0,.35);
scroll-margin-top: 12px;
/* ✅ żadne dziecko nie ma prawa wyjść poza szerokość ramki */
max-width: 100%;
}
.frame::before{
content:"";
position:absolute;
inset:0;
background:
linear-gradient(180deg, rgba(0,0,0,.20), rgba(0,0,0,.60)),
url("../tlo1.jpg") center/cover no-repeat;
filter: saturate(1.05) contrast(1.05);
transform: scale(1.02);
z-index: 0;
}
.frame-inner{
position: relative;
z-index: 1;
padding: 22px;
max-width: 100%;
}
.title-big{
text-align:center;
font-size: 44px;
letter-spacing: 1px;
margin: 4px 0 14px;
font-weight: 800;
}
@media (max-width: 520px){
.title-big{ font-size: 34px; }
}
.card{
border: 1px solid var(--line);
background: rgba(0,0,0,.45);
border-radius: 18px;
padding: 18px;
max-width: 100%;
overflow: hidden; /* ✅ żeby nic nie wychodziło */
}
.quote{
text-align:center;
font-size: 18px;
color: var(--text);
line-height: 1.55;
}
.quote span{ color: var(--muted); }
.contact-big{
margin-top: 18px;
text-align:center;
}
.contact-big .phone{
font-size: 42px;
font-weight: 800;
letter-spacing: 1px;
}
.contact-big .addr{
margin-top: 8px;
color: var(--muted);
font-size: 20px;
line-height: 1.4;
}
.footer{
margin-top: 16px;
padding: 14px 6px 0;
color: var(--muted);
font-size: 13px;
display:flex;
justify-content:space-between;
flex-wrap:wrap;
gap: 10px;
}
/* ====== GALERIA ====== */
.gallery{
display:flex;
flex-direction:column;
gap: 12px;
max-width: 100%;
}
.viewer{
position: relative;
border: 1px solid var(--line);
border-radius: 18px;
overflow:hidden;
background: rgba(0,0,0,.55);
max-width: 100%;
}
/* ✅ klikane duże zdjęcie */
.viewer-media{
width:100%;
height: 360px;
display:block;
object-fit: contain;
background: rgba(0,0,0,.55);
cursor: zoom-in;
max-width: 100%;
}
@media (max-width: 520px){
.viewer-media{ height: 280px; }
}
.viewer .btn{
position:absolute;
top:50%;
transform: translateY(-50%);
border: 1px solid rgba(255,255,255,.22);
background: rgba(0,0,0,.40);
color: var(--text);
width: 44px;
height: 44px;
border-radius: 999px;
cursor:pointer;
display:grid;
place-items:center;
transition:.12s ease;
z-index: 2;
}
.viewer .btn:hover{
background: rgba(0,0,0,.55);
border-color: rgba(255,255,255,.35);
}
.viewer .btn.prev{ left: 12px; }
.viewer .btn.next{ right: 12px; }
.thumbs{
display:flex;
gap: 10px;
overflow:auto;
padding: 6px 2px;
scroll-behavior:smooth;
max-width: 100%;
}
.thumbs::-webkit-scrollbar{ height: 10px; }
.thumbs::-webkit-scrollbar-thumb{ background: rgba(255,255,255,.12); border-radius: 999px; }
.thumb{
border: 1px solid rgba(255,255,255,.16);
background: rgba(0,0,0,.35);
border-radius: 14px;
overflow:hidden;
cursor:pointer;
flex: 0 0 auto;
width: 120px;
height: 78px;
position: relative;
}
.thumb img{
width:100%;
height:100%;
object-fit: cover;
display:block;
opacity:.92;
}
.thumb.active{
border-color: rgba(122,184,255,.60);
box-shadow: 0 0 0 3px rgba(122,184,255,.16);
}
.boat-head{
display:flex;
align-items:flex-end;
justify-content:space-between;
flex-wrap:wrap;
gap: 10px;
margin-bottom: 8px;
}
.boat-head h2{
margin:0;
font-size: 26px;
}
.boat-head .meta{
color: var(--muted);
font-size: 14px;
}
/* chips */
.chips{
display:flex;
gap: 8px;
flex-wrap:wrap;
margin-top: 10px;
}
.chip{
border: 1px solid rgba(255,255,255,.16);
background: rgba(255,255,255,.06);
color: var(--text);
border-radius: 999px;
padding: 6px 10px;
font-size: 13px;
}
.backrow{
display:flex;
justify-content:space-between;
align-items:center;
gap: 10px;
flex-wrap:wrap;
margin-bottom: 14px;
}
.backrow button{
border: 1px solid rgba(255,255,255,.18);
background: rgba(255,255,255,.06);
color: var(--text);
padding: 10px 12px;
border-radius: 14px;
cursor:pointer;
}
.backrow button:hover{
background: rgba(255,255,255,.09);
border-color: rgba(255,255,255,.28);
}
/* =========================================================
✅ MOBILE kolejność: frame -> sidebar
========================================================= */
@media (max-width: 900px){
.panel{
display:flex;
flex-direction:column;
gap: 14px;
}
.frame{ order: 1; min-height: unset; }
.sidebar{ order: 2; }
.frame-inner{ padding: 16px; }
:root{ --tile-h: 104px; }
}
/* =========================================================
✅ FULLSCREEN LIGHTBOX
========================================================= */
.lightbox{
position: fixed;
inset: 0;
background: rgba(0,0,0,.88);
backdrop-filter: blur(3px);
z-index: 9999;
display: none;
align-items: center;
justify-content: center;
padding: 14px;
}
.lightbox.open{ display: flex; }
.lightbox img{
max-width: min(1200px, 96vw);
max-height: 92vh;
width: auto;
height: auto;
object-fit: contain;
border-radius: 14px;
border: 1px solid rgba(255,255,255,.16);
box-shadow: 0 20px 70px rgba(0,0,0,.65);
background: rgba(0,0,0,.35);
}
/* przyciski w lightbox */
.lb-btn{
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 52px;
height: 52px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,.22);
background: rgba(0,0,0,.35);
color: var(--text);
cursor: pointer;
display: grid;
place-items: center;
font-size: 26px;
user-select: none;
}
.lb-btn:hover{ background: rgba(0,0,0,.55); border-color: rgba(255,255,255,.35); }
.lb-prev{ left: 14px; }
.lb-next{ right: 14px; }
.lb-close{
position: absolute;
top: 14px;
right: 14px;
width: 44px;
height: 44px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,.22);
background: rgba(0,0,0,.35);
color: var(--text);
cursor: pointer;
display: grid;
place-items: center;
font-size: 22px;
}
.lb-close:hover{ background: rgba(0,0,0,.55); border-color: rgba(255,255,255,.35); }
.lb-hint{
position: absolute;
bottom: 14px;
left: 50%;
transform: translateX(-50%);
font-size: 12px;
color: rgba(255,255,255,.70);
padding: 6px 10px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,.14);
background: rgba(0,0,0,.30);
}
cancel_reservation.html (lines 1–72)
- Repository
- wynajem_motorowek
- Path
cancel_reservation.html
<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Anulowanie rezerwacji</title>
<style>
body { font-family: Arial, sans-serif; background: #11151c; color: #f2f4f8; margin: 0; }
.wrap { max-width: 680px; margin: 0 auto; padding: 24px; }
.card { background: #1a2130; border: 1px solid #2e3a4f; border-radius: 12px; padding: 16px; }
input, button { height: 38px; border-radius: 8px; border: 1px solid #3b4b67; background: #0f1623; color: #f2f4f8; padding: 0 12px; }
button { cursor: pointer; }
.row { display: flex; gap: 10px; flex-wrap: wrap; }
.msg { margin-top: 12px; }
.ok { color: #a4f1bf; }
.err { color: #ffb2b9; }
</style>
</head>
<body>
<div class="wrap">
<h1>Anulowanie rezerwacji</h1>
<div class="card">
<p>Podaj numer rezerwacji oraz kod anulowania otrzymany w linku potwierdzającym.</p>
<div class="row">
<input id="orderNumber" placeholder="Numer rezerwacji" style="min-width:260px;" />
<input id="cancelToken" placeholder="Kod anulowania" style="min-width:260px;" />
<button id="btnCancel">Anuluj rezerwację</button>
</div>
<div id="msg" class="msg"></div>
</div>
</div>
<script>
const API_BASE = window.BOOKING_API_BASE || window.location.origin;
const orderInput = document.getElementById("orderNumber");
const tokenInput = document.getElementById("cancelToken");
const msg = document.getElementById("msg");
const params = new URLSearchParams(window.location.search);
const orderFromQuery = params.get("order") || "";
const tokenFromQuery = params.get("token") || "";
if (orderFromQuery) orderInput.value = orderFromQuery;
if (tokenFromQuery) tokenInput.value = tokenFromQuery;
document.getElementById("btnCancel").onclick = async () => {
const orderNumber = (orderInput.value || "").trim();
const cancelToken = (tokenInput.value || "").trim();
if (!orderNumber || !cancelToken) {
msg.className = "msg err";
msg.textContent = "Podaj numer rezerwacji i kod anulowania.";
return;
}
try {
const res = await fetch(API_BASE + "/api/cancellations/request", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ orderNumber, cancelToken }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) throw new Error(data.reason || "CANCEL_FAILED");
msg.className = "msg ok";
msg.textContent = `Rezerwacja anulowana. Status płatności: ${data.paymentStatus || "-"}.`;
} catch (e) {
msg.className = "msg err";
msg.textContent = "Błąd anulowania: " + e.message;
}
};
</script>
</body>
</html>