// app.jsx — main shell, router, language + tweaks state // ─── Per-route SEO: [title, meta description] by language + page ─────── // Hash routing means Google indexes one URL, but updating these still gives // each view a distinct, keyword-targeted title/description in the SERP, // social shares, and browser tab as the guest navigates. const SEO = { pl: { home: ["Long Street Aparthostel — pokoje w centrum Krakowa, ul. Długa", "Kameralny aparthostel przy ul. Długiej 43 w Krakowie. Pokoje Standard i Komfort z prywatną łazienką, wspólną kuchnią i samodzielnym zameldowaniem. Rezerwuj online."], rooms: ["Pokoje Standard i Komfort — Long Street Aparthostel Kraków", "Dwa pokoje Standard 15 m² i dwa Komfortowe 23 m², każdy z prywatną łazienką i dostępem do wspólnej kuchni. Kraków, Stare Miasto, ul. Długa."], "room-detail": ["Pokój — Long Street Aparthostel, Kraków", "Pokój z prywatną łazienką w centrum Krakowa, przy ul. Długiej. Wspólna kuchnia, samodzielne zameldowanie. Rezerwuj bezpośrednio online."], gallery: ["Galeria — Long Street Aparthostel, Kraków Stare Miasto", "Zdjęcia pokoi, wnętrz i okolicy Long Street Aparthostel przy ul. Długiej w Krakowie — kilka minut od Rynku Głównego i Wawelu."], reservation: ["Rezerwacja online — Long Street Aparthostel Kraków", "Zarezerwuj pokój bezpośrednio online z natychmiastowym potwierdzeniem. Płatność Przelewy24 lub przelew Revolut. Ul. Długa, Kraków."], contact: ["Kontakt — Long Street Aparthostel, ul. Długa 43, Kraków", "Kontakt: ul. Długa 43/6, 31-047 Kraków, tel. +48 602 321 838. Mapa dojazdu i formularz kontaktowy."], }, en: { home: ["Long Street Aparthostel — rooms in central Kraków, ul. Długa", "A small aparthostel on Długa Street in Kraków's Old Town. Standard and Comfort rooms with private bathrooms, a shared kitchen and self check-in. Book online."], rooms: ["Standard & Comfort rooms — Long Street Aparthostel Kraków", "Two 15 m² Standard rooms and two 23 m² Comfort rooms, each with a private bathroom and shared kitchen access. Old Town, Długa Street, Kraków."], "room-detail": ["Room — Long Street Aparthostel, Kraków", "A private-bathroom room in central Kraków on Długa Street. Shared kitchen, self check-in. Book direct online with instant confirmation."], gallery: ["Gallery — Long Street Aparthostel, Kraków Old Town", "Photos of the rooms, interiors and neighbourhood of Long Street Aparthostel on Długa Street — minutes from the Main Square and Wawel."], reservation: ["Book online — Long Street Aparthostel Kraków", "Book a room directly online with instant confirmation. Pay via Przelewy24 or Revolut transfer. Długa Street, Kraków."], contact: ["Contact — Long Street Aparthostel, ul. Długa 43, Kraków", "Contact: ul. Długa 43/6, 31-047 Kraków, +48 602 321 838. Directions map and contact form."], }, }; function seoTag(kind, idAttr, idVal) { let el = document.head.querySelector(`${kind}[${idAttr}="${idVal}"]`); if (!el) { el = document.createElement(kind); el.setAttribute(idAttr, idVal); document.head.appendChild(el); } return el; } function applySeo(lang, page, params) { const table = SEO[lang] || SEO.pl; let [title, desc] = table[page] || table.home; // Per-room title/description so each /rooms/ is its own indexable page. if (page === "room-detail") { const cards = (window.I18N[lang] || window.I18N.pl).rooms.cards; const card = cards[(params && params.roomIdx) || 0] || cards[0]; title = `${card.name} — Long Street Aparthostel, Kraków`; desc = card.desc || desc; } const O = window.SITE_ORIGIN; const plUrl = O + window.routeToPath(page, params, "pl"); const enUrl = O + window.routeToPath(page, params, "en"); const selfUrl = lang === "en" ? enUrl : plUrl; document.documentElement.setAttribute("lang", lang); if (title) document.title = title; seoTag("meta", "name", "description").setAttribute("content", desc || ""); seoTag("meta", "property", "og:title").setAttribute("content", title || ""); seoTag("meta", "property", "og:description").setAttribute("content", desc || ""); seoTag("meta", "property", "og:url").setAttribute("content", selfUrl); seoTag("meta", "property", "og:locale").setAttribute("content", lang === "en" ? "en_US" : "pl_PL"); seoTag("meta", "property", "og:locale:alternate").setAttribute("content", lang === "en" ? "pl_PL" : "en_US"); seoTag("link", "rel", "canonical").setAttribute("href", selfUrl); // hreflang alternates so Google serves the right language and avoids duplicate-content. const alt = (hl, href) => { const e = seoTag("link", "hreflang", hl); e.setAttribute("rel", "alternate"); e.setAttribute("href", href); }; alt("pl", plUrl); alt("en", enUrl); alt("x-default", plUrl); } function App() { // ─── Tweaks (persisted) ────────────────────────────────────────────── const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ "hero": "editorial", "palette": "ivory", "type": "cormorant", "currency": "PLN", "sectionOrder": "default", "bgOverride": "", "inkOverride": "", "accentOverride": "" }/*EDITMODE-END*/; const [tweaks, setTweak] = useTweaks(TWEAK_DEFAULTS); // ─── Apply palette + type via data attrs on html ───────────────────── useEffect(() => { document.documentElement.setAttribute("data-palette", tweaks.palette || "warmth"); document.documentElement.setAttribute("data-type", tweaks.type || "cormorant"); }, [tweaks.palette, tweaks.type]); // ─── Reset color overrides when palette changes ────────────────────── const prevPaletteRef = useRef(tweaks.palette); useEffect(() => { if (prevPaletteRef.current !== tweaks.palette) { setTweak({ bgOverride: "", inkOverride: "", accentOverride: "" }); prevPaletteRef.current = tweaks.palette; } }, [tweaks.palette]); // ─── Apply per-color overrides as inline custom properties ─────────── useEffect(() => { const el = document.documentElement; if (tweaks.bgOverride) { el.style.setProperty("--bg", tweaks.bgOverride); el.style.setProperty("--bg-2", shadeHex(tweaks.bgOverride, -6)); el.style.setProperty("--bg-3", shadeHex(tweaks.bgOverride, -14)); } else { ["--bg", "--bg-2", "--bg-3"].forEach((p) => el.style.removeProperty(p)); } if (tweaks.inkOverride) { el.style.setProperty("--ink", tweaks.inkOverride); } else { el.style.removeProperty("--ink"); } if (tweaks.accentOverride) { el.style.setProperty("--accent", tweaks.accentOverride); el.style.setProperty("--accent-2", shadeHex(tweaks.accentOverride, -14)); el.style.setProperty("--on-accent", isLightHex(tweaks.accentOverride) ? "#1A1B1E" : "#F8F8F7"); } else { ["--accent", "--accent-2", "--on-accent"].forEach((p) => el.style.removeProperty(p)); } }, [tweaks.bgOverride, tweaks.inkOverride, tweaks.accentOverride]); // ─── Router (real URL paths via the History API) ───────────────────── const [route, setRoute] = useState(() => window.pathToRoute(window.location.pathname)); const navigate = (page, params = {}, lng) => { const lang = lng || route.lang; setRoute({ page, params, lang }); window.history.pushState({}, "", window.routeToPath(page, params, lang)); window.scrollTo({ top: 0, behavior: "smooth" }); }; useEffect(() => { const onPop = () => setRoute(window.pathToRoute(window.location.pathname)); window.addEventListener("popstate", onPop); return () => window.removeEventListener("popstate", onPop); }, []); // ─── Language derives from the URL (/en/* = English) ───────────────── const lang = route.lang; const t = window.I18N[lang] || window.I18N.pl; const setLang = (nl) => { try { localStorage.setItem("dluga.lang", nl); } catch (e) {} navigate(route.page, route.params, nl); // switch to the same page in the other language }; useEffect(() => { track("page_view", { page_path: window.routeToPath(route.page, route.params), page_title: route.page }); }, [route.page, route.params]); // Keep , meta description, canonical and OG tags in sync per route/lang useEffect(() => { applySeo(lang, route.page, route.params); }, [route.page, route.params, lang]); // ─── Context value ─────────────────────────────────────────────────── const ctx = { lang, setLang, t, tweaks, setTweak, currency: tweaks.currency || "PLN", page: route.page, route, navigate, }; const heroFullBleed = tweaks.hero === "fullbleed" && route.page === "home"; return ( <AppCtx.Provider value={ctx}> <Header transparent={heroFullBleed} /> <main> {route.page === "home" && <HomePage />} {route.page === "rooms" && <RoomsPage />} {route.page === "room-detail" && <RoomDetailPage />} {route.page === "gallery" && <GalleryPage />} {route.page === "contact" && <ContactPage />} {route.page === "reservation" && <ReservationPage />} </main> <Footer /> <TweaksPanelWrapper t={t} tweaks={tweaks} setTweak={setTweak} lang={lang} /> </AppCtx.Provider> ); } // ─── Tweaks panel ───────────────────────────────────────────────────── function TweaksPanelWrapper({ t, tweaks, setTweak, lang }) { return ( <TweaksPanel> <TweakSection label={lang === "pl" ? "Hero" : "Hero"} /> <TweakRadio label={lang === "pl" ? "Styl" : "Style"} value={tweaks.hero} options={[ { value: "editorial", label: "Editorial" }, { value: "fullbleed", label: lang === "pl" ? "Pełen" : "Bleed" }, { value: "split", label: "Split" }, ]} onChange={(v) => setTweak("hero", v)} /> <TweakSection label={lang === "pl" ? "Wygląd" : "Appearance"} /> <TweakRow label={lang === "pl" ? "Paleta" : "Palette"}> <PalettePicker value={tweaks.palette} options={[ { value: "warmth", colors: ["#F8F8F7", "#1F1612", "#550B14"] }, { value: "steel", colors: ["#FFFFFF", "#1A1B1E", "#BB7A3C"] }, { value: "ivory", colors: ["#FAF7F2", "#1F2D24", "#B89368"] }, { value: "sage", colors: ["#F5F2EA", "#1F2A22", "#6B8369"] }, { value: "olive", colors: ["#F4F2EB", "#1E2118", "#6F7340"] }, { value: "navy", colors: ["#F4F2EC", "#131A26", "#1F3A5E"] }, { value: "cobalt", colors: ["#F6F7F9", "#0F1419", "#2D55A8"] }, { value: "plum", colors: ["#F2EDE6", "#2E1A2A", "#8B5A6B"] }, { value: "oxblood", colors: ["#F6F1EA", "#1B1318", "#7C2A33"] }, { value: "slate", colors: ["#EEEDE8", "#1E2329", "#C6873A"] }, { value: "charcoal", colors: ["#15120E", "#EFE6D9", "#C7A77A"] }, ]} onChange={(v) => setTweak("palette", v)} /> </TweakRow> <TweakRadio label={lang === "pl" ? "Typografia" : "Typography"} value={tweaks.type} options={[ { value: "cormorant", label: "Cormorant" }, { value: "fraunces", label: "Fraunces" }, { value: "sans", label: "Sans" }, ]} onChange={(v) => setTweak("type", v)} /> <TweakSection label={lang === "pl" ? "Własne kolory" : "Custom colors"} /> <ColorOverride label={lang === "pl" ? "Tło" : "Background"} varName="--bg" paletteKey={tweaks.palette} value={tweaks.bgOverride} onChange={(v) => setTweak("bgOverride", v || "")} /> <ColorOverride label={lang === "pl" ? "Tekst" : "Text"} varName="--ink" paletteKey={tweaks.palette} value={tweaks.inkOverride} onChange={(v) => setTweak("inkOverride", v || "")} /> <ColorOverride label={lang === "pl" ? "Akcent" : "Accent"} varName="--accent" paletteKey={tweaks.palette} value={tweaks.accentOverride} onChange={(v) => setTweak("accentOverride", v || "")} /> <TweakSection label={lang === "pl" ? "Waluta" : "Currency"} /> <TweakRadio label={lang === "pl" ? "Waluta" : "Currency"} value={tweaks.currency} options={["PLN", "EUR", "USD"]} onChange={(v) => setTweak("currency", v)} /> </TweaksPanel> ); } // ─── Color helpers ─────────────────────────────────────────────────── function shadeHex(hex, percent) { // percent: -100..+100 (negative = darker) const h = hex.replace("#", ""); const num = parseInt(h.length === 3 ? h.split("").map((c) => c + c).join("") : h, 16); let r = (num >> 16) + Math.round(percent * 2.55); let g = ((num >> 8) & 0xff) + Math.round(percent * 2.55); let b = (num & 0xff) + Math.round(percent * 2.55); r = Math.max(0, Math.min(255, r)); g = Math.max(0, Math.min(255, g)); b = Math.max(0, Math.min(255, b)); return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1); } function isLightHex(hex) { const h = hex.replace("#", ""); const num = parseInt(h.length === 3 ? h.split("").map((c) => c + c).join("") : h, 16); const r = num >> 16, g = (num >> 8) & 0xff, b = num & 0xff; return (0.299 * r + 0.587 * g + 0.114 * b) > 150; } function rgbStringToHex(rgb) { if (!rgb) return "#000000"; rgb = rgb.trim(); if (rgb.startsWith("#")) { return rgb.length === 4 ? "#" + rgb.slice(1).split("").map((c) => c + c).join("") : rgb; } const m = rgb.match(/rgba?\((\d+)[ ,]+(\d+)[ ,]+(\d+)/); if (!m) return "#000000"; return "#" + [m[1], m[2], m[3]].map((n) => parseInt(n).toString(16).padStart(2, "0")).join(""); } // ─── ColorOverride control ──────────────────────────────────────────── function ColorOverride({ label, varName, paletteKey, value, onChange }) { // When no override, resolve effective value from the live CSS var const [effective, setEffective] = useState(value || "#000000"); useEffect(() => { if (value) { setEffective(value); } else { // Read the palette value off documentElement // Need to temporarily clear inline override (if any) — but the parent already does that const v = getComputedStyle(document.documentElement).getPropertyValue(varName).trim(); setEffective(rgbStringToHex(v)); } }, [value, varName, paletteKey]); return ( <div className="twk-row twk-row-h"> <div className="twk-lbl"><span>{label}</span></div> <div style={{ display: "flex", gap: 6, alignItems: "center" }}> <input type="color" value={effective} onChange={(e) => onChange(e.target.value)} style={{ width: 32, height: 22, padding: 0, border: "1px solid rgba(0,0,0,.15)", borderRadius: 4, cursor: "pointer", background: "none", }} /> <span style={{ fontSize: 9.5, color: "rgba(0,0,0,.5)", fontFamily: "ui-monospace, monospace", minWidth: 56 }}> {effective.toUpperCase()} </span> {value && ( <button type="button" onClick={() => onChange("")} title="Reset to palette" style={{ border: 0, background: "rgba(0,0,0,.06)", borderRadius: 4, width: 18, height: 18, fontSize: 10, color: "rgba(0,0,0,.55)", cursor: "pointer", padding: 0, display: "grid", placeItems: "center", }} > ↻ </button> )} </div> </div> ); } // Custom palette picker — like TweakColor but with named values function PalettePicker({ value, options, onChange }) { return ( <div style={{ display: "grid", gridTemplateColumns: "repeat(5, 1fr)", gap: 6 }}> {options.map((opt) => { const active = opt.value === value; const [bg, ink, accent] = opt.colors; return ( <button key={opt.value} type="button" onClick={() => onChange(opt.value)} title={opt.value} style={{ aspectRatio: "1", border: active ? "2px solid rgba(0,0,0,.6)" : "1px solid rgba(0,0,0,.12)", borderRadius: 6, background: bg, padding: 0, position: "relative", overflow: "hidden", cursor: "pointer", }} > <span style={{ position: "absolute", inset: "40% 8% 8% 40%", background: ink, borderRadius: 3 }} /> <span style={{ position: "absolute", inset: "60% 50% 8% 8%", background: accent, borderRadius: 3 }} /> </button> ); })} </div> ); } ReactDOM.createRoot(document.getElementById("root")).render(<App />);