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 = () => {
+ );
+};
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 (
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));
+}