From 3e452191de87beea384fe3e15783fed24ebb59d5 Mon Sep 17 00:00:00 2001 From: oskar Date: Fri, 31 Jul 2026 15:54:49 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20wyb=C3=B3r=20j=C4=99zyka=20przez=20URL?= =?UTF-8?q?=20(/en)=20=E2=80=94=20reaktywny=20hook,=20prze=C5=82=C4=85czni?= =?UTF-8?q?k,=20hreflang?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Język wynikał z navigator.language liczonego przy każdym renderze, więc nie dało się go wybrać ani zalinkować. Teraz nośnikiem języka jest ścieżka. - src/router.ts: usePathname() na useSyncExternalStore + navigate(). pushState nie emituje zdarzenia, więc bez tego React nie widzi zmiany URL-a. - i18n: useLang(), localizePath(), stripLangPrefix(), setLang(). useT() czyta język ze ścieżki i jest reaktywne na nawigację. - Detekcja przeglądarki tylko przy pierwszym wejściu na "/" i tylko gdy w localStorage nic nie ma; wynik zapisujemy od razu, więc przekierowanie na /en zadziała najwyżej raz i nie nadpisze późniejszego wyboru użytkownika. Deep linki honorujemy bez ruszania. - App.tsx: jedna tablica tras dla obu języków (/en/privacy -> '/privacy'). Ustawia też document.documentElement.lang, bo index.html ma na sztywno "pl". - Header: przełącznik PL/EN (desktop + mobile), + + ))} + + ); +}; 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 (
- + humanAI - + ← Wróć na stronę główną
@@ -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)); +}