Jazyky, skutecny snimek v heru a opravene titulky

Prepinani jazyku (web/src/i18n/). Vybrany jazyk je jedna hodnota pro celou
aplikaci drzena mimo React - tentyz vzorec jako u vybrane firmy, a ze
stejneho duvodu: musi byt dostupny i mimo komponenty a prepnuti nesmi byt
stav jedne stranky. Volba prezije obnoveni a prepisuje <html lang>.

Anglicky slovnik je Partial: co v nem neni, spadne na cestinu. Kdyby musel
byt uplny, znamenal by kazdy novy cesky text rozbity build, dokud ho nekdo
neprelozi - a vysledkem by bylo, ze se texty nepridavaji. Prelozena je
hlavicka, hero vcetne snimku, pas s logy, cisla, zaverecna vyzva a cast
paticky; zbytek je mechanicky a popsany v 23-jazyky.md.

Snimek v heru vypada jako portal doopravdy. Byl svetly a ukazoval obrazovku,
ktera v produktu neexistuje. Ted ma tmavy podklad, tytez zalozky vcetne
prepinace firmy, tytez sloupce tabulky i tytez odznaky stavu jako stranka
Tickety. Kdyz se navstevnik prihlasi a uvidi neco jineho, prvni dojem
z produktu je, ze web lhal.

Jmeno znacky si sklada usePageMeta, ne stranky. Bylo napsane natvrdo ve
dvaceti ctyrech titulcich, takze prejmenovani zmenilo brand.ts a titulky ne:
hlavicka rikala nove jmeno a zalozka v prohlizeci stare.

