diff --git a/index.html b/index.html index d923f0d..d982e35 100644 --- a/index.html +++ b/index.html @@ -7,9 +7,16 @@ humanAI — ludzka strona AI + + + + + - + diff --git a/src/App.tsx b/src/App.tsx index d6bcd68..5a931c7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,9 +10,23 @@ import CTASection from './components/CTASection'; import Footer from './components/Footer'; import PrivacyPolicyPage from './components/PrivacyPolicyPage'; import PlatformPage from './components/PlatformPage'; +import { useLang, stripLangPrefix } from './i18n'; +import { usePathname } from './router'; function App() { - const path = window.location.pathname.replace(/\/$/, '') || '/'; + // Reactive, so the language switcher's pushState re-renders the tree instead + // of leaving a stale page under a changed URL. + const pathname = usePathname(); + const lang = useLang(); + // One route table for both languages: /en/privacy and /privacy both match + // '/privacy' here. + const route = stripLangPrefix(pathname); + + // index.html ships lang="pl"; keep the document honest on the English routes + // (screen readers, translation prompts, search engines all read this). + useEffect(() => { + document.documentElement.lang = lang; + }, [lang]); // The landing sections are client-rendered, so when a /#section URL arrives // with the initial request — following "Oferta"/"Kontakt" from /privacy or @@ -22,19 +36,19 @@ function App() { // navigations that the browser handles natively (smoothly, via .scroll-smooth) // without remounting App. useEffect(() => { - if (path !== '/') return; + if (route !== '/') return; const id = window.location.hash.slice(1); if (!id) return; // Jump rather than smooth-scroll: this only runs on a fresh load, where // animating the full page height would just be a slow detour. document.getElementById(id)?.scrollIntoView({ behavior: 'auto' }); - }, [path]); + }, [route]); - if (path === '/privacy') { + if (route === '/privacy') { return ; } - if (path === '/platforma') { + if (route === '/platforma') { return ; } diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index ec957b3..a34f890 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -1,14 +1,16 @@ import React from 'react'; +import { localizePath, stripLangPrefix, useLang } from '../i18n'; const Footer = () => { const currentYear = new Date().getFullYear(); + const lang = useLang(); // Same reasoning as the /#section nav links below: this footer is shared with // /privacy and /platforma, so the logo needs a real href to get home from // there. On the homepage that href would mean a full reload, so intercept the // click and scroll to the top instead. const handleLogoClick = (e: React.MouseEvent) => { - if (window.location.pathname !== '/') return; + if (stripLangPrefix(window.location.pathname) !== '/') return; e.preventDefault(); window.scrollTo({ top: 0, behavior: 'smooth' }); }; @@ -19,7 +21,7 @@ const Footer = () => {
- + humanAI

@@ -38,17 +40,17 @@ const Footer = () => {

diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 3c120c8..2eede96 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,8 +1,42 @@ import React, { useState, useEffect } from 'react'; import { Menu, X } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; +import { localizePath, setLang, useLang, type Lang } from '../i18n'; + +const LANGS: Lang[] = ['pl', 'en']; + +// PL / EN toggle. Buttons rather than links: switching is a client-side +// navigation (setLang), so a real href would reload the whole document. +const LangSwitcher = ({ onSwitch }: { onSwitch?: () => void }) => { + const lang = useLang(); + + return ( +
+ {LANGS.map((code, i) => ( + + {i > 0 && } + + + ))} +
+ ); +}; const Header = () => { + const lang = useLang(); const [isScrolled, setIsScrolled] = useState(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); @@ -14,13 +48,15 @@ const Header = () => { return () => window.removeEventListener('scroll', handleScroll); }, []); + // Root-relative so the links also work from the standalone pages (/privacy, + // /platforma), where these sections do not exist — and localized so they keep + // the visitor on the language they are currently reading. Labels stay Polish + // until the section copy itself is translated. const navLinks = [ - // Root-relative so the links also work from the standalone pages - // (/privacy, /platforma), where these sections do not exist. - { name: 'Oferta', href: '/#offerings' }, - { name: 'Platforma', href: '/platforma' }, - { name: 'Proces', href: '/#process' }, - { name: 'Kontakt', href: '/#contact' }, + { name: 'Oferta', href: localizePath('/#offerings', lang) }, + { name: 'Platforma', href: localizePath('/platforma', lang) }, + { name: 'Proces', href: localizePath('/#process', lang) }, + { name: 'Kontakt', href: localizePath('/#contact', lang) }, ]; return ( @@ -45,9 +81,10 @@ const Header = () => { {link.name} ))} - + Porozmawiajmy + {/* Mobile Menu Toggle */} @@ -79,13 +116,16 @@ const Header = () => { {link.name} ))} - setIsMobileMenuOpen(false)} > Porozmawiajmy +
+ setIsMobileMenuOpen(false)} /> +
)} diff --git a/src/components/PlatformPage.tsx b/src/components/PlatformPage.tsx index 09aa145..3e8076f 100644 --- a/src/components/PlatformPage.tsx +++ b/src/components/PlatformPage.tsx @@ -1,18 +1,22 @@ import React from 'react'; import { ArrowRight, Building2, CheckCircle2, ChevronRight, Home } from 'lucide-react'; import Footer from './Footer'; +import { localizePath, useLang } from '../i18n'; -// Standalone product page at /platforma. Deliberately not localized — the site -// only translates contact.* and privacy.*, everything else is Polish-only. +// Standalone product page at /platforma (and /en/platforma). The copy is not +// localized yet — the site only translates contact.* and privacy.* — but the +// links are, so reaching this page from /en does not strand you in Polish. const PlatformPage = () => { + const lang = useLang(); + return (
@@ -224,7 +228,7 @@ const PlatformPage = () => {

{'Powiedz, co chcesz zautomatyzować albo uporządkować — zobaczymy, jak to zbudować i objąć nadzorem, który sami na sobie testujemy.'}

- + Porozmawiajmy diff --git a/src/components/PrivacyPolicyPage.tsx b/src/components/PrivacyPolicyPage.tsx index 6381a0f..240f83e 100644 --- a/src/components/PrivacyPolicyPage.tsx +++ b/src/components/PrivacyPolicyPage.tsx @@ -1,18 +1,20 @@ import React from 'react'; -import { useT } from '../i18n'; +import { localizePath, useLang, useT } from '../i18n'; import Footer from './Footer'; const PrivacyPolicyPage = () => { const t = useT(); + const lang = useLang(); + const home = localizePath('/', lang); return (
diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 0288bd7..e3e1d57 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -1,7 +1,110 @@ import { pl } from './pl'; import { en } from './en'; +import { navigate, usePathname } from '../router'; -export function useT() { - const lang = navigator.language?.startsWith('pl') ? 'pl' : 'en'; - return lang === 'pl' ? pl : en; +export type Lang = 'pl' | 'en'; + +const DICTIONARIES = { pl, en } as const; + +const EN_PREFIX = '/en'; +const STORAGE_KEY = 'humanai:lang'; + +/** Trailing slashes are cosmetic here: /en/ and /en are the same route. */ +function normalize(pathname: string): string { + return pathname.replace(/\/+$/, '') || '/'; +} + +/** Language a URL asks for. Everything outside /en is Polish. */ +export function langFromPath(pathname: string): Lang { + const path = normalize(pathname); + return path === EN_PREFIX || path.startsWith(`${EN_PREFIX}/`) ? 'en' : 'pl'; +} + +/** + * The route with its language prefix removed, so the router can match one set + * of paths for both languages: '/en/privacy' and '/privacy' both give + * '/privacy'. + */ +export function stripLangPrefix(pathname: string): string { + const path = normalize(pathname); + if (path === EN_PREFIX) return '/'; + if (path.startsWith(`${EN_PREFIX}/`)) return path.slice(EN_PREFIX.length); + return path; +} + +/** + * Builds the URL of `path` in `lang`, preserving any #hash. Idempotent and + * prefix-agnostic — pass either the Polish path or an already-prefixed one: + * localizePath('/#contact', 'en') -> '/en#contact' + * localizePath('/en/privacy', 'pl') -> '/privacy' + */ +export function localizePath(path: string, lang: Lang): string { + const hashIndex = path.indexOf('#'); + const hash = hashIndex === -1 ? '' : path.slice(hashIndex); + const route = stripLangPrefix(hashIndex === -1 ? path : path.slice(0, hashIndex)); + if (lang === 'pl') return route + hash; + return (route === '/' ? EN_PREFIX : EN_PREFIX + route) + hash; +} + +function readStoredLang(): Lang | null { + try { + const stored = localStorage.getItem(STORAGE_KEY); + return stored === 'pl' || stored === 'en' ? stored : null; + } catch { + // Storage can be blocked (Safari private mode, strict cookie settings). + // Losing the preference is acceptable; throwing on page load is not. + return null; + } +} + +function storeLang(lang: Lang): void { + try { + localStorage.setItem(STORAGE_KEY, lang); + } catch { + /* see readStoredLang */ + } +} + +/** + * One-time browser-language detection, run before the first render (main.tsx). + * + * Only "/" is redirected: a deep link is an explicit URL and is honoured as + * given. The detected language is persisted immediately, so this fires at most + * once per browser — a visitor who afterwards navigates back to "/" stays on + * Polish instead of being bounced to /en against their choice. + * + * Deep links do not write the preference: a first visit to /en/privacy leaves + * "/" still eligible for detection later. + */ +export function applyInitialLangRedirect(): void { + if (normalize(window.location.pathname) !== '/') return; + if (readStoredLang()) return; + + const detected: Lang = navigator.language?.startsWith('pl') ? 'pl' : 'en'; + storeLang(detected); + if (detected === 'en') { + // replaceState, not a redirect: no reload, and no junk entry in history. + const { search, hash } = window.location; + window.history.replaceState({}, '', EN_PREFIX + search + hash); + } +} + +/** Current language, re-rendering on navigation. */ +export function useLang(): Lang { + return langFromPath(usePathname()); +} + +/** Current translations, re-rendering on navigation. */ +export function useT() { + return DICTIONARIES[useLang()]; +} + +/** + * Switches language in place: same page, other prefix. Records the choice so + * detection never overrides it, then navigates client-side. + */ +export function setLang(lang: Lang): void { + storeLang(lang); + const { pathname, search, hash } = window.location; + navigate(localizePath(pathname, lang) + search + hash); } diff --git a/src/main.tsx b/src/main.tsx index 964aeb4..ae491c6 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,8 +1,12 @@ import React from 'react' import ReactDOM from 'react-dom/client' import App from './App' +import { applyInitialLangRedirect } from './i18n' import './index.css' +// Before the first render, so a non-Polish visitor never sees Polish flash past. +applyInitialLangRedirect() + ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/src/router.ts b/src/router.ts new file mode 100644 index 0000000..5475e00 --- /dev/null +++ b/src/router.ts @@ -0,0 +1,43 @@ +import { useSyncExternalStore } from 'react'; + +// Minimal client-side navigation for this hand-rolled SPA router. The site has +// three routes (see App.tsx), so pulling in react-router would cost more than +// it saves — but the language switcher needs to change the URL without a full +// reload, and App.tsx needs to re-render when it does. That is exactly what +// history.pushState does not give us for free: it fires no event. + +const NAVIGATION_EVENT = 'humanai:navigation'; + +function subscribe(onStoreChange: () => void) { + // popstate covers back/forward; the custom event covers our own navigate(). + window.addEventListener('popstate', onStoreChange); + window.addEventListener(NAVIGATION_EVENT, onStoreChange); + return () => { + window.removeEventListener('popstate', onStoreChange); + window.removeEventListener(NAVIGATION_EVENT, onStoreChange); + }; +} + +// Returns a string, so React's identity check settles immediately — no risk of +// the "getSnapshot should be cached" loop that object snapshots cause. +const getPathnameSnapshot = () => window.location.pathname; + +/** Current pathname, re-rendering the component whenever it changes. */ +export function usePathname(): string { + return useSyncExternalStore(subscribe, getPathnameSnapshot); +} + +/** + * Client-side navigation: swaps the URL and tells usePathname subscribers, + * without the full document reload a plain would trigger. + */ +export function navigate(to: string, options: { replace?: boolean } = {}): void { + const { pathname, search, hash } = window.location; + if (to === pathname + search + hash) return; + if (options.replace) { + window.history.replaceState({}, '', to); + } else { + window.history.pushState({}, '', to); + } + window.dispatchEvent(new Event(NAVIGATION_EVENT)); +}