Chunk 002: wynajem_motorowek
index.html (lines 1–724)
- Repository
- wynajem_motorowek
- Path
index.html
<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Firma Szuwary — motorówki bez patentu</title>
<link rel="stylesheet" href="assets/style.css?v=20260223-5" />
<link rel="stylesheet" href="assets/booking.css" />
<script src="assets/booking.js?v=20260304-1" defer></script>
<style>
.tiles .tile{
border: 0 !important;
background: transparent !important;
padding: 8px !important;
box-shadow: none !important;
outline: none;
position: relative;
}
.tiles .tile .tile-name{
position: absolute;
top: 2px;
left: 8px;
right: 8px;
z-index: 2;
text-align: center;
color: #fff;
font-size: 11px;
font-weight: 800;
letter-spacing: .8px;
text-transform: uppercase;
pointer-events: none;
text-shadow: 0 1px 2px rgba(0,0,0,.85);
}
.tiles .tile img{
display:block;
width: 140px;
height: 80px;
object-fit: contain;
background: transparent !important;
border: 0 !important;
border-radius: 0 !important;
box-shadow: none !important;
filter: none;
}
/* =========================
✅ CTA domyślnie ukryte (desktop/tablet bez zmian)
========================= */
.cta-bar{ display:none; }
/* =========================
✅ Menu łódek "bardziej na wierzchu"/mniej przygaszone
(nie rusza układu)
========================= */
.sidebar{
position: relative;
z-index: 5;
background: rgba(0,0,0,.35) !important;
border-color: rgba(255,255,255,.18) !important;
}
.sidebar .tiles .tile img{
filter: brightness(1.15) contrast(1.05) saturate(1.15) !important;
opacity: 1 !important;
}
/* =========================
✅ MOBILE: sidebar ma licować z frame + większe miniatury + CTA między
========================= */
@media (max-width: 900px){
/* Uwaga: nie zmieniamy desktop layoutu – tylko mobile */
.panel{
display: flex !important;
flex-direction: column !important;
gap: 12px !important;
}
/* Kolejność NA TELEFONIE:
1) treść
2) CTA
3) miniatury
*/
.frame{ order: 1 !important; width:100% !important; max-width:100% !important; }
.cta-bar{ order: 2 !important; }
.sidebar{ order: 3 !important; width:100% !important; max-width:100% !important; }
/* ✅ CTA (3 przyciski) widoczne tylko na telefonie */
.cta-bar{
display: grid !important;
grid-template-columns: repeat(3, 1fr) !important;
gap: 10px !important;
width: 100% !important;
margin: 0 !important;
}
.cta-btn{
display: flex !important;
align-items: center !important;
justify-content: center !important;
min-height: 54px !important;
border-radius: 16px !important;
border: 1px solid rgba(255,255,255,.22) !important;
background: rgba(0,0,0,.35) !important;
color: rgba(255,255,255,.92) !important;
font-weight: 700 !important;
letter-spacing: .2px !important;
text-decoration: none !important;
user-select: none !important;
-webkit-tap-highlight-color: transparent !important;
}
.cta-btn:active{ transform: translateY(1px); }
/* ✅ wyśrodkowanie kafelków + dopasowanie do szerokości */
.sidebar .menu{
display: flex !important;
justify-content: center !important;
}
.sidebar .tiles{
width: auto !important;
max-width: 100% !important;
margin: 0 auto !important;
justify-content: center !important;
align-items: center !important;
opacity: 1 !important;
}
/* ✅ miniatury +25% */
.tiles .tile{ padding: 10px !important; }
.tiles .tile img{
width: 175px !important;
height: 100px !important;
object-fit: contain !important;
}
}
</style>
</head>
<body>
<div class="bg">
<div class="shell">
<header class="top">
<div class="brand" role="button" tabindex="0" id="goHome" title="Wróć do strony głównej">
<h1>Firma Szuwary</h1>
<p class="tag">Wypożyczalnia łódek • motorówki bez patentu</p>
</div>
<nav class="nav" aria-label="Nawigacja">
<button data-view="oferta" id="btnOferta">Oferta</button>
<button data-view="kontakt" id="btnKontakt">Kontakt</button>
</nav>
</header>
<!-- ✅ WRACAMY DO STAREJ KOLEJNOŚCI: sidebar -> frame (desktop znów OK) -->
<main class="panel">
<!-- ✅ Miniatury -->
<aside class="sidebar">
<h3>Nasze łódki</h3>
<div class="menu" id="menu"></div>
</aside>
<!-- ✅ Treść -->
<section class="frame" id="frame">
<div class="frame-inner" id="app"></div>
<!-- ✅ CTA (na telefonie będzie między treścią a miniaturami dzięki order) -->
<div class="cta-bar" id="ctaBar" aria-label="Szybkie akcje" style="margin-top:12px;">
<a class="cta-btn" id="ctaCall" href="#" aria-label="Zadzwoń">Zadzwoń</a>
<a class="cta-btn" id="ctaFb" href="#" target="_blank" rel="noopener" aria-label="Facebook">Facebook</a>
<button class="cta-btn" id="ctaShare" type="button" aria-label="Udostępnij">Udostępnij</button>
</div>
</section>
</main>
<footer class="footer">
<div>© <span id="year"></span> Firma Szuwary</div>
<div style="color:rgba(255,255,255,.55)">Tel: +48 000 000 000 • Zegrze</div>
</footer>
</div>
</div>
<!-- ✅ FULLSCREEN LIGHTBOX -->
<div class="lightbox" id="lightbox" aria-hidden="true">
<button class="lb-close" id="lbClose" aria-label="Zamknij">✕</button>
<button class="lb-btn lb-prev" id="lbPrev" aria-label="Poprzednie">‹</button>
<img id="lbImg" alt="Zdjęcie">
<button class="lb-btn lb-next" id="lbNext" aria-label="Następne">›</button>
<div class="lb-hint">ESC zamyka • ← → zmienia zdjęcie • klik poza zdjęciem zamyka</div>
</div>
<script>
/* =========================
DANE
========================= */
const BOAT_FOLDERS = [
"Bora-Bora",
"Borneo",
"Formuła",
"Ibiza",
];
const CONTACT = {
phone: "+48 000 000 000",
phoneDigits: "48000000000",
addr1: "Zegrze",
addr2: "woj. mazowieckie",
mapsLink: "https://www.google.com/maps/search/?api=1&query=Zegrze",
mapsEmbedSrc: "https://www.google.com/maps?q=Zegrze&z=13&output=embed"
};
const FB_LINK = "#";
const OFFER_TEXT = `Łodzie motorowe na Zalewie Zegrzyńskim - lub bardziej współcześnie "nad Zegrzem :D " `;
/* =========================
HELPERS
========================= */
const $ = (sel) => document.querySelector(sel);
const app = $("#app");
const menu = $("#menu");
const frame = $("#frame");
$("#year").textContent = new Date().getFullYear();
function isMobile(){ return window.matchMedia("(max-width: 900px)").matches; }
function scrollToFrameIfMobile(){
if(!isMobile()) return;
requestAnimationFrame(() => frame.scrollIntoView({ behavior:"smooth", block:"start" }));
}
function setActiveNav(view){
$("#btnOferta").classList.toggle("active", view === "oferta");
$("#btnKontakt").classList.toggle("active", view === "kontakt");
}
function setActiveBoat(boatId){
[...menu.querySelectorAll("button.tile")].forEach(b=>{
b.classList.toggle("active", b.dataset.id === boatId);
});
}
function folderToId(folderName){
return encodeURIComponent(String(folderName||"").trim());
}
function folderToUrl(folderName){
return `./krypy/${encodeURIComponent(String(folderName||"").trim())}/`;
}
/* =========================
BOATS
========================= */
const BOATS = BOAT_FOLDERS.map(folderName => {
const raw = String(folderName || "").trim();
return {
id: folderToId(raw),
name: raw,
folder: folderToUrl(raw),
desc: `Krótki opis ${raw}`,
chips: ["bez patentu"]
};
});
/* =========================
CTA BUTTONS
========================= */
function setupCta(){
const call = $("#ctaCall");
const fb = $("#ctaFb");
const share = $("#ctaShare");
if(call) call.href = "tel:" + CONTACT.phoneDigits;
if(fb) fb.href = FB_LINK;
async function doShare(){
const url = location.href;
const title = document.title || "Firma Szuwary";
const text = "Firma Szuwary — motorówki bez patentu";
if(navigator.share){
try{ await navigator.share({ title, text, url }); return; }catch(_){}
}
try{
await navigator.clipboard.writeText(url);
share.textContent = "Skopiowano!";
setTimeout(()=>{ share.textContent = "Udostępnij"; }, 1400);
}catch(_){
prompt("Skopiuj link:", url);
}
}
if(share) share.onclick = doShare;
}
setupCta();
/* =========================
✅ BIAŁY => PRZEZROCZYSTY (miniatury)
========================= */
const WHITE_ALPHA_CACHE = new Map();
async function whiteToTransparent(imgEl, opts = {}){
const threshold = Number.isFinite(opts.threshold) ? opts.threshold : 250;
const softness = Number.isFinite(opts.softness) ? opts.softness : 12;
if(!imgEl || !imgEl.src) return;
if(!imgEl.classList.contains("tile-thumb")) return;
const src = imgEl.currentSrc || imgEl.src;
if(WHITE_ALPHA_CACHE.has(src)){
imgEl.src = WHITE_ALPHA_CACHE.get(src);
return;
}
if(/^data:/.test(src)) return;
if(!imgEl.complete || !imgEl.naturalWidth) return;
try{
const w = imgEl.naturalWidth;
const h = imgEl.naturalHeight;
if(w*h > 3_000_000) return;
const c = document.createElement("canvas");
c.width = w; c.height = h;
const ctx = c.getContext("2d", { willReadFrequently:true });
if(!ctx) return;
ctx.drawImage(imgEl, 0, 0);
const imgData = ctx.getImageData(0, 0, w, h);
const d = imgData.data;
const t = threshold, s = softness;
for(let i=0;i<d.length;i+=4){
const r=d[i], g=d[i+1], b=d[i+2], a=d[i+3];
if(a===0) continue;
if(r >= (t-s) && g >= (t-s) && b >= (t-s)){
const whiteness = (r+g+b)/3;
const k = Math.min(1, Math.max(0, (whiteness-(t-s))/(255-(t-s))));
d[i+3] = Math.round(a*(1-k));
}
}
ctx.putImageData(imgData, 0, 0);
const out = c.toDataURL("image/png");
WHITE_ALPHA_CACHE.set(src, out);
imgEl.src = out;
}catch(_){}
}
/* =========================
✅ MINIATURY: mini.* priorytet + cache-buster
========================= */
const THUMB_VERSION = "20260223-5";
function withV(u){
return u + (u.includes("?") ? "&" : "?") + "v=" + encodeURIComponent(THUMB_VERSION);
}
function setThumbWithFallback(imgEl, baseFolderUrl){
const chain = [
withV(`${baseFolderUrl}mini.jpg`),
withV(`${baseFolderUrl}mini.png`),
withV(`${baseFolderUrl}mini.jpeg`),
withV(`${baseFolderUrl}mini.webp`),
withV(`${baseFolderUrl}Mini.jpg`),
withV(`${baseFolderUrl}Mini.png`),
withV(`${baseFolderUrl}Mini.jpeg`),
withV(`${baseFolderUrl}Mini.webp`),
`${baseFolderUrl}1.jpg`,
`${baseFolderUrl}1.jpeg`,
`${baseFolderUrl}1.png`,
`${baseFolderUrl}1.webp`,
];
let i = 0;
imgEl.onerror = () => {
i++;
if(i < chain.length) imgEl.src = chain[i];
else imgEl.onerror = null;
};
imgEl.onload = () => whiteToTransparent(imgEl, { threshold:250, softness:12 });
imgEl.src = chain[0];
}
/* =========================
✅ GALERIA: sondowanie obrazków
========================= */
function probeImage(url){
return new Promise((resolve)=>{
const img = new Image();
img.onload = ()=> resolve(true);
img.onerror = ()=> resolve(false);
img.src = url + (url.includes("?") ? "&" : "?") + "p=" + Date.now();
});
}
async function firstWorkingImage(urls){
for(const u of urls){
const ok = await probeImage(u);
if(ok) return u;
}
return "";
}
const MEDIA_CACHE = new Map();
const MAX_PROBE = 80;
const STOP_AFTER_CONSECUTIVE_MISSES = 10;
const IMG_EXTS = ["jpg","jpeg","png","webp"];
async function resolveNumberedImage(boat, i){
const urls = IMG_EXTS.map(ext => `${boat.folder}${i}.${ext}`);
return await firstWorkingImage(urls);
}
async function buildMediaList(boat){
if(MEDIA_CACHE.has(boat.id)) return MEDIA_CACHE.get(boat.id);
const media = [];
let misses = 0;
for(let i=1; i<=MAX_PROBE; i++){
const found = await resolveNumberedImage(boat, i);
if(found){
media.push({ type:"image", src: found });
misses = 0;
}else{
misses++;
if(misses >= STOP_AFTER_CONSECUTIVE_MISSES) break;
}
}
MEDIA_CACHE.set(boat.id, media);
return media;
}
/* =========================
MENU
========================= */
function buildMenu() {
menu.innerHTML = `
<div class="tiles">
${BOATS.map(b => `
<button class="tile" data-id="${b.id}" title="${b.name}">
<span class="tile-name">${b.name}</span>
<img class="tile-thumb" data-folder="${b.folder}" alt="${b.name}" loading="eager" decoding="async" />
</button>
`).join("")}
</div>
`;
[...menu.querySelectorAll("img.tile-thumb")].forEach(img=>{
const folder = img.getAttribute("data-folder");
setThumbWithFallback(img, folder);
});
menu.onclick = e => {
const btn = e.target.closest(".tile");
if (!btn) return;
location.hash = btn.dataset.id;
scrollToFrameIfMobile();
};
}
/* =========================
LIGHTBOX
========================= */
const lightbox = $("#lightbox");
const lbImg = $("#lbImg");
const lbClose = $("#lbClose");
const lbPrev = $("#lbPrev");
const lbNext = $("#lbNext");
let LB_MEDIA = [];
let LB_INDEX = 0;
function openLightbox(index){
if(!LB_MEDIA.length) return;
LB_INDEX = (index + LB_MEDIA.length) % LB_MEDIA.length;
lbImg.src = LB_MEDIA[LB_INDEX].src;
lightbox.classList.add("open");
lightbox.setAttribute("aria-hidden", "false");
document.body.style.overflow = "hidden";
}
function closeLightbox(){
lightbox.classList.remove("open");
lightbox.setAttribute("aria-hidden", "true");
lbImg.src = "";
document.body.style.overflow = "";
}
function lbStep(dir){
if(!LB_MEDIA.length) return;
LB_INDEX = (LB_INDEX + dir + LB_MEDIA.length) % LB_MEDIA.length;
lbImg.src = LB_MEDIA[LB_INDEX].src;
}
lbClose.onclick = closeLightbox;
lbPrev.onclick = (e)=>{ e.stopPropagation(); lbStep(-1); };
lbNext.onclick = (e)=>{ e.stopPropagation(); lbStep(1); };
lightbox.addEventListener("click", (e)=>{ if(e.target === lightbox) closeLightbox(); });
window.addEventListener("keydown", (e)=>{
if(!lightbox.classList.contains("open")) return;
if(e.key === "Escape") closeLightbox();
if(e.key === "ArrowLeft") lbStep(-1);
if(e.key === "ArrowRight") lbStep(1);
});
/* =========================
VIEW
========================= */
function renderHome(){
setActiveNav("home");
setActiveBoat(null);
app.innerHTML = `
<div class="title-big">MOTORÓWKI BEZ PATENTU</div>
<div class="card">
<div class="quote">
“<strong>${OFFER_TEXT.replaceAll("\n","<br/>")}</strong>”
<br/><span></span>
</div>
<div class="contact-big">
<div class="phone">${CONTACT.phone}</div>
<div class="addr">${CONTACT.addr1}<br/>${CONTACT.addr2}</div>
</div>
</div>
<div class="card">
<h2 style="margin:0 0 8px;">Rezerwacje</h2>
<div id="booking-root"></div>
</div>
`;
}
function renderOferta(){
setActiveNav("oferta");
setActiveBoat(null);
app.innerHTML = `
<div class="backrow">
<div style="color:var(--muted)">Oferta</div>
<button onclick="go('home')">Powrót</button>
</div>
<div class="card">
<h2 style="margin:0 0 10px;">Oferta</h2>
<p style="margin:0; color:var(--muted); line-height:1.7;">
${OFFER_TEXT.replaceAll("\n","<br/>")}
<br/><br/>
<strong style="color:var(--text)">Przykładowo:</strong><br/>
• 1h: … zł<br/>
• 2h: … zł<br/>
• Dzień: … zł<br/>
• Kaucja: … zł<br/>
</p>
</div>
`;
}
function renderKontakt(){
setActiveNav("kontakt");
setActiveBoat(null);
app.innerHTML = `
<div class="backrow">
<div style="color:var(--muted)">Kontakt</div>
<button onclick="go('home')">Powrót</button>
</div>
<div class="card">
<h2 style="margin:0 0 10px;">Kontakt</h2>
<p style="margin:0; color:var(--muted); line-height:1.85;">
<strong style="color:var(--text)">Telefon:</strong> ${CONTACT.phone}<br/>
<strong style="color:var(--text)">Adres:</strong> ${CONTACT.addr1}, ${CONTACT.addr2}<br/>
<strong style="color:var(--text)">Godziny:</strong> …<br/>
</p>
<div style="margin-top:14px; padding-top:14px; border-top:1px solid var(--line);">
<div style="display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap;">
<div style="color:var(--muted)">Mapa dojazdu</div>
<a href="${CONTACT.mapsLink}" target="_blank" rel="noopener"
style="color:var(--accent); text-decoration:none;">
Otwórz w Google Maps →
</a>
</div>
<div style="margin-top:10px; border:1px solid var(--line); border-radius:16px; overflow:hidden; background:rgba(0,0,0,.35);">
<iframe
src="${CONTACT.mapsEmbedSrc}"
width="100%"
height="320"
style="border:0; display:block;"
loading="lazy"
referrerpolicy="no-referrer-when-downgrade"
allowfullscreen>
</iframe>
</div>
</div>
</div>
`;
}
async function renderBoat(boatId){
const boat = BOATS.find(b=>b.id===boatId);
if(!boat){ renderHome(); return; }
setActiveNav("none");
setActiveBoat(boatId);
app.innerHTML = `
<div class="backrow">
<div style="color:var(--muted)">Galeria</div>
<button onclick="go('home')">Powrót</button>
</div>
<div class="card">
<div class="boat-head">
<h2>${boat.name}</h2>
<div class="meta">${boat.desc}</div>
</div>
<div style="margin-top:12px; color:var(--muted);">Ładuję zdjęcia…</div>
</div>
`;
const media = await buildMediaList(boat);
LB_MEDIA = media;
let idx = 0;
function mediaEl(m){
if(!m){
return `<div class="viewer-media" style="display:flex;align-items:center;justify-content:center;color:var(--muted);height:100%;">Brak zdjęć w folderze</div>`;
}
return `<img class="viewer-media" id="viewerMedia" src="${m.src}" alt="${boat.name}" />`;
}
function thumbsHtml(activeIndex){
return media.map((m,i)=>{
const isActive = i===activeIndex ? "active" : "";
return `
<div class="thumb ${isActive}" data-i="${i}" title="Zdjęcie">
<img src="${m.src}" alt="">
</div>
`;
}).join("");
}
function render(){
app.innerHTML = `
<div class="backrow">
<div style="color:var(--muted)">Galeria</div>
<button onclick="go('home')">Powrót</button>
</div>
<div class="card">
<div class="boat-head">
<h2>${boat.name}</h2>
<div class="meta">${boat.desc}</div>
</div>
<div class="chips">
${(boat.chips||[]).map(x=>`<div class="chip">${x}</div>`).join("")}
</div>
<div class="gallery" style="margin-top:14px;">
<div class="viewer" id="viewer">
${mediaEl(media[idx])}
<button class="btn prev" aria-label="Poprzednie" id="prevBtn">‹</button>
<button class="btn next" aria-label="Następne" id="nextBtn">›</button>
</div>
<div class="thumbs" id="thumbs">
${thumbsHtml(idx)}
</div>
</div>
</div>
`;
$("#prevBtn").onclick = ()=>{ if(media.length){ idx = (idx - 1 + media.length) % media.length; render(); } };
$("#nextBtn").onclick = ()=>{ if(media.length){ idx = (idx + 1) % media.length; render(); } };
$("#thumbs").onclick = (e)=>{
const t = e.target.closest(".thumb");
if(!t) return;
const i = Number(t.dataset.i);
if(Number.isFinite(i)){ idx = i; render(); }
};
const vm = $("#viewerMedia");
if(vm) vm.onclick = ()=> openLightbox(idx);
window.onkeydown = (e)=>{
if(lightbox.classList.contains("open")) return;
if(!media.length) return;
if(e.key === "ArrowLeft"){ idx = (idx - 1 + media.length) % media.length; render(); }
if(e.key === "ArrowRight"){ idx = (idx + 1) % media.length; render(); }
};
}
render();
scrollToFrameIfMobile();
}
/* =========================
ROUTING
========================= */
function go(view){
if(view === "home") location.hash = "";
else location.hash = "#" + view;
}
async function onRoute(){
const hash = (location.hash || "").replace("#","").trim();
if(!hash){ renderHome(); return; }
if(hash === "oferta") { renderOferta(); scrollToFrameIfMobile(); return; }
if(hash === "kontakt"){ renderKontakt(); scrollToFrameIfMobile(); return; }
await renderBoat(hash);
}
/* =========================
INIT
========================= */
$("#btnOferta").onclick = ()=> { go("oferta"); scrollToFrameIfMobile(); };
$("#btnKontakt").onclick = ()=> { go("kontakt"); scrollToFrameIfMobile(); };
$("#goHome").onclick = ()=> { go("home"); scrollToFrameIfMobile(); };
$("#goHome").onkeydown = (e)=>{ if(e.key==="Enter"){ go("home"); scrollToFrameIfMobile(); } };
buildMenu();
window.addEventListener("hashchange", ()=>{ onRoute(); });
onRoute();
</script>
</body>
</html>
owner_manager.html (lines 1–516)
- Repository
- wynajem_motorowek
- Path
owner_manager.html
<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Owner Menager</title>
<style>
body { font-family: Arial, sans-serif; margin: 0; background: #0f1115; color: #eef1f6; }
.wrap { max-width: 1440px; margin: 0 auto; padding: 20px; }
.card { background: #191d25; border: 1px solid #2a3140; border-radius: 12px; padding: 14px; margin-bottom: 14px; }
input, select, button { height: 36px; border-radius: 8px; border: 1px solid #3a4458; background: #0f131b; color: #eef1f6; padding: 0 10px; }
button { cursor: pointer; }
.grid { display: grid; gap: 10px; grid-template-columns: repeat(4, minmax(0, 1fr)); }
.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.small { color: #9fb0cc; font-size: 13px; }
table { width: 100%; border-collapse: collapse; font-size: 12px; }
th, td { border-bottom: 1px solid #2a3140; padding: 8px; text-align: left; vertical-align: top; }
.hide { display: none; }
.ok { color: #8ff1b5; }
.err { color: #ff9ca3; }
.pill { display: inline-block; padding: 2px 8px; border-radius: 99px; background: #27334b; color: #d3e3ff; font-size: 11px; }
.pill.p-success { background: rgba(88, 183, 128, 0.28); color: #b8ffd0; border: 1px solid rgba(88, 183, 128, 0.45); }
.pill.p-warn { background: rgba(196, 170, 78, 0.28); color: #fff1ad; border: 1px solid rgba(196, 170, 78, 0.45); }
.pill.p-info { background: rgba(79, 129, 255, 0.28); color: #c8dcff; border: 1px solid rgba(79, 129, 255, 0.45); }
.inv { white-space: pre-line; color: #dbe8ff; }
.muted { color: #a3b4d6; }
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,.55); display: none; align-items: center; justify-content: center; z-index: 9999; }
.modal-backdrop.show { display: flex; }
.modal-card { width: min(680px, calc(100vw - 24px)); background: #171e2a; border: 1px solid #34435d; border-radius: 12px; padding: 16px; }
.modal-title { margin: 0 0 10px; }
.modal-content { white-space: pre-line; line-height: 1.45; color: #dfe9ff; }
.invoice-btn { height: 30px; font-size: 12px; }
@media (max-width: 900px) { .grid { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<div class="wrap">
<h1>Owner Menager</h1>
<section class="card" id="loginCard">
<h3>Logowanie</h3>
<div class="row">
<input id="ownerLogin" placeholder="login" autocomplete="username" />
<input id="ownerPassword" type="password" placeholder="hasło" autocomplete="current-password" />
<button id="btnLogin">Zaloguj</button>
</div>
<div class="small" id="loginMsg"></div>
</section>
<section class="card hide" id="panelCard">
<div class="row" style="justify-content:space-between;">
<h3 style="margin:0;">Panel właściciela</h3>
<button id="btnLogout">Wyloguj</button>
</div>
<div class="card">
<h4>Analityka</h4>
<div class="grid" id="analytics"></div>
</div>
<div class="card">
<h4>Blokada terminu (OWNER_BLOCK)</h4>
<div class="grid">
<select id="blockResource"></select>
<input id="blockDate" type="date" />
<input id="blockStart" type="time" value="10:00" step="900" min="08:00" max="20:00" />
<input id="blockEnd" type="time" value="12:00" step="900" min="08:00" max="20:00" />
</div>
<div class="row" style="margin-top:10px;">
<button id="btnBlock">Zablokuj termin</button>
<span class="small" id="blockMsg"></span>
</div>
</div>
<div class="card">
<h4>Adresy obsługi (duplikat potwierdzeń)</h4>
<div class="row">
<input id="supportEmail" type="email" placeholder="adres@email.pl" style="min-width:320px;" />
<button id="btnSupportAdd">Dodaj adres</button>
<span class="small" id="supportMsg"></span>
</div>
<div id="supportList" class="row" style="margin-top:10px;"></div>
</div>
<div class="card">
<div class="row" style="justify-content:space-between;">
<h4 style="margin:0;">Rezerwacje i blokady</h4>
<div class="row">
<button id="btnPrevDate">◀ Poprzedni dzień</button>
<input id="filterDate" type="date" />
<button id="btnNextDate">Następny dzień ▶</button>
<button id="btnReload">Odśwież</button>
</div>
</div>
<div style="overflow:auto; margin-top:10px;">
<table>
<thead>
<tr>
<th>ID</th><th>Nr zamówienia</th><th>Zasób</th><th>Od</th><th>Do</th><th>Status</th><th>Typ</th><th>Klient</th><th>Kontakt</th><th>Osób</th><th>Faktura</th><th>Płatność</th><th>Akcja</th>
</tr>
</thead>
<tbody id="reservationsBody"></tbody>
</table>
</div>
</div>
</section>
</div>
<div id="invoiceModal" class="modal-backdrop" aria-hidden="true">
<div class="modal-card">
<h3 class="modal-title">Dane do faktury</h3>
<div id="invoiceModalBody" class="modal-content"></div>
<div class="row" style="margin-top:12px; justify-content:flex-end;">
<button id="btnCloseInvoiceModal">Zamknij</button>
</div>
</div>
</div>
<script>
const API_BASE = window.BOOKING_API_BASE || window.location.origin;
const auth = { token: "" };
const reservationMap = new Map();
const previousReservationState = new Map();
const newReservationSound = new Audio("/assets/new.ogg");
const cancelReservationSound = new Audio("/assets/new.ogg");
let refreshInFlight = null;
let pollTimer = null;
let hasInitialSnapshot = false;
function $(id) { return document.getElementById(id); }
function playSound(audio) {
audio.currentTime = 0;
audio.play().catch(() => {});
}
function isPaidConfirmed(rowLike) {
return String(rowLike?.status || "") === "CONFIRMED" && String(rowLike?.paymentStatus || "") === "PAID";
}
function detectReservationChanges(rows, notify) {
const currentState = new Map();
let hasNewPaid = false;
let hasCancelled = false;
rows.forEach((row) => {
const id = Number(row.id);
const status = String(row.status || "");
const paymentStatus = String(row.paymentStatus || "");
currentState.set(id, { status, paymentStatus });
if (!previousReservationState.has(id)) {
if (status === "CANCELLED") {
hasCancelled = true;
}
if (isPaidConfirmed(row)) {
hasNewPaid = true;
}
return;
}
const oldState = previousReservationState.get(id) || {};
const oldStatus = String(oldState.status || "");
if (oldStatus !== "CANCELLED" && status === "CANCELLED") {
hasCancelled = true;
}
if (!isPaidConfirmed(oldState) && isPaidConfirmed({ status, paymentStatus })) {
hasNewPaid = true;
}
});
previousReservationState.clear();
currentState.forEach((item, id) => previousReservationState.set(id, item));
if (!hasInitialSnapshot) {
hasInitialSnapshot = true;
return;
}
if (!notify) return;
if (hasCancelled) playSound(cancelReservationSound);
if (hasNewPaid) {
if (hasCancelled) {
setTimeout(() => playSound(newReservationSound), 250);
} else {
playSound(newReservationSound);
}
}
}
function startPolling() {
if (pollTimer) return;
pollTimer = setInterval(async () => {
try {
await refreshPanel({ notify: true });
} catch (_e) {
}
}, 10000);
}
function stopPolling() {
if (!pollTimer) return;
clearInterval(pollTimer);
pollTimer = null;
}
function formatDateTime(input) {
if (!input) return "";
const d = new Date(String(input).replace(" ", "T"));
if (Number.isNaN(d.getTime())) return String(input);
const dd = String(d.getDate()).padStart(2, "0");
const mm = String(d.getMonth() + 1).padStart(2, "0");
const yyyy = d.getFullYear();
const hh = String(d.getHours()).padStart(2, "0");
const min = String(d.getMinutes()).padStart(2, "0");
return `${dd}.${mm}.${yyyy} ${hh}.${min}`;
}
function formatInvoice(data) {
if (!data) return "";
const parts = [];
if (data.companyName) parts.push(`Firma: ${data.companyName}`);
if (data.nip) parts.push(`NIP: ${data.nip}`);
if (data.address) parts.push(`Adres: ${data.address}`);
if (data.email) parts.push(`Email: ${data.email}`);
return parts.join("\n");
}
function parseDate(dateText) {
const d = new Date(String(dateText || "").replace(" ", "T"));
return Number.isNaN(d.getTime()) ? null : d;
}
function isActiveNow(row) {
if (String(row.status || "") !== "CONFIRMED") return false;
const now = new Date();
const from = parseDate(row.start);
const to = parseDate(row.end);
if (!from || !to) return false;
return now >= from && now < to;
}
function reservationPillClass(row) {
if (isActiveNow(row)) return "p-info";
if (String(row.paymentMethod || "") === "CASH") return "p-warn";
if (String(row.status || "") === "CONFIRMED" && String(row.paymentStatus || "") === "PAID") return "p-success";
return "";
}
function showInvoiceModal(invoiceData) {
const modal = $("invoiceModal");
$("invoiceModalBody").textContent = formatInvoice(invoiceData) || "Brak danych";
modal.classList.add("show");
modal.setAttribute("aria-hidden", "false");
}
function hideInvoiceModal() {
const modal = $("invoiceModal");
modal.classList.remove("show");
modal.setAttribute("aria-hidden", "true");
}
async function ownerApi(path, method = "GET", body) {
const res = await fetch(API_BASE + path, {
method,
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${auth.token}`,
},
body: body ? JSON.stringify(body) : undefined,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.reason || "API_ERROR");
return data;
}
async function loadResources() {
const data = await fetch(API_BASE + "/api/resources").then((r) => r.json());
const select = $("blockResource");
select.innerHTML = (data.items || []).map((x) => `<option value="${x.name}">${x.name} (max ${x.capacity})</option>`).join("");
}
function renderAnalytics(a) {
const box = $("analytics");
box.innerHTML = `
<div class="card"><div class="small">Wszystkie</div><strong>${a.totalReservations}</strong></div>
<div class="card"><div class="small">Potwierdzone</div><strong>${a.confirmedReservations}</strong></div>
<div class="card"><div class="small">Blokady właściciela</div><strong>${a.ownerBlocks}</strong></div>
<div class="card"><div class="small">Aktywne HOLD</div><strong>${a.activeHolds}</strong></div>
`;
}
function renderReservations(rows) {
reservationMap.clear();
rows.forEach((row) => reservationMap.set(Number(row.id), row));
const body = $("reservationsBody");
body.innerHTML = rows.map((r) => `
<tr>
<td>${r.id}</td>
<td>${r.orderNumber || ""}</td>
<td>${r.resourceName}</td>
<td>${formatDateTime(r.start)}</td>
<td>${formatDateTime(r.end)}</td>
<td><span class="pill ${reservationPillClass(r)}">${r.status || ""}</span></td>
<td><span class="pill ${reservationPillClass(r)}">${r.kind || ""}</span></td>
<td>${r.customerName || ""}</td>
<td><div>${r.phone || ""}</div><div class="muted">${r.customerEmail || ""}</div></td>
<td>${r.peopleCount || ""}</td>
<td>${r.wantInvoice && r.invoiceData ? `<button class="invoice-btn" data-invoice-id="${r.id}">FAKTURA</button>` : ""}</td>
<td>
<div>${r.paymentStatus || ""}</div>
<div class="muted">${r.paymentMethod || ""}</div>
<div class="muted">ref: ${r.paymentRef || "-"}</div>
</td>
<td><button data-del="${r.id}">Usuń</button></td>
</tr>
`).join("");
body.querySelectorAll("button[data-del]").forEach((btn) => {
btn.onclick = async () => {
if (!confirm("Usunąć ten rekord?")) return;
try {
await ownerApi(`/api/owner/reservations/${btn.getAttribute("data-del")}`, "DELETE");
await refreshPanel();
} catch (e) {
alert("Błąd usuwania: " + e.message);
}
};
});
body.querySelectorAll("button[data-invoice-id]").forEach((btn) => {
btn.onclick = () => {
const id = Number(btn.getAttribute("data-invoice-id") || 0);
const row = reservationMap.get(id);
if (!row) return;
showInvoiceModal(row.invoiceData);
};
});
}
function renderSupportEmails(items) {
const root = $("supportList");
root.innerHTML = items.map((item) => `
<span class="pill">${item.email} <button data-support-del="${item.id}" style="height:24px;margin-left:8px;">Usuń</button></span>
`).join("");
root.querySelectorAll("button[data-support-del]").forEach((btn) => {
btn.onclick = async () => {
const id = btn.getAttribute("data-support-del");
try {
await ownerApi(`/api/owner/support-emails/${id}`, "DELETE");
await loadSupportEmails();
} catch (e) {
alert("Błąd usuwania adresu: " + e.message);
}
};
});
}
async function loadSupportEmails() {
const data = await ownerApi("/api/owner/support-emails");
renderSupportEmails(data.emails || []);
}
async function refreshPanel(options = {}) {
const notify = options.notify === true;
if (refreshInFlight) return refreshInFlight;
refreshInFlight = (async () => {
const date = $("filterDate").value;
const analytics = await ownerApi("/api/owner/analytics");
const reservations = await ownerApi(`/api/owner/reservations${date ? `?date=${encodeURIComponent(date)}` : ""}`);
const rows = reservations.reservations || [];
detectReservationChanges(rows, notify);
renderAnalytics(analytics.analytics);
renderReservations(rows);
await loadSupportEmails();
})();
try {
await refreshInFlight;
} finally {
refreshInFlight = null;
}
}
$("btnLogin").onclick = async () => {
const login = $("ownerLogin").value.trim();
const password = $("ownerPassword").value;
const msg = $("loginMsg");
try {
const res = await fetch(API_BASE + "/api/owner/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ login, password }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) throw new Error(data.reason || "LOGIN_FAILED");
auth.token = String(data.token || "");
if (!auth.token) throw new Error("LOGIN_TOKEN_MISSING");
sessionStorage.setItem("ownerToken", auth.token);
$("loginCard").classList.add("hide");
$("panelCard").classList.remove("hide");
msg.textContent = "";
await loadResources();
await refreshPanel({ notify: false });
startPolling();
} catch (e) {
msg.className = "small err";
msg.textContent = "Błąd logowania: " + e.message;
}
};
$("btnLogout").onclick = () => {
stopPolling();
if (auth.token) {
fetch(API_BASE + "/api/owner/logout", {
method: "POST",
headers: { "Authorization": `Bearer ${auth.token}` },
}).catch(() => {});
}
sessionStorage.removeItem("ownerToken");
location.reload();
};
$("btnReload").onclick = () => refreshPanel({ notify: false });
$("btnPrevDate").onclick = async () => {
const date = $("filterDate").value;
if (!date) return;
const d = new Date(`${date}T00:00:00`);
d.setDate(d.getDate() - 1);
$("filterDate").value = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
await refreshPanel({ notify: false });
};
$("btnNextDate").onclick = async () => {
const date = $("filterDate").value;
if (!date) return;
const d = new Date(`${date}T00:00:00`);
d.setDate(d.getDate() + 1);
$("filterDate").value = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
await refreshPanel({ notify: false });
};
$("btnCloseInvoiceModal").onclick = hideInvoiceModal;
$("invoiceModal").onclick = (evt) => {
if (evt.target.id === "invoiceModal") hideInvoiceModal();
};
window.addEventListener("beforeunload", stopPolling);
$("btnSupportAdd").onclick = async () => {
const email = $("supportEmail").value.trim();
const msg = $("supportMsg");
try {
await ownerApi("/api/owner/support-emails", "POST", { email });
$("supportEmail").value = "";
msg.className = "small ok";
msg.textContent = "Adres dodany";
await loadSupportEmails();
} catch (e) {
msg.className = "small err";
msg.textContent = "Błąd: " + e.message;
}
};
$("btnBlock").onclick = async () => {
const payload = {
resourceName: $("blockResource").value,
date: $("blockDate").value,
start: $("blockStart").value,
end: $("blockEnd").value,
};
const msg = $("blockMsg");
try {
await ownerApi("/api/owner/blocks", "POST", payload);
msg.className = "small ok";
msg.textContent = "Termin zablokowany";
await refreshPanel({ notify: false });
} catch (e) {
msg.className = "small err";
msg.textContent = "Błąd: " + e.message;
}
};
(async function boot() {
const d = new Date();
const date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
$("filterDate").value = date;
$("blockDate").value = date;
const token = sessionStorage.getItem("ownerToken") || "";
if (!token) return;
auth.token = token;
try {
await ownerApi("/api/owner/analytics");
$("loginCard").classList.add("hide");
$("panelCard").classList.remove("hide");
await loadResources();
await refreshPanel({ notify: false });
startPolling();
} catch (_e) {
sessionStorage.removeItem("ownerToken");
}
})();
</script>
</body>
</html>
README.md (lines 1–152)
- Repository
- wynajem_motorowek
- Path
README.md
# Boat Rental Booking Prototype
Prototyp serwisu rezerwacyjnego dla planowanej niewielkiej wypożyczalni łodzi.
Projekt powstał na potrzeby przygotowania przedsięwzięcia do wniosku o dotację. Punktem wyjścia była zwykła strona ofertowa, ale zakres szybko rozwinął się w mały system rezerwacyjny: klient wybiera łódź i termin, system sprawdza dostępność, czasowo blokuje termin, zbiera dane rezerwacji, a właściciel zarządza kalendarzem z własnego panelu.
Projekt zatrzymał się na etapie integracji płatności online. Uruchomienie rzeczywistej bramki płatniczej wymagało już danych i umowy działającej firmy, która ostatecznie nie została założona.
## Co działa
- responsywna strona ofertowa z prezentacją łodzi,
- kilka niezależnie rezerwowanych zasobów,
- kalendarz dostępności,
- wybór przedziału godzinowego i liczby osób,
- walidacja pojemności łodzi,
- wykrywanie konfliktów terminów,
- propozycja najbliższego wolnego terminu,
- 15-minutowa czasowa blokada terminu (`HOLD`) podczas wypełniania rezerwacji,
- automatyczny 15-minutowy blok serwisowy po rezerwacji,
- dane klienta i opcjonalne dane do faktury,
- płatność gotówką oraz symulowany przebieg płatności online,
- numery rezerwacji i bezpieczny token anulowania,
- powiadomienia e-mail przez SMTP,
- anulowanie rezerwacji,
- panel właściciela,
- ręczne blokowanie terminów,
- lista rezerwacji i podstawowa analityka,
- lokalna baza SQLite.
## Przepływ rezerwacji
```text
Wybór łodzi i terminu
↓
Sprawdzenie dostępności
↓
Tymczasowy HOLD na 15 minut
↓
Dane klienta / faktury
↓
Płatność gotówką lub test płatności online
↓
Potwierdzenie rezerwacji
↓
E-mail + zapis w SQLite
```
## Panel właściciela
Panel administracyjny pozwala m.in. przeglądać rezerwacje, filtrować je po dacie, dodawać własne blokady terminów, zarządzać adresami otrzymującymi kopie powiadomień oraz podejrzeć podstawowe statystyki.
## Technologie
- HTML / CSS / JavaScript
- Node.js
- Express
- SQLite
- Nodemailer
- REST API
Frontend i backend działają z jednego serwera Node.js.
## Uruchomienie lokalne
Wymagany jest Node.js.
```bash
cd server
npm install
```
Skopiuj:
```text
.env.example → .env
```
i ustaw własne wartości konfiguracyjne.
Następnie:
```bash
npm start
```
Aplikacja będzie dostępna domyślnie pod:
```text
http://localhost:3030
```
Przy pierwszym uruchomieniu baza SQLite jest tworzona automatycznie. Lista łodzi jest inicjalizowana na podstawie katalogów w `krypy/`.
## Konfiguracja
Najważniejsze zmienne środowiskowe:
```env
SQLITE_PATH=./data/booking.sqlite
PORT=3030
PUBLIC_BASE_URL=http://localhost:3030
OWNER_LOGIN=
OWNER_PASSWORD=
SMTP_HOST=
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=
SMTP_PASS=
MAIL_FROM=
OWNER_NOTIFY_EMAIL=
SUPPORT_EMAILS=
ENABLE_TEST_PAYMENTS=true
PAYMENT_PROVIDER=not-configured
```
Repozytorium nie zawiera prawdziwych haseł, danych SMTP ani danych klientów.
## Płatności
Warstwa rezerwacyjna i cały przepływ przed/po płatności zostały zbudowane, natomiast właściwa integracja z operatorem płatności nie została ukończona.
W projekcie pozostaje tryb testowy pozwalający zasymulować udaną lub nieudaną płatność. Adapter zwrotów jest szkieletem przygotowanym pod późniejsze podłączenie konkretnego operatora.
To jest świadomy punkt zatrzymania projektu — dalsza konfiguracja wymagała już działającej firmy i rzeczywistego konta u operatora płatności.
## Status projektu
**Prototype / pre-production**
Projekt pokazuje drogę:
**pomysł biznesowy → proces rezerwacji → model dostępności → backend → baza → panel właściciela → przygotowanie do integracji płatności**
Nie jest przedstawiany jako gotowy system produkcyjny.
Przed publicznym wdrożeniem należałoby jeszcze m.in. podłączyć rzeczywistą bramkę płatniczą, dodać produkcyjne monitorowanie i rate limiting oraz wzmocnić ochronę przed równoczesnymi próbami zajęcia tego samego terminu.
## Portfolio i prywatność
Repozytorium jest przygotowane jako wersja portfolio. Usunięto z niego:
- prawdziwe dane logowania do poczty,
- lokalny plik `.env`,
- historię testowych rezerwacji,
- dane e-mail i telefoniczne zapisane w bazie,
- `node_modules`.
Projekt może pozostać prywatny ze względu na klientowski branding, zdjęcia i kontekst biznesowy.
rezerwacja_dziekujemy.html (lines 1–74)
- Repository
- wynajem_motorowek
- Path
rezerwacja_dziekujemy.html
<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Dziękujemy za rezerwację</title>
<style>
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
background: #0f1115;
color: #eef1f6;
font-family: Arial, sans-serif;
padding: 20px;
box-sizing: border-box;
}
.card {
width: min(680px, 100%);
background: #191d25;
border: 1px solid #2a3140;
border-radius: 14px;
padding: 28px;
text-align: center;
}
h1 {
margin: 0 0 12px;
font-size: 32px;
line-height: 1.2;
}
p {
margin: 0;
color: #cdd8ef;
font-size: 18px;
line-height: 1.5;
}
.email {
color: #ffffff;
font-weight: 700;
word-break: break-word;
}
.actions {
margin-top: 22px;
}
a {
display: inline-block;
text-decoration: none;
border: 1px solid #3a4458;
color: #eef1f6;
padding: 10px 14px;
border-radius: 10px;
background: #121826;
}
</style>
</head>
<body>
<main class="card">
<h1>Dziękujemy za Rezerwację</h1>
<p>Wiadomość ze szczegółami wysłana na adres <span class="email" id="emailValue">podany w formularzu</span></p>
<div class="actions">
<a href="/">Wróć na stronę główną</a>
</div>
</main>
<script>
const params = new URLSearchParams(window.location.search);
const email = (params.get("email") || "").trim();
if (email) {
document.getElementById("emailValue").textContent = email;
}
</script>
</body>
</html>
SECURITY_REVIEW.md (lines 1–27)
- Repository
- wynajem_motorowek
- Path
SECURITY_REVIEW.md
# Portfolio security review
This repository is a sanitized portfolio copy of a pre-production prototype.
Changes made before publication/share:
- removed the original SMTP configuration and password,
- removed the original SQLite database containing test reservations/contact data,
- removed `node_modules`,
- stopped serving the entire project directory as public static files,
- removed default `admin/admin` owner credentials,
- replaced browser-side storage of the owner password with a short-lived server session token,
- changed cancellation to require both order number and cancellation token,
- strengthened cancellation token generation,
- restricted simulated online payment endpoints behind `ENABLE_TEST_PAYMENTS=true`,
- made CORS opt-in rather than open by default.
Remaining pre-production limitations:
- no real online payment provider is connected,
- refund handling is an adapter stub,
- in-memory owner sessions disappear on server restart,
- rate limiting is minimal and in-memory,
- the availability check and reservation insert are not protected by a database-level exclusion/transaction strategy for high concurrency,
- customer and invoice data are stored in local SQLite without application-level encryption.
These limitations are intentionally documented rather than presented as production-ready behavior.