Opraveno: odkaz "Prohlednout si to" vedl na koren domeny misto
/apps/<app-id>/sluzby, protoze to byl <a href> a ne Link z routeru. Byl to
jediny takovy odkaz v celem webu.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-08-26 10:37:58 +02:00
co-authored by Claude Opus 5
parent 33b4a3fdd6
commit d71c111cf3
37 changed files with 807 additions and 232 deletions
+84
View File
@@ -0,0 +1,84 @@
# 23 - Jazyky
Rozdelane. Mechanismus je hotovy a overeny, prevod obsahu bezi po castech.
## Jak to funguje
| Co | Kde |
| ------------------- | ---------------------------------------------- |
| Vrstva, prepinani | `web/src/i18n/index.ts` |
| Ceske texty | `web/src/i18n/cs.ts` |
| Anglicke texty | `web/src/i18n/en.ts` |
| Prepinac v rozhrani | `web/src/components/layout/LanguageSwitch.tsx` |
Vybrany jazyk je **jedna hodnota pro celou aplikaci**, drzena v modulu mimo
React. Je to tentyz vzorec jako u vybrane firmy (`lib/tenant.ts`) a ze stejneho
duvodu: hodnota musi byt dostupna i mimo komponenty a prepnuti nesmi byt stav
jedne stranky.
Volba se uklada a prezije obnoveni stranky. Pri prvnim nacteni rozhoduje
ulozena volba, pak jazyk prohlizece, pak cestina.
Pri kazde zmene se prepisuje i `<html lang>`. Ctecky a prohlizece podle nej
poznaji jazyk stranky a nenabizeji preklad textu, ktery uz prelozeny je.
## Klice jsou ploche
```ts
t('hero.titleAccent') // ne t('hero').titleAccent
```
Ploche klice jdou vygrepovat: `grep "hero.titleAccent"` najde definici
i vsechna pouziti. U zanorenych objektu to nejde a prave tohle clovek pri
prekladu dela nejcasteji.
## Chybejici preklad nespadne
`en.ts` je `Partial`, takze **nemusi byt uplny**. Co v nem neni, spadne
na cestinu:
```ts
const text = dictionaries[locale][key] ?? cs[key] ?? key;
```
Diky tomu jde preklad doplnovat po castech. Kdyby anglictina musela byt uplna,
znamenal by kazdy novy cesky text rozbity build, dokud ho nekdo neprelozi -
a vysledkem by bylo, ze se texty nepridavaji.
Cestina je zaroven zaloha, takze nedokoncena anglictina znamena misty cestinu.
To je horsi nez hotovy preklad, ale lepsi nez prazdno nebo `hero.title`
v nadpisu.
## Co uz je prelozene
| Cast | Stav |
| ----------------------------------- | ------------------------------------ |
| Hlavicka a mobilni menu | hotovo |
| Hero vcetne snimku portalu | hotovo |
| Pas s logy klientu | hotovo |
| Cisla | hotovo |
| Zaverecna vyzva | hotovo |
| Paticka | castecne, sloupce odkazu zatim cesky |
| Sekce Produkty, Reference, Postup | ne |
| Stranky O nas, Sluzby, Kontakt, 404 | ne |
| Portal za prihlasenim | ne |
Prevod zbytku je mechanicky: text ven do `cs.ts`, klic do `en.ts`, v komponente
`const t = useT()` a `{t('klic')}`. Zadna dalsi prace na vrstve uz potreba neni.
## Co se neprekada
Jmeno znacky, kontakty, ICO a adresa zustavaji v `config/brand.ts`. Nemeni se
s jazykem a mit je ve slovniku dvakrat by znamenalo, ze se jednou zmeni jen
jedno misto.
Claim se **prekada** (`brand.claim`), protoze to je text, ne udaj.
## Co chybi
| Chybi | Poznamka |
| ---------------------- | ----------------------------------------------------------------- |
| Prevod zbytku obsahu | viz tabulka vys |
| Jazyk v adrese | `/en/sluzby` misto volby v prohlizeci. Chce to kvuli vyhledavacum |
| Preklad dat ze serveru | nazvy sluzeb, stavu a chybovych hlasek chodi z API cesky |
| Format cisel a datumu | `toLocaleString` se zatim vola natvrdo s `cs-CZ` |
+30
View File
@@ -2,6 +2,36 @@
Nejnovejsi nahore.
## 2026-08-26 - jazyky, skutecny snimek v heru a opravene titulky
### Pridano
- **Prepinani jazyku** (`web/src/i18n/`). Vybrany jazyk je jedna hodnota pro
celou aplikaci, drzena mimo React - tentyz vzorec jako u vybrane firmy.
Volba prezije obnoveni stranky a prepisuje `<html lang>`. Anglicky slovnik
je `Partial`: co v nem neni, spadne na cestinu, takze preklad jde doplnovat
po castech misto jednoho velkeho skoku. Podrobnosti
v [23-jazyky.md](23-jazyky.md).
- **Prepinac jazyka** v hlavicce a v mobilnim menu.
### Zmeneno
- **Snimek v heru vypada jako portal doopravdy.** Byl svetly a ukazoval
obrazovku, ktera v produktu neexistuje. Ted ma tmavy podklad, tytez zalozky
vcetne prepinace firmy, tytez sloupce tabulky i tytez odznaky stavu jako
stranka Tickety. Kdyz se navstevnik prihlasi a uvidi neco jineho, prvni
dojem z produktu je, ze web lhal.
- **Jmeno znacky si sklada `usePageMeta`, ne stranky.** Bylo napsane natvrdo
ve dvaceti ctyrech titulcich, takze prejmenovani produktu zmenilo
`brand.ts` a titulky ne - hlavicka rikala nove jmeno a zalozka v prohlizeci
stare.
### Opraveno
- **Odkaz "Prohlednout si to" vedl mimo proxy.** Byl to `<a href="/sluzby">`,
tedy koren domeny, ne `/apps/<app-id>/sluzby`. Ted je to `Link` z routeru
jako vsude jinde. Byl to jediny takovy odkaz v celem webu.
## 2026-08-26 - na aplikaci je videt, ktery build to je
Nasazena aplikace o dva commity pozadu funguje a vypada skoro stejne jako
+6 -4
View File
@@ -2,9 +2,12 @@ import { ArrowRight, Mail, Phone } from 'lucide-react';
import { ButtonLink } from '@/components/ui/Button';
import { Container } from '@/components/ui/Container';
import { brand } from '@/config/brand';
import { useT } from '@/i18n';
/** Zaverecna vyzva k akci pred paticku. */
export function CallToAction() {
const t = useT();
return (
<section className="py-20 sm:py-24">
<Container>
@@ -14,16 +17,15 @@ export function CallToAction() {
className="animate-pulse-slow pointer-events-none absolute -top-24 left-1/2 size-96 -translate-x-1/2 rounded-full bg-brand-500/20 blur-[100px]"
/>
<h2 className="relative text-3xl font-bold text-white sm:text-4xl">
Máte proces, který vás žere? <span className="text-gradient">Pojďme se na něj podívat.</span>
{t('cta.titleFirst')} <span className="text-gradient">{t('cta.titleAccent')}</span>
</h2>
<p className="relative mx-auto mt-4 max-w-xl text-white/60">
Půlhodinový hovor, ve kterém si řekneme, co se dá automatizovat hned, co později a co
nemá smysl vůbec. Bez závazku a bez prezentací na 40 slidů.
{t('cta.lead')}
</p>
<div className="relative mt-9 flex flex-wrap justify-center gap-3">
<ButtonLink to="/kontakt" size="lg">
Napsat nám
{t('cta.write')}
<ArrowRight className="size-4" />
</ButtonLink>
<ButtonLink to={`tel:${brand.phoneHref}`} variant="secondary" size="lg">
+197 -174
View File
@@ -1,18 +1,29 @@
import {
ArrowRight,
Blocks,
Building2,
ChevronDown,
FileInput,
LayoutDashboard,
LifeBuoy,
Mail,
MessageCircle,
MessageSquarePlus,
Play,
Plug,
Plus,
Search,
Settings2,
Share2,
SlidersHorizontal,
Users,
Workflow,
Zap,
} from 'lucide-react';
import { Link } from 'react-router-dom';
import { ButtonLink } from '@/components/ui/Button';
import { Container } from '@/components/ui/Container';
import { LogoMark } from '@/components/layout/Logo';
import { useT, type MessageKey } from '@/i18n';
/**
* Hero.
@@ -20,35 +31,21 @@ import { LogoMark } from '@/components/layout/Logo';
* Zacina prikazem a slibem, ne pozdravem a predstavenim firmy. Prvni je
* o zakaznikovi, druhe o nas - a na hlavni stranu patri to prvni.
*
* Slozeni: proklada nadtitulek, dvoubarevny nadpis, jedno primarni tlacitko
* Slozeni: prokladany nadtitulek, dvoubarevny nadpis, jedno primarni tlacitko
* plus textovy odkaz, ctyri slovesa a snimek portalu vybihajici z prave hrany.
*/
/** Ctyri kroky, ktere produkt opravdu dela. Nazvy sedi na zalozky v portalu. */
const steps = [
{
icon: Zap,
title: 'Sbírá',
text: 'Události ze všech vašich nástrojů na jednom místě.',
},
{
icon: Settings2,
title: 'Automatizuje',
text: 'Odbaví, co jde odbavit bez člověka.',
},
{
icon: Share2,
title: 'Propojuje',
text: 'Spustí navazující kroky a celé workflow.',
},
{
icon: Users,
title: 'Deleguje',
text: 'Zapojí správné lidi, když je potřeba.',
},
const steps: Array<{ icon: typeof Zap; title: MessageKey; text: MessageKey }> = [
{ icon: Zap, title: 'hero.step1.title', text: 'hero.step1.text' },
{ icon: Settings2, title: 'hero.step2.title', text: 'hero.step2.text' },
{ icon: Share2, title: 'hero.step3.title', text: 'hero.step3.text' },
{ icon: Users, title: 'hero.step4.title', text: 'hero.step4.text' },
];
export function Hero() {
const t = useT();
return (
/*
Zelena zare je pozadi **sekce**, ne vrstva pod ni.
@@ -78,39 +75,41 @@ export function Hero() {
<div className="grid items-center gap-12 lg:grid-cols-[0.92fr_1.08fr]">
<div className="animate-rise">
<p className="font-mono text-xs tracking-[0.24em] text-white/45 uppercase">
Události dovnitř. Akce ven.
{t('hero.kicker')}
</p>
<h1 className="mt-5 text-5xl font-extrabold text-white sm:text-6xl lg:text-7xl">
Konec
{t('hero.titleFirst')}
<br />
<span className="text-brand-400">ruční práce.</span>
<span className="text-brand-400">{t('hero.titleAccent')}</span>
</h1>
<p className="mt-6 max-w-md text-lg leading-relaxed text-white/65">
WorkNuke sbírá události, zautomatizuje, co jde, propojí další kroky a zapojí
správné lidi, když je potřeba. Vaše práce prostě plyne.
{t('hero.lead')}
</p>
<div className="mt-8 flex flex-wrap items-center gap-5">
<ButtonLink to="/kontakt" size="lg">
Začít zdarma
{t('hero.primary')}
<ArrowRight className="size-4" />
</ButtonLink>
{/*
Vedle primarniho tlacitka uz zadne druhe. Textovy odkaz rekne,
ze je to mensi zavazek, aniz by o pozornost soutezil.
`Link`, ne `<a href>`: aplikace bezi za proxy na /apps/<app-id>,
takze holy absolutni odkaz miri na koren domeny a odejde mimo.
*/}
<a
href="/sluzby"
<Link
to="/sluzby"
className="group inline-flex items-center gap-2.5 text-sm font-semibold text-white transition-colors hover:text-brand-300"
>
<span className="grid size-9 place-items-center rounded-full border border-ink-600/80 transition-colors group-hover:border-brand-400/60">
<Play className="size-3.5 fill-current" />
</span>
Prohlédnout si to
</a>
{t('hero.secondary')}
</Link>
</div>
</div>
@@ -124,8 +123,8 @@ export function Hero() {
{steps.map((step) => (
<li key={step.title} className="animate-rise">
<step.icon className="size-5 text-brand-400" />
<p className="mt-3.5 font-display text-base font-bold text-white">{step.title}</p>
<p className="mt-1.5 text-sm leading-relaxed text-white/50">{step.text}</p>
<p className="mt-3.5 font-display text-base font-bold text-white">{t(step.title)}</p>
<p className="mt-1.5 text-sm leading-relaxed text-white/50">{t(step.text)}</p>
</li>
))}
</ul>
@@ -139,196 +138,220 @@ export function Hero() {
/**
* Snimek portalu.
*
* Vybiha z prave hrany, protoze oriznuty snimek pusobi jako vyrez z neceho
* vetsiho, kdezto cely v ramecku pusobi jako obrazek. Data jsou ilustracni,
* ale nazvy sloupcu i stavy jsou ty, ktere portal opravdu ma.
* **Musi vypadat jako portal doopravdy**, ne jako hezky obrazek vedle nej.
* Kdyz se navstevnik prihlasi a uvidi neco jineho, prvni dojem z produktu je,
* ze web lhal. Proto tmavy podklad, tytez zalozky vcetne prepinace firmy,
* tytez sloupce tabulky i tytez odznaky stavu jako na strance Tickety.
*
* Vybiha z prave hrany: oriznuty snimek pusobi jako vyrez z neceho vetsiho,
* cely v ramecku pusobi jako obrazek.
*/
const rows = [
{
event: 'Platba selhala',
detail: 'Nepodařilo se strhnout platbu',
source: 'Stripe',
color: 'bg-[#635bff]',
status: 'Automatizováno',
tone: 'bg-lime-100 text-lime-800',
next: 'Zkusit znovu za hodinu',
time: 'před 2 min',
},
{
event: 'Nový požadavek',
detail: 'Problém s přihlášením',
source: 'Zendesk',
color: 'bg-[#03363d]',
status: 'Delegováno',
tone: 'bg-indigo-100 text-indigo-800',
next: 'Přiřazeno Petrovi',
time: 'před 12 min',
},
{
event: 'Vysoké vytížení CPU',
detail: 'Nad 90 % po 5 minut',
source: 'Datadog',
color: 'bg-[#632ca6]',
status: 'Probíhá',
tone: 'bg-sky-100 text-sky-800',
next: 'Spustit nápravu',
time: 'před 28 min',
},
{
event: 'Nová registrace',
detail: 'Zkušební účet vytvořen',
source: 'Intercom',
color: 'bg-[#1f8ded]',
status: 'Automatizováno',
tone: 'bg-lime-100 text-lime-800',
next: 'Odeslat uvítání',
time: 'před 1 hod',
},
{
event: 'Odeslán formulář',
detail: 'Poptávka z webu',
source: 'HubSpot',
color: 'bg-[#ff7a59]',
status: 'Otevřené',
tone: 'bg-orange-100 text-orange-800',
next: 'Kvalifikovat lead',
time: 'před 2 hod',
},
const panelNav: Array<{ icon: typeof Zap; label: MessageKey }> = [
{ icon: LayoutDashboard, label: 'panel.nav.overview' },
{ icon: LifeBuoy, label: 'panel.nav.tickets' },
{ icon: MessageSquarePlus, label: 'panel.nav.helpdesk' },
{ icon: Users, label: 'panel.nav.people' },
{ icon: Workflow, label: 'panel.nav.automations' },
{ icon: Blocks, label: 'panel.nav.services' },
{ icon: Plug, label: 'panel.nav.connectors' },
];
const nav = [
'Přehled',
'Události',
'Automatizace',
'Workflow',
'Lidé',
'Integrace',
'Analytika',
'Nastavení',
const tones = {
brand: 'border-brand-400/35 bg-brand-500/12 text-brand-300',
warn: 'border-warn-400/35 bg-warn-500/12 text-warn-400',
ok: 'border-ok-400/35 bg-ok-500/12 text-ok-400',
neutral: 'border-ink-600/70 bg-ink-700/50 text-white/55',
};
const panelRows: Array<{
id: string;
subject: MessageKey;
customer: string;
channel: MessageKey;
channelIcon: typeof Zap;
status: MessageKey;
tone: keyof typeof tones;
/** Jmeno cloveka se neprekada, "ve fronte" ano - proto oboji. */
assignee: string;
assigneeKey?: MessageKey;
time: MessageKey;
}> = [
{
id: 'TK-4821',
subject: 'panel.row1.subject',
customer: 'Nordis a.s.',
channel: 'panel.row1.channel',
channelIcon: Mail,
status: 'panel.row1.status',
tone: 'brand' as const,
assignee: 'Petr N.',
time: 'panel.ago2min',
},
{
id: 'TK-4820',
subject: 'panel.row2.subject',
customer: 'LogiTrans',
channel: 'panel.row2.channel',
channelIcon: FileInput,
status: 'panel.row2.status',
tone: 'warn' as const,
assignee: 'Eva N.',
time: 'panel.ago24min',
},
{
id: 'TK-4819',
subject: 'panel.row3.subject',
customer: 'MediPoint',
channel: 'panel.row3.channel',
channelIcon: MessageCircle,
status: 'panel.row3.status',
tone: 'neutral' as const,
assignee: '',
assigneeKey: 'panel.queue',
time: 'panel.ago1h',
},
{
id: 'TK-4818',
subject: 'panel.row4.subject',
customer: 'Bistro Kolektiv',
channel: 'panel.row4.channel',
channelIcon: LayoutDashboard,
status: 'panel.row4.status',
tone: 'ok' as const,
assignee: 'Karel V.',
time: 'panel.ago3h',
},
];
function HeroPanel() {
const t = useT();
return (
/*
Snimek musi vybihat z hrany na kazde sirce, ne jen na notebooku.
Pevny zaporny okraj ve vw prestal stacit nad 1600 px: kontejner ma strop
max-w-6xl (72rem), takze postranni mezera roste a snimek se do ni vesel.
Vypocet ji odecte, at je obrazovka jakkoliv siroka.
*/
<div
className="animate-rise relative [animation-delay:150ms]"
style={{ marginRight: 'var(--hero-bleed, 0px)' }}
>
<div className="overflow-hidden rounded-2xl border border-ink-600/70 bg-white shadow-2xl shadow-brand-500/10">
<div className="overflow-hidden rounded-2xl border border-ink-600/70 bg-ink-900 shadow-2xl shadow-brand-500/10">
<div className="flex min-h-[26rem]">
{/* Postranni navigace */}
<aside className="hidden w-44 shrink-0 border-r border-slate-200 bg-slate-50/80 p-3 sm:block">
<div className="flex items-center gap-2 px-2 pb-4">
<LogoMark className="size-5 text-lime-500" />
<span className="font-display text-sm font-extrabold text-slate-900">WorkNuke</span>
{/* Postranni menu vcetne prepinace firmy, stejne jako v portalu. */}
<aside className="hidden w-48 shrink-0 flex-col border-r border-ink-600/60 bg-ink-950/60 sm:flex">
<div className="flex h-14 items-center gap-2 border-b border-ink-600/60 px-4">
<LogoMark className="size-5 text-brand-400" />
<span className="font-display text-sm font-extrabold text-white">WorkNuke</span>
</div>
<ul className="space-y-0.5">
{nav.map((item, index) => (
<div className="border-b border-ink-600/60 p-2.5">
<div className="flex items-center gap-2 rounded-xl border border-ink-600/70 bg-ink-850/70 px-2.5 py-1.5">
<Building2 className="size-3.5 shrink-0 text-white/45" />
<span className="truncate text-xs font-semibold text-white">Acme s.r.o.</span>
<ChevronDown className="ml-auto size-3 shrink-0 text-white/45" />
</div>
</div>
<ul className="space-y-0.5 p-2.5">
{panelNav.map((item, index) => (
<li
key={item}
key={item.label}
className={
index === 1
? 'rounded-lg bg-white px-2.5 py-1.5 text-xs font-semibold text-slate-900 shadow-sm'
: 'px-2.5 py-1.5 text-xs text-slate-500'
? 'flex items-center gap-2.5 rounded-xl bg-brand-500/12 px-2.5 py-1.5 text-xs font-medium text-brand-200'
: 'flex items-center gap-2.5 rounded-xl px-2.5 py-1.5 text-xs font-medium text-white/50'
}
>
{item}
<item.icon className="size-3.5 shrink-0" />
{t(item.label)}
</li>
))}
</ul>
</aside>
{/* Obsah */}
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between border-b border-slate-200 px-4 py-2.5">
<Search className="size-3.5 text-slate-400" />
<div className="flex items-center gap-2">
<span className="grid size-6 place-items-center rounded-full bg-slate-900 text-[0.6rem] font-bold text-white">
JK
</span>
<div className="hidden leading-tight sm:block">
<p className="text-[0.7rem] font-semibold text-slate-900">Jan Koutný</p>
<p className="text-[0.62rem] text-slate-400">Acme s.r.o.</p>
</div>
<ChevronDown className="size-3 text-slate-400" />
<div className="flex h-14 items-center gap-3 border-b border-ink-600/60 px-4">
<div className="min-w-0">
<p className="truncate text-xs font-semibold text-white">Acme s.r.o.</p>
<p className="truncate text-[0.65rem] text-white/40">{t('panel.role')}</p>
</div>
<span className="ml-auto inline-flex items-center gap-1.5 rounded-full border border-ok-400/35 bg-ok-500/12 px-2 py-0.5 text-[0.65rem] text-ok-400">
<span className="size-1.5 rounded-full bg-ok-400" />
{t('panel.live')}
</span>
<span className="grid size-7 shrink-0 place-items-center rounded-xl bg-brand-500/20 text-[0.6rem] font-bold text-brand-200">
JK
</span>
</div>
<div className="p-4">
<div className="flex items-center justify-between">
<h2 className="font-display text-xl font-extrabold tracking-tight text-slate-900">
Události
<div className="flex items-center justify-between gap-3">
<h2 className="font-display text-xl font-extrabold tracking-tight text-white">
{t('panel.tickets')}
</h2>
<span className="inline-flex items-center gap-1.5 rounded-full bg-lime-300 px-3 py-1.5 text-[0.7rem] font-semibold text-slate-900">
<span className="inline-flex items-center gap-1.5 rounded-full bg-brand-400 px-3 py-1.5 text-[0.7rem] font-semibold text-ink-950">
<Plus className="size-3" />
Nová automatizace
{t('panel.newTicket')}
</span>
</div>
<div className="mt-3 flex items-center justify-between gap-3 border-b border-slate-200 pb-2">
<ul className="flex gap-4 text-[0.7rem]">
{['Vše', 'Otevřené', 'Automatizované', 'Delegované', 'Vyřešené'].map(
(tab, index) => (
<li
key={tab}
className={
index === 0
? 'border-b-2 border-slate-900 pb-2 font-semibold text-slate-900'
: 'pb-2 text-slate-400'
}
>
{tab}
</li>
),
)}
</ul>
<span className="hidden items-center gap-1.5 rounded-lg border border-slate-200 px-2.5 py-1 text-[0.7rem] text-slate-500 lg:inline-flex">
<SlidersHorizontal className="size-3" />
Filtry
<div className="mt-3 flex flex-wrap items-center gap-1.5">
<span className="inline-flex items-center gap-1.5 rounded-lg border border-ink-600/70 px-2.5 py-1 text-[0.68rem] text-white/40">
<Search className="size-3" />
{t('panel.search')}
</span>
{(['panel.chip.mine', 'panel.chip.queue', 'panel.chip.new'] as const).map(
(chip, index) => (
<span
key={chip}
className={
index === 2
? 'rounded-lg border border-brand-400/35 bg-brand-500/12 px-2.5 py-1 text-[0.68rem] text-brand-300'
: 'rounded-lg border border-ink-600/70 px-2.5 py-1 text-[0.68rem] text-white/50'
}
>
{t(chip)}
</span>
),
)}
</div>
<table className="mt-1 w-full border-collapse text-left">
<table className="mt-3 w-full border-collapse text-left">
<thead>
<tr className="text-[0.62rem] tracking-wide text-slate-400 uppercase">
<th className="py-2 font-medium">Událost</th>
<th className="py-2 font-medium">Zdroj</th>
<th className="py-2 font-medium">Stav</th>
<th className="hidden py-2 font-medium md:table-cell">Další akce</th>
<th className="hidden py-2 font-medium lg:table-cell">Čas</th>
<tr className="border-b border-ink-600/60 text-[0.6rem] tracking-wide text-white/40 uppercase">
<th className="py-2 pr-3 font-medium">{t('panel.col.ticket')}</th>
<th className="hidden py-2 pr-3 font-medium md:table-cell">{t('panel.col.customer')}</th>
<th className="hidden py-2 pr-3 font-medium lg:table-cell">{t('panel.col.channel')}</th>
<th className="py-2 pr-3 font-medium">{t('panel.col.status')}</th>
<th className="hidden py-2 pr-3 font-medium xl:table-cell">{t('panel.col.assignee')}</th>
<th className="hidden py-2 text-right font-medium lg:table-cell">{t('panel.col.updated')}</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.event} className="border-t border-slate-100">
{panelRows.map((row) => (
<tr key={row.id} className="border-b border-ink-600/40 last:border-0">
<td className="py-2.5 pr-3">
<p className="text-[0.72rem] font-semibold text-slate-900">{row.event}</p>
<p className="text-[0.62rem] text-slate-400">{row.detail}</p>
<p className="text-[0.72rem] font-medium text-white">{t(row.subject)}</p>
<p className="font-mono text-[0.6rem] text-white/35">{row.id}</p>
</td>
<td className="py-2.5 pr-3">
<span className="inline-flex items-center gap-1.5 text-[0.7rem] text-slate-600">
<span className={`size-4 rounded ${row.color}`} />
{row.source}
<td className="hidden py-2.5 pr-3 text-[0.7rem] text-white/60 md:table-cell">
{row.customer}
</td>
<td className="hidden py-2.5 pr-3 lg:table-cell">
<span className="inline-flex items-center gap-1.5 rounded-lg border border-ink-600/70 px-2 py-0.5 text-[0.65rem] whitespace-nowrap text-white/55">
<row.channelIcon className="size-3" />
{t(row.channel)}
</span>
</td>
<td className="py-2.5 pr-3">
<span
className={`inline-block rounded-full px-2 py-0.5 text-[0.62rem] font-semibold ${row.tone}`}
className={`inline-block rounded-full border px-2 py-0.5 text-[0.63rem] font-medium whitespace-nowrap ${tones[row.tone]}`}
>
{row.status}
{t(row.status)}
</span>
</td>
<td className="hidden py-2.5 pr-3 text-[0.7rem] text-slate-600 md:table-cell">
{row.next}
<td className="hidden py-2.5 pr-3 text-[0.7rem] whitespace-nowrap text-white/45 xl:table-cell">
{row.assigneeKey ? t(row.assigneeKey) : row.assignee}
</td>
<td className="hidden py-2.5 text-[0.7rem] whitespace-nowrap text-slate-400 lg:table-cell">
{row.time}
<td className="hidden py-2.5 text-right text-[0.7rem] whitespace-nowrap text-white/35 lg:table-cell">
{t(row.time)}
</td>
</tr>
))}
+3 -1
View File
@@ -1,8 +1,10 @@
import { Container } from '@/components/ui/Container';
import { clientLogos } from '@/data/references';
import { useT } from '@/i18n';
/** Nekonecny pas nazvu klientu (mockup misto realnych log). */
export function LogoCloud() {
const t = useT();
// Seznam zdvojujeme, aby marquee navazovalo bez skoku (posun je -50 %).
const items = [...clientLogos, ...clientLogos];
@@ -10,7 +12,7 @@ export function LogoCloud() {
<section className="border-y border-ink-600/50 bg-ink-950/50 py-10">
<Container>
<p className="mb-6 text-center text-xs font-semibold tracking-[0.18em] text-white/35 uppercase">
Pracujeme pro firmy, které nemají čas na ruční práci
{t('logos.title')}
</p>
</Container>
<div className="mask-fade-x overflow-hidden">
+10 -7
View File
@@ -1,14 +1,17 @@
import { Container } from '@/components/ui/Container';
import { useT, type MessageKey } from '@/i18n';
const stats = [
{ value: '120+', label: 'nasazených automatizací' },
{ value: '99,98 %', label: 'dostupnost provozovaných služeb' },
{ value: '11 min', label: 'medián první reakce podpory' },
{ value: '7 let', label: 'na trhu' },
const stats: Array<{ value: string; label: MessageKey }> = [
{ value: '120+', label: 'stats.automations' },
{ value: '99,98 %', label: 'stats.uptime' },
{ value: '11 min', label: 'stats.response' },
{ value: '7', label: 'stats.years' },
];
/** Pas s cisly mezi sekcemi - drzi pozornost a rozdeluje obsah. */
export function Stats() {
const t = useT();
return (
<section className="relative overflow-hidden py-14">
<div
@@ -19,10 +22,10 @@ export function Stats() {
<dl className="grid grid-cols-2 gap-8 lg:grid-cols-4">
{stats.map((stat) => (
<div key={stat.label} className="text-center">
<dt className="sr-only">{stat.label}</dt>
<dt className="sr-only">{t(stat.label)}</dt>
<dd>
<p className="text-3xl font-extrabold text-white sm:text-4xl">{stat.value}</p>
<p className="mt-2 text-sm text-white/50">{stat.label}</p>
<p className="mt-2 text-sm text-white/50">{t(stat.label)}</p>
</dd>
</div>
))}
+4 -2
View File
@@ -4,8 +4,10 @@ import { Logo } from '@/components/layout/Logo';
import { Container } from '@/components/ui/Container';
import { brand } from '@/config/brand';
import { footerNav } from '@/data/navigation';
import { useT } from '@/i18n';
export function Footer() {
const t = useT();
const year = new Date().getFullYear();
return (
@@ -14,7 +16,7 @@ export function Footer() {
<div className="grid gap-10 md:grid-cols-[1.4fr_1fr_1fr_1.2fr]">
<div>
<Logo />
<p className="mt-4 max-w-xs text-sm leading-relaxed text-white/55">{brand.claim}</p>
<p className="mt-4 max-w-xs text-sm leading-relaxed text-white/55">{t('brand.claim')}</p>
</div>
{footerNav.map((column) => (
@@ -36,7 +38,7 @@ export function Footer() {
))}
<div>
<h3 className="mb-4 text-sm font-semibold text-white">Kontakt</h3>
<h3 className="mb-4 text-sm font-semibold text-white">{t('footer.contact')}</h3>
<ul className="space-y-3 text-sm text-white/55">
<li>
<a
@@ -0,0 +1,70 @@
import { Globe } from 'lucide-react';
import { cn } from '@/lib/cn';
import { locales, setLocale, useLocale, useT } from '@/i18n';
/**
* Prepinac jazyka.
*
* Vybrany jazyk plati pro celou aplikaci, ne pro jednu stranku - stejne jako
* vybrana firma. Prepnuti se ulozi a prezije obnoveni stranky.
*
* Dve varianty, protoze v hlavicce je misto na dva kratke kody, kdezto
* v mobilnim menu a v portalu je vic mista a mene kontextu.
*/
export function LanguageSwitch({
variant = 'compact',
className,
}: {
/** `compact` = CS / EN vedle sebe, `full` = cele nazvy pod sebou. */
variant?: 'compact' | 'full';
className?: string;
}) {
const active = useLocale();
const t = useT();
if (variant === 'full') {
return (
<div className={cn('flex flex-col gap-1', className)}>
<span className="px-1 text-xs text-white/40">{t('nav.language')}</span>
<div className="flex gap-2">
{locales.map((locale) => (
<button
key={locale.code}
type="button"
onClick={() => setLocale(locale.code)}
aria-pressed={locale.code === active}
className={cn(
'rounded-xl border px-3 py-1.5 text-sm font-medium transition-colors',
locale.code === active
? 'border-brand-400/50 bg-brand-500/12 text-brand-200'
: 'border-ink-600/70 text-white/55 hover:text-white',
)}
>
{locale.label}
</button>
))}
</div>
</div>
);
}
return (
<div className={cn('inline-flex items-center gap-1', className)} aria-label={t('nav.language')}>
<Globe className="mr-0.5 size-3.5 shrink-0 text-white/35" />
{locales.map((locale) => (
<button
key={locale.code}
type="button"
onClick={() => setLocale(locale.code)}
aria-pressed={locale.code === active}
className={cn(
'rounded-lg px-1.5 py-1 font-mono text-xs font-semibold transition-colors',
locale.code === active ? 'text-brand-300' : 'text-white/35 hover:text-white/70',
)}
>
{locale.short}
</button>
))}
</div>
);
}
+15 -10
View File
@@ -2,10 +2,12 @@ import { LayoutDashboard, LogIn, Menu, X } from 'lucide-react';
import { useEffect, useState } from 'react';
import { NavLink, useLocation } from 'react-router-dom';
import { useAuth } from '@/auth/AuthContext';
import { LanguageSwitch } from '@/components/layout/LanguageSwitch';
import { Logo } from '@/components/layout/Logo';
import { ButtonLink } from '@/components/ui/Button';
import { Container } from '@/components/ui/Container';
import { mainNav } from '@/data/navigation';
import { useT } from '@/i18n';
import { cn } from '@/lib/cn';
export function Navbar() {
@@ -13,6 +15,7 @@ export function Navbar() {
const [mobileOpen, setMobileOpen] = useState(false);
const location = useLocation();
const { user } = useAuth();
const t = useT();
// Sticky hlavicka ztmavne po odscrollovani.
useEffect(() => {
@@ -54,26 +57,25 @@ export function Navbar() {
)
}
>
{item.label}
{t(item.label)}
</NavLink>
))}
</nav>
<div className="hidden items-center gap-3 md:flex">
<LanguageSwitch />
{user ? (
<ButtonLink to="/dashboard" size="sm">
<LayoutDashboard className="size-4" />
Dashboard
{t('nav.dashboard')}
</ButtonLink>
) : (
<>
<ButtonLink to="/prihlaseni" variant="ghost" size="sm">
<LogIn className="size-4" />
Přihlášení
</ButtonLink>
<ButtonLink to="/kontakt" size="sm">
Začít zdarma
{t('nav.signIn')}
</ButtonLink>
<ButtonLink to="/kontakt" size="sm">{t('nav.getStarted')}</ButtonLink>
</>
)}
</div>
@@ -105,22 +107,25 @@ export function Navbar() {
)
}
>
{item.label}
{t(item.label)}
</NavLink>
))}
<LanguageSwitch variant="full" className="mt-3 px-1" />
<div className="mt-3 flex flex-col gap-2">
{user ? (
<ButtonLink to="/dashboard">
<LayoutDashboard className="size-4" />
Dashboard
{t('nav.dashboard')}
</ButtonLink>
) : (
<>
<ButtonLink to="/prihlaseni" variant="secondary">
<LogIn className="size-4" />
Přihlášení
{t('nav.signIn')}
</ButtonLink>
<ButtonLink to="/kontakt">Začít zdarma</ButtonLink>
<ButtonLink to="/kontakt">{t('nav.getStarted')}</ButtonLink>
</>
)}
</div>
+9 -6
View File
@@ -1,18 +1,21 @@
import type { MessageKey } from '@/i18n';
export interface NavItem {
label: string;
/** Klic prekladu, ne hotovy text. Nazvy stranek se prekladaji jako vse ostatni. */
label: MessageKey;
to: string;
}
/** Hlavni navigace verejneho webu. Poradi = poradi v hlavicce. */
export const mainNav: NavItem[] = [
{ label: 'Domů', to: '/' },
{ label: 'Služby', to: '/sluzby' },
{ label: 'O nás', to: '/o-nas' },
{ label: 'Kontakt', to: '/kontakt' },
{ label: 'nav.home', to: '/' },
{ label: 'nav.services', to: '/sluzby' },
{ label: 'nav.about', to: '/o-nas' },
{ label: 'nav.contact', to: '/kontakt' },
];
/** Odkazy v paticce, rozdelene do sloupcu. */
export const footerNav: Array<{ title: string; items: NavItem[] }> = [
export const footerNav: Array<{ title: string; items: Array<{ label: string; to: string }> }> = [
{
title: 'Služby',
items: [
+106
View File
@@ -0,0 +1,106 @@
/**
* Ceske texty. **Zaklad i zaloha zaroven.**
*
* Chybejici preklad v jinem jazyce spadne sem, takze rozdelany preklad
* znamena misty cestinu, ne prazdno ani klic v nadpisu.
*
* Klice maji tvar `oblast.co`, aby se dalo grepovat: `grep hero.` najde
* vsechny texty hera naraz.
*/
export const cs = {
// ------------------------------------------------------------- navigace
'nav.home': 'Domů',
'nav.services': 'Služby',
'nav.about': 'O nás',
'nav.contact': 'Kontakt',
'nav.signIn': 'Přihlášení',
'nav.getStarted': 'Začít zdarma',
'nav.dashboard': 'Portál',
'nav.openMenu': 'Otevřít menu',
'nav.closeMenu': 'Zavřít menu',
'nav.language': 'Jazyk',
// ------------------------------------------------------------------ hero
'hero.kicker': 'Události dovnitř. Akce ven.',
'hero.titleFirst': 'Konec',
'hero.titleAccent': 'ruční práce.',
'hero.lead':
'WorkNuke sbírá události, zautomatizuje, co jde, propojí další kroky a zapojí správné lidi, když je potřeba. Vaše práce prostě plyne.',
'hero.primary': 'Začít zdarma',
'hero.secondary': 'Prohlédnout si to',
'hero.step1.title': 'Sbírá',
'hero.step1.text': 'Události ze všech vašich nástrojů na jednom místě.',
'hero.step2.title': 'Automatizuje',
'hero.step2.text': 'Odbaví, co jde odbavit bez člověka.',
'hero.step3.title': 'Propojuje',
'hero.step3.text': 'Spustí navazující kroky a celé workflow.',
'hero.step4.title': 'Deleguje',
'hero.step4.text': 'Zapojí správné lidi, když je potřeba.',
// ------------------------------------------------------- snimek portalu
'panel.tickets': 'Tickety',
'panel.newTicket': 'Nový ticket',
'panel.search': 'Hledat podle čísla, předmětu nebo firmy...',
'panel.chip.mine': 'Moje tickety',
'panel.chip.queue': 'Ve frontě',
'panel.chip.new': 'Nový',
'panel.col.ticket': 'Ticket',
'panel.col.customer': 'Zákazník',
'panel.col.channel': 'Kanál',
'panel.col.status': 'Stav',
'panel.col.assignee': 'Řeší',
'panel.col.updated': 'Aktualizace',
'panel.role': 'Vedoucí týmu',
'panel.nav.overview': 'Přehled',
'panel.nav.tickets': 'Tickety',
'panel.nav.helpdesk': 'Helpdesk',
'panel.nav.people': 'Lidé',
'panel.nav.automations': 'Automatizace',
'panel.nav.services': 'Služby',
'panel.nav.connectors': 'Konektory',
'panel.row1.subject': 'Nefunguje export faktur',
'panel.row1.channel': 'E-mail',
'panel.row1.status': 'Nový',
'panel.row2.subject': 'Objednávka se nepropsala do skladu',
'panel.row2.channel': 'Formulář',
'panel.row2.status': 'Probíhá',
'panel.row3.subject': 'Zákazník se ptá na stav dodávky',
'panel.row3.channel': 'WhatsApp',
'panel.row3.status': 'Čeká na zákazníka',
'panel.row4.subject': 'Chyba při zaúčtování platby',
'panel.row4.channel': 'Portál',
'panel.row4.status': 'Vyřešeno',
'panel.queue': 've frontě',
'panel.ago2min': 'před 2 min',
'panel.ago24min': 'před 24 min',
'panel.ago1h': 'před 1 hod',
'panel.ago3h': 'před 3 hod',
'panel.live': 'Živě',
// ------------------------------------------------------------ reference
'logos.title': 'Pracujeme pro firmy, které nemají čas na ruční práci',
// ---------------------------------------------------------------- cisla
'stats.automations': 'nasazených automatizací',
'stats.uptime': 'dostupnost provozovaných služeb',
'stats.response': 'medián první reakce podpory',
'stats.years': 'na trhu',
// ------------------------------------------------------------ zaver webu
'cta.titleFirst': 'Máte proces, který vás žere?',
'cta.titleAccent': 'Pojďme se na něj podívat.',
'cta.lead':
'Půlhodinový hovor, ve kterém si řekneme, co se dá automatizovat hned, co později a co nemá smysl vůbec. Bez závazku a bez prezentací na 40 slidů.',
'cta.write': 'Napsat nám',
// ---------------------------------------------------------------- paticka
'footer.contact': 'Kontakt',
'footer.rights': 'Všechna práva vyhrazena.',
// ----------------------------------------------------------------- claim
'brand.claim': 'Konec ruční práce.',
} as const;
export type MessageKey = keyof typeof cs;
+104
View File
@@ -0,0 +1,104 @@
import type { MessageKey } from './cs';
/**
* Anglicke texty.
*
* `Partial` je zamer: co tu neni, spadne na cestinu a stranka se nerozbije.
* Diky tomu jde preklad doplnovat po castech misto jednoho velkeho skoku,
* pri kterem by pulka webu chybela.
*/
export const en: Partial<Record<MessageKey, string>> = {
// ------------------------------------------------------------- navigace
'nav.home': 'Home',
'nav.services': 'Services',
'nav.about': 'About',
'nav.contact': 'Contact',
'nav.signIn': 'Sign in',
'nav.getStarted': 'Get started',
'nav.dashboard': 'Dashboard',
'nav.openMenu': 'Open menu',
'nav.closeMenu': 'Close menu',
'nav.language': 'Language',
// ------------------------------------------------------------------ hero
'hero.kicker': 'Events in. Actions out.',
'hero.titleFirst': 'Nuke the',
'hero.titleAccent': 'busywork.',
'hero.lead':
'WorkNuke collects events, automates what can be done, connects the next steps and gets the right people involved. So your work just flows.',
'hero.primary': 'Get started free',
'hero.secondary': 'Take a look',
'hero.step1.title': 'Capture',
'hero.step1.text': 'Events from all your tools in one place.',
'hero.step2.title': 'Automate',
'hero.step2.text': 'Handles what can be done without a person.',
'hero.step3.title': 'Orchestrate',
'hero.step3.text': 'Triggers next actions and whole workflows.',
'hero.step4.title': 'Delegate',
'hero.step4.text': 'Gets the right people involved when needed.',
// ------------------------------------------------------- snimek portalu
'panel.tickets': 'Tickets',
'panel.newTicket': 'New ticket',
'panel.search': 'Search by number, subject or company...',
'panel.chip.mine': 'My tickets',
'panel.chip.queue': 'In queue',
'panel.chip.new': 'New',
'panel.col.ticket': 'Ticket',
'panel.col.customer': 'Customer',
'panel.col.channel': 'Channel',
'panel.col.status': 'Status',
'panel.col.assignee': 'Owner',
'panel.col.updated': 'Updated',
'panel.role': 'Team lead',
'panel.nav.overview': 'Overview',
'panel.nav.tickets': 'Tickets',
'panel.nav.helpdesk': 'Helpdesk',
'panel.nav.people': 'People',
'panel.nav.automations': 'Automations',
'panel.nav.services': 'Services',
'panel.nav.connectors': 'Connectors',
'panel.row1.subject': 'Invoice export is failing',
'panel.row1.channel': 'E-mail',
'panel.row1.status': 'New',
'panel.row2.subject': 'Order did not reach the warehouse',
'panel.row2.channel': 'Form',
'panel.row2.status': 'In progress',
'panel.row3.subject': 'Customer asks about delivery status',
'panel.row3.channel': 'WhatsApp',
'panel.row3.status': 'Waiting for customer',
'panel.row4.subject': 'Payment posting failed',
'panel.row4.channel': 'Portal',
'panel.row4.status': 'Resolved',
'panel.queue': 'in queue',
'panel.ago2min': '2 min ago',
'panel.ago24min': '24 min ago',
'panel.ago1h': '1 hour ago',
'panel.ago3h': '3 hours ago',
'panel.live': 'Live',
// ------------------------------------------------------------ reference
'logos.title': 'Trusted by teams with no time for busywork',
// ---------------------------------------------------------------- cisla
'stats.automations': 'automations in production',
'stats.uptime': 'uptime of services we run',
'stats.response': 'median first support response',
'stats.years': 'years on the market',
// ------------------------------------------------------------ zaver webu
'cta.titleFirst': 'Got a process that eats your day?',
'cta.titleAccent': "Let's take a look at it.",
'cta.lead':
'A half-hour call where we work out what can be automated now, what later and what is not worth it at all. No commitment and no 40-slide deck.',
'cta.write': 'Write to us',
// ---------------------------------------------------------------- paticka
'footer.contact': 'Contact',
'footer.rights': 'All rights reserved.',
// ----------------------------------------------------------------- claim
'brand.claim': 'Nuke the busywork.',
};
+119
View File
@@ -0,0 +1,119 @@
/**
* Jazyk verejneho webu.
*
* Stejny vzorec jako u vybrane firmy (`lib/tenant.ts`) a ze stejneho duvodu:
* hodnota musi byt dostupna i mimo React, drzi se v modulu a komponenty se
* na ni prihlasi hookem. Prepnuti tak neni stav jedne stranky.
*
* **Klice jsou ploche retezce**, ne zanorene objekty. Ploche jdou vygrepovat
* (`grep "hero.title"` najde definici i pouziti), zanorene ne - a prave tohle
* clovek pri prekladu dela nejcasteji.
*
* Cestina je zaklad a zaroven zaloha. Chybejici preklad **nespadne ani
* nevypise klic**, jen zustane cesky: rozdelany preklad je porad lepsi nez
* `hero.title` v nadpisu.
*/
import { useEffect, useState } from 'react';
import { cs, type MessageKey } from './cs';
import { en } from './en';
export type Locale = 'cs' | 'en';
export const locales: Array<{ code: Locale; label: string; short: string }> = [
{ code: 'cs', label: 'Čeština', short: 'CS' },
{ code: 'en', label: 'English', short: 'EN' },
];
const STORAGE_KEY = 'automia.locale';
const dictionaries: Record<Locale, Partial<Record<MessageKey, string>>> = { cs, en };
/**
* Ktery jazyk pouzit pri prvnim nacteni.
*
* Poradi: ulozena volba, pak jazyk prohlizece, pak cestina. Prohlizec se pta
* az na druhem miste - kdo si jednou prepnul, mysli to vazne.
*/
function detect(): Locale {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === 'cs' || stored === 'en') return stored;
} catch (err) {
console.warn('[i18n] localStorage neni dostupne:', err);
}
const preferred = typeof navigator === 'undefined' ? '' : navigator.language.toLowerCase();
return preferred.startsWith('cs') || preferred.startsWith('sk') ? 'cs' : 'en';
}
let active: Locale = detect();
const listeners = new Set<(locale: Locale) => void>();
if (typeof document !== 'undefined') document.documentElement.lang = active;
export function getLocale(): Locale {
return active;
}
export function setLocale(locale: Locale): void {
if (locale === active) return;
active = locale;
// Kvuli cteckam a prohlizecum, ktere podle toho nabizeji preklad stranky.
document.documentElement.lang = locale;
try {
localStorage.setItem(STORAGE_KEY, locale);
} catch (err) {
console.warn('[i18n] volbu jazyka nelze ulozit:', err);
}
for (const listener of listeners) listener(locale);
}
export function onLocaleChange(listener: (locale: Locale) => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
/** Vybrany jazyk jako stav komponenty. Prekresli se pri kazdem prepnuti. */
export function useLocale(): Locale {
const [locale, setLocaleState] = useState(getLocale);
useEffect(() => onLocaleChange(setLocaleState), []);
return locale;
}
/**
* Preklad jednoho klice.
*
* `values` dosadi `{jmeno}` v textu. Zamerne jedna slozena zavorka, ne dve
* jako u sablon kroku: tohle jsou texty rozhrani, ne data od uzivatele,
* a nesmi se to plest s `{{parametr}}` v builderu.
*/
export function translate(
locale: Locale,
key: MessageKey,
values?: Record<string, string | number>,
): string {
const text = dictionaries[locale][key] ?? cs[key] ?? key;
if (!values) return text;
return Object.entries(values).reduce(
(result, [name, value]) => result.replaceAll(`{${name}}`, String(value)),
text,
);
}
/**
* Prekladac pro komponentu. Vraci funkci, ne retezec, aby slo prelozit vic
* klicu bez desateho volani hooku.
*/
export function useT(): (key: MessageKey, values?: Record<string, string | number>) => string {
const locale = useLocale();
return (key, values) => translate(locale, key, values);
}
export type { MessageKey };
+25 -3
View File
@@ -1,12 +1,34 @@
import { useEffect } from 'react';
import { brand } from '@/config/brand';
/**
* Nastavi <title> a meta description pro danou stranku.
* Zamerne bez knihovny (react-helmet) - pro prototyp staci.
*
* **Jmeno znacky si sklada hook, ne stranky.** Driv si ho psala kazda stranka
* do titulku sama, takze prejmenovani produktu ho zmenilo v `brand.ts` a na
* dvaceti ctyrech mistech ne. Vysledek: hlavicka rikala nove jmeno a zalozka
* v prohlizeci stare.
*/
export function usePageMeta({ title, description }: { title: string; description?: string }) {
export function usePageMeta({
title,
description,
area,
}: {
/** Nazev stranky bez znacky. Vynechany znamena domovskou stranku. */
title?: string;
description?: string;
/** `portal` prida slovo "portal" - stranky za prihlasenim. */
area?: 'portal';
}) {
const fullTitle = !title
? `${brand.name} - ${brand.claim}`
: area === 'portal'
? `${title} - portál ${brand.name}`
: `${title} - ${brand.name}`;
useEffect(() => {
document.title = title;
document.title = fullTitle;
if (!description) return;
const tag = document.querySelector('meta[name="description"]');
@@ -15,5 +37,5 @@ export function usePageMeta({ title, description }: { title: string; description
return;
}
tag.setAttribute('content', description);
}, [title, description]);
}, [fullTitle, description]);
}
+1 -1
View File
@@ -45,7 +45,7 @@ const milestones = [
export default function About() {
usePageMeta({
title: 'O nás - Automia',
title: 'O nás',
description:
'Malý tým, který firmám staví automatizace, voiceboty a integrace - a pak je i provozuje.',
});
+1 -1
View File
@@ -26,7 +26,7 @@ const inputClass =
export default function Contact() {
usePageMeta({
title: 'Kontakt - Automia',
title: 'Kontakt',
description: 'Napište nám nebo zavolejte. Ozveme se do jednoho pracovního dne.',
});
+2 -2
View File
@@ -8,10 +8,10 @@ import { Stats } from '@/components/home/Stats';
import { usePageMeta } from '@/lib/usePageMeta';
export default function Home() {
// Bez `title` slozi hook titulek ze znacky a claimu, tedy jedno misto pro obojí.
usePageMeta({
title: 'Automia - automatizace, voiceboti a integrace na míru',
description:
'Automatizujeme firemní procesy, stavíme voiceboty a propojujeme systémy. K tomu dashboardy, tickety a incident management.',
'WorkNuke sbírá události ze všech vašich nástrojů, zautomatizuje, co jde, propojí další kroky a zapojí správné lidi. Vaše práce prostě plyne.',
});
return (
+1 -1
View File
@@ -32,7 +32,7 @@ export default function Invite() {
const navigate = useNavigate();
const { login } = useAuth();
usePageMeta({ title: 'Pozvánka - portál Automia' });
usePageMeta({ title: 'Pozvánka', area: 'portal' });
const [info, setInfo] = useState<InviteInfo | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
+2 -2
View File
@@ -32,8 +32,8 @@ const inputClass =
/** Prihlaseni do klientskeho portalu. Demo ucty jsou vypsane pod formularem. */
export default function Login() {
usePageMeta({
title: 'Přihlášení - Automia',
description: 'Přihlášení do klientského portálu Automia.',
title: 'Přihlášení',
description: `Přihlášení do klientského portálu ${brand.name}.`,
});
const { user, loading, login } = useAuth();
+1 -1
View File
@@ -3,7 +3,7 @@ import { Container } from '@/components/ui/Container';
import { usePageMeta } from '@/lib/usePageMeta';
export default function NotFound() {
usePageMeta({ title: 'Stránka nenalezena - Automia' });
usePageMeta({ title: 'Stránka nenalezena' });
return (
<Container className="flex min-h-[60vh] flex-col items-center justify-center py-20 text-center">
+1 -1
View File
@@ -8,7 +8,7 @@ import { cn } from '@/lib/cn';
export default function Services() {
usePageMeta({
title: 'Služby - Automia',
title: 'Služby',
description:
'Automatizace procesů, voiceboti, integrace systémů, dashboardy, tickety a incident management.',
});
+1 -1
View File
@@ -73,7 +73,7 @@ export default function ActionDetail() {
const [saveError, setSaveError] = useState<string | null>(null);
const [savedAt, setSavedAt] = useState<string | null>(null);
usePageMeta({ title: `${action.data?.label ?? 'Akce'} - portál Automia` });
usePageMeta({ title: `${action.data?.label ?? 'Akce'}`, area: 'portal' });
useEffect(() => {
if (!action.data) return;
+1 -1
View File
@@ -35,7 +35,7 @@ interface ActionRecord {
}
export default function Actions() {
usePageMeta({ title: 'Akce na ticketu - portál Automia' });
usePageMeta({ title: 'Akce na ticketu', area: 'portal' });
const [types, setTypes] = useState<Array<{ id: string; name: string }>>([]);
/*
+1 -1
View File
@@ -56,7 +56,7 @@ export default function AutomationDetail() {
/** Co podle serveru chybi k zapnuti. Prepocitava ho server pri kazdem ulozeni. */
const [issues, setIssues] = useState<string[]>([]);
usePageMeta({ title: `${name || 'Automatizace'} - portál Automia` });
usePageMeta({ title: `${name || 'Automatizace'}`, area: 'portal' });
// Prevzeti dat ze serveru do editovatelneho stavu.
useEffect(() => {
+1 -1
View File
@@ -29,7 +29,7 @@ const kindMeta: Record<Automation['kind'], { label: string; icon: LucideIcon }>
};
export default function Automations() {
usePageMeta({ title: 'Automatizace - portál Automia' });
usePageMeta({ title: 'Automatizace', area: 'portal' });
const { data, loading, error, reload } = useApiQuery<ListResponse<Automation>>(
'/api/dashboard/automations',
{
+1 -1
View File
@@ -49,7 +49,7 @@ import type {
* svoje. Viz documentation/12-sluzby-a-konektory.md.
*/
export default function Connectors() {
usePageMeta({ title: 'Konektory - portál Automia' });
usePageMeta({ title: 'Konektory', area: 'portal' });
const { user } = useAuth();
const [params, setParams] = useSearchParams();
+1 -1
View File
@@ -41,7 +41,7 @@ const inputClass =
'w-full rounded-lg border border-ink-600/70 bg-ink-900/70 px-3 py-2 text-sm text-white placeholder:text-white/25 focus:border-brand-400/70 focus:outline-none';
export default function Helpdesk() {
usePageMeta({ title: 'Helpdesk - portál Automia' });
usePageMeta({ title: 'Helpdesk', area: 'portal' });
const helpdesk = useApiQuery<HelpdeskResponse>('/api/dashboard/helpdesk', {
refetchOn: ['ticket.created', 'ticket.updated', 'ticket.resolved'],
+1 -1
View File
@@ -6,7 +6,7 @@ import { usePageMeta } from '@/lib/usePageMeta';
import type { Incident, ListResponse } from '@/types/dashboard';
export default function Incidents() {
usePageMeta({ title: 'Incidenty - portál Automia' });
usePageMeta({ title: 'Incidenty', area: 'portal' });
const { data, loading, error, reload } = useApiQuery<ListResponse<Incident>>(
'/api/dashboard/incidents',
{ refetchOn: ['incident.started', 'incident.updated', 'incident.resolved'] },
+1 -1
View File
@@ -55,7 +55,7 @@ const sizeLabels: Record<WidgetSize, string> = {
};
export default function Overview() {
usePageMeta({ title: 'Přehled - portál Automia' });
usePageMeta({ title: 'Přehled', area: 'portal' });
const { user } = useAuth();
+1 -1
View File
@@ -24,7 +24,7 @@ interface NamedRecord {
* dlazdice na rychly prehled - proto obojí, ne jen jedno.
*/
export default function People() {
usePageMeta({ title: 'Lidé - portál Automia' });
usePageMeta({ title: 'Lidé', area: 'portal' });
const [view, setView] = useState<ListView>('table');
const [tab, setTab] = useState('prehled');
+1 -1
View File
@@ -37,7 +37,7 @@ export default function PersonDetail() {
refetchOn: ['ticket.created', 'ticket.updated', 'ticket.assigned', 'ticket.resolved'],
});
usePageMeta({ title: `${detail.data?.person.name ?? 'Řešitel'} - portál Automia` });
usePageMeta({ title: `${detail.data?.person.name ?? 'Řešitel'}`, area: 'portal' });
return (
<div className="space-y-6">
+1 -1
View File
@@ -34,7 +34,7 @@ import type {
* Popis modelu je v documentation/11-skripty-konektoru.md.
*/
export default function Scripts() {
usePageMeta({ title: 'Skripty konektorů - portál Automia' });
usePageMeta({ title: 'Skripty konektorů', area: 'portal' });
const { user } = useAuth();
const canEdit = user?.platformAdmin === true;
+1 -1
View File
@@ -44,7 +44,7 @@ function viewOf(service: ServiceWithUsage): ServiceView {
}
export default function Services() {
usePageMeta({ title: 'Služby - portál Automia' });
usePageMeta({ title: 'Služby', area: 'portal' });
const { data, loading, error, reload } = useApiQuery<ServiceOverview>(
'/api/dashboard/connectors/services',
+1 -1
View File
@@ -45,7 +45,7 @@ interface NamedRecord {
}
export default function Settings() {
usePageMeta({ title: 'Nastavení - portál Automia' });
usePageMeta({ title: 'Nastavení', area: 'portal' });
const { user } = useAuth();
const access = useApiQuery<Access>('/api/dashboard/access');
+1 -1
View File
@@ -97,7 +97,7 @@ export default function TicketDetail() {
*/
const [statusDraft, setStatusDraft] = useState<string | null>(null);
usePageMeta({ title: `${ticket.data ? ticket.data.id : 'Ticket'} - portál Automia` });
usePageMeta({ title: `${ticket.data ? ticket.data.id : 'Ticket'}`, area: 'portal' });
// Po zmene ticketu zmizi stara chyba, at nevisi u uz opraveneho stavu.
useEffect(() => {
+1 -1
View File
@@ -41,7 +41,7 @@ const channelFilters: Array<{ value: TicketChannel; label: string }> = [
];
export default function Tickets() {
usePageMeta({ title: 'Tickety - portál Automia' });
usePageMeta({ title: 'Tickety', area: 'portal' });
// Co uzivatel smi, rozhoduje server. Klient jen kresli, co prislo.
const access = useApiQuery<Access>('/api/dashboard/access');
+1 -1
View File
@@ -39,7 +39,7 @@ const sourceHint = `Příklady:
{"kind":"connector","serviceId":"idoklad","operationId":"find-issued-invoice","connectorId":null,"inputs":{},"path":"total","ttlSec":300}`;
export default function Widgets() {
usePageMeta({ title: 'Widgety - portál Automia' });
usePageMeta({ title: 'Widgety', area: 'portal' });
const [services, setServices] = useState<Service[]>([]);