const { useState } = React;
/* Mobile-first — single column, ≤420px content width.
Bottom tab bar instead of top nav. Sticky bottom CTAs.
Tap targets ≥48px; primary CTA 56px. */
function TopBar({ title, onBack }) {
return (
{onBack ? (
←
) : (
)}
{onBack ? (
{title}
) : (
<>
Zookoutek
u Nováčků
>
)}
);
}
function TabBar({ view, setView }) {
const tabs = [
['catalog', '🧭', 'Trasy'],
['shop', '🛒', 'Obchod'],
['about', '🏡', 'O nás'],
];
return (
{tabs.map(([k, e, l]) => (
setView(k)} style={{
flex: 1, minHeight: 56, background: 'transparent', border: 'none',
display: 'flex', flexDirection: 'column', alignItems: 'center',
gap: 2, padding: '8px 4px', cursor: 'pointer', fontFamily: 'inherit',
color: view === k ? '#5a3a1e' : '#8a6a4a',
}}>
{e}
{l}
))}
);
}
/* Strukturované filtry — 4 dimenze odpovídající backend modelu:
x_region | x_age (small/big/adult) | x_theme | x_difficulty
Mobilně: kompaktní řádek s počtem aktivních + sheet pro detailní výběr.
Stavem je objekt { region, age, theme, difficulty, sort }. */
const REGIONS = [
['jihomoravsky','Jihomoravský'], ['praha','Praha'], ['stredocesky','Středočeský'],
['vysocina','Vysočina'], ['moravskoslezsky','Moravskoslezský'], ['plzensky','Plzeňský'],
['karlovarsky','Karlovarský'], ['ustecky','Ústecký'], ['liberecky','Liberecký'],
['kralovehradecky','Královéhradecký'], ['pardubicky','Pardubický'], ['jihocesky','Jihočeský'],
['olomoucky','Olomoucký'], ['zlinsky','Zlínský'],
];
const AGES = [['small','3–6'], ['big','6–12'], ['adult','12+']];
const THEMES = [
['priroda','🌿 Příroda'], ['pohadka','🧚 Pohádka'], ['dobrodruzstvi','🗺️ Dobrodružství'],
['vzdelavani','📖 Vzdělávání'], ['historie','🏰 Historie'], ['mesto','🏙️ Město'], ['sport','🏃 Sport'],
];
const DIFFS = [['easy','🟢 Lehká'], ['medium','🟡 Střední'], ['hard','🔴 Těžká']];
const SORTS = [['nearby','📍 Nejbližší'], ['newest','🆕 Nejnovější'], ['rated','⭐ Nejlépe hodnocené'], ['short','🚶 Nejkratší']];
function countActive(f) {
return (f.region ? 1 : 0) + (f.age ? 1 : 0) + (f.theme ? 1 : 0) + (f.difficulty ? 1 : 0);
}
function FilterBar({ filter, setFilter, onOpenSheet, onOpenMap, mapMode }) {
const n = countActive(filter);
const pillBase = {
flex: '0 0 auto', height: 38, padding: '0 14px', borderRadius: 999,
fontSize: 13, fontWeight: 700, cursor: 'pointer', fontFamily: 'inherit',
whiteSpace: 'nowrap', display: 'inline-flex', alignItems: 'center', gap: 6,
};
const sortLabel = (SORTS.find(s => s[0] === filter.sort) || SORTS[0])[1];
return (
⚙️ Filtry{n > 0 && {n} }
🗺️ Mapa
setFilter({ ...filter, sort: filter.sort === 'rated' ? 'nearby' : 'rated' })}
style={{ ...pillBase, background: '#fff', color: '#5a3a1e', border: '1px solid #d4c4a8' }}>
↕️ {sortLabel}
{AGES.map(([k, l]) => (
setFilter({ ...filter, age: filter.age === k ? null : k })} style={{
...pillBase, background: filter.age === k ? '#ff8c42' : '#fff',
color: filter.age === k ? '#fff' : '#5a3a1e',
border: `1px solid ${filter.age === k ? '#ff8c42' : '#d4c4a8'}`,
}}>👶 {l}
))}
);
}
function FilterSheet({ filter, setFilter, onClose }) {
const [draft, setDraft] = useState(filter);
const set = (k, v) => setDraft(p => ({ ...p, [k]: p[k] === v ? null : v }));
return (
e.stopPropagation()} style={{
background: '#f8f5f0', width: '100%', maxWidth: 480, margin: '0 auto',
borderTopLeftRadius: 22, borderTopRightRadius: 22,
padding: 18, paddingBottom: 'calc(18px + env(safe-area-inset-bottom))',
maxHeight: '85vh', overflowY: 'auto', boxShadow: '0 -8px 24px rgba(90,58,30,.18)',
}}>
Filtry
setDraft({ region: null, age: null, theme: null, difficulty: null, sort: draft.sort })}
style={{ marginLeft: 'auto', background: 'transparent', border: 'none', color: '#8a6a4a', fontSize: 13, fontWeight: 700, fontFamily: 'inherit', cursor: 'pointer' }}>Zrušit vše
{REGIONS.map(([k, l]) => (
set('region', k)}>📍 {l}
))}
{AGES.map(([k, l]) => (
set('age', k)}>👶 {l} let
))}
{THEMES.map(([k, l]) => (
set('theme', k)}>{l}
))}
{DIFFS.map(([k, l]) => (
set('difficulty', k)}>{l}
))}
{SORTS.map(([k, l]) => (
setDraft(p => ({ ...p, sort: k }))}>{l}
))}
Zavřít
{ setFilter(draft); onClose(); }} style={{
flex: 1, height: 52, background: '#ff8c42', color: '#fff',
border: 'none', borderRadius: 16, fontSize: 16, fontWeight: 800, fontFamily: 'inherit', cursor: 'pointer',
}}>Použít{countActive(draft) > 0 && ` (${countActive(draft)})`}
);
}
function FilterGroup({ title, hint, children }) {
return (
{title}
{hint && {hint}}
{children}
);
}
function ChipBtn({ active, onClick, children }) {
return (
{children}
);
}
function MapView({ trails, onPick, onClose }) {
// Leaflet + Mapy.cz outdoor (same tiles as engine.js)
const containerRef = React.useRef(null);
const mapRef = React.useRef(null);
React.useEffect(() => {
if (!containerRef.current) return;
if (mapRef.current) { try { mapRef.current.remove(); } catch(e){} mapRef.current = null; }
function init() {
if (!window.L) { setTimeout(init, 200); return; }
const points = trails.filter(t => t.lat && t.lng);
const m = window.L.map(containerRef.current, { zoomControl: true, scrollWheelZoom: true })
.setView([49.5, 16.5], 7);
window.L.tileLayer(
'https://api.mapy.cz/v1/maptiles/outdoor/256/{z}/{x}/{y}?apikey=nyEN0DoYhZat_CdtIHZ0jnCBq2vqvxOKd0lepCZpPjA',
{ attribution: '\u00a9 Mapy.cz', maxZoom: 19 }
).addTo(m);
const bounds = [];
points.forEach(t => {
const html = '' + (t.locked ? '🔒' : t.emoji) + '
';
const icon = window.L.divIcon({ html, className: '', iconSize: [52, 52], iconAnchor: [23, 46] });
const marker = window.L.marker([t.lat, t.lng], { icon }).addTo(m);
const safe = (s) => String(s || '').replace(/[<>"']/g, c => ({ '<':'<','>':'>','"':'"',"'":''' }[c]));
marker.bindPopup(
'' + safe(t.emoji + ' ' + t.title) + '
' +
'' + safe(t.place || '') + '
' +
'' + (t.locked ? '🔒 Detail' : '▶ Otevřít') + ' '
);
marker.on('click', () => onPick(t));
bounds.push([t.lat, t.lng]);
});
if (bounds.length > 1) m.fitBounds(bounds, { padding: [30, 30] });
else if (bounds.length === 1) m.setView(bounds[0], 13);
mapRef.current = m;
}
init();
return () => { if (mapRef.current) { try { mapRef.current.remove(); } catch(e){} mapRef.current = null; } };
}, [trails]);
return (
Klepněte na pin pro detail trasy
);
}
function TrailCard({ trail, onOpen }) {
return (
!trail.locked && onOpen()}
style={{
background: '#fff', border: '1px solid #d4c4a8', borderRadius: 14,
overflow: 'hidden', boxShadow: '0 1px 2px rgba(90,58,30,.06)',
cursor: trail.locked ? 'default' : 'pointer',
opacity: trail.locked ? 0.6 : 1,
}}>
{trail.locked ? '🔒' : trail.emoji}
{trail.title}
{trail.place}
{trail.locked
? 🔒 Brzy
: trail.meta.map((m, i) => {m} )}
);
}
const pillStyle = (bg) => ({
display: 'inline-flex', alignItems: 'center', gap: 4,
height: 26, padding: '0 10px', background: bg,
border: '1px solid #d4c4a8', borderRadius: 999,
fontSize: 12, fontWeight: 700, color: '#8a6a4a',
});
function CatalogView({ onOpenTrail }) {
const [filter, setFilter] = useState({ region: 'jihomoravsky', age: null, theme: null, difficulty: null, sort: 'nearby' });
const [sheetOpen, setSheetOpen] = useState(false);
const [mapMode, setMapMode] = useState(false);
// Live data from Odoo backend (window.__zooTrails is set by QWeb template)
const REGION_LABELS = { jihomoravsky:'Jihomoravský', vysocina:'Vysočina', stredocesky:'Středočeský', praha:'Praha', moravskoslezsky:'Moravskoslezský', plzensky:'Plzeňský', karlovarsky:'Karlovarský', ustecky:'Ústecký', liberecky:'Liberecký', kralovehradecky:'Královéhradecký', pardubicky:'Pardubický', jihocesky:'Jihočeský', olomoucky:'Olomoucký', zlinsky:'Zlínský' };
const _src = (window.__zooTrails && window.__zooTrails.length) ? window.__zooTrails : [
{ id: 'demo', slug: 'demo', emoji: '🦙', bg: '#f5ead8', title: 'Demo trasa', place: 'Bosonohy · 1,8 km',
region: 'jihomoravsky', ages: ['small','big'], theme: 'priroda', difficulty: 'easy',
meta: ['🚶 1,8 km', '⏱️ 45 min', '🏁 6'], state: 'published', lat: 49.19, lng: 16.52, url: '/za-cecilem' },
];
const trails = _src.map(t => ({
id: t.id,
slug: t.slug || ('g' + t.id),
emoji: t.emoji || '🧭',
bg: t.bg || t.color_bg || '#f5ead8',
title: t.title || t.name || 'Bez názvu',
place: t.place || ((t.location || '') + (t.distance_km ? ' · ' + t.distance_km + ' km' : '')),
region_label: REGION_LABELS[t.region] || t.region || '',
region_key: t.region || '',
ages: t.ages || [],
theme: t.theme || '',
difficulty: t.difficulty || '',
meta: (t.meta && t.meta.length) ? t.meta : [
t.distance_km ? ('🚶 ' + t.distance_km + ' km') : null,
t.time_min ? ('⏱️ ' + t.time_min + ' min') : null,
t.station_count ? ('🏁 ' + t.station_count) : null,
(t.rating && t.rating_count) ? ('⭐ ' + (typeof t.rating === 'number' ? t.rating.toFixed(1) : t.rating)) : null,
].filter(Boolean),
locked: t.state === 'draft' || !!t.locked,
state: t.state || 'published',
lat: t.lat || t.center_lat || 0,
lng: t.lng || t.center_lng || 0,
url: t.url || ('/' + (t.slug || ('g' + t.id))),
}));
const matches = (t) => {
if (t.locked) return true;
if (filter.region && t.region_key !== filter.region) return false;
if (filter.age && !t.ages.includes(filter.age)) return false;
if (filter.theme && t.theme !== filter.theme) return false;
if (filter.difficulty && t.difficulty !== filter.difficulty) return false;
return true;
};
const visible = trails.filter(matches);
const count = visible.filter(t => !t.locked).length;
return (
Trasy
Najděte trasu pro vaši rodinu. U každého stanoviště krátký příběh a jeden úkol.
setSheetOpen(true)}
onOpenMap={() => setMapMode(m => !m)} mapMode={mapMode}/>
{!mapMode && (
{count} {count === 1 ? 'trasa' : count < 5 ? 'trasy' : 'tras'} {filter.region && `· ${REGION_LABELS[filter.region] || filter.region}`}
)}
{mapMode
? setMapMode(false)}/>
: (
{visible.length === 0 && (
🦙
Žádná trasa neodpovídá
Zkuste uvolnit filtry.
)}
{visible.map(t =>
onOpenTrail(t)} />)}
)}
{sheetOpen && setSheetOpen(false)}/>}
);
}
function TrailDetailView({ trail, onBack, onStart }) {
return (
{trail.emoji}
{trail.title}
{trail.place}
{(trail.meta && trail.meta.length ? trail.meta : ['🚶 1,8 km', '⏱️ 45 min', '🏁 6', '⭐ 4,8', '👶 3–12']).map((m, i) => (
{m}
))}
{trail.description ? (
{trail.description}
) : null}
Stanoviště
{(() => {
const allStations = window.__zooStations || {};
const stations = (allStations[trail.id] || []).slice().sort((a, b) => (a.num || 0) - (b.num || 0));
const active = stations.filter(s => s.active !== false);
const drafts = stations.filter(s => s.active === false);
if (stations.length === 0) {
return (
🌱
Tato trasa zatím nemá stanoviště
{trail.locked ? 'Připravujeme.' : 'Přidejte první stanoviště v gps-admin režimu.'}
);
}
return (
<>
{active.length > 0 && (
{active.map((s, i) => (
{s.num || (i + 1)}
{s.emoji ? {s.emoji} : null}
{s.title || ('Stanoviště ' + s.num)}
🎧
))}
)}
{drafts.length > 0 && (
📝 Surové body z průzkumu ({drafts.length})
Tyto body teprve čekají na zpracování. Editor z nich vytvoří finální stanoviště s příběhem a úkoly.
{drafts.slice(0, 12).map((s) => (
{s.num}
{s.note ? s.note.substring(0, 140) + (s.note.length > 140 ? '…' : '') : (s.title || ('Bod ' + s.num))}
))}
{drafts.length > 12 && (
… a dalších {drafts.length - 12}
)}
)}
>
);
})()}
🔗
{trail.locked ? (
trail.survey_url ? (
🧭 Pomoci s průzkumem
) : (
🔒 Brzy k dispozici
)
) : (
▶ Začít hrát
)}
);
}
function ShopView() {
const products = [
{ emoji: '🍯', title: 'Med z Bosonoh', price: '180 Kč', sub: '450 g · lipový' },
{ emoji: '🧀', title: 'Kozí sýr', price: '140 Kč', sub: '200 g · čerstvý' },
{ emoji: '🥚', title: 'Vejce z farmy', price: '90 Kč', sub: '10 ks · domácí' },
{ emoji: '🧴', title: 'Mýdlo s mlékem', price: '120 Kč', sub: '100 g · ručně' },
];
return (
Obchod
Pár věcí přímo z našeho zookoutku. Sběrné místo v Bosonohách, zasíláme i poštou.
{products.map((p, i) => (
{p.emoji}
))}
);
}
function AboutView() {
return (
O nás
Zookoutek u Nováčků je malý rodinný zookoutek v Brně-Bosonohách. Staráme se o pár kravek, kozy, slepice, lamu a další zvířata. Děláme také procházkové trasy pro rodiny s dětmi.
Jsme dobrovolnický projekt. Když něco funguje divně, napište nám.
Kontakt
📍 Bosonohy, Brno
✉️ ahoj@zookoutekunovacku.cz
🌐 zookoutekunovacku.cz
);
}
function CatalogApp() {
const [view, setView] = useState('catalog');
const [trail, setTrail] = useState(null);
const inDetail = view === 'catalog' && trail;
return (
setTrail(null) : null}
/>
{view === 'catalog' && !trail && setTrail(t)} />}
{view === 'catalog' && trail && setTrail(null)} onStart={() => { window.location.href = trail.url; }} />}
{view === 'shop' && }
{view === 'about' && }
{ setView(v); setTrail(null); }} />
);
}
ReactDOM.createRoot(document.getElementById('root')).render( );