feat: wybór języka przez URL (/en) — reaktywny hook, przełącznik, hreflang
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), <button>, nie <a> — link przeładowałby dokument. - Hrefy w Header/Footer/PrivacyPolicyPage/PlatformPage przez localizePath, inaczej /en jest pułapką jednokierunkową: każdy klik wraca na PL. - index.html: hreflang pl/en/x-default; og:url z gethumanai.com na .pl (wskazywał na cudzą domenę). Bez nowych tłumaczeń treści — to osobny krok. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f062e23dba
commit
3e452191de
|
|
@ -7,9 +7,16 @@
|
||||||
<title>humanAI — ludzka strona AI</title>
|
<title>humanAI — ludzka strona AI</title>
|
||||||
<meta name="description" content="Strategia, automatyzacje, produkty AI i systemy multiagentowe projektowane z człowiekiem w centrum." />
|
<meta name="description" content="Strategia, automatyzacje, produkty AI i systemy multiagentowe projektowane z człowiekiem w centrum." />
|
||||||
|
|
||||||
|
<!-- Language alternates. Static: this single index.html is served for every
|
||||||
|
route, so all pages advertise the same pair. Per-page alternates would
|
||||||
|
need prerendering. -->
|
||||||
|
<link rel="alternate" hreflang="pl" href="https://gethumanai.pl/" />
|
||||||
|
<link rel="alternate" hreflang="en" href="https://gethumanai.pl/en/" />
|
||||||
|
<link rel="alternate" hreflang="x-default" href="https://gethumanai.pl/" />
|
||||||
|
|
||||||
<!-- Open Graph / Facebook -->
|
<!-- Open Graph / Facebook -->
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:url" content="https://gethumanai.com/" />
|
<meta property="og:url" content="https://gethumanai.pl/" />
|
||||||
<meta property="og:title" content="humanAI — ludzka strona AI" />
|
<meta property="og:title" content="humanAI — ludzka strona AI" />
|
||||||
<meta property="og:description" content="Strategia, automatyzacje, produkty AI i systemy multiagentowe projektowane z człowiekiem w centrum." />
|
<meta property="og:description" content="Strategia, automatyzacje, produkty AI i systemy multiagentowe projektowane z człowiekiem w centrum." />
|
||||||
|
|
||||||
|
|
|
||||||
24
src/App.tsx
24
src/App.tsx
|
|
@ -10,9 +10,23 @@ import CTASection from './components/CTASection';
|
||||||
import Footer from './components/Footer';
|
import Footer from './components/Footer';
|
||||||
import PrivacyPolicyPage from './components/PrivacyPolicyPage';
|
import PrivacyPolicyPage from './components/PrivacyPolicyPage';
|
||||||
import PlatformPage from './components/PlatformPage';
|
import PlatformPage from './components/PlatformPage';
|
||||||
|
import { useLang, stripLangPrefix } from './i18n';
|
||||||
|
import { usePathname } from './router';
|
||||||
|
|
||||||
function App() {
|
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
|
// The landing sections are client-rendered, so when a /#section URL arrives
|
||||||
// with the initial request — following "Oferta"/"Kontakt" from /privacy or
|
// 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)
|
// navigations that the browser handles natively (smoothly, via .scroll-smooth)
|
||||||
// without remounting App.
|
// without remounting App.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (path !== '/') return;
|
if (route !== '/') return;
|
||||||
const id = window.location.hash.slice(1);
|
const id = window.location.hash.slice(1);
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
// Jump rather than smooth-scroll: this only runs on a fresh load, where
|
// Jump rather than smooth-scroll: this only runs on a fresh load, where
|
||||||
// animating the full page height would just be a slow detour.
|
// animating the full page height would just be a slow detour.
|
||||||
document.getElementById(id)?.scrollIntoView({ behavior: 'auto' });
|
document.getElementById(id)?.scrollIntoView({ behavior: 'auto' });
|
||||||
}, [path]);
|
}, [route]);
|
||||||
|
|
||||||
if (path === '/privacy') {
|
if (route === '/privacy') {
|
||||||
return <PrivacyPolicyPage />;
|
return <PrivacyPolicyPage />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === '/platforma') {
|
if (route === '/platforma') {
|
||||||
return <PlatformPage />;
|
return <PlatformPage />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,16 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { localizePath, stripLangPrefix, useLang } from '../i18n';
|
||||||
|
|
||||||
const Footer = () => {
|
const Footer = () => {
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
|
const lang = useLang();
|
||||||
|
|
||||||
// Same reasoning as the /#section nav links below: this footer is shared with
|
// 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
|
// /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
|
// there. On the homepage that href would mean a full reload, so intercept the
|
||||||
// click and scroll to the top instead.
|
// click and scroll to the top instead.
|
||||||
const handleLogoClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
const handleLogoClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||||
if (window.location.pathname !== '/') return;
|
if (stripLangPrefix(window.location.pathname) !== '/') return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
};
|
};
|
||||||
|
|
@ -19,7 +21,7 @@ const Footer = () => {
|
||||||
<div className="container mx-auto px-6">
|
<div className="container mx-auto px-6">
|
||||||
<div className="grid md:grid-cols-4 gap-12 mb-12">
|
<div className="grid md:grid-cols-4 gap-12 mb-12">
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<a href="/" onClick={handleLogoClick} className="text-2xl font-bold tracking-tight mb-4 block">
|
<a href={localizePath('/', lang)} onClick={handleLogoClick} className="text-2xl font-bold tracking-tight mb-4 block">
|
||||||
human<span className="text-primary">AI</span>
|
human<span className="text-primary">AI</span>
|
||||||
</a>
|
</a>
|
||||||
<p className="text-muted max-w-xs mb-6">
|
<p className="text-muted max-w-xs mb-6">
|
||||||
|
|
@ -38,17 +40,17 @@ const Footer = () => {
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-bold mb-6 text-sm uppercase tracking-wider">Nawigacja</h4>
|
<h4 className="font-bold mb-6 text-sm uppercase tracking-wider">Nawigacja</h4>
|
||||||
<ul className="space-y-4">
|
<ul className="space-y-4">
|
||||||
<li><a href="/#offerings" className="text-muted hover:text-foreground transition-colors">Oferta</a></li>
|
<li><a href={localizePath('/#offerings', lang)} className="text-muted hover:text-foreground transition-colors">Oferta</a></li>
|
||||||
<li><a href="/platforma" className="text-muted hover:text-foreground transition-colors">Platforma</a></li>
|
<li><a href={localizePath('/platforma', lang)} className="text-muted hover:text-foreground transition-colors">Platforma</a></li>
|
||||||
<li><a href="/#contact" className="text-muted hover:text-foreground transition-colors">Kontakt</a></li>
|
<li><a href={localizePath('/#contact', lang)} className="text-muted hover:text-foreground transition-colors">Kontakt</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-bold mb-6 text-sm uppercase tracking-wider">Prywatność</h4>
|
<h4 className="font-bold mb-6 text-sm uppercase tracking-wider">Prywatność</h4>
|
||||||
<ul className="space-y-4">
|
<ul className="space-y-4">
|
||||||
<li><a href="/privacy" className="text-muted hover:text-foreground transition-colors">Polityka prywatności</a></li>
|
<li><a href={localizePath('/privacy', lang)} className="text-muted hover:text-foreground transition-colors">Polityka prywatności</a></li>
|
||||||
<li><a href="/privacy#cookies" className="text-muted hover:text-foreground transition-colors">Cookies</a></li>
|
<li><a href={localizePath('/privacy#cookies', lang)} className="text-muted hover:text-foreground transition-colors">Cookies</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,42 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Menu, X } from 'lucide-react';
|
import { Menu, X } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
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 (
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium" role="group" aria-label="Język / Language">
|
||||||
|
{LANGS.map((code, i) => (
|
||||||
|
<React.Fragment key={code}>
|
||||||
|
{i > 0 && <span aria-hidden="true" className="text-muted/40">/</span>}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
lang={code}
|
||||||
|
aria-current={code === lang ? 'true' : undefined}
|
||||||
|
onClick={() => {
|
||||||
|
setLang(code);
|
||||||
|
onSwitch?.();
|
||||||
|
}}
|
||||||
|
className={`uppercase tracking-wide transition-colors ${
|
||||||
|
code === lang ? 'text-foreground' : 'text-muted hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{code}
|
||||||
|
</button>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const Header = () => {
|
const Header = () => {
|
||||||
|
const lang = useLang();
|
||||||
const [isScrolled, setIsScrolled] = useState(false);
|
const [isScrolled, setIsScrolled] = useState(false);
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
|
@ -14,13 +48,15 @@ const Header = () => {
|
||||||
return () => window.removeEventListener('scroll', handleScroll);
|
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 = [
|
const navLinks = [
|
||||||
// Root-relative so the links also work from the standalone pages
|
{ name: 'Oferta', href: localizePath('/#offerings', lang) },
|
||||||
// (/privacy, /platforma), where these sections do not exist.
|
{ name: 'Platforma', href: localizePath('/platforma', lang) },
|
||||||
{ name: 'Oferta', href: '/#offerings' },
|
{ name: 'Proces', href: localizePath('/#process', lang) },
|
||||||
{ name: 'Platforma', href: '/platforma' },
|
{ name: 'Kontakt', href: localizePath('/#contact', lang) },
|
||||||
{ name: 'Proces', href: '/#process' },
|
|
||||||
{ name: 'Kontakt', href: '/#contact' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -45,9 +81,10 @@ const Header = () => {
|
||||||
{link.name}
|
{link.name}
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
<a href="/#contact" className="btn-primary text-sm px-5 py-2">
|
<a href={localizePath('/#contact', lang)} className="btn-primary text-sm px-5 py-2">
|
||||||
Porozmawiajmy
|
Porozmawiajmy
|
||||||
</a>
|
</a>
|
||||||
|
<LangSwitcher />
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Mobile Menu Toggle */}
|
{/* Mobile Menu Toggle */}
|
||||||
|
|
@ -80,12 +117,15 @@ const Header = () => {
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
<a
|
<a
|
||||||
href="/#contact"
|
href={localizePath('/#contact', lang)}
|
||||||
className="btn-primary text-center"
|
className="btn-primary text-center"
|
||||||
onClick={() => setIsMobileMenuOpen(false)}
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
>
|
>
|
||||||
Porozmawiajmy
|
Porozmawiajmy
|
||||||
</a>
|
</a>
|
||||||
|
<div className="pt-2 border-t border-white/10">
|
||||||
|
<LangSwitcher onSwitch={() => setIsMobileMenuOpen(false)} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,22 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { ArrowRight, Building2, CheckCircle2, ChevronRight, Home } from 'lucide-react';
|
import { ArrowRight, Building2, CheckCircle2, ChevronRight, Home } from 'lucide-react';
|
||||||
import Footer from './Footer';
|
import Footer from './Footer';
|
||||||
|
import { localizePath, useLang } from '../i18n';
|
||||||
|
|
||||||
// Standalone product page at /platforma. Deliberately not localized — the site
|
// Standalone product page at /platforma (and /en/platforma). The copy is not
|
||||||
// only translates contact.* and privacy.*, everything else is Polish-only.
|
// 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 PlatformPage = () => {
|
||||||
|
const lang = useLang();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background selection:bg-primary selection:text-background">
|
<div className="min-h-screen bg-background selection:bg-primary selection:text-background">
|
||||||
<header className="bg-background/80 backdrop-blur-lg border-b border-white/5">
|
<header className="bg-background/80 backdrop-blur-lg border-b border-white/5">
|
||||||
<div className="container mx-auto px-6 py-4 flex items-center justify-between">
|
<div className="container mx-auto px-6 py-4 flex items-center justify-between">
|
||||||
<a href="/" className="text-2xl font-bold tracking-tight hover:opacity-80 transition-opacity">
|
<a href={localizePath('/', lang)} className="text-2xl font-bold tracking-tight hover:opacity-80 transition-opacity">
|
||||||
human<span className="text-primary">AI</span>
|
human<span className="text-primary">AI</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/" className="text-sm text-muted hover:text-foreground transition-colors">
|
<a href={localizePath('/', lang)} className="text-sm text-muted hover:text-foreground transition-colors">
|
||||||
← Wróć na stronę główną
|
← Wróć na stronę główną
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -224,7 +228,7 @@ const PlatformPage = () => {
|
||||||
<p className="text-foreground/80 leading-relaxed mb-8">
|
<p className="text-foreground/80 leading-relaxed mb-8">
|
||||||
{'Powiedz, co chcesz zautomatyzować albo uporządkować — zobaczymy, jak to zbudować i objąć nadzorem, który sami na sobie testujemy.'}
|
{'Powiedz, co chcesz zautomatyzować albo uporządkować — zobaczymy, jak to zbudować i objąć nadzorem, który sami na sobie testujemy.'}
|
||||||
</p>
|
</p>
|
||||||
<a href="/#contact" className="btn-primary">
|
<a href={localizePath('/#contact', lang)} className="btn-primary">
|
||||||
Porozmawiajmy
|
Porozmawiajmy
|
||||||
<ArrowRight className="ml-2" size={20} />
|
<ArrowRight className="ml-2" size={20} />
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,20 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useT } from '../i18n';
|
import { localizePath, useLang, useT } from '../i18n';
|
||||||
import Footer from './Footer';
|
import Footer from './Footer';
|
||||||
|
|
||||||
const PrivacyPolicyPage = () => {
|
const PrivacyPolicyPage = () => {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
|
const lang = useLang();
|
||||||
|
const home = localizePath('/', lang);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background selection:bg-primary selection:text-background">
|
<div className="min-h-screen bg-background selection:bg-primary selection:text-background">
|
||||||
<header className="bg-background/80 backdrop-blur-lg border-b border-white/5">
|
<header className="bg-background/80 backdrop-blur-lg border-b border-white/5">
|
||||||
<div className="container mx-auto px-6 py-4 flex items-center justify-between">
|
<div className="container mx-auto px-6 py-4 flex items-center justify-between">
|
||||||
<a href="/" className="text-2xl font-bold tracking-tight hover:opacity-80 transition-opacity">
|
<a href={home} className="text-2xl font-bold tracking-tight hover:opacity-80 transition-opacity">
|
||||||
human<span className="text-primary">AI</span>
|
human<span className="text-primary">AI</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/" className="text-sm text-muted hover:text-foreground transition-colors">
|
<a href={home} className="text-sm text-muted hover:text-foreground transition-colors">
|
||||||
{t.privacy.backToHome}
|
{t.privacy.backToHome}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,110 @@
|
||||||
import { pl } from './pl';
|
import { pl } from './pl';
|
||||||
import { en } from './en';
|
import { en } from './en';
|
||||||
|
import { navigate, usePathname } from '../router';
|
||||||
|
|
||||||
export function useT() {
|
export type Lang = 'pl' | 'en';
|
||||||
const lang = navigator.language?.startsWith('pl') ? 'pl' : 'en';
|
|
||||||
return lang === 'pl' ? 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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
|
import { applyInitialLangRedirect } from './i18n'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
|
// Before the first render, so a non-Polish visitor never sees Polish flash past.
|
||||||
|
applyInitialLangRedirect()
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<App />
|
||||||
|
|
|
||||||
43
src/router.ts
Normal file
43
src/router.ts
Normal file
|
|
@ -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 <a href> 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));
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue