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:
co-authored by
Claude Opus 5
parent
33b4a3fdd6
commit
d71c111cf3
@@ -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
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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;
|
||||
@@ -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.',
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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.',
|
||||
});
|
||||
|
||||
@@ -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.',
|
||||
});
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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.',
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }>>([]);
|
||||
/*
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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',
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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'] },
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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[]>([]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user