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>
|
||||
<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 -->
|
||||
<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: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 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 <PrivacyPolicyPage />;
|
||||
}
|
||||
|
||||
if (path === '/platforma') {
|
||||
if (route === '/platforma') {
|
||||
return <PlatformPage />;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<HTMLAnchorElement>) => {
|
||||
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 = () => {
|
|||
<div className="container mx-auto px-6">
|
||||
<div className="grid md:grid-cols-4 gap-12 mb-12">
|
||||
<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>
|
||||
</a>
|
||||
<p className="text-muted max-w-xs mb-6">
|
||||
|
|
@ -38,17 +40,17 @@ const Footer = () => {
|
|||
<div>
|
||||
<h4 className="font-bold mb-6 text-sm uppercase tracking-wider">Nawigacja</h4>
|
||||
<ul className="space-y-4">
|
||||
<li><a href="/#offerings" 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="/#contact" className="text-muted hover:text-foreground transition-colors">Kontakt</a></li>
|
||||
<li><a href={localizePath('/#offerings', lang)} className="text-muted hover:text-foreground transition-colors">Oferta</a></li>
|
||||
<li><a href={localizePath('/platforma', lang)} className="text-muted hover:text-foreground transition-colors">Platforma</a></li>
|
||||
<li><a href={localizePath('/#contact', lang)} className="text-muted hover:text-foreground transition-colors">Kontakt</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-bold mb-6 text-sm uppercase tracking-wider">Prywatność</h4>
|
||||
<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="/privacy#cookies" className="text-muted hover:text-foreground transition-colors">Cookies</a></li>
|
||||
<li><a href={localizePath('/privacy', lang)} className="text-muted hover:text-foreground transition-colors">Polityka prywatności</a></li>
|
||||
<li><a href={localizePath('/privacy#cookies', lang)} className="text-muted hover:text-foreground transition-colors">Cookies</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<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 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}
|
||||
</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
|
||||
</a>
|
||||
<LangSwitcher />
|
||||
</nav>
|
||||
|
||||
{/* Mobile Menu Toggle */}
|
||||
|
|
@ -80,12 +117,15 @@ const Header = () => {
|
|||
</a>
|
||||
))}
|
||||
<a
|
||||
href="/#contact"
|
||||
href={localizePath('/#contact', lang)}
|
||||
className="btn-primary text-center"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
>
|
||||
Porozmawiajmy
|
||||
</a>
|
||||
<div className="pt-2 border-t border-white/10">
|
||||
<LangSwitcher onSwitch={() => setIsMobileMenuOpen(false)} />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<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">
|
||||
<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>
|
||||
</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ą
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -224,7 +228,7 @@ const PlatformPage = () => {
|
|||
<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.'}
|
||||
</p>
|
||||
<a href="/#contact" className="btn-primary">
|
||||
<a href={localizePath('/#contact', lang)} className="btn-primary">
|
||||
Porozmawiajmy
|
||||
<ArrowRight className="ml-2" size={20} />
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<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">
|
||||
<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>
|
||||
</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}
|
||||
</a>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<React.StrictMode>
|
||||
<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