Nahrazeni sablony kompletnim webem a klientskym portalem

Web a portal Automia v jednom containeru. Express obsluhuje API
i zbuildovanou React aplikaci z dist/public.

Obsah:
- verejny web: homepage, sluzby, o nas, kontakt, 404
- prihlaseni pres JWT, demo ucty
- portal: prehled s grafem, tickety, incidenty, automatizace, konektory
- builder automatizaci: strom akci, vetveni podminkou
- katalog 25 konektoru v 8 kategoriich
- webhook s registrovanou adresou, token generuje server
- zivy dashboard pres SSE vcetne simulace provozu
- Swagger UI na /docs a OpenAPI na /openapi.json

Soulad s AGENTS.md:
- ROOT_PATH z prostredi, prefix proxy nikde nehardcodovan
- mount na koren i na prefix, funguje s handle_path i bez nej
- base tag a window.__BASE_PATH__ vkladane do index.html za behu
- OpenAPI servers obsahuje prefix, Try it out vola spravnou adresu
- povinne /health a /docs, port 3000, naslouchani na 0.0.0.0
- secrets jen z environment variables, nikdy v logu

Dokumentace ve slozce documentation/.
This commit is contained in:
JiriUhlir
2026-07-31 17:00:37 +02:00
parent 46f2f0b07e
commit 7b045a9f20
100 changed files with 15409 additions and 35 deletions
+24
View File
@@ -0,0 +1,24 @@
<!doctype html>
<html lang="cs" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Automia — automatizace, voiceboti a integrace na míru</title>
<meta
name="description"
content="Automatizujeme procesy, stavíme voiceboty a propojujeme systémy. K tomu dashboardy, tickety a incident management pod jednou střechou."
/>
<meta name="theme-color" content="#070b16" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+18
View File
@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="64" y2="64" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#22d3ee" />
<stop offset="1" stop-color="#8b5cf6" />
</linearGradient>
</defs>
<rect width="64" height="64" rx="16" fill="#070b16" />
<path
d="M20 44 32 18l12 26"
fill="none"
stroke="url(#g)"
stroke-width="5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path d="M25 36h14" fill="none" stroke="url(#g)" stroke-width="5" stroke-linecap="round" />
</svg>

After

Width:  |  Height:  |  Size: 596 B

+71
View File
@@ -0,0 +1,71 @@
import { Suspense, lazy } from 'react';
import { Route, Routes } from 'react-router-dom';
import { RequireAuth } from '@/auth/RequireAuth';
import { DashboardLayout } from '@/components/dashboard/DashboardLayout';
import { PublicLayout } from '@/components/layout/PublicLayout';
import { Spinner } from '@/components/ui/Spinner';
// Verejny web se nacita hned, dashboard az po prihlaseni (mensi initial bundle).
import Home from '@/pages/Home';
import About from '@/pages/About';
import Contact from '@/pages/Contact';
import Login from '@/pages/Login';
import NotFound from '@/pages/NotFound';
import Services from '@/pages/Services';
const Overview = lazy(() => import('@/pages/dashboard/Overview'));
const Automations = lazy(() => import('@/pages/dashboard/Automations'));
const AutomationDetail = lazy(() => import('@/pages/dashboard/AutomationDetail'));
const Connectors = lazy(() => import('@/pages/dashboard/Connectors'));
const Tickets = lazy(() => import('@/pages/dashboard/Tickets'));
const Incidents = lazy(() => import('@/pages/dashboard/Incidents'));
const Settings = lazy(() => import('@/pages/dashboard/Settings'));
/**
* Routovani. Cesty jsou zamerne cesky (SEO + citelnost URL),
* mapa vsech cest je v docs/03-frontend.md.
*/
export default function App() {
return (
<Routes>
{/* Verejny web */}
<Route element={<PublicLayout />}>
<Route path="/" element={<Home />} />
<Route path="/sluzby" element={<Services />} />
<Route path="/o-nas" element={<About />} />
<Route path="/kontakt" element={<Contact />} />
<Route path="*" element={<NotFound />} />
</Route>
{/* Prihlaseni - vlastni layout bez hlavicky a paticky */}
<Route path="/prihlaseni" element={<Login />} />
{/* Klientsky portal */}
<Route element={<RequireAuth />}>
<Route
path="/dashboard"
element={
<Suspense
fallback={
<div className="grid min-h-screen place-items-center bg-ink-950">
<Spinner label="Načítám portál…" />
</div>
}
>
<DashboardLayout />
</Suspense>
}
>
<Route index element={<Overview />} />
<Route path="automatizace" element={<Automations />} />
<Route path="automatizace/:id" element={<AutomationDetail />} />
<Route path="konektory" element={<Connectors />} />
<Route path="tickety" element={<Tickets />} />
<Route path="incidenty" element={<Incidents />} />
<Route path="nastaveni" element={<Settings />} />
<Route path="*" element={<NotFound />} />
</Route>
</Route>
</Routes>
);
}
+89
View File
@@ -0,0 +1,89 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { apiFetch, getToken, setToken } from '@/lib/api';
export interface AuthUser {
id: string;
email: string;
name: string;
role: 'admin' | 'client';
company: string;
}
interface AuthState {
user: AuthUser | null;
/** true dokud probiha prvotni overeni ulozeneho tokenu */
loading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthState | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null);
const [loading, setLoading] = useState(true);
// Pri startu zkusime obnovit session z ulozeneho tokenu.
useEffect(() => {
let cancelled = false;
async function restore() {
if (!getToken()) {
setLoading(false);
return;
}
try {
const data = await apiFetch<{ user: AuthUser }>('/api/auth/me');
if (!cancelled) setUser(data.user);
} catch (err) {
// Expirovany/neplatny token - zahodime ho a pokracujeme jako neprihlaseny.
console.warn('[auth] obnoveni session selhalo, mazu token:', err);
setToken(null);
if (!cancelled) setUser(null);
} finally {
if (!cancelled) setLoading(false);
}
}
void restore();
return () => {
cancelled = true;
};
}, []);
const login = useCallback(async (email: string, password: string) => {
const data = await apiFetch<{ token: string; user: AuthUser }>('/api/auth/login', {
method: 'POST',
auth: false,
body: { email, password },
});
setToken(data.token);
setUser(data.user);
}, []);
const logout = useCallback(async () => {
try {
await apiFetch<void>('/api/auth/logout', { method: 'POST' });
} catch (err) {
// Odhlaseni na klientovi musi projit i kdyz server nedostupny.
console.warn('[auth] logout na serveru selhal, odhlasuji lokalne:', err);
} finally {
setToken(null);
setUser(null);
}
}, []);
const value = useMemo<AuthState>(
() => ({ user, loading, login, logout }),
[user, loading, login, logout],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthState {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth musí být použit uvnitř <AuthProvider>.');
return ctx;
}
+23
View File
@@ -0,0 +1,23 @@
import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { useAuth } from '@/auth/AuthContext';
import { Spinner } from '@/components/ui/Spinner';
/** Obalka pro chranene routy. Neprihlaseneho posle na /prihlaseni a zapamatuje cil. */
export function RequireAuth() {
const { user, loading } = useAuth();
const location = useLocation();
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center bg-ink-900">
<Spinner label="Načítám session…" />
</div>
);
}
if (!user) {
return <Navigate to="/prihlaseni" state={{ from: location.pathname }} replace />;
}
return <Outlet />;
}
@@ -0,0 +1,199 @@
import {
AlarmClock,
ExternalLink,
FlaskConical,
LayoutDashboard,
LifeBuoy,
LogOut,
Menu,
Plug,
Settings,
Workflow,
X,
} from 'lucide-react';
import { useEffect, useState } from 'react';
import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '@/auth/AuthContext';
import { EventStreamProvider } from '@/components/dashboard/EventStreamProvider';
import { EventToasts } from '@/components/dashboard/EventToasts';
import { LiveIndicator } from '@/components/dashboard/LiveIndicator';
import { SimulationModal } from '@/components/dashboard/SimulationModal';
import { Logo } from '@/components/layout/Logo';
import { cn } from '@/lib/cn';
const nav = [
{ to: '/dashboard', label: 'Přehled', icon: LayoutDashboard, end: true },
{ to: '/dashboard/automatizace', label: 'Automatizace', icon: Workflow, end: false },
{ to: '/dashboard/konektory', label: 'Konektory', icon: Plug, end: false },
{ to: '/dashboard/tickety', label: 'Tickety', icon: LifeBuoy, end: false },
{ to: '/dashboard/incidenty', label: 'Incidenty', icon: AlarmClock, end: false },
{ to: '/dashboard/nastaveni', label: 'Nastavení', icon: Settings, end: false },
];
/**
* Shell klientskeho portalu: fixni sidebar (desktop) / vysouvaci (mobil) + obsah.
* Sem se budou pripojovat dalsi moduly dashboardu - viz docs/06-dashboard.md.
*/
export function DashboardLayout() {
return (
// Jedno spojeni na stream pro cely portal, proto az tady nahore.
<EventStreamProvider>
<DashboardShell />
</EventStreamProvider>
);
}
function DashboardShell() {
const { user, logout } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const [sidebarOpen, setSidebarOpen] = useState(false);
const [simulationOpen, setSimulationOpen] = useState(false);
useEffect(() => {
setSidebarOpen(false);
}, [location.pathname]);
async function handleLogout() {
await logout();
navigate('/', { replace: true });
}
return (
<div className="min-h-screen bg-ink-950">
{/* Sidebar */}
<aside
className={cn(
'fixed inset-y-0 left-0 z-50 flex w-64 flex-col border-r border-ink-600/60 bg-ink-900 transition-transform duration-300 lg:translate-x-0',
sidebarOpen ? 'translate-x-0' : '-translate-x-full',
)}
>
<div className="flex h-18 items-center justify-between border-b border-ink-600/60 px-5">
<Logo to="/dashboard" />
<button
type="button"
onClick={() => setSidebarOpen(false)}
className="grid size-9 place-items-center rounded-lg text-white/50 hover:bg-white/5 hover:text-white lg:hidden"
aria-label="Zavřít menu"
>
<X className="size-5" />
</button>
</div>
<nav className="flex-1 space-y-1 overflow-y-auto p-3">
{nav.map((item) => {
const Icon = item.icon;
return (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) =>
cn(
'flex items-center gap-3 rounded-xl px-3.5 py-2.5 text-sm font-medium transition-colors',
isActive
? 'bg-brand-500/12 text-brand-200'
: 'text-white/55 hover:bg-white/5 hover:text-white',
)
}
>
<Icon className="size-4 shrink-0" />
{item.label}
</NavLink>
);
})}
{/* Nastroj pro nahled zivého dashboardu, proto az za beznou navigaci. */}
<button
type="button"
onClick={() => setSimulationOpen(true)}
className="mt-2 flex w-full items-center gap-3 rounded-xl border border-dashed border-ink-600/70 px-3.5 py-2.5 text-sm font-medium text-white/55 transition-colors hover:border-accent-400/60 hover:text-white"
>
<FlaskConical className="size-4 shrink-0" />
Simulace
</button>
</nav>
<div className="border-t border-ink-600/60 p-3">
<Link
to="/"
className="flex items-center gap-3 rounded-xl px-3.5 py-2.5 text-sm text-white/45 transition-colors hover:bg-white/5 hover:text-white"
>
<ExternalLink className="size-4 shrink-0" />
Veřejný web
</Link>
<button
type="button"
onClick={handleLogout}
className="flex w-full items-center gap-3 rounded-xl px-3.5 py-2.5 text-sm text-white/45 transition-colors hover:bg-danger-500/10 hover:text-danger-400"
>
<LogOut className="size-4 shrink-0" />
Odhlásit se
</button>
</div>
</aside>
{/* Prekryv pri otevrenem mobilnim sidebaru */}
{sidebarOpen && (
<button
type="button"
aria-label="Zavřít menu"
onClick={() => setSidebarOpen(false)}
className="fixed inset-0 z-40 bg-ink-950/70 backdrop-blur-sm lg:hidden"
/>
)}
<div className="lg:pl-64">
{/* Topbar */}
<header className="sticky top-0 z-30 flex h-18 items-center gap-4 border-b border-ink-600/60 bg-ink-900/85 px-5 backdrop-blur-xl sm:px-7">
<button
type="button"
onClick={() => setSidebarOpen(true)}
className="grid size-10 place-items-center rounded-lg text-white/60 hover:bg-white/5 hover:text-white lg:hidden"
aria-label="Otevřít menu"
>
<Menu className="size-5" />
</button>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-white">
{user?.company ?? 'Klientský portál'}
</p>
<p className="truncate text-xs text-white/40">
{user?.role === 'admin' ? 'Interní přístup' : 'Klientský přístup'}
</p>
</div>
<LiveIndicator className="hidden sm:inline-flex" />
<div className="flex items-center gap-3">
<div className="hidden text-right sm:block">
<p className="text-sm font-medium text-white">{user?.name}</p>
<p className="text-xs text-white/40">{user?.email}</p>
</div>
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-gradient-to-br from-brand-400/25 to-accent-500/25 font-mono text-sm font-bold text-brand-200">
{initials(user?.name)}
</span>
</div>
</header>
<main className="px-5 py-7 sm:px-7">
<Outlet />
</main>
</div>
<EventToasts />
<SimulationModal open={simulationOpen} onClose={() => setSimulationOpen(false)} />
</div>
);
}
function initials(name: string | undefined): string {
if (!name) return '?';
return name
.split(' ')
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? '')
.join('');
}
@@ -0,0 +1,60 @@
import { AlertCircle, Inbox, RefreshCw } from 'lucide-react';
import type { ReactNode } from 'react';
import { Button } from '@/components/ui/Button';
import { Spinner } from '@/components/ui/Spinner';
/**
* Jednotne stavy pro data v dashboardu: nacitani / chyba / prazdno.
* Chyba se vzdy zobrazi uzivateli, nikdy se nespolkne.
*/
export function DataState({
loading,
error,
empty,
onRetry,
children,
emptyLabel = 'Žádná data k zobrazení.',
}: {
loading: boolean;
error: string | null;
empty?: boolean;
onRetry?: () => void;
children: ReactNode;
emptyLabel?: string;
}) {
if (loading) {
return (
<div className="grid min-h-40 place-items-center">
<Spinner label="Načítám data…" />
</div>
);
}
if (error) {
return (
<div className="flex min-h-40 flex-col items-center justify-center gap-4 text-center">
<p className="flex items-center gap-2 text-sm text-danger-400">
<AlertCircle className="size-4 shrink-0" />
{error}
</p>
{onRetry && (
<Button variant="secondary" size="sm" onClick={onRetry}>
<RefreshCw className="size-4" />
Zkusit znovu
</Button>
)}
</div>
);
}
if (empty) {
return (
<div className="flex min-h-40 flex-col items-center justify-center gap-2 text-center text-white/45">
<Inbox className="size-6" />
<p className="text-sm">{emptyLabel}</p>
</div>
);
}
return <>{children}</>;
}
@@ -0,0 +1,94 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { connectEventStream, type StreamStatus } from '@/lib/eventStream';
import type { DashboardEvent, DashboardEventType } from '@/types/events';
interface EventStreamState {
status: StreamStatus;
/** Poslednich par udalosti, nejnovejsi prvni. */
events: DashboardEvent[];
/** Prihlaseni k odberu. Vraci funkci pro odhlaseni. */
subscribe: (listener: (event: DashboardEvent) => void) => () => void;
}
const noop = () => () => {};
const EventStreamContext = createContext<EventStreamState>({
status: 'closed',
events: [],
subscribe: () => {
console.warn('[stream] subscribe mimo EventStreamProvider, udalosti nedorazi');
return noop();
},
});
const MAX_KEPT = 30;
/**
* Drzi jedno spojeni na SSE stream pro cely dashboard a rozesila udalosti
* vsem, kdo o ne stoji. Jedno spojeni na aplikaci, ne jedno na komponentu.
*/
export function EventStreamProvider({ children }: { children: ReactNode }) {
const [status, setStatus] = useState<StreamStatus>('connecting');
const [events, setEvents] = useState<DashboardEvent[]>([]);
const listeners = useRef(new Set<(event: DashboardEvent) => void>());
useEffect(() => {
const close = connectEventStream('/api/dashboard/stream', {
onStatus: setStatus,
onEvent: (event) => {
setEvents((previous) => [event, ...previous].slice(0, MAX_KEPT));
for (const listener of listeners.current) {
try {
listener(event);
} catch (err) {
// Chyba jednoho odberatele nesmi shodit rozesilani ostatnim.
console.error('[stream] chyba v posluchaci udalosti:', err);
}
}
},
});
return close;
}, []);
const subscribe = useCallback((listener: (event: DashboardEvent) => void) => {
listeners.current.add(listener);
return () => {
listeners.current.delete(listener);
};
}, []);
const value = useMemo<EventStreamState>(
() => ({ status, events, subscribe }),
[status, events, subscribe],
);
return <EventStreamContext.Provider value={value}>{children}</EventStreamContext.Provider>;
}
export function useEventStream(): EventStreamState {
return useContext(EventStreamContext);
}
/** Zavola callback jen pri udalostech uvedenych typu. */
export function useEventListener(
types: DashboardEventType[],
handler: (event: DashboardEvent) => void,
) {
const { subscribe } = useEventStream();
const handlerRef = useRef(handler);
handlerRef.current = handler;
// Klic z pole, aby se odber neobnovoval pri kazdem prekresleni.
const key = types.join(',');
useEffect(() => {
if (types.length === 0) return;
const wanted = new Set(key.split(','));
return subscribe((event) => {
if (wanted.has(event.type)) handlerRef.current(event);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [subscribe, key]);
}
@@ -0,0 +1,88 @@
import { AlarmClock, CheckCircle2, LifeBuoy, Webhook, Workflow, X } from 'lucide-react';
import { useEffect, useState } from 'react';
import type { LucideIcon } from 'lucide-react';
import { useEventStream } from '@/components/dashboard/EventStreamProvider';
import { cn } from '@/lib/cn';
import type { DashboardEvent, DashboardEventType } from '@/types/events';
/** Jak dlouho bublina zustane, nez sama zmizi. */
const LIFETIME_MS = 6_000;
const MAX_VISIBLE = 4;
const style: Record<DashboardEventType, { icon: LucideIcon; tone: string }> = {
'ticket.created': { icon: LifeBuoy, tone: 'text-brand-300 bg-brand-500/12' },
'ticket.updated': { icon: LifeBuoy, tone: 'text-brand-300 bg-brand-500/12' },
'ticket.resolved': { icon: CheckCircle2, tone: 'text-ok-400 bg-ok-500/12' },
'incident.started': { icon: AlarmClock, tone: 'text-danger-400 bg-danger-500/12' },
'incident.updated': { icon: AlarmClock, tone: 'text-warn-400 bg-warn-500/12' },
'incident.resolved': { icon: CheckCircle2, tone: 'text-ok-400 bg-ok-500/12' },
'automation.created': { icon: Workflow, tone: 'text-brand-300 bg-brand-500/12' },
'automation.updated': { icon: Workflow, tone: 'text-brand-300 bg-brand-500/12' },
'automation.deleted': { icon: Workflow, tone: 'text-white/50 bg-white/5' },
'automation.run': { icon: Workflow, tone: 'text-ok-400 bg-ok-500/12' },
'webhook.received': { icon: Webhook, tone: 'text-accent-300 bg-accent-500/12' },
};
/** Bubliny o tom, co se prave stalo. Bez nich by zive zmeny nebyly poznat. */
export function EventToasts() {
const { events } = useEventStream();
const [visible, setVisible] = useState<DashboardEvent[]>([]);
const [dismissed, setDismissed] = useState<Set<string>>(new Set());
// Nove udalosti pridame mezi viditelne a po chvili je odebereme.
useEffect(() => {
const newest = events[0];
if (!newest || dismissed.has(newest.id)) return;
setVisible((previous) => {
if (previous.some((e) => e.id === newest.id)) return previous;
return [newest, ...previous].slice(0, MAX_VISIBLE);
});
const timer = window.setTimeout(() => {
setVisible((previous) => previous.filter((e) => e.id !== newest.id));
}, LIFETIME_MS);
return () => window.clearTimeout(timer);
}, [events, dismissed]);
function dismiss(id: string) {
setDismissed((previous) => new Set(previous).add(id));
setVisible((previous) => previous.filter((e) => e.id !== id));
}
if (visible.length === 0) return null;
return (
<div
className="pointer-events-none fixed right-4 bottom-4 z-50 flex w-full max-w-sm flex-col gap-2"
role="status"
aria-live="polite"
>
{visible.map((event) => {
const look = style[event.type] ?? { icon: Workflow, tone: 'text-white/60 bg-white/5' };
const Icon = look.icon;
return (
<div
key={event.id}
className="animate-rise glass pointer-events-auto flex items-start gap-3 rounded-xl p-3.5 shadow-xl"
>
<span className={cn('grid size-8 shrink-0 place-items-center rounded-lg', look.tone)}>
<Icon className="size-4" />
</span>
<p className="min-w-0 flex-1 text-sm text-white/80">{event.message}</p>
<button
type="button"
onClick={() => dismiss(event.id)}
className="grid size-6 shrink-0 place-items-center rounded text-white/35 transition-colors hover:bg-white/5 hover:text-white"
aria-label="Zavřít oznámení"
>
<X className="size-3.5" />
</button>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,33 @@
import { useEventStream } from '@/components/dashboard/EventStreamProvider';
import { cn } from '@/lib/cn';
const meta = {
connecting: { label: 'Připojuji', dot: 'bg-warn-400', text: 'text-warn-400', pulse: true },
open: { label: 'Živě', dot: 'bg-ok-400', text: 'text-ok-400', pulse: true },
reconnecting: { label: 'Obnovuji spojení', dot: 'bg-warn-400', text: 'text-warn-400', pulse: true },
closed: { label: 'Odpojeno', dot: 'bg-white/40', text: 'text-white/45', pulse: false },
} as const;
/** Stav spojeni se streamem. Uzivatel musi poznat, ze data nejsou zive. */
export function LiveIndicator({ className }: { className?: string }) {
const { status } = useEventStream();
const current = meta[status];
return (
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-full border border-ink-600/70 px-2.5 py-1 text-xs font-medium',
current.text,
className,
)}
title={
status === 'open'
? 'Dashboard dostává změny okamžitě, bez obnovování stránky.'
: 'Změny se teď nemusí projevovat okamžitě.'
}
>
<span className={cn('size-1.5 rounded-full', current.dot, current.pulse && 'animate-pulse')} />
{current.label}
</span>
);
}
+113
View File
@@ -0,0 +1,113 @@
import { formatDay, formatNumber } from '@/lib/format';
import type { SeriesPoint } from '@/types/dashboard';
/**
* Sloupcovy graf spusteni automatizaci za poslednich 14 dni.
*
* Zamerne JEDNA serie (pocet spusteni) a jedna osa - chybovost je radove
* mensi cislo a druha osa by graf jen zkreslila. Chyby jsou v tooltipu
* a jako zvyrazneni sloupce, kde jich bylo nejvic.
* Pod grafem je tabulka pro cteni bez barev (screen readery, tisk).
*/
export function RunsChart({ series }: { series: SeriesPoint[] }) {
if (series.length === 0) {
console.warn('[chart] RunsChart dostal prazdnou serii');
return <p className="text-sm text-white/45">Zatím nemáme dost dat pro graf.</p>;
}
const max = Math.max(...series.map((point) => point.runs));
const peak = series.reduce((best, point) => (point.runs > best.runs ? point : best), series[0]);
const total = series.reduce((sum, point) => sum + point.runs, 0);
const failures = series.reduce((sum, point) => sum + point.failures, 0);
return (
<figure className="m-0">
<figcaption className="mb-5 flex flex-wrap items-end justify-between gap-3">
<div>
<h3 className="font-semibold text-white">Spuštění automatizací</h3>
<p className="mt-0.5 text-xs text-white/45">
posledních {series.length} dní · celkem {formatNumber(total)} spuštění,{' '}
{formatNumber(failures)} chyb
</p>
</div>
<p className="text-right text-xs text-white/35">
maximum {formatNumber(peak.runs)} · {formatDay(peak.date)}
</p>
</figcaption>
<div
className="relative h-44"
role="img"
aria-label={`Sloupcový graf spuštění automatizací za posledních ${series.length} dní. Celkem ${formatNumber(total)} spuštění, maximum ${formatNumber(peak.runs)} dne ${formatDay(peak.date)}.`}
>
{/* Recesivni mrizka - jen tri linky, aby nepretahovaly pozornost */}
<div aria-hidden className="absolute inset-0 flex flex-col justify-between">
{[0, 1, 2].map((line) => (
<span key={line} className="h-px w-full bg-ink-600/40" />
))}
</div>
<div className="relative flex h-full items-end gap-[2px]">
{series.map((point) => {
const height = Math.max(4, Math.round((point.runs / max) * 100));
const isPeak = point.date === peak.date;
return (
<div key={point.date} className="group relative flex h-full flex-1 items-end">
<div
className={
isPeak
? 'w-full rounded-t bg-brand-400 transition-opacity group-hover:opacity-100'
: 'w-full rounded-t bg-brand-500/55 transition-colors group-hover:bg-brand-400'
}
style={{ height: `${height}%` }}
/>
{/* Tooltip - vetsi hit area diky rodici pres celou vysku */}
<div className="pointer-events-none absolute bottom-full left-1/2 z-10 mb-2 hidden -translate-x-1/2 group-hover:block">
<div className="rounded-lg border border-ink-600 bg-ink-950/95 px-3 py-2 whitespace-nowrap shadow-xl">
<p className="text-xs font-semibold text-white">{formatDay(point.date)}</p>
<p className="mt-0.5 text-xs text-white/60">
{formatNumber(point.runs)} spuštění
</p>
<p className="text-xs text-white/60">{formatNumber(point.failures)} chyb</p>
</div>
</div>
</div>
);
})}
</div>
</div>
<div className="mt-2 flex justify-between text-xs text-white/35">
<span>{formatDay(series[0].date)}</span>
<span>{formatDay(series[series.length - 1].date)}</span>
</div>
{/* Alternativa k barevnemu cteni grafu */}
<details className="mt-4 text-xs text-white/40">
<summary className="cursor-pointer transition-colors hover:text-white/70">
Zobrazit data v tabulce
</summary>
<table className="mt-3 w-full text-left">
<thead className="text-white/35">
<tr>
<th className="py-1 font-medium">Den</th>
<th className="py-1 text-right font-medium">Spuštění</th>
<th className="py-1 text-right font-medium">Chyby</th>
</tr>
</thead>
<tbody className="font-mono text-white/55">
{series.map((point) => (
<tr key={point.date} className="border-t border-ink-600/40">
<td className="py-1">{formatDay(point.date)}</td>
<td className="py-1 text-right tabular-nums">{formatNumber(point.runs)}</td>
<td className="py-1 text-right tabular-nums">{formatNumber(point.failures)}</td>
</tr>
))}
</tbody>
</table>
</details>
</figure>
);
}
@@ -0,0 +1,281 @@
import { AlarmClock, AlertCircle, CheckCircle2, LifeBuoy, Play, Workflow } from 'lucide-react';
import { useState } from 'react';
import type { FormEvent, ReactNode } from 'react';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/cn';
type Action =
| 'ticket.created'
| 'ticket.resolved'
| 'incident.started'
| 'incident.resolved'
| 'automation.run';
interface Result {
ok: boolean;
message: string;
}
const priorities = [
{ value: 'low', label: 'Nízká' },
{ value: 'normal', label: 'Běžná' },
{ value: 'high', label: 'Vysoká' },
{ value: 'critical', label: 'Kritická' },
] as const;
const severities = [
{ value: 'sev3', label: 'SEV3 - menší' },
{ value: 'sev2', label: 'SEV2 - vážný' },
{ value: 'sev1', label: 'SEV1 - kritický' },
] as const;
const inputClass =
'w-full rounded-xl border border-ink-600/70 bg-ink-850/70 px-3.5 py-2.5 text-sm text-white placeholder:text-white/30 focus:border-brand-400/70 focus:outline-none';
/**
* Simulace provoznich udalosti. Slouzi k nahledu zivého dashboardu bez toho,
* aby se muselo cekat na skutecny provoz.
*
* Nejde o falesne notifikace - kazda akce opravdu zmeni data na serveru,
* takze se projevi i v seznamech a v souhrnu.
*/
export function SimulationModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const [running, setRunning] = useState<Action | null>(null);
const [result, setResult] = useState<Result | null>(null);
const [subject, setSubject] = useState('');
const [requester, setRequester] = useState('');
const [priority, setPriority] = useState<(typeof priorities)[number]['value']>('normal');
const [title, setTitle] = useState('');
const [service, setService] = useState('');
const [severity, setSeverity] = useState<(typeof severities)[number]['value']>('sev2');
async function run(action: Action, body: Record<string, unknown> = {}) {
setRunning(action);
setResult(null);
try {
await apiFetch<unknown>('/api/simulate', {
method: 'POST',
body: { action, ...body },
});
setResult({ ok: true, message: 'Hotovo. Změna se objevila v dashboardu.' });
} catch (err) {
const message = err instanceof Error ? err.message : 'Simulaci se nepodařilo spustit.';
console.error('[simulace] selhalo:', err);
setResult({ ok: false, message });
} finally {
setRunning(null);
}
}
function submitTicket(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
void run('ticket.created', {
// Prazdna pole neposilame, server pak doplni ukazkovou hodnotu.
...(subject.trim() ? { subject: subject.trim() } : {}),
...(requester.trim() ? { requester: requester.trim() } : {}),
priority,
});
setSubject('');
}
function submitIncident(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
void run('incident.started', {
...(title.trim() ? { title: title.trim() } : {}),
...(service.trim() ? { service: service.trim() } : {}),
severity,
});
setTitle('');
}
return (
<Modal
open={open}
onClose={onClose}
title="Simulace provozu"
description="Vyvolá skutečnou událost, aby bylo vidět, jak dashboard reaguje živě."
>
<div className="max-h-[65vh] space-y-5 overflow-y-auto p-5">
<Panel icon={LifeBuoy} title="Nový ticket">
<form onSubmit={submitTicket} className="space-y-3">
<input
value={subject}
onChange={(event) => setSubject(event.target.value)}
placeholder="Předmět (nepovinné, jinak se doplní ukázkový)"
className={inputClass}
/>
<div className="flex flex-wrap gap-3">
<input
value={requester}
onChange={(event) => setRequester(event.target.value)}
placeholder="Zadavatel"
className={cn(inputClass, 'min-w-0 flex-1')}
/>
<select
value={priority}
onChange={(event) =>
setPriority(event.target.value as (typeof priorities)[number]['value'])
}
aria-label="Priorita"
className={cn(inputClass, 'w-40')}
>
{priorities.map((item) => (
<option key={item.value} value={item.value} className="bg-ink-850">
{item.label}
</option>
))}
</select>
</div>
<Button type="submit" size="sm" disabled={running !== null}>
<Play className="size-4" />
{running === 'ticket.created' ? 'Zakládám...' : 'Založit ticket'}
</Button>
</form>
</Panel>
<Panel icon={AlarmClock} title="Nový incident">
<form onSubmit={submitIncident} className="space-y-3">
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="Popis (nepovinné, jinak se doplní ukázkový)"
className={inputClass}
/>
<div className="flex flex-wrap gap-3">
<input
value={service}
onChange={(event) => setService(event.target.value)}
placeholder="Služba"
className={cn(inputClass, 'min-w-0 flex-1')}
/>
<select
value={severity}
onChange={(event) =>
setSeverity(event.target.value as (typeof severities)[number]['value'])
}
aria-label="Závažnost"
className={cn(inputClass, 'w-44')}
>
{severities.map((item) => (
<option key={item.value} value={item.value} className="bg-ink-850">
{item.label}
</option>
))}
</select>
</div>
<Button type="submit" size="sm" disabled={running !== null}>
<Play className="size-4" />
{running === 'incident.started' ? 'Zakládám...' : 'Vyvolat incident'}
</Button>
</form>
</Panel>
<Panel icon={Workflow} title="Rychlé akce">
<p className="mb-3 text-sm text-white/50">
Působí na první vhodný záznam, nemusíte nic vybírat.
</p>
<div className="flex flex-wrap gap-2">
<QuickButton
onClick={() => void run('ticket.resolved')}
disabled={running !== null}
busy={running === 'ticket.resolved'}
>
Vyřešit ticket
</QuickButton>
<QuickButton
onClick={() => void run('incident.resolved')}
disabled={running !== null}
busy={running === 'incident.resolved'}
>
Vyřešit incident
</QuickButton>
<QuickButton
onClick={() => void run('automation.run', { ok: true })}
disabled={running !== null}
busy={running === 'automation.run'}
>
Spustit automatizaci
</QuickButton>
<QuickButton
onClick={() => void run('automation.run', { ok: false })}
disabled={running !== null}
busy={running === 'automation.run'}
>
Neúspěšný běh
</QuickButton>
</div>
</Panel>
{result && (
<p
className={cn(
'flex items-center gap-2 rounded-xl border px-4 py-3 text-sm',
result.ok
? 'border-ok-400/30 bg-ok-500/10 text-ok-400'
: 'border-danger-400/30 bg-danger-500/10 text-danger-400',
)}
>
{result.ok ? (
<CheckCircle2 className="size-4 shrink-0" />
) : (
<AlertCircle className="size-4 shrink-0" />
)}
{result.message}
</p>
)}
<p className="rounded-xl border border-ink-600/60 px-4 py-3 text-xs leading-relaxed text-white/40">
Simulace zapisuje do stejných dat jako běžný provoz. Data jsou v paměti API,
takže se restartem serveru vrátí do výchozího stavu.
</p>
</div>
</Modal>
);
}
function Panel({
icon: Icon,
title,
children,
}: {
icon: typeof LifeBuoy;
title: string;
children: ReactNode;
}) {
return (
<section className="rounded-xl border border-ink-600/60 bg-ink-850/40 p-4">
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-white">
<Icon className="size-4 text-brand-300" />
{title}
</h3>
{children}
</section>
);
}
function QuickButton({
onClick,
disabled,
busy,
children,
}: {
onClick: () => void;
disabled: boolean;
busy: boolean;
children: ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className="rounded-lg border border-ink-600/70 px-3.5 py-2 text-sm text-white/70 transition-colors hover:border-brand-400/60 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
>
{busy ? 'Spouštím...' : children}
</button>
);
}
+42
View File
@@ -0,0 +1,42 @@
import type { LucideIcon } from 'lucide-react';
import { cn } from '@/lib/cn';
/**
* Dlazdice s jednim cislem. Hodnota je hero prvek, popisek pod ni,
* ikona jen jako orientacni znacka - text vzdy nese informaci.
*/
export function StatTile({
icon: Icon,
label,
value,
hint,
tone = 'brand',
className,
}: {
icon: LucideIcon;
label: string;
value: string;
hint?: string;
tone?: 'brand' | 'ok' | 'warn' | 'danger';
className?: string;
}) {
const tones = {
brand: 'bg-brand-500/12 text-brand-300',
ok: 'bg-ok-500/12 text-ok-400',
warn: 'bg-warn-500/12 text-warn-400',
danger: 'bg-danger-500/12 text-danger-400',
} as const;
return (
<div className={cn('glass rounded-card p-5', className)}>
<div className="flex items-start justify-between gap-3">
<p className="text-sm text-white/50">{label}</p>
<span className={cn('grid size-8 shrink-0 place-items-center rounded-lg', tones[tone])}>
<Icon className="size-4" />
</span>
</div>
<p className="mt-3 text-3xl font-extrabold text-white tabular-nums">{value}</p>
{hint && <p className="mt-1 text-xs text-white/40">{hint}</p>}
</div>
);
}
@@ -0,0 +1,82 @@
import { AlertTriangle, CheckCircle2, CircleDot, Clock, Search, ShieldAlert } from 'lucide-react';
import { Badge, type BadgeTone } from '@/components/ui/Badge';
import type {
IncidentSeverity,
IncidentStatus,
TicketPriority,
TicketStatus,
} from '@/types/dashboard';
/**
* Stavove odznaky. Barva NIKDY nenese informaci sama - vzdy je s ni
* text i ikona (pristupnost, viz docs/05-design-system.md).
*/
const ticketStatusMap: Record<TicketStatus, { label: string; tone: BadgeTone }> = {
new: { label: 'Nový', tone: 'brand' },
open: { label: 'V řešení', tone: 'warn' },
waiting: { label: 'Čeká na klienta', tone: 'neutral' },
resolved: { label: 'Vyřešeno', tone: 'ok' },
};
const ticketPriorityMap: Record<TicketPriority, { label: string; tone: BadgeTone }> = {
low: { label: 'Nízká', tone: 'neutral' },
normal: { label: 'Běžná', tone: 'neutral' },
high: { label: 'Vysoká', tone: 'warn' },
critical: { label: 'Kritická', tone: 'danger' },
};
const incidentSeverityMap: Record<IncidentSeverity, { label: string; tone: BadgeTone }> = {
sev1: { label: 'SEV1 — kritický', tone: 'danger' },
sev2: { label: 'SEV2 — vážný', tone: 'warn' },
sev3: { label: 'SEV3 — menší', tone: 'neutral' },
};
const incidentStatusMap: Record<IncidentStatus, { label: string; tone: BadgeTone }> = {
investigating: { label: 'Zkoumáme', tone: 'warn' },
identified: { label: 'Příčina známa', tone: 'warn' },
monitoring: { label: 'Sledujeme', tone: 'brand' },
resolved: { label: 'Vyřešeno', tone: 'ok' },
};
export function TicketStatusBadge({ status }: { status: TicketStatus }) {
const { label, tone } = ticketStatusMap[status];
const Icon = status === 'resolved' ? CheckCircle2 : status === 'waiting' ? Clock : CircleDot;
return (
<Badge tone={tone}>
<Icon className="size-3.5" />
{label}
</Badge>
);
}
export function TicketPriorityBadge({ priority }: { priority: TicketPriority }) {
const { label, tone } = ticketPriorityMap[priority];
return (
<Badge tone={tone}>
{(priority === 'critical' || priority === 'high') && <AlertTriangle className="size-3.5" />}
{label}
</Badge>
);
}
export function IncidentSeverityBadge({ severity }: { severity: IncidentSeverity }) {
const { label, tone } = incidentSeverityMap[severity];
return (
<Badge tone={tone}>
<ShieldAlert className="size-3.5" />
{label}
</Badge>
);
}
export function IncidentStatusBadge({ status }: { status: IncidentStatus }) {
const { label, tone } = incidentStatusMap[status];
const Icon = status === 'resolved' ? CheckCircle2 : status === 'investigating' ? Search : CircleDot;
return (
<Badge tone={tone}>
<Icon className="size-3.5" />
{label}
</Badge>
);
}
@@ -0,0 +1,567 @@
import { ChevronDown, ChevronUp, GitBranch, Plus, Trash2, Zap } from 'lucide-react';
import type { ReactNode } from 'react';
import { TriggerConfig } from '@/components/dashboard/flow/TriggerConfig';
import { Badge } from '@/components/ui/Badge';
import { cn } from '@/lib/cn';
import { connectorIcon } from '@/lib/connectorIcons';
import {
defaultOperatorFor,
describeCondition,
isUnaryOperator,
operatorLabel,
operatorsByType,
resolveOperation,
type FlowPath,
} from '@/lib/flow';
import type {
AutomationFlow,
ConditionOperator,
Connector,
FlowStep,
TriggerField,
} from '@/types/dashboard';
/**
* Vizualizace stromu akci. Sama nic nemeni - vsechny zmeny hlasi nahoru
* pres callbacky, stav drzi stranka (AutomationDetail).
*
* Struktura: karta spoustece -> sekvence kroku. Podminka rozdeluje beh
* na vetev ANO a NE, kazda ma vlastni sekvenci a vlastni "+".
*/
interface CanvasCallbacks {
onAddStep: (path: FlowPath, index: number) => void;
onRemoveStep: (stepId: string) => void;
onUpdateCondition: (
stepId: string,
patch: { fieldId?: string; operator?: ConditionOperator; value?: string },
) => void;
onMoveStep: (stepId: string, offset: number) => void;
}
export function FlowCanvas({
flow,
connectors,
webhookBaseUrl,
regenerating,
onPickTrigger,
onChangeFields,
onRegenerateToken,
...callbacks
}: CanvasCallbacks & {
flow: AutomationFlow;
connectors: Connector[];
webhookBaseUrl: string;
regenerating: boolean;
onPickTrigger: () => void;
onChangeFields: (fields: TriggerField[]) => void;
onRegenerateToken: () => void;
}) {
return (
<div className="flex flex-col items-stretch">
<TriggerCard
flow={flow}
connectors={connectors}
webhookBaseUrl={webhookBaseUrl}
regenerating={regenerating}
onPick={onPickTrigger}
onChangeFields={onChangeFields}
onRegenerateToken={onRegenerateToken}
/>
{flow.trigger && (
<StepSequence
steps={flow.steps}
path={[]}
connectors={connectors}
fields={flow.trigger.fields}
{...callbacks}
/>
)}
</div>
);
}
/** Prvni karta stromu - dokud neni vybrany spoustec, je to vyzva ke kliknuti. */
function TriggerCard({
flow,
connectors,
webhookBaseUrl,
regenerating,
onPick,
onChangeFields,
onRegenerateToken,
}: {
flow: AutomationFlow;
connectors: Connector[];
webhookBaseUrl: string;
regenerating: boolean;
onPick: () => void;
onChangeFields: (fields: TriggerField[]) => void;
onRegenerateToken: () => void;
}) {
if (!flow.trigger) {
return (
<button
type="button"
onClick={onPick}
className="group flex items-center gap-4 rounded-card border border-dashed border-brand-400/40 bg-brand-500/5 p-5 text-left transition-colors hover:border-brand-400/80 hover:bg-brand-500/10"
>
<span className="grid size-11 shrink-0 place-items-center rounded-xl bg-brand-500/15 text-brand-300 transition-transform group-hover:scale-105">
<Zap className="size-5" />
</span>
<span>
<span className="block font-semibold text-white">Vyberte spouštěč</span>
<span className="mt-0.5 block text-sm text-white/55">
Webhook, plánovač, formulář, nebo událost v napojené službě.
</span>
</span>
</button>
);
}
const resolved = resolveOperation(
connectors,
flow.trigger.connectorId,
flow.trigger.operationId,
'trigger',
);
if (!resolved) {
return (
<BrokenCard
label="Spouštěč odkazuje na neznámý konektor"
detail={`${flow.trigger.connectorId} / ${flow.trigger.operationId}`}
onFix={onPick}
/>
);
}
const Icon = connectorIcon(resolved.connector.icon);
return (
<div className="rounded-card border border-brand-400/35 bg-gradient-to-br from-brand-500/12 to-transparent p-5">
<div className="flex items-start gap-4">
<span className="grid size-11 shrink-0 place-items-center rounded-xl bg-brand-500/20 text-brand-200">
<Icon className="size-5" />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<Badge tone="brand">
<Zap className="size-3" />
Spouštěč
</Badge>
<span className="text-xs text-white/40">{resolved.connector.name}</span>
</div>
<p className="mt-2 font-semibold text-white">{resolved.operation.name}</p>
<p className="mt-0.5 text-sm text-white/55">{resolved.operation.description}</p>
</div>
<button
type="button"
onClick={onPick}
className="shrink-0 rounded-lg px-3 py-1.5 text-xs font-medium text-white/50 transition-colors hover:bg-white/5 hover:text-white"
>
Změnit
</button>
</div>
<TriggerConfig
trigger={flow.trigger}
connector={resolved.connector}
webhookBaseUrl={webhookBaseUrl}
onChangeFields={onChangeFields}
onRegenerateToken={onRegenerateToken}
regenerating={regenerating}
/>
</div>
);
}
/** Sekvence kroku s "+" na konci i mezi kroky. */
function StepSequence({
steps,
path,
connectors,
fields,
compact = false,
...callbacks
}: CanvasCallbacks & {
steps: FlowStep[];
path: FlowPath;
connectors: Connector[];
fields: TriggerField[];
compact?: boolean;
}) {
const { onAddStep, onRemoveStep, onMoveStep } = callbacks;
return (
<div className="flex flex-col items-stretch">
{steps.map((step, index) => (
<div key={step.id} className="flex flex-col items-stretch">
<AddStepButton onClick={() => onAddStep(path, index)} compact={compact} />
{step.kind === 'action' ? (
<ActionCard
step={step}
connectors={connectors}
canMoveUp={index > 0}
canMoveDown={index < steps.length - 1}
onRemove={() => onRemoveStep(step.id)}
onMove={(offset) => onMoveStep(step.id, offset)}
/>
) : (
<ConditionCard
step={step}
path={path}
connectors={connectors}
fields={fields}
canMoveUp={index > 0}
canMoveDown={index < steps.length - 1}
{...callbacks}
/>
)}
</div>
))}
<AddStepButton onClick={() => onAddStep(path, steps.length)} compact={compact} last />
</div>
);
}
/** Spojnice + tlacitko "+". Tohle je to misto, kde se strom rozsiruje. */
function AddStepButton({
onClick,
compact = false,
last = false,
}: {
onClick: () => void;
compact?: boolean;
last?: boolean;
}) {
return (
<div className="flex flex-col items-center">
<span className={cn('w-px bg-ink-600', compact ? 'h-3' : 'h-4')} aria-hidden />
<button
type="button"
onClick={onClick}
title="Přidat krok"
className={cn(
'group grid place-items-center rounded-full border border-dashed border-ink-600 bg-ink-850 text-white/40 transition-all hover:scale-110 hover:border-brand-400 hover:bg-brand-500/15 hover:text-brand-300',
compact ? 'size-7' : 'size-8',
)}
>
<Plus className={compact ? 'size-3.5' : 'size-4'} />
<span className="sr-only">Přidat krok</span>
</button>
{!last && <span className={cn('w-px bg-ink-600', compact ? 'h-3' : 'h-4')} aria-hidden />}
</div>
);
}
function ActionCard({
step,
connectors,
canMoveUp,
canMoveDown,
onRemove,
onMove,
}: {
step: Extract<FlowStep, { kind: 'action' }>;
connectors: Connector[];
canMoveUp: boolean;
canMoveDown: boolean;
onRemove: () => void;
onMove: (offset: number) => void;
}) {
const resolved = resolveOperation(connectors, step.connectorId, step.operationId, 'action');
if (!resolved) {
return (
<BrokenCard
label="Krok odkazuje na neznámou akci"
detail={`${step.connectorId} / ${step.operationId}`}
onRemove={onRemove}
/>
);
}
const Icon = connectorIcon(resolved.connector.icon);
return (
<StepShell>
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-ink-700/70 text-brand-300">
<Icon className="size-4.5" />
</span>
<div className="min-w-0 flex-1">
<p className="text-xs text-white/40">{resolved.connector.name}</p>
<p className="mt-0.5 font-semibold text-white">{resolved.operation.name}</p>
<p className="mt-0.5 text-sm text-white/50">{resolved.operation.description}</p>
</div>
<StepControls
canMoveUp={canMoveUp}
canMoveDown={canMoveDown}
onMove={onMove}
onRemove={onRemove}
/>
</StepShell>
);
}
function ConditionCard({
step,
path,
connectors,
fields,
canMoveUp,
canMoveDown,
...callbacks
}: CanvasCallbacks & {
step: Extract<FlowStep, { kind: 'condition' }>;
path: FlowPath;
connectors: Connector[];
fields: TriggerField[];
canMoveUp: boolean;
canMoveDown: boolean;
}) {
const { onRemoveStep, onMoveStep, onUpdateCondition } = callbacks;
const field = fields.find((f) => f.id === step.fieldId);
const allowedOperators = field ? operatorsByType[field.type] : [];
const needsValue = !isUnaryOperator(step.operator);
const valueMissing = needsValue && (step.value ?? '').trim().length === 0;
/** Zmena parametru muze zneplatnit operator - v tom pripade ho prepneme. */
function changeField(fieldId: string) {
const next = fields.find((f) => f.id === fieldId);
if (!next) {
console.warn(`[flow] podminka: neznamy parametr ${fieldId}`);
return;
}
const operatorStillValid = operatorsByType[next.type].includes(step.operator);
onUpdateCondition(step.id, {
fieldId,
operator: operatorStillValid ? step.operator : defaultOperatorFor(next.type),
value: operatorStillValid ? step.value : '',
});
}
return (
<div className="rounded-card border border-accent-400/30 bg-accent-500/6 p-4">
<div className="flex items-start gap-3">
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-accent-500/20 text-accent-300">
<GitBranch className="size-4.5" />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-xs text-white/40">Podmínka</p>
{valueMissing && <Badge tone="warn">Doplňte hodnotu</Badge>}
</div>
{!field ? (
<p className="mt-1 text-sm text-danger-400">
Parametr, na který se podmínka odkazovala, neexistuje. Vyberte jiný, nebo
podmínku odeberte.
</p>
) : (
<p className="mt-0.5 font-semibold text-white">
{describeCondition(field, step.operator, step.value)}
</p>
)}
{/* Vlastni editor: parametr - operator - hodnota */}
<div className="mt-2.5 flex flex-wrap items-center gap-2">
<select
value={field ? step.fieldId : ''}
onChange={(event) => changeField(event.target.value)}
aria-label="Parametr"
className="rounded-lg border border-ink-600/70 bg-ink-900/70 px-3 py-1.5 font-mono text-sm text-white focus:border-accent-400/70 focus:outline-none"
>
{!field && (
<option value="" className="bg-ink-850">
vyberte parametr
</option>
)}
{fields.map((option) => (
<option key={option.id} value={option.id} className="bg-ink-850">
{option.name || '(bez názvu)'}
</option>
))}
</select>
<select
value={step.operator}
onChange={(event) =>
onUpdateCondition(step.id, {
operator: event.target.value as ConditionOperator,
})
}
disabled={!field}
aria-label="Operátor"
className="rounded-lg border border-ink-600/70 bg-ink-900/70 px-3 py-1.5 text-sm text-white focus:border-accent-400/70 focus:outline-none disabled:opacity-50"
>
{allowedOperators.map((operator) => (
<option key={operator} value={operator} className="bg-ink-850">
{field ? operatorLabel(operator, field.type) : operator}
</option>
))}
</select>
{needsValue && field && (
<input
value={step.value ?? ''}
onChange={(event) => onUpdateCondition(step.id, { value: event.target.value })}
type={field.type === 'number' ? 'number' : field.type === 'date' ? 'date' : 'text'}
placeholder={field.type === 'number' ? '15' : 'hodnota'}
aria-label="Hodnota"
className={cn(
'w-32 rounded-lg border bg-ink-900/70 px-3 py-1.5 text-sm text-white placeholder:text-white/25 focus:outline-none',
valueMissing
? 'border-warn-400/60'
: 'border-ink-600/70 focus:border-accent-400/70',
)}
/>
)}
</div>
</div>
<StepControls
canMoveUp={canMoveUp}
canMoveDown={canMoveDown}
onMove={(offset) => onMoveStep(step.id, offset)}
onRemove={() => onRemoveStep(step.id)}
/>
</div>
<div className="mt-4 grid gap-4 lg:grid-cols-2">
{(['yes', 'no'] as const).map((branch) => (
<div
key={branch}
className={cn(
'rounded-xl border p-3',
branch === 'yes'
? 'border-ok-400/25 bg-ok-500/5'
: 'border-danger-400/25 bg-danger-500/5',
)}
>
<p
className={cn(
'mb-1 text-xs font-semibold tracking-wide uppercase',
branch === 'yes' ? 'text-ok-400' : 'text-danger-400',
)}
>
{branch === 'yes' ? 'Ano' : 'Ne'}
</p>
{step[branch].length === 0 && (
<p className="mb-1 text-xs text-white/35">
Zatím prázdná větev přidejte krok tlačítkem níž.
</p>
)}
<StepSequence
steps={step[branch]}
path={[...path, { stepId: step.id, branch }]}
connectors={connectors}
fields={fields}
compact
{...callbacks}
/>
</div>
))}
</div>
</div>
);
}
function StepShell({ children }: { children: ReactNode }) {
return (
<div className="flex items-start gap-3 rounded-card border border-ink-600/60 bg-ink-800/60 p-4 transition-colors hover:border-ink-600">
{children}
</div>
);
}
function StepControls({
canMoveUp,
canMoveDown,
onMove,
onRemove,
}: {
canMoveUp: boolean;
canMoveDown: boolean;
onMove: (offset: number) => void;
onRemove: () => void;
}) {
return (
<div className="flex shrink-0 items-center gap-0.5">
<button
type="button"
onClick={() => onMove(-1)}
disabled={!canMoveUp}
title="Posunout výš"
className="grid size-8 place-items-center rounded-lg text-white/35 transition-colors hover:bg-white/5 hover:text-white disabled:pointer-events-none disabled:opacity-25"
>
<ChevronUp className="size-4" />
<span className="sr-only">Posunout výš</span>
</button>
<button
type="button"
onClick={() => onMove(1)}
disabled={!canMoveDown}
title="Posunout níž"
className="grid size-8 place-items-center rounded-lg text-white/35 transition-colors hover:bg-white/5 hover:text-white disabled:pointer-events-none disabled:opacity-25"
>
<ChevronDown className="size-4" />
<span className="sr-only">Posunout níž</span>
</button>
<button
type="button"
onClick={onRemove}
title="Odebrat krok"
className="grid size-8 place-items-center rounded-lg text-white/35 transition-colors hover:bg-danger-500/12 hover:text-danger-400"
>
<Trash2 className="size-3.5" />
<span className="sr-only">Odebrat krok</span>
</button>
</div>
);
}
/** Krok odkazuje na neco, co v katalogu neexistuje - nesmi to tise zmizet. */
function BrokenCard({
label,
detail,
onFix,
onRemove,
}: {
label: string;
detail: string;
onFix?: () => void;
onRemove?: () => void;
}) {
return (
<div className="flex items-start gap-3 rounded-card border border-danger-400/40 bg-danger-500/8 p-4">
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-danger-400">{label}</p>
<p className="mt-0.5 font-mono text-xs text-white/45">{detail}</p>
</div>
{onFix && (
<button
type="button"
onClick={onFix}
className="shrink-0 rounded-lg px-3 py-1.5 text-xs font-medium text-white/60 hover:bg-white/5 hover:text-white"
>
Vybrat znovu
</button>
)}
{onRemove && (
<button
type="button"
onClick={onRemove}
className="grid size-8 shrink-0 place-items-center rounded-lg text-white/40 hover:bg-danger-500/12 hover:text-danger-400"
title="Odebrat krok"
>
<Trash2 className="size-3.5" />
<span className="sr-only">Odebrat krok</span>
</button>
)}
</div>
);
}
@@ -0,0 +1,370 @@
import { ArrowLeft, ChevronRight, GitBranch, Search } from 'lucide-react';
import { useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { Badge } from '@/components/ui/Badge';
import { Modal } from '@/components/ui/Modal';
import { cn } from '@/lib/cn';
import { connectorIcon } from '@/lib/connectorIcons';
import type { Connector, ConnectorCategory, ConnectorOperation } from '@/types/dashboard';
/**
* Vyber toho, co se ma pridat do stromu.
*
* mode = 'trigger' -> nabizi jen udalosti, kterymi muze automatizace zacit
* mode = 'action' -> nabizi akce konektoru + moznost rozvetvit podminkou
*
* Prvni krok = vyber konektoru, druhy = vyber operace. Kdyz uzivatel zacne
* hledat, preskoci se rovnou na plochy seznam operaci napric konektory.
*/
export function StepPicker({
open,
mode,
connectors,
categories,
onClose,
onPickOperation,
onPickCondition,
canAddCondition,
}: {
open: boolean;
mode: 'trigger' | 'action';
connectors: Connector[];
categories: Array<{ id: ConnectorCategory; label: string }>;
onClose: () => void;
onPickOperation: (connectorId: string, operationId: string) => void;
onPickCondition?: () => void;
/** false = spoustec nema zadne parametry, nebylo by podle ceho se rozhodovat */
canAddCondition?: boolean;
}) {
const [selected, setSelected] = useState<Connector | null>(null);
const [query, setQuery] = useState('');
const [category, setCategory] = useState<ConnectorCategory | 'all'>('all');
const operationsOf = (connector: Connector) =>
mode === 'trigger' ? connector.triggers : connector.actions;
// Konektory, ktere v tomhle rezimu vubec maji co nabidnout.
const usable = useMemo(
() => connectors.filter((connector) => operationsOf(connector).length > 0),
[connectors, mode],
);
const visibleCategories = useMemo(
() => categories.filter((cat) => usable.some((connector) => connector.category === cat.id)),
[categories, usable],
);
const filtered = useMemo(
() => (category === 'all' ? usable : usable.filter((c) => c.category === category)),
[usable, category],
);
const trimmedQuery = query.trim().toLowerCase();
const searching = trimmedQuery.length >= 2;
// Ploche vysledky hledani - hleda v nazvu konektoru i v nazvech operaci.
const searchResults = useMemo(() => {
if (!searching) return [];
const results: Array<{ connector: Connector; operation: ConnectorOperation }> = [];
for (const connector of usable) {
for (const operation of operationsOf(connector)) {
const haystack = `${connector.name} ${operation.name} ${operation.description}`.toLowerCase();
if (haystack.includes(trimmedQuery)) results.push({ connector, operation });
}
}
return results;
}, [usable, trimmedQuery, searching, mode]);
function close() {
setSelected(null);
setQuery('');
setCategory('all');
onClose();
}
function pick(connector: Connector, operation: ConnectorOperation) {
if (connector.status === 'planned') {
console.warn(`[picker] konektor ${connector.id} je na roadmape, nelze pouzit`);
return;
}
onPickOperation(connector.id, operation.id);
setSelected(null);
setQuery('');
}
return (
<Modal
open={open}
onClose={close}
title={mode === 'trigger' ? 'Čím má automatizace začít?' : 'Co se má stát?'}
description={
mode === 'trigger'
? 'Vyberte spouštěč — webhook, čas, nebo událost ve službě, kterou máte napojenou.'
: 'Vyberte službu nebo konektor a pak konkrétní akci.'
}
>
<div className="border-b border-ink-600/60 p-5">
<div className="relative">
<Search className="pointer-events-none absolute top-1/2 left-3.5 size-4 -translate-y-1/2 text-white/35" />
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={mode === 'trigger' ? 'Hledat spouštěč…' : 'Hledat akci nebo službu…'}
className="w-full rounded-xl border border-ink-600/70 bg-ink-850/70 py-2.5 pr-4 pl-10 text-sm text-white placeholder:text-white/30 focus:border-brand-400/70 focus:outline-none"
/>
</div>
{!searching && !selected && visibleCategories.length > 1 && (
<div className="mt-4 flex flex-wrap gap-2">
<CategoryChip active={category === 'all'} onClick={() => setCategory('all')}>
Vše
</CategoryChip>
{visibleCategories.map((cat) => (
<CategoryChip
key={cat.id}
active={category === cat.id}
onClick={() => setCategory(cat.id)}
>
{cat.label}
</CategoryChip>
))}
</div>
)}
</div>
<div className="max-h-[55vh] overflow-y-auto p-5">
{/* --- vysledky hledani napric konektory --- */}
{searching && (
<>
{searchResults.length === 0 ? (
<p className="py-8 text-center text-sm text-white/45">
Nic jsme nenašli. Zkuste jiné slovo, nebo použijte konektor HTTP požadavek".
</p>
) : (
<ul className="space-y-2">
{searchResults.map(({ connector, operation }) => (
<li key={`${connector.id}.${operation.id}`}>
<OperationRow
connector={connector}
operation={operation}
showConnectorName
onClick={() => pick(connector, operation)}
/>
</li>
))}
</ul>
)}
</>
)}
{/* --- druhy krok: operace vybraneho konektoru --- */}
{!searching && selected && (
<>
<button
type="button"
onClick={() => setSelected(null)}
className="mb-4 inline-flex items-center gap-2 text-sm text-white/50 transition-colors hover:text-white"
>
<ArrowLeft className="size-4" />
Zpět na výběr služby
</button>
<div className="mb-4 flex items-center gap-3">
<ConnectorGlyph connector={selected} />
<div>
<p className="font-semibold text-white">{selected.name}</p>
<p className="text-xs text-white/45">{selected.description}</p>
</div>
</div>
<ul className="space-y-2">
{operationsOf(selected).map((operation) => (
<li key={operation.id}>
<OperationRow
connector={selected}
operation={operation}
onClick={() => pick(selected, operation)}
/>
</li>
))}
</ul>
</>
)}
{/* --- prvni krok: vyber konektoru --- */}
{!searching && !selected && (
<>
{mode === 'action' && onPickCondition && (
<button
type="button"
onClick={() => {
onPickCondition();
close();
}}
disabled={canAddCondition === false}
className={cn(
'mb-5 flex w-full items-start gap-3 rounded-xl border border-accent-400/30 bg-accent-500/8 p-4 text-left transition-colors',
canAddCondition === false
? 'cursor-not-allowed opacity-50'
: 'hover:border-accent-400/60 hover:bg-accent-500/14',
)}
>
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-accent-500/20 text-accent-300">
<GitBranch className="size-5" />
</span>
<span className="min-w-0 flex-1">
<span className="block font-semibold text-white">Rozvětvit podmínkou</span>
<span className="mt-0.5 block text-sm text-white/55">
{canAddCondition === false
? 'Nejdřív u spouštěče nadeklarujte vstupní parametry — bez nich není podle čeho se rozhodovat.'
: 'Porovná hodnotu vstupního parametru (např. score ≥ 15) a rozdělí běh na větev ANO a NE.'}
</span>
</span>
<ChevronRight className="mt-2.5 size-4 shrink-0 text-white/30" />
</button>
)}
<div className="grid gap-2 sm:grid-cols-2">
{filtered.map((connector) => (
<ConnectorCard
key={connector.id}
connector={connector}
operationCount={operationsOf(connector).length}
onClick={() => setSelected(connector)}
/>
))}
</div>
</>
)}
</div>
</Modal>
);
}
function CategoryChip({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
'rounded-full border px-3 py-1.5 text-xs font-medium transition-colors',
active
? 'border-brand-400/50 bg-brand-500/15 text-brand-200'
: 'border-ink-600/70 text-white/50 hover:border-white/20 hover:text-white',
)}
>
{children}
</button>
);
}
function ConnectorGlyph({ connector }: { connector: Connector }) {
const Icon = connectorIcon(connector.icon);
return (
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-gradient-to-br from-brand-500/20 to-accent-500/20 text-brand-300">
<Icon className="size-5" />
</span>
);
}
function ConnectorCard({
connector,
operationCount,
onClick,
}: {
connector: Connector;
operationCount: number;
onClick: () => void;
}) {
const planned = connector.status === 'planned';
return (
<button
type="button"
onClick={onClick}
disabled={planned}
className={cn(
'flex w-full items-start gap-3 rounded-xl border border-ink-600/60 bg-ink-850/50 p-4 text-left transition-colors',
planned
? 'cursor-not-allowed opacity-50'
: 'hover:border-brand-400/50 hover:bg-ink-800/70',
)}
>
<ConnectorGlyph connector={connector} />
<span className="min-w-0 flex-1">
<span className="flex items-center gap-2">
<span className="truncate font-semibold text-white">{connector.name}</span>
{connector.status === 'available' && <Badge tone="neutral">Umíme napojit</Badge>}
{planned && <Badge tone="warn">Na roadmapě</Badge>}
</span>
<span className="mt-1 block line-clamp-2 text-xs text-white/50">
{connector.description}
</span>
<span className="mt-1.5 block text-xs text-white/30">
{operationCount} {operationCount === 1 ? 'možnost' : 'možnosti'}
</span>
</span>
<ChevronRight className="mt-2.5 size-4 shrink-0 text-white/25" />
</button>
);
}
function OperationRow({
connector,
operation,
showConnectorName = false,
onClick,
}: {
connector: Connector;
operation: ConnectorOperation;
showConnectorName?: boolean;
onClick: () => void;
}) {
const planned = connector.status === 'planned';
return (
<button
type="button"
onClick={onClick}
disabled={planned}
className={cn(
'flex w-full items-start gap-3 rounded-xl border border-ink-600/50 bg-ink-850/40 p-4 text-left transition-colors',
planned ? 'cursor-not-allowed opacity-50' : 'hover:border-brand-400/50 hover:bg-ink-800/70',
)}
>
{showConnectorName && <ConnectorGlyph connector={connector} />}
<span className="min-w-0 flex-1">
<span className="flex flex-wrap items-center gap-2">
<span className="font-semibold text-white">{operation.name}</span>
{showConnectorName && (
<span className="text-xs text-white/40">· {connector.name}</span>
)}
{planned && <Badge tone="warn">Na roadmapě</Badge>}
</span>
<span className="mt-1 block text-sm text-white/55">{operation.description}</span>
{operation.fields && operation.fields.length > 0 && (
<span className="mt-2 flex flex-wrap gap-1.5">
{operation.fields.map((field) => (
<span
key={field}
className="rounded-md border border-ink-600/60 px-2 py-0.5 font-mono text-[0.7rem] text-white/40"
>
{field}
</span>
))}
</span>
)}
</span>
<ChevronRight className="mt-1 size-4 shrink-0 text-white/25" />
</button>
);
}
@@ -0,0 +1,277 @@
import { AlertTriangle, Check, Copy, KeyRound, Plus, RefreshCw, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { Badge } from '@/components/ui/Badge';
import { cn } from '@/lib/cn';
import { fieldTypeLabels, newFieldId } from '@/lib/flow';
import type { Connector, FieldType, FlowTrigger, TriggerField } from '@/types/dashboard';
const fieldTypes: FieldType[] = ['string', 'number', 'boolean', 'date'];
/**
* Nastaveni spoustece: registrovana adresa webhooku a deklarace parametru,
* ktere na ni budou chodit. Podminky ve strome se pak odkazuji prave na ne.
*/
export function TriggerConfig({
trigger,
connector,
webhookBaseUrl,
onChangeFields,
onRegenerateToken,
regenerating,
}: {
trigger: FlowTrigger;
connector: Connector | undefined;
webhookBaseUrl: string;
onChangeFields: (fields: TriggerField[]) => void;
onRegenerateToken: () => void;
regenerating: boolean;
}) {
const isWebhook = trigger.connectorId === 'webhook';
const operation = connector?.triggers.find((t) => t.id === trigger.operationId);
const editable = operation?.customPayload === true;
function addField() {
onChangeFields([
...trigger.fields,
{ id: newFieldId(), name: '', type: 'string', required: true },
]);
}
function updateField(id: string, patch: Partial<TriggerField>) {
onChangeFields(trigger.fields.map((f) => (f.id === id ? { ...f, ...patch } : f)));
}
function removeField(id: string) {
onChangeFields(trigger.fields.filter((f) => f.id !== id));
}
const duplicates = new Set(
trigger.fields
.map((f) => f.name.trim())
.filter((name, index, all) => name.length > 0 && all.indexOf(name) !== index),
);
return (
<div className="mt-4 space-y-4 border-t border-ink-600/50 pt-4">
{isWebhook && (
<WebhookAddress
token={trigger.webhookToken}
baseUrl={webhookBaseUrl}
onRegenerate={onRegenerateToken}
regenerating={regenerating}
/>
)}
<div>
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="text-sm font-semibold text-white">Vstupní parametry</h3>
<p className="mt-0.5 text-xs text-white/45">
{editable
? 'Co bude na spouštěč přicházet. Podle těchto hodnot pak stavíte podmínky.'
: 'Tato služba předává vlastní data. Parametry pro podmínky si můžete doplnit ručně.'}
</p>
</div>
<button
type="button"
onClick={addField}
className="inline-flex items-center gap-1.5 rounded-lg border border-ink-600/70 px-3 py-1.5 text-xs font-medium text-white/70 transition-colors hover:border-brand-400/60 hover:text-white"
>
<Plus className="size-3.5" />
Přidat parametr
</button>
</div>
{trigger.fields.length === 0 ? (
<p className="mt-3 rounded-xl border border-dashed border-ink-600/70 px-4 py-3 text-xs text-white/40">
Zatím žádné parametry. Bez nich nelze přidat podmínku nebylo by podle čeho
se rozhodovat.
</p>
) : (
<ul className="mt-3 space-y-2">
{trigger.fields.map((field) => {
const duplicate = field.name.trim().length > 0 && duplicates.has(field.name.trim());
return (
<li
key={field.id}
className="flex flex-wrap items-center gap-2 rounded-xl border border-ink-600/50 bg-ink-850/50 p-2.5"
>
<input
value={field.name}
onChange={(event) => updateField(field.id, { name: event.target.value })}
placeholder="nazev_parametru"
aria-label="Název parametru"
className={cn(
'min-w-0 flex-1 rounded-lg border bg-ink-900/70 px-3 py-1.5 font-mono text-sm text-white placeholder:text-white/25 focus:outline-none',
duplicate
? 'border-danger-400/60'
: 'border-ink-600/70 focus:border-brand-400/70',
)}
/>
<select
value={field.type}
onChange={(event) =>
updateField(field.id, { type: event.target.value as FieldType })
}
aria-label="Typ parametru"
className="rounded-lg border border-ink-600/70 bg-ink-900/70 px-3 py-1.5 text-sm text-white focus:border-brand-400/70 focus:outline-none"
>
{fieldTypes.map((type) => (
<option key={type} value={type} className="bg-ink-850">
{fieldTypeLabels[type]}
</option>
))}
</select>
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-white/55">
<input
type="checkbox"
checked={field.required}
onChange={(event) =>
updateField(field.id, { required: event.target.checked })
}
className="size-3.5 accent-cyan-400"
/>
povinný
</label>
<button
type="button"
onClick={() => removeField(field.id)}
title="Odebrat parametr"
className="grid size-8 place-items-center rounded-lg text-white/35 transition-colors hover:bg-danger-500/12 hover:text-danger-400"
>
<Trash2 className="size-3.5" />
<span className="sr-only">Odebrat parametr</span>
</button>
{duplicate && (
<p className="w-full text-xs text-danger-400">
Tento název je použitý parametry musí být unikátní.
</p>
)}
</li>
);
})}
</ul>
)}
</div>
</div>
);
}
/** Registrovana adresa webhooku - token generuje server, tady se jen ukazuje. */
function WebhookAddress({
token,
baseUrl,
onRegenerate,
regenerating,
}: {
token: string | undefined;
baseUrl: string;
onRegenerate: () => void;
regenerating: boolean;
}) {
const [copied, setCopied] = useState(false);
const [confirming, setConfirming] = useState(false);
if (!token) {
return (
<div className="rounded-xl border border-warn-400/35 bg-warn-500/8 p-4">
<p className="flex items-center gap-2 text-sm text-warn-400">
<AlertTriangle className="size-4 shrink-0" />
Adresa se vygeneruje při prvním uložení.
</p>
</div>
);
}
const url = `${baseUrl}/${token}`;
async function copy() {
try {
await navigator.clipboard.writeText(url);
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
} catch (err) {
// Clipboard API muze byt zakazane (http, oprávnění) - reklame to uzivateli.
console.warn('[webhook] kopirovani do schranky selhalo:', err);
window.prompt('Zkopírujte adresu ručně:', url);
}
}
return (
<div className="rounded-xl border border-ink-600/60 bg-ink-850/60 p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="flex items-center gap-2 text-sm font-semibold text-white">
<KeyRound className="size-4 text-brand-300" />
Adresa webhooku
</h3>
<Badge tone="ok">
<Check className="size-3" />
Zaregistrováno
</Badge>
</div>
<div className="mt-3 flex items-stretch gap-2">
<code className="min-w-0 flex-1 overflow-x-auto rounded-lg border border-ink-600/70 bg-ink-900 px-3 py-2.5 font-mono text-xs whitespace-nowrap text-brand-200">
POST {url}
</code>
<button
type="button"
onClick={() => void copy()}
title="Kopírovat adresu"
className="grid w-10 shrink-0 place-items-center rounded-lg border border-ink-600/70 text-white/55 transition-colors hover:border-brand-400/60 hover:text-white"
>
{copied ? <Check className="size-4 text-ok-400" /> : <Copy className="size-4" />}
<span className="sr-only">Kopírovat adresu</span>
</button>
</div>
<p className="mt-2.5 text-xs leading-relaxed text-white/45">
Token v adrese je jediná ochrana kdo ji zná, může automatizaci spustit.
Nesdílejte ji veřejně a neposílejte ji v odkazech.
</p>
{confirming ? (
<div className="mt-3 rounded-lg border border-danger-400/40 bg-danger-500/8 p-3">
<p className="text-xs text-white/70">
Vygenerovat novou adresu? <strong className="text-danger-400">Ta stará okamžitě
přestane fungovat</strong> a musíte ji přenastavit všude, odkud se volá.
</p>
<div className="mt-2.5 flex gap-2">
<button
type="button"
onClick={() => {
onRegenerate();
setConfirming(false);
}}
disabled={regenerating}
className="rounded-lg bg-danger-500/20 px-3 py-1.5 text-xs font-semibold text-danger-400 transition-colors hover:bg-danger-500/30 disabled:opacity-50"
>
{regenerating ? 'Generuji…' : 'Ano, vygenerovat novou'}
</button>
<button
type="button"
onClick={() => setConfirming(false)}
className="rounded-lg px-3 py-1.5 text-xs text-white/55 hover:text-white"
>
Zrušit
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setConfirming(true)}
className="mt-3 inline-flex items-center gap-1.5 text-xs font-medium text-white/45 transition-colors hover:text-white"
>
<RefreshCw className="size-3.5" />
Vygenerovat novou adresu
</button>
)}
</div>
);
}
+43
View File
@@ -0,0 +1,43 @@
import { ArrowRight, Mail, Phone } from 'lucide-react';
import { ButtonLink } from '@/components/ui/Button';
import { Container } from '@/components/ui/Container';
import { brand } from '@/config/brand';
/** Zaverecna vyzva k akci pred paticku. */
export function CallToAction() {
return (
<section className="py-20 sm:py-24">
<Container>
<div className="relative overflow-hidden rounded-3xl border border-brand-400/25 bg-gradient-to-br from-brand-500/12 via-ink-800/60 to-accent-500/12 px-6 py-14 text-center sm:px-14">
<div
aria-hidden
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>
</h2>
<p className="relative mx-auto mt-4 max-w-xl text-white/60">
Půlhodinový hovor, ve kterém si řekneme, co se automatizovat hned, co později a co
nemá smysl vůbec. Bez závazku a bez prezentací na 40 slidů.
</p>
<div className="relative mt-9 flex flex-wrap justify-center gap-3">
<ButtonLink to="/kontakt" size="lg">
Napsat nám
<ArrowRight className="size-4" />
</ButtonLink>
<ButtonLink to={`tel:${brand.phoneHref}`} variant="secondary" size="lg">
<Phone className="size-4" />
{brand.phone}
</ButtonLink>
</div>
<p className="relative mt-6 inline-flex items-center gap-2 text-sm text-white/45">
<Mail className="size-4" />
{brand.email}
</p>
</div>
</Container>
</section>
);
}
+134
View File
@@ -0,0 +1,134 @@
import { ArrowRight, CheckCircle2, PhoneCall, Sparkles } from 'lucide-react';
import { ButtonLink } from '@/components/ui/Button';
import { Container } from '@/components/ui/Container';
/** Krátké uvítání + hlavní CTA + mockup živého provozu. */
export function Hero() {
return (
<section className="relative overflow-hidden pt-16 pb-20 sm:pt-24 sm:pb-28">
{/* Dekorativni pozadi */}
<div aria-hidden className="pointer-events-none absolute inset-0 -z-10">
<div className="absolute inset-0 bg-grid mask-fade-b opacity-40" />
<div className="animate-pulse-slow absolute -top-40 left-1/2 size-[38rem] -translate-x-1/2 rounded-full bg-brand-500/18 blur-[120px]" />
<div className="animate-pulse-slow absolute top-40 -right-32 size-[30rem] rounded-full bg-accent-500/16 blur-[120px]" />
</div>
<Container>
<div className="grid items-center gap-14 lg:grid-cols-[1.05fr_0.95fr]">
<div className="animate-rise">
<span className="glass inline-flex items-center gap-2 rounded-full px-4 py-1.5 text-xs font-medium text-brand-300">
<Sparkles className="size-3.5" />
Automatizace · Voiceboti · Integrace
</span>
<h1 className="mt-6 text-4xl leading-[1.08] font-extrabold text-white sm:text-5xl lg:text-6xl">
Vítejte. Děláme z ruční práce{' '}
<span className="text-gradient">procesy, které běží samy</span>.
</h1>
<p className="mt-6 max-w-xl text-lg leading-relaxed text-white/65">
Jsme malý tým, který firmám staví automatizace, hlasové asistenty a propojení systémů
a pak je i provozuje. Vy vidíte výsledky v dashboardu, my držíme provoz.
</p>
<div className="mt-9 flex flex-wrap items-center gap-3">
<ButtonLink to="/kontakt" size="lg">
Nezávazná konzultace
<ArrowRight className="size-4" />
</ButtonLink>
<ButtonLink to="/sluzby" variant="secondary" size="lg">
Co umíme
</ButtonLink>
</div>
<ul className="mt-9 flex flex-wrap gap-x-6 gap-y-2.5 text-sm text-white/55">
{['Nasazení v týdnech, ne kvartálech', 'Provoz a podpora v ceně', 'Bez vendor lock-inu'].map(
(item) => (
<li key={item} className="flex items-center gap-2">
<CheckCircle2 className="size-4 shrink-0 text-ok-400" />
{item}
</li>
),
)}
</ul>
</div>
<HeroPanel />
</div>
</Container>
</section>
);
}
/** Mockup panelu "z provozu" - jen ilustrace, zadna realna data. */
function HeroPanel() {
const steps = [
{ label: 'Příchozí hovor rozpoznán', time: '0,4 s', done: true },
{ label: 'Poptávka založena v CRM', time: '1,1 s', done: true },
{ label: 'Nabídka vygenerována', time: '2,8 s', done: true },
{ label: 'Předáno obchodníkovi', time: 'právě teď', done: false },
];
return (
<div className="animate-rise relative [animation-delay:150ms]">
<div className="animate-float glass rounded-2xl p-5 shadow-2xl shadow-brand-500/10">
<div className="flex items-center justify-between border-b border-ink-600/60 pb-4">
<div className="flex items-center gap-2.5">
<span className="grid size-9 place-items-center rounded-xl bg-brand-500/15 text-brand-300">
<PhoneCall className="size-4" />
</span>
<div>
<p className="text-sm font-semibold text-white">Voicebot příjem poptávek</p>
<p className="font-mono text-xs text-white/40">AUT-02 · běží</p>
</div>
</div>
<span className="inline-flex items-center gap-1.5 rounded-full border border-ok-400/35 bg-ok-500/12 px-2.5 py-1 text-xs font-medium text-ok-400">
<span className="size-1.5 animate-pulse rounded-full bg-ok-400" />
live
</span>
</div>
<ol className="mt-4 space-y-3">
{steps.map((step) => (
<li key={step.label} className="flex items-center gap-3">
<span
className={
step.done
? 'grid size-5 shrink-0 place-items-center rounded-full bg-ok-500/20 text-ok-400'
: 'grid size-5 shrink-0 place-items-center rounded-full bg-brand-500/20 text-brand-300'
}
>
{step.done ? (
<CheckCircle2 className="size-3.5" />
) : (
<span className="size-1.5 animate-pulse rounded-full bg-brand-300" />
)}
</span>
<span className="flex-1 text-sm text-white/75">{step.label}</span>
<span className="font-mono text-xs text-white/35">{step.time}</span>
</li>
))}
</ol>
<div className="mt-5 grid grid-cols-3 gap-3 border-t border-ink-600/60 pt-4">
{[
{ value: '137', label: 'hovorů dnes' },
{ value: '96,1 %', label: 'úspěšnost' },
{ value: '1,2 s', label: 'odezva' },
].map((stat) => (
<div key={stat.label}>
<p className="text-lg font-bold text-white">{stat.value}</p>
<p className="text-xs text-white/45">{stat.label}</p>
</div>
))}
</div>
</div>
{/* Male "odlepene" karticky pro hloubku */}
<div className="glass animate-float absolute -bottom-6 -left-6 hidden rounded-xl px-4 py-3 shadow-xl [animation-delay:1.2s] sm:block">
<p className="text-xs text-white/45">Ušetřeno tento měsíc</p>
<p className="text-base font-bold text-white">312 hodin</p>
</div>
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
import { Container } from '@/components/ui/Container';
import { clientLogos } from '@/data/references';
/** Nekonecny pas nazvu klientu (mockup misto realnych log). */
export function LogoCloud() {
// Seznam zdvojujeme, aby marquee navazovalo bez skoku (posun je -50 %).
const items = [...clientLogos, ...clientLogos];
return (
<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
</p>
</Container>
<div className="mask-fade-x overflow-hidden">
<div className="animate-marquee flex w-max items-center gap-14">
{items.map((name, index) => (
<span
key={`${name}-${index}`}
className="text-lg font-semibold whitespace-nowrap text-white/25 transition-colors hover:text-white/50"
>
{name}
</span>
))}
</div>
</div>
</section>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { Section, SectionHeading } from '@/components/ui/Section';
const steps = [
{
number: '01',
title: 'Konzultace a mapování',
text: 'Projdeme s vámi proces tak, jak reálně běží. Najdeme místa, kde se ztrácí čas a data.',
duration: '12 týdny',
},
{
number: '02',
title: 'Návrh a prototyp',
text: 'Postavíme funkční prototyp na vašich datech. Uvidíte, jak to bude fungovat, než se doplatí zbytek.',
duration: '23 týdny',
},
{
number: '03',
title: 'Nasazení do provozu',
text: 'Napojíme reálné systémy, zapneme monitoring a zaškolíme lidi. Přepínáme postupně, ne přes noc.',
duration: '24 týdny',
},
{
number: '04',
title: 'Provoz a rozvoj',
text: 'Držíme SLA, řešíme tickety a incidenty, průběžně přidáváme, co se v provozu ukáže jako potřebné.',
duration: 'trvale',
},
];
/** Jak spoluprace probiha - casova osa ve 4 krocich. */
export function Process() {
return (
<Section id="postup">
<SectionHeading
eyebrow="Jak to probíhá"
title={
<>
Od prvního hovoru <span className="text-gradient">do provozu za pár týdnů</span>
</>
}
subtitle="Žádné půlroční analýzy. Nejdřív malý funkční celek, který přinese úsporu, potom rozšiřování."
/>
<ol className="grid gap-5 md:grid-cols-2 lg:grid-cols-4">
{steps.map((step) => (
<li
key={step.number}
className="group relative rounded-card border border-ink-600/60 bg-ink-800/40 p-6 transition-colors hover:border-brand-400/40"
>
<span className="font-mono text-3xl font-bold text-brand-400/25 transition-colors group-hover:text-brand-400/45">
{step.number}
</span>
<h3 className="mt-3 font-semibold text-white">{step.title}</h3>
<p className="mt-2 text-sm leading-relaxed text-white/55">{step.text}</p>
<p className="mt-4 text-xs font-medium tracking-wide text-brand-300/70 uppercase">
{step.duration}
</p>
</li>
))}
</ol>
</Section>
);
}
+74
View File
@@ -0,0 +1,74 @@
import { ArrowRight, Check } from 'lucide-react';
import { Link } from 'react-router-dom';
import { Card } from '@/components/ui/Card';
import { Section, SectionHeading } from '@/components/ui/Section';
import { products, type Product } from '@/data/products';
import { cn } from '@/lib/cn';
/** Mockup produktu / sluzeb - grid karet. */
export function Products() {
return (
<Section id="produkty">
<SectionHeading
eyebrow="Produkty"
title={
<>
Šest věcí, které <span className="text-gradient">umíme dodat a udržet</span>
</>
}
subtitle="Každou z nich umíme nasadit samostatně, nebo je pospojovat do jednoho celku. Ceny a rozsah vždy podle skutečného provozu."
/>
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{products.map((product) => (
<ProductCard key={product.slug} product={product} />
))}
</div>
</Section>
);
}
export function ProductCard({ product }: { product: Product }) {
const Icon = product.icon;
return (
<Card
interactive
className={cn(
'flex flex-col',
product.featured && 'border-brand-400/35 bg-gradient-to-b from-brand-500/8 to-transparent',
)}
>
<div className="flex items-start justify-between gap-3">
<span className="grid size-11 place-items-center rounded-xl bg-gradient-to-br from-brand-500/20 to-accent-500/20 text-brand-300">
<Icon className="size-5" />
</span>
<div className="text-right">
<p className="text-lg font-bold text-white">{product.metric.value}</p>
<p className="text-[0.7rem] text-white/40">{product.metric.label}</p>
</div>
</div>
<h3 className="mt-5 text-lg font-semibold text-white">{product.name}</h3>
<p className="mt-1 text-sm font-medium text-brand-300/80">{product.tagline}</p>
<p className="mt-3 text-sm leading-relaxed text-white/60">{product.description}</p>
<ul className="mt-5 space-y-2">
{product.features.map((feature) => (
<li key={feature} className="flex items-start gap-2 text-sm text-white/55">
<Check className="mt-0.5 size-3.5 shrink-0 text-ok-400" />
{feature}
</li>
))}
</ul>
<Link
to={`/sluzby#${product.slug}`}
className="mt-6 inline-flex items-center gap-1.5 text-sm font-semibold text-brand-300 transition-colors hover:text-brand-200"
>
Detail služby
<ArrowRight className="size-3.5" />
</Link>
</Card>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { Quote, TrendingUp } from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Section, SectionHeading } from '@/components/ui/Section';
import { references, type Reference } from '@/data/references';
/** Mockup referenci - citaty klientu + merutelny vysledek. */
export function References() {
return (
<Section id="reference" className="bg-ink-950/40">
<SectionHeading
eyebrow="Reference"
title={
<>
Co říkají firmy, <span className="text-gradient">se kterými to běží</span>
</>
}
subtitle="Ukázka spoluprací napříč obory. Čísla vždy měříme před nasazením a po něm — jinak by to byl jen marketing."
/>
<div className="grid gap-5 md:grid-cols-2">
{references.map((reference) => (
<ReferenceCard key={reference.id} reference={reference} />
))}
</div>
<p className="mt-8 text-center text-xs text-white/30">
Reference jsou v této verzi webu ukázkové (mockup) a slouží k náhledu layoutu.
</p>
</Section>
);
}
function ReferenceCard({ reference }: { reference: Reference }) {
return (
<Card interactive className="flex h-full flex-col">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<span className="grid size-11 shrink-0 place-items-center rounded-xl border border-ink-600/70 bg-ink-700/60 font-mono text-sm font-bold text-brand-300">
{reference.initials}
</span>
<div>
<p className="font-semibold text-white">{reference.company}</p>
<p className="text-xs text-white/40">{reference.industry}</p>
</div>
</div>
<Quote className="size-6 shrink-0 text-brand-400/25" />
</div>
<blockquote className="mt-5 flex-1 text-[0.975rem] leading-relaxed text-white/70">
{reference.quote}
</blockquote>
<div className="mt-6 flex flex-wrap items-end justify-between gap-4 border-t border-ink-600/60 pt-4">
<div>
<p className="text-sm font-medium text-white">{reference.author}</p>
<p className="text-xs text-white/40">{reference.role}</p>
</div>
<div className="flex items-center gap-2 rounded-lg border border-ok-400/25 bg-ok-500/10 px-3 py-1.5">
<TrendingUp className="size-4 text-ok-400" />
<span className="text-sm font-bold text-ok-400">{reference.result.value}</span>
<span className="text-xs text-white/45">{reference.result.label}</span>
</div>
</div>
</Card>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { Container } from '@/components/ui/Container';
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' },
];
/** Pas s cisly mezi sekcemi - drzi pozornost a rozdeluje obsah. */
export function Stats() {
return (
<section className="relative overflow-hidden py-14">
<div
aria-hidden
className="pointer-events-none absolute inset-0 -z-10 bg-gradient-to-r from-brand-500/8 via-transparent to-accent-500/8"
/>
<Container>
<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>
<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>
</dd>
</div>
))}
</dl>
</Container>
</section>
);
}
+82
View File
@@ -0,0 +1,82 @@
import { Mail, MapPin, Phone } from 'lucide-react';
import { Link } from 'react-router-dom';
import { Logo } from '@/components/layout/Logo';
import { Container } from '@/components/ui/Container';
import { brand } from '@/config/brand';
import { footerNav } from '@/data/navigation';
export function Footer() {
const year = new Date().getFullYear();
return (
<footer className="border-t border-ink-600/60 bg-ink-950/80">
<Container className="py-14">
<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>
</div>
{footerNav.map((column) => (
<div key={column.title}>
<h3 className="mb-4 text-sm font-semibold text-white">{column.title}</h3>
<ul className="space-y-2.5">
{column.items.map((item) => (
<li key={item.to + item.label}>
<Link
to={item.to}
className="text-sm text-white/55 transition-colors hover:text-brand-300"
>
{item.label}
</Link>
</li>
))}
</ul>
</div>
))}
<div>
<h3 className="mb-4 text-sm font-semibold text-white">Kontakt</h3>
<ul className="space-y-3 text-sm text-white/55">
<li>
<a
href={`mailto:${brand.email}`}
className="inline-flex items-center gap-2 transition-colors hover:text-brand-300"
>
<Mail className="size-4 shrink-0 text-brand-400" />
{brand.email}
</a>
</li>
<li>
<a
href={`tel:${brand.phoneHref}`}
className="inline-flex items-center gap-2 transition-colors hover:text-brand-300"
>
<Phone className="size-4 shrink-0 text-brand-400" />
{brand.phone}
</a>
</li>
<li className="flex items-start gap-2">
<MapPin className="mt-0.5 size-4 shrink-0 text-brand-400" />
<span>
{brand.address.street}
<br />
{brand.address.zip} {brand.address.city}
</span>
</li>
</ul>
</div>
</div>
<div className="mt-12 flex flex-col gap-3 border-t border-ink-600/50 pt-6 text-xs text-white/40 sm:flex-row sm:items-center sm:justify-between">
<p>
© {year} {brand.legalName} · IČO {brand.ico}
</p>
<p className="font-mono">
Prototyp webu obsah a reference jsou ukázkové (mockup).
</p>
</div>
</Container>
</footer>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { Link } from 'react-router-dom';
import { brand } from '@/config/brand';
import { cn } from '@/lib/cn';
export function Logo({ className, to = '/' }: { className?: string; to?: string }) {
return (
<Link to={to} className={cn('group inline-flex items-center gap-2.5', className)}>
<span className="relative grid size-9 place-items-center rounded-xl bg-gradient-to-br from-brand-400 to-accent-500 shadow-lg shadow-brand-500/25 transition-transform duration-300 group-hover:scale-105">
<span className="font-mono text-sm font-bold text-ink-950">A</span>
</span>
<span className="text-lg font-bold tracking-tight text-white">{brand.name}</span>
</Link>
);
}
+129
View File
@@ -0,0 +1,129 @@
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 { Logo } from '@/components/layout/Logo';
import { ButtonLink } from '@/components/ui/Button';
import { Container } from '@/components/ui/Container';
import { mainNav } from '@/data/navigation';
import { cn } from '@/lib/cn';
export function Navbar() {
const [scrolled, setScrolled] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const location = useLocation();
const { user } = useAuth();
// Sticky hlavicka ztmavne po odscrollovani.
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 12);
onScroll();
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, []);
// Zavreni mobilniho menu pri zmene stranky.
useEffect(() => {
setMobileOpen(false);
}, [location.pathname]);
return (
<header
className={cn(
'fixed inset-x-0 top-0 z-50 transition-all duration-300',
scrolled ? 'border-b border-ink-600/60 bg-ink-900/85 backdrop-blur-xl' : 'bg-transparent',
)}
>
<Container>
<div className="flex h-18 items-center justify-between gap-4">
<Logo />
<nav className="hidden items-center gap-1 md:flex">
{mainNav.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
className={({ isActive }) =>
cn(
'rounded-full px-4 py-2 text-sm font-medium transition-colors',
isActive ? 'bg-white/8 text-white' : 'text-white/60 hover:text-white',
)
}
>
{item.label}
</NavLink>
))}
</nav>
<div className="hidden items-center gap-3 md:flex">
{user ? (
<ButtonLink to="/dashboard" size="sm">
<LayoutDashboard className="size-4" />
Dashboard
</ButtonLink>
) : (
<>
<ButtonLink to="/prihlaseni" variant="ghost" size="sm">
<LogIn className="size-4" />
Přihlášení
</ButtonLink>
<ButtonLink to="/kontakt" size="sm">
Nezávazná konzultace
</ButtonLink>
</>
)}
</div>
<button
type="button"
className="grid size-10 place-items-center rounded-lg text-white/70 hover:bg-white/5 hover:text-white md:hidden"
onClick={() => setMobileOpen((open) => !open)}
aria-label={mobileOpen ? 'Zavřít menu' : 'Otevřít menu'}
aria-expanded={mobileOpen}
>
{mobileOpen ? <X className="size-5" /> : <Menu className="size-5" />}
</button>
</div>
</Container>
{mobileOpen && (
<div className="border-t border-ink-600/60 bg-ink-900/98 backdrop-blur-xl md:hidden">
<Container className="flex flex-col gap-1 py-4">
{mainNav.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
className={({ isActive }) =>
cn(
'rounded-lg px-4 py-3 text-base font-medium transition-colors',
isActive ? 'bg-white/8 text-white' : 'text-white/70 hover:bg-white/5',
)
}
>
{item.label}
</NavLink>
))}
<div className="mt-3 flex flex-col gap-2">
{user ? (
<ButtonLink to="/dashboard">
<LayoutDashboard className="size-4" />
Dashboard
</ButtonLink>
) : (
<>
<ButtonLink to="/prihlaseni" variant="secondary">
<LogIn className="size-4" />
Přihlášení
</ButtonLink>
<ButtonLink to="/kontakt">Nezávazná konzultace</ButtonLink>
</>
)}
</div>
</Container>
</div>
)}
</header>
);
}
@@ -0,0 +1,32 @@
import { useEffect } from 'react';
import { Outlet, useLocation } from 'react-router-dom';
import { Footer } from '@/components/layout/Footer';
import { Navbar } from '@/components/layout/Navbar';
/** Layout verejneho webu: sticky hlavicka + obsah + paticka. */
export function PublicLayout() {
const { pathname, hash } = useLocation();
// Pri prechodu na jinou stranku scrollujeme nahoru, pri #odkazu na cilovou sekci.
useEffect(() => {
if (hash) {
const target = document.querySelector(hash);
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
return;
}
console.warn(`[layout] cil hashe ${hash} na strance neexistuje`);
}
window.scrollTo({ top: 0, behavior: 'instant' as ScrollBehavior });
}, [pathname, hash]);
return (
<div className="flex min-h-screen flex-col bg-ink-900">
<Navbar />
<main className="flex-1 pt-18">
<Outlet />
</main>
<Footer />
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
import type { ReactNode } from 'react';
import { cn } from '@/lib/cn';
export type BadgeTone = 'neutral' | 'brand' | 'ok' | 'warn' | 'danger';
const tones: Record<BadgeTone, string> = {
neutral: 'border-white/12 bg-white/5 text-white/70',
brand: 'border-brand-400/35 bg-brand-500/12 text-brand-300',
ok: 'border-ok-400/35 bg-ok-500/12 text-ok-400',
warn: 'border-warn-400/35 bg-warn-500/12 text-warn-400',
danger: 'border-danger-400/35 bg-danger-500/12 text-danger-400',
};
export function Badge({
tone = 'neutral',
className,
children,
}: {
tone?: BadgeTone;
className?: string;
children: ReactNode;
}) {
return (
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium whitespace-nowrap',
tones[tone],
className,
)}
>
{children}
</span>
);
}
+74
View File
@@ -0,0 +1,74 @@
import { Link } from 'react-router-dom';
import type { ButtonHTMLAttributes, ReactNode } from 'react';
import { cn } from '@/lib/cn';
type Variant = 'primary' | 'secondary' | 'ghost';
type Size = 'sm' | 'md' | 'lg';
const base =
'inline-flex items-center justify-center gap-2 rounded-full font-semibold transition-all duration-200 disabled:cursor-not-allowed disabled:opacity-55';
const variants: Record<Variant, string> = {
primary:
'bg-gradient-to-r from-brand-400 to-accent-500 text-ink-950 shadow-lg shadow-brand-500/20 hover:shadow-xl hover:shadow-brand-500/30 hover:brightness-110',
secondary: 'glass text-white hover:border-brand-400/60 hover:bg-ink-700/70',
ghost: 'text-white/70 hover:bg-white/5 hover:text-white',
};
const sizes: Record<Size, string> = {
sm: 'h-9 px-4 text-sm',
md: 'h-11 px-6 text-[0.95rem]',
lg: 'h-13 px-8 text-base',
};
interface CommonProps {
variant?: Variant;
size?: Size;
className?: string;
children: ReactNode;
}
export function Button({
variant = 'primary',
size = 'md',
className,
children,
...rest
}: CommonProps & ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button className={cn(base, variants[variant], sizes[size], className)} {...rest}>
{children}
</button>
);
}
/** Stejny vzhled jako Button, ale routovaci odkaz. */
export function ButtonLink({
to,
variant = 'primary',
size = 'md',
className,
children,
}: CommonProps & { to: string }) {
const isExternal = /^(https?:|mailto:|tel:)/.test(to);
const classes = cn(base, variants[variant], sizes[size], className);
if (isExternal) {
return (
<a
href={to}
className={classes}
target={to.startsWith('http') ? '_blank' : undefined}
rel={to.startsWith('http') ? 'noreferrer' : undefined}
>
{children}
</a>
);
}
return (
<Link to={to} className={classes}>
{children}
</Link>
);
}
+23
View File
@@ -0,0 +1,23 @@
import type { ReactNode } from 'react';
import { cn } from '@/lib/cn';
interface CardProps {
className?: string;
children: ReactNode;
/** Zvyrazni kartu pri hoveru - pro klikatelne / produktove karty. */
interactive?: boolean;
}
export function Card({ className, children, interactive = false }: CardProps) {
return (
<div
className={cn(
'glass rounded-card p-6 transition-all duration-300',
interactive && 'hover:-translate-y-1 hover:border-brand-400/50 hover:shadow-2xl hover:shadow-brand-500/10',
className,
)}
>
{children}
</div>
);
}
+7
View File
@@ -0,0 +1,7 @@
import type { ReactNode } from 'react';
import { cn } from '@/lib/cn';
/** Jednotna sirka obsahu a horizontalni odsazeni pro cely web. */
export function Container({ className, children }: { className?: string; children: ReactNode }) {
return <div className={cn('mx-auto w-full max-w-6xl px-5 sm:px-8', className)}>{children}</div>;
}
+90
View File
@@ -0,0 +1,90 @@
import { X } from 'lucide-react';
import { useEffect, useRef } from 'react';
import type { ReactNode } from 'react';
import { cn } from '@/lib/cn';
/**
* Dialog nad obsahem. Zavira se Esc, klikem na pozadi i krizkem.
* Pri otevreni zamkne scroll stranky a preda focus dovnitr.
*/
export function Modal({
open,
onClose,
title,
description,
children,
className,
}: {
open: boolean;
onClose: () => void;
title: string;
description?: string;
children: ReactNode;
className?: string;
}) {
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose();
};
document.addEventListener('keydown', onKeyDown);
// Zamek scrollu, aby se pod dialogem nescrollovalo.
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
// Focus do dialogu - jinak by tabulator pokracoval na strance za nim.
const firstFocusable = panelRef.current?.querySelector<HTMLElement>(
'input, button, [tabindex]:not([tabindex="-1"])',
);
firstFocusable?.focus();
return () => {
document.removeEventListener('keydown', onKeyDown);
document.body.style.overflow = previousOverflow;
};
}, [open, onClose]);
if (!open) return null;
return (
<div className="fixed inset-0 z-[60] flex items-start justify-center overflow-y-auto p-4 sm:items-center sm:p-6">
<div
className="fixed inset-0 bg-ink-950/80 backdrop-blur-sm"
onClick={onClose}
aria-hidden
/>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label={title}
className={cn(
'relative my-auto w-full max-w-3xl rounded-2xl border border-ink-600/70 bg-ink-900 shadow-2xl',
className,
)}
>
<div className="flex items-start justify-between gap-4 border-b border-ink-600/60 p-5">
<div>
<h2 className="text-lg font-bold text-white">{title}</h2>
{description && <p className="mt-1 text-sm text-white/50">{description}</p>}
</div>
<button
type="button"
onClick={onClose}
className="grid size-9 shrink-0 place-items-center rounded-lg text-white/50 transition-colors hover:bg-white/5 hover:text-white"
aria-label="Zavřít"
>
<X className="size-5" />
</button>
</div>
{children}
</div>
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
import type { ReactNode } from 'react';
import { Container } from '@/components/ui/Container';
/** Hlavicka podstranky - jednotny vzhled pro O nas, Sluzby, Kontakt. */
export function PageHeader({
eyebrow,
title,
subtitle,
}: {
eyebrow?: string;
title: ReactNode;
subtitle?: ReactNode;
}) {
return (
<section className="relative overflow-hidden border-b border-ink-600/50 py-16 sm:py-20">
<div aria-hidden className="pointer-events-none absolute inset-0 -z-10">
<div className="absolute inset-0 bg-grid mask-fade-b opacity-30" />
<div className="absolute -top-32 left-1/3 size-96 rounded-full bg-brand-500/12 blur-[110px]" />
</div>
<Container>
{eyebrow && (
<p className="mb-3 text-xs font-semibold tracking-[0.18em] text-brand-300 uppercase">
{eyebrow}
</p>
)}
<h1 className="max-w-3xl text-3xl font-extrabold text-white sm:text-5xl">{title}</h1>
{subtitle && (
<p className="mt-5 max-w-2xl text-lg leading-relaxed text-white/60">{subtitle}</p>
)}
</Container>
</section>
);
}
+53
View File
@@ -0,0 +1,53 @@
import type { ReactNode } from 'react';
import { Container } from '@/components/ui/Container';
import { cn } from '@/lib/cn';
/** Sekce s jednotnym vertikalnim rytmem. */
export function Section({
id,
className,
children,
}: {
id?: string;
className?: string;
children: ReactNode;
}) {
return (
<section id={id} className={cn('relative py-20 sm:py-28', className)}>
<Container>{children}</Container>
</section>
);
}
/** Nadpis sekce: mala popiska + titulek + podtitulek. */
export function SectionHeading({
eyebrow,
title,
subtitle,
align = 'center',
className,
}: {
eyebrow?: string;
title: ReactNode;
subtitle?: ReactNode;
align?: 'center' | 'left';
className?: string;
}) {
return (
<div
className={cn(
'mb-14 max-w-2xl',
align === 'center' ? 'mx-auto text-center' : 'text-left',
className,
)}
>
{eyebrow && (
<p className="mb-3 text-xs font-semibold tracking-[0.18em] text-brand-300 uppercase">
{eyebrow}
</p>
)}
<h2 className="text-3xl font-bold text-white sm:text-4xl">{title}</h2>
{subtitle && <p className="mt-4 text-base leading-relaxed text-white/60">{subtitle}</p>}
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { cn } from '@/lib/cn';
export function Spinner({ label, className }: { label?: string; className?: string }) {
return (
<div className={cn('flex items-center gap-3 text-sm text-white/60', className)} role="status">
<span className="size-5 animate-spin rounded-full border-2 border-white/15 border-t-brand-400" />
{label && <span>{label}</span>}
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
/**
* Jedine misto s firemnimi udaji a texty, ktere se opakuji na webu.
* Prejmenovani firmy = zmena tady (viz docs/03-frontend.md).
*/
export const brand = {
name: 'Automia',
legalName: 'Automia s.r.o.',
claim: 'Automatizace, voiceboti a integrace, které vydrží provoz.',
description:
'Navrhujeme a provozujeme automatizace firemních procesů, hlasové asistenty a propojení systémů. K tomu dodáváme dashboardy, tickety a incident management pod jednou střechou.',
email: 'info@automia.cz',
phone: '+420 777 123 456',
phoneHref: '+420777123456',
address: {
street: 'Náměstí Míru 12',
city: 'Brno',
zip: '602 00',
country: 'Česká republika',
},
ico: '12345678',
dic: 'CZ12345678',
founded: 2018,
social: {
linkedin: 'https://www.linkedin.com/',
github: 'https://github.com/',
},
/** Otevírací doba podpory zobrazená na kontaktech. */
support: {
hours: 'PoPá 8:0018:00',
sla: 'SLA 24/7 pro klienty s tarifem Provoz',
},
} as const;
+33
View File
@@ -0,0 +1,33 @@
export interface NavItem {
label: string;
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' },
];
/** Odkazy v paticce, rozdelene do sloupcu. */
export const footerNav: Array<{ title: string; items: NavItem[] }> = [
{
title: 'Služby',
items: [
{ label: 'Automatizace procesů', to: '/sluzby#automatizace' },
{ label: 'Voiceboti', to: '/sluzby#voiceboti' },
{ label: 'Integrace systémů', to: '/sluzby#integrace' },
{ label: 'Dashboardy', to: '/sluzby#dashboardy' },
],
},
{
title: 'Firma',
items: [
{ label: 'O nás', to: '/o-nas' },
{ label: 'Kontakt', to: '/kontakt' },
{ label: 'Klientský portál', to: '/prihlaseni' },
],
},
];
+90
View File
@@ -0,0 +1,90 @@
import {
AlarmClock,
BarChart3,
LifeBuoy,
Network,
PhoneCall,
Workflow,
type LucideIcon,
} from 'lucide-react';
export interface Product {
slug: string;
name: string;
tagline: string;
description: string;
icon: LucideIcon;
features: string[];
/** Ukazkova metrika na karte produktu. */
metric: { value: string; label: string };
/** Zvyrazneni jedne karty v gridu. */
featured?: boolean;
}
/**
* MOCKUP produktu pro homepage a stranku Sluzby.
* Realna nabidka se doplni pozdeji - viz docs/07-obsah-a-copy.md.
*/
export const products: Product[] = [
{
slug: 'automatizace',
name: 'Automatizace procesů',
tagline: 'Od objednávky po fakturu bez ručního přepisování',
description:
'Zmapujeme proces, najdeme ruční kroky a nahradíme je workflow, které běží samo. Včetně kontrol, notifikací a auditní stopy.',
icon: Workflow,
features: ['Návrh procesu a analýza', 'Workflow engine s retry logikou', 'Auditní log každého kroku'],
metric: { value: '312 h', label: 'ušetřeno měsíčně' },
featured: true,
},
{
slug: 'voiceboti',
name: 'Voiceboti a hlasové linky',
tagline: 'Telefon, který obsluhuje i ve tři ráno',
description:
'Hlasový asistent přijme volání, rozpozná záměr, založí poptávku nebo ticket a předá člověku jen to, co má cenu řešit.',
icon: PhoneCall,
features: ['Čeština i angličtina', 'Předání na operátora se souhrnem', 'Přepisy a analytika hovorů'],
metric: { value: '24/7', label: 'dostupnost linky' },
},
{
slug: 'integrace',
name: 'Integrace systémů',
tagline: 'CRM, účetnictví, e-shop i sklad mluví stejným jazykem',
description:
'Propojíme systémy, které spolu nikdy neměly mluvit. Obousměrná synchronizace, mapování polí, řešení konfliktů.',
icon: Network,
features: ['API i webhooky', 'Mapování a transformace dat', 'Odolnost proti výpadkům'],
metric: { value: '40+', label: 'napojených služeb' },
},
{
slug: 'dashboardy',
name: 'Dashboardy a reporting',
tagline: 'Jedna obrazovka místo pěti exportů',
description:
'Data z různých zdrojů na jednom místě — v reálném čase, s historií a s alerty, když se něco vymkne.',
icon: BarChart3,
features: ['Metriky v reálném čase', 'Automatické reporty e-mailem', 'Přístupy podle rolí'],
metric: { value: '5 s', label: 'aktualizace dat' },
},
{
slug: 'tickety',
name: 'Tickety a servicedesk',
tagline: 'Požadavky, které se neztratí v e-mailu',
description:
'Sběr požadavků z e-mailu, webu i telefonu do jedné fronty. Kategorizace, SLA, eskalace a přehled pro management.',
icon: LifeBuoy,
features: ['Sběr z více kanálů', 'SLA a eskalace', 'Znalostní báze'],
metric: { value: '11 min', label: 'medián první reakce' },
},
{
slug: 'incidenty',
name: 'Monitoring a incidenty',
tagline: 'O výpadku víme dřív než váš zákazník',
description:
'Healthchecky, alerting a incident management s jasnými rolemi. Po vyřešení dostanete post-mortem, ne výmluvu.',
icon: AlarmClock,
features: ['Healthchecky a alerty', 'On-call rozpis', 'Post-mortem po každém incidentu'],
metric: { value: '99,98 %', label: 'dostupnost 2025' },
},
];
+75
View File
@@ -0,0 +1,75 @@
export interface Reference {
id: string;
company: string;
/** Obor - zobrazuje se pod nazvem firmy. */
industry: string;
/** Kratky iniciálový "logotyp" - misto obrazku, dokud nejsou realna loga. */
initials: string;
quote: string;
author: string;
role: string;
result: { value: string; label: string };
}
/**
* MOCKUP referenci. Vsechny firmy, citaty i cisla jsou VYMYSLENE
* a slouzi jen pro nahled layoutu - viz docs/07-obsah-a-copy.md.
*/
export const references: Reference[] = [
{
id: 'ref-nordis',
company: 'Nordis a.s.',
industry: 'Velkoobchod',
initials: 'NO',
quote:
'Přepisování objednávek mezi e-shopem a účetnictvím nám žralo dva lidi na plný úvazek. Dnes to běží samo a my řešíme jen výjimky.',
author: 'Tomáš Vrána',
role: 'provozní ředitel',
result: { value: '78 %', label: 'ruční práce v administraci' },
},
{
id: 'ref-logitrans',
company: 'LogiTrans',
industry: 'Doprava a logistika',
initials: 'LT',
quote:
'Voicebot bere objednávky svozů i v noci a o víkendu. Dispečeři ráno vidí hotový seznam, ne dvacet hlasových zpráv.',
author: 'Klára Doubravová',
role: 'vedoucí dispečinku',
result: { value: '1 400', label: 'hovorů zpracovaných měsíčně' },
},
{
id: 'ref-medipoint',
company: 'MediPoint',
industry: 'Zdravotnictví',
initials: 'MP',
quote:
'Potřebovali jsme přehled napříč šesti ambulancemi. Dashboard postavili za tři týdny a od té doby ho používáme každé ráno.',
author: 'MUDr. Jan Sedlák',
role: 'jednatel',
result: { value: '6', label: 'provozoven v jednom přehledu' },
},
{
id: 'ref-bistro',
company: 'Bistro Kolektiv',
industry: 'Gastro',
initials: 'BK',
quote:
'Rezervace, sklad a docházka konečně mluví dohromady. A když se něco rozbije, víme to od nich, ne od zákazníků.',
author: 'Eliška Marešová',
role: 'majitelka',
result: { value: '99,9 %', label: 'dostupnost systémů' },
},
];
/** Nazvy firem pro "logo cloud" pas nad referencemi (take mockup). */
export const clientLogos: string[] = [
'Nordis',
'LogiTrans',
'MediPoint',
'Bistro Kolektiv',
'Stavko Group',
'Voltera',
'Reticum',
'Kovoplast',
];
+190
View File
@@ -0,0 +1,190 @@
@import 'tailwindcss';
/* ------------------------------------------------------------------
Design tokeny. Zmena barevnosti celeho webu = zmena tady.
Dokumentace: docs/05-design-system.md
------------------------------------------------------------------- */
@theme {
/* Podklady - tmave modrociste odstiny */
--color-ink-950: #05070f;
--color-ink-900: #070b16;
--color-ink-850: #0a0f1f;
--color-ink-800: #0e1526;
--color-ink-700: #16203a;
--color-ink-600: #1f2c4d;
/* Primarni akcent - cyan */
--color-brand-200: #a5f3fc;
--color-brand-300: #67e8f9;
--color-brand-400: #22d3ee;
--color-brand-500: #06b6d4;
--color-brand-600: #0891b2;
/* Sekundarni akcent - violet */
--color-accent-300: #c4b5fd;
--color-accent-400: #a78bfa;
--color-accent-500: #8b5cf6;
--color-accent-600: #7c3aed;
/* Stavove barvy (tickety, incidenty, healthchecky) */
--color-ok-400: #34d399;
--color-ok-500: #10b981;
--color-warn-400: #fbbf24;
--color-warn-500: #f59e0b;
--color-danger-400: #fb7185;
--color-danger-500: #f43f5e;
--font-sans: 'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', monospace;
--radius-card: 1rem;
--animate-marquee: marquee 38s linear infinite;
--animate-float: float 7s ease-in-out infinite;
--animate-pulse-slow: pulse-slow 4s ease-in-out infinite;
--animate-rise: rise 0.7s cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes marquee {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
@keyframes float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-12px);
}
}
@keyframes pulse-slow {
0%,
100% {
opacity: 0.35;
}
50% {
opacity: 0.75;
}
}
@keyframes rise {
from {
opacity: 0;
transform: translateY(18px);
}
to {
opacity: 1;
transform: none;
}
}
@layer base {
* {
border-color: --alpha(var(--color-ink-600) / 60%);
}
html {
scroll-behavior: smooth;
-webkit-tap-highlight-color: transparent;
}
body {
background-color: var(--color-ink-900);
color: --alpha(#ffffff / 88%);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
text-wrap: pretty;
}
h1,
h2,
h3,
h4 {
text-wrap: balance;
letter-spacing: -0.02em;
}
::selection {
background-color: --alpha(var(--color-brand-400) / 30%);
}
/* Viditelny focus ring pro klavesovou navigaci */
:focus-visible {
outline: 2px solid var(--color-brand-400);
outline-offset: 2px;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: var(--color-ink-950);
}
::-webkit-scrollbar-thumb {
background: var(--color-ink-700);
border-radius: 999px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-ink-600);
}
/* Respekt k uzivatelum, kteri nechteji animace */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
}
/* ------------------------------------------------------------------
Vlastni utility. Pouzivat jen pro veci, ktere se opakuji vsude.
------------------------------------------------------------------- */
/* Sklenena karta - zaklad vetsiny panelu na webu i v dashboardu */
@utility glass {
background-color: --alpha(var(--color-ink-800) / 70%);
border: 1px solid --alpha(var(--color-ink-600) / 70%);
backdrop-filter: blur(14px);
}
/* Gradientni text pro zvyrazneni slov v nadpisech */
@utility text-gradient {
background-image: linear-gradient(
100deg,
var(--color-brand-300),
var(--color-brand-400) 40%,
var(--color-accent-400)
);
background-clip: text;
color: transparent;
}
/* Jemna mrizka na pozadi sekci */
@utility bg-grid {
background-image:
linear-gradient(to right, --alpha(var(--color-ink-600) / 45%) 1px, transparent 1px),
linear-gradient(to bottom, --alpha(var(--color-ink-600) / 45%) 1px, transparent 1px);
background-size: 56px 56px;
}
/* Maska pro postupne zmizeni obsahu na okrajích (marquee, mrizka) */
@utility mask-fade-x {
mask-image: linear-gradient(to right, transparent, black 12%, black 88%, transparent);
}
@utility mask-fade-b {
mask-image: linear-gradient(to bottom, black, transparent);
}
+92
View File
@@ -0,0 +1,92 @@
/**
* Tenka vrstva nad fetch. Vsechny volani API jdou pres ni,
* aby se autorizace a chybove hlaseni resily na jednom miste.
*/
const TOKEN_KEY = 'automia.token';
/**
* Prefix reverse proxy, napr. "/apps/csbot-prototype".
* Server ho vklada do index.html podle ROOT_PATH, viz src/index.ts.
* Bez nej by requesty smerovaly na koren domeny, kam aplikace nepatri.
*/
export function basePath(): string {
const value = window.__BASE_PATH__;
return typeof value === 'string' ? value : '';
}
/** Slozi absolutni cestu vcetne prefixu proxy. */
export function apiUrl(path: string): string {
return `${basePath()}${path}`;
}
export class ApiError extends Error {
constructor(
message: string,
readonly status: number,
readonly code?: string,
) {
super(message);
this.name = 'ApiError';
}
}
export function getToken(): string | null {
try {
return localStorage.getItem(TOKEN_KEY);
} catch (err) {
// Privatni rezim / zakazane storage - nesmi to spadnout, ale chceme vedet.
console.warn('[api] localStorage neni dostupne:', err);
return null;
}
}
export function setToken(token: string | null) {
try {
if (token) localStorage.setItem(TOKEN_KEY, token);
else localStorage.removeItem(TOKEN_KEY);
} catch (err) {
console.warn('[api] token nelze ulozit:', err);
}
}
interface RequestOptions extends Omit<RequestInit, 'body'> {
body?: unknown;
/** true = pridat Authorization hlavicku (default pro vse krome loginu) */
auth?: boolean;
}
export async function apiFetch<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { body, auth = true, headers, ...rest } = options;
const token = auth ? getToken() : null;
const response = await fetch(apiUrl(path), {
...rest,
headers: {
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...headers,
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (response.status === 204) return undefined as T;
const isJson = response.headers.get('content-type')?.includes('application/json');
const payload = isJson ? await response.json().catch(() => null) : await response.text();
if (!response.ok) {
const message =
(isJson && payload && typeof payload === 'object' && 'message' in payload
? String((payload as { message: unknown }).message)
: null) ?? `Požadavek selhal (HTTP ${response.status}).`;
const code =
isJson && payload && typeof payload === 'object' && 'error' in payload
? String((payload as { error: unknown }).error)
: undefined;
console.error(`[api] ${path} -> ${response.status} ${code ?? ''} ${message}`);
throw new ApiError(message, response.status, code);
}
return payload as T;
}
+9
View File
@@ -0,0 +1,9 @@
/**
* Minimalisticke spojovani class names. Zamerne bez zavislosti (clsx/twMerge) -
* v prototypu si vystacime s filtrovanim falsy hodnot.
*/
export type ClassValue = string | number | false | null | undefined;
export function cn(...values: ClassValue[]): string {
return values.filter(Boolean).join(' ');
}
+71
View File
@@ -0,0 +1,71 @@
import {
BarChart3,
Building2,
Clock,
FileAudio,
FileInput,
Globe,
Hash,
Landmark,
LifeBuoy,
Mail,
Megaphone,
MessageSquare,
MousePointer,
MousePointerClick,
Plug,
PhoneCall,
Receipt,
ScrollText,
Search,
ShoppingCart,
Shuffle,
Sparkles,
Timer,
Truck,
Users,
Webhook,
type LucideIcon,
} from 'lucide-react';
/**
* API posila u konektoru jen klic ikony (string) - React komponentu poslat nemuze.
* Mapovani je tady. Neznamy klic se zaloguje a dostane obecnou ikonu,
* aby chybejici zapis nerozbil celou stranku.
*/
const icons: Record<string, LucideIcon> = {
BarChart3,
Building2,
Clock,
FileAudio,
FileInput,
Globe,
Hash,
Landmark,
LifeBuoy,
Mail,
Megaphone,
MessageSquare,
MousePointer,
MousePointerClick,
PhoneCall,
Receipt,
ScrollText,
Search,
ShoppingCart,
Shuffle,
Sparkles,
Timer,
Truck,
Users,
Webhook,
};
export function connectorIcon(key: string): LucideIcon {
const icon = icons[key];
if (!icon) {
console.warn(`[connectorIcons] neznamy klic ikony "${key}", pouzivam zastupnou`);
return Plug;
}
return icon;
}
+112
View File
@@ -0,0 +1,112 @@
import { apiUrl, getToken } from '@/lib/api';
import type { DashboardEvent } from '@/types/events';
/**
* Cteni SSE streamu pres fetch.
*
* Zamerne se nepouziva EventSource - ten neumi poslat Authorization hlavicku
* a token by musel byt v adrese, odkud se dostane do access logu.
* Cenou je rucni parsovani formatu a rucni znovupripojeni.
*/
export type StreamStatus = 'connecting' | 'open' | 'reconnecting' | 'closed';
interface StreamHandlers {
onEvent: (event: DashboardEvent) => void;
onStatus: (status: StreamStatus) => void;
}
/** Vraci funkci, ktera stream zavre. */
export function connectEventStream(path: string, handlers: StreamHandlers): () => void {
let closed = false;
let controller: AbortController | null = null;
let retryTimer: number | undefined;
let attempt = 0;
async function run() {
if (closed) return;
controller = new AbortController();
handlers.onStatus(attempt === 0 ? 'connecting' : 'reconnecting');
try {
const token = getToken();
const response = await fetch(apiUrl(path), {
headers: {
Accept: 'text/event-stream',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
signal: controller.signal,
});
if (!response.ok || !response.body) {
throw new Error(`Stream se nepodařilo otevřít (HTTP ${response.status}).`);
}
attempt = 0;
handlers.onStatus('open');
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Jednotlive zpravy oddeluje prazdny radek.
let separator = buffer.indexOf('\n\n');
while (separator !== -1) {
const raw = buffer.slice(0, separator);
buffer = buffer.slice(separator + 2);
handleMessage(raw, handlers.onEvent);
separator = buffer.indexOf('\n\n');
}
}
throw new Error('Stream ukončen serverem.');
} catch (err) {
if (closed) return;
if (err instanceof DOMException && err.name === 'AbortError') return;
console.warn('[stream] spojeni preruseno, zkousim znovu:', err);
handlers.onStatus('reconnecting');
// Exponencialni odstup se stropem, at neubijime server pri vypadku.
attempt += 1;
const delay = Math.min(1000 * 2 ** (attempt - 1), 15_000);
retryTimer = window.setTimeout(run, delay);
}
}
function handleMessage(raw: string, onEvent: (event: DashboardEvent) => void) {
// Radky zacinajici dvojteckou jsou komentare (heartbeat).
const lines = raw.split('\n').filter((line) => !line.startsWith(':'));
const dataLines = lines
.filter((line) => line.startsWith('data:'))
.map((line) => line.slice(5).trim());
if (dataLines.length === 0) return;
const eventName = lines.find((line) => line.startsWith('event:'))?.slice(6).trim();
if (eventName === 'connected') return;
try {
const parsed = JSON.parse(dataLines.join('\n')) as DashboardEvent;
onEvent(parsed);
} catch (err) {
console.error('[stream] nelze precist udalost:', err, dataLines);
}
}
void run();
return () => {
closed = true;
if (retryTimer) window.clearTimeout(retryTimer);
controller?.abort();
handlers.onStatus('closed');
};
}
+244
View File
@@ -0,0 +1,244 @@
import type {
ConditionOperator,
Connector,
ConnectorOperation,
FieldType,
FlowStep,
TriggerField,
} from '@/types/dashboard';
/**
* Ciste funkce pro praci se stromem akci. Zadny React, zadny stav -
* vsechno vraci novy strom, aby se React prekreslil a slo to snadno testovat.
*
* Popis modelu: docs/08-automatizace-builder.md
*/
/**
* Cesta k jedne sekvenci kroku ve strome.
* Prazdne pole = hlavni (korenova) sekvence.
* `[{ stepId: 'st_3', branch: 'yes' }]` = vetev ANO podminky st_3.
*/
export type FlowPath = Array<{ stepId: string; branch: 'yes' | 'no' }>;
let idCounter = 0;
/** Docasne ID kroku na klientovi. Server si ho pri ulozeni prevezme. */
export function newStepId(): string {
idCounter += 1;
return `st_${Date.now().toString(36)}_${idCounter}`;
}
export function createActionStep(connectorId: string, operationId: string): FlowStep {
return { id: newStepId(), kind: 'action', connectorId, operationId };
}
export function createConditionStep(field: TriggerField): FlowStep {
return {
id: newStepId(),
kind: 'condition',
fieldId: field.id,
operator: defaultOperatorFor(field.type),
value: '',
yes: [],
no: [],
};
}
export function newFieldId(): string {
idCounter += 1;
return `f_${Date.now().toString(36)}_${idCounter}`;
}
// ---------------------------------------------------------------- podminky
/**
* Ktere operatory maji smysl pro ktery typ parametru.
* POZOR: stejna tabulka je na serveru v apps/api/src/data/conditions.ts.
* Server je autorita - tady je jen proto, aby UI nenabidlo nesmysl.
*/
export const operatorsByType: Record<FieldType, ConditionOperator[]> = {
string: ['eq', 'neq', 'contains', 'startsWith', 'isEmpty', 'isNotEmpty'],
number: ['eq', 'neq', 'gt', 'gte', 'lt', 'lte'],
boolean: ['isTrue', 'isFalse'],
date: ['eq', 'gt', 'lt'],
};
/** Operatory, ktere nepotrebuji hodnotu k porovnani. */
const unaryOperators: ConditionOperator[] = ['isEmpty', 'isNotEmpty', 'isTrue', 'isFalse'];
export function isUnaryOperator(operator: ConditionOperator): boolean {
return unaryOperators.includes(operator);
}
export function defaultOperatorFor(type: FieldType): ConditionOperator {
const first = operatorsByType[type][0];
if (!first) {
console.warn(`[flow] typ ${type} nema zadny operator, pouzivam "eq"`);
return 'eq';
}
return first;
}
const operatorLabels: Record<ConditionOperator, string> = {
eq: 'je rovno',
neq: 'není rovno',
gt: 'je větší než',
gte: 'je větší nebo rovno',
lt: 'je menší než',
lte: 'je menší nebo rovno',
contains: 'obsahuje',
startsWith: 'začíná na',
isEmpty: 'je prázdné',
isNotEmpty: 'není prázdné',
isTrue: 'je splněno',
isFalse: 'není splněno',
};
/** U datumu zni porovnani prirozeneji jinak nez u cisel. */
const dateOperatorLabels: Partial<Record<ConditionOperator, string>> = {
gt: 'je po',
lt: 'je před',
eq: 'je přesně',
};
export function operatorLabel(operator: ConditionOperator, type: FieldType): string {
if (type === 'date' && dateOperatorLabels[operator]) return dateOperatorLabels[operator];
return operatorLabels[operator];
}
export const fieldTypeLabels: Record<FieldType, string> = {
string: 'text',
number: 'číslo',
boolean: 'ano/ne',
date: 'datum',
};
/** Lidsky citelny zapis podminky, napr. „score je větší nebo rovno 15". */
export function describeCondition(
field: TriggerField | undefined,
operator: ConditionOperator,
value: string | undefined,
): string {
if (!field) return 'Neznámý parametr';
const label = operatorLabel(operator, field.type);
if (isUnaryOperator(operator)) return `${field.name} ${label}`;
return `${field.name} ${label} ${value?.trim() || '…'}`;
}
/** Zmena vlastnosti podminky kdekoliv ve strome. */
export function updateCondition(
steps: FlowStep[],
stepId: string,
patch: { fieldId?: string; operator?: ConditionOperator; value?: string },
): FlowStep[] {
return steps.map((step) => {
if (step.kind !== 'condition') return step;
if (step.id === stepId) return { ...step, ...patch };
return {
...step,
yes: updateCondition(step.yes, stepId, patch),
no: updateCondition(step.no, stepId, patch),
};
});
}
/** Vloz krok do sekvence na dane cesta+pozice. */
export function insertStep(
steps: FlowStep[],
path: FlowPath,
index: number,
step: FlowStep,
): FlowStep[] {
if (path.length === 0) {
const next = [...steps];
next.splice(index, 0, step);
return next;
}
const [head, ...rest] = path;
return steps.map((current) => {
if (current.id !== head.stepId) return current;
if (current.kind !== 'condition') {
console.warn(`[flow] cesta vede pres krok ${current.id}, ktery neni podminka`);
return current;
}
if (head.branch === 'yes') {
return { ...current, yes: insertStep(current.yes, rest, index, step) };
}
return { ...current, no: insertStep(current.no, rest, index, step) };
});
}
/** Odstran krok podle ID kdekoliv ve strome (vcetne jeho vetvi). */
export function removeStep(steps: FlowStep[], stepId: string): FlowStep[] {
return steps
.filter((step) => step.id !== stepId)
.map((step) =>
step.kind === 'condition'
? { ...step, yes: removeStep(step.yes, stepId), no: removeStep(step.no, stepId) }
: step,
);
}
/** Posun krok v ramci jeho sekvence o `offset` pozic. */
export function moveStep(steps: FlowStep[], stepId: string, offset: number): FlowStep[] {
const index = steps.findIndex((step) => step.id === stepId);
if (index !== -1) {
const target = index + offset;
if (target < 0 || target >= steps.length) return steps;
const next = [...steps];
const [moved] = next.splice(index, 1);
next.splice(target, 0, moved);
return next;
}
return steps.map((step) =>
step.kind === 'condition'
? { ...step, yes: moveStep(step.yes, stepId, offset), no: moveStep(step.no, stepId, offset) }
: step,
);
}
/** Pocet vsech kroku vcetne vnorenych vetvi. */
export function countSteps(steps: FlowStep[]): number {
return steps.reduce(
(sum, step) =>
step.kind === 'condition'
? sum + 1 + countSteps(step.yes) + countSteps(step.no)
: sum + 1,
0,
);
}
interface ResolvedOperation {
connector: Connector;
operation: ConnectorOperation;
}
/**
* Najde konektor a operaci pro krok. Vraci null, pokud je odkaz rozbity
* (napr. konektor byl z katalogu odebran) - volajici to musi zobrazit.
*/
export function resolveOperation(
connectors: Connector[],
connectorId: string,
operationId: string,
type: 'trigger' | 'action',
): ResolvedOperation | null {
const connector = connectors.find((c) => c.id === connectorId);
if (!connector) {
console.warn(`[flow] neznamy konektor ${connectorId}`);
return null;
}
const pool = type === 'trigger' ? connector.triggers : connector.actions;
const operation = pool.find((op) => op.id === operationId);
if (!operation) {
console.warn(`[flow] konektor ${connectorId} nema ${type} ${operationId}`);
return null;
}
return { connector, operation };
}
+59
View File
@@ -0,0 +1,59 @@
/** Formatovaci helpery - vsude cs-CZ, aby se cisla a datumy nemichaly. */
const numberFormat = new Intl.NumberFormat('cs-CZ');
const dateTimeFormat = new Intl.DateTimeFormat('cs-CZ', {
day: 'numeric',
month: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
const dayFormat = new Intl.DateTimeFormat('cs-CZ', { day: 'numeric', month: 'numeric' });
export function formatNumber(value: number): string {
return numberFormat.format(value);
}
export function formatPercent(value: number, digits = 1): string {
return `${value.toFixed(digits).replace('.', ',')} %`;
}
export function formatDateTime(iso: string): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) {
console.warn('[format] neplatne datum:', iso);
return '—';
}
return dateTimeFormat.format(date);
}
export function formatDay(iso: string): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) {
console.warn('[format] neplatne datum:', iso);
return '—';
}
return dayFormat.format(date);
}
/** "před 12 min" / "před 3 h" / "před 2 dny" */
export function formatRelative(iso: string): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) {
console.warn('[format] neplatne datum:', iso);
return '—';
}
const diffMin = Math.round((Date.now() - date.getTime()) / 60_000);
if (diffMin < 1) return 'právě teď';
if (diffMin < 60) return `před ${diffMin} min`;
const diffHours = Math.round(diffMin / 60);
if (diffHours < 24) return `před ${diffHours} h`;
const diffDays = Math.round(diffHours / 24);
return diffDays === 1 ? 'včera' : `před ${diffDays} dny`;
}
/** Trvani v ms na citelny tvar (1,2 s / 74 s / 5 min). */
export function formatDuration(ms: number): string {
if (ms < 10_000) return `${(ms / 1000).toFixed(1).replace('.', ',')} s`;
if (ms < 120_000) return `${Math.round(ms / 1000)} s`;
return `${Math.round(ms / 60_000)} min`;
}
+79
View File
@@ -0,0 +1,79 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useEventStream } from '@/components/dashboard/EventStreamProvider';
import { apiFetch } from '@/lib/api';
import type { DashboardEventType } from '@/types/events';
interface QueryState<T> {
data: T | null;
loading: boolean;
error: string | null;
reload: () => void;
}
interface QueryOptions {
/**
* Typy udalosti, po kterych se maji data znovu nacist.
* Diky tomu je dashboard zivy bez pravidelneho dotazovani serveru.
*/
refetchOn?: DashboardEventType[];
}
/**
* Minimalisticky data-fetching hook pro dashboard.
* Az bude dashboard vetsi, nahradit TanStack Query.
*/
export function useApiQuery<T>(path: string, options: QueryOptions = {}): QueryState<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [tick, setTick] = useState(0);
const reload = useCallback(() => setTick((value) => value + 1), []);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
apiFetch<T>(path)
.then((result) => {
if (!cancelled) setData(result);
})
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : 'Data se nepodařilo načíst.';
console.error(`[query] ${path} selhalo:`, err);
if (!cancelled) setError(message);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [path, tick]);
// Obnoveni dat pri prichozi udalosti ze streamu.
const { subscribe } = useEventStream();
const refetchKey = (options.refetchOn ?? []).join(',');
const debounce = useRef<number | undefined>(undefined);
useEffect(() => {
if (refetchKey.length === 0) return;
const wanted = new Set(refetchKey.split(','));
const unsubscribe = subscribe((event) => {
if (!wanted.has(event.type)) return;
// Kdyz prijde vic udalosti tesne po sobe, nacitame data jen jednou.
if (debounce.current) window.clearTimeout(debounce.current);
debounce.current = window.setTimeout(reload, 150);
});
return () => {
unsubscribe();
if (debounce.current) window.clearTimeout(debounce.current);
};
}, [subscribe, refetchKey, reload]);
return { data, loading, error, reload };
}
+19
View File
@@ -0,0 +1,19 @@
import { useEffect } from 'react';
/**
* Nastavi <title> a meta description pro danou stranku.
* Zamerne bez knihovny (react-helmet) - pro prototyp staci.
*/
export function usePageMeta({ title, description }: { title: string; description?: string }) {
useEffect(() => {
document.title = title;
if (!description) return;
const tag = document.querySelector('meta[name="description"]');
if (!tag) {
console.warn('[meta] tag <meta name="description"> v index.html chybi');
return;
}
tag.setAttribute('content', description);
}, [title, description]);
}
+24
View File
@@ -0,0 +1,24 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from '@/App';
import { AuthProvider } from '@/auth/AuthContext';
import { basePath } from '@/lib/api';
import '@/index.css';
const container = document.getElementById('root');
if (!container) {
// Bez #root nema smysl pokracovat - chceme jasnou chybu, ne bilou stranku.
throw new Error('Element #root nebyl v index.html nalezen.');
}
createRoot(container).render(
<StrictMode>
{/* basename kvuli reverse proxy, aplikace nebezi v korenu domeny */}
<BrowserRouter basename={basePath() || undefined}>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</StrictMode>,
);
+145
View File
@@ -0,0 +1,145 @@
import { Compass, HeartHandshake, Radar, ShieldCheck } from 'lucide-react';
import { CallToAction } from '@/components/home/CallToAction';
import { Card } from '@/components/ui/Card';
import { PageHeader } from '@/components/ui/PageHeader';
import { Section, SectionHeading } from '@/components/ui/Section';
import { brand } from '@/config/brand';
import { usePageMeta } from '@/lib/usePageMeta';
const values = [
{
icon: Compass,
title: 'Nejdřív pochopit, potom kódovat',
text: 'Než něco postavíme, projdeme proces s lidmi, kteří ho denně dělají. Většina úspor je vidět už tam.',
},
{
icon: ShieldCheck,
title: 'Bez vendor lock-inu',
text: 'Stavíme na standardních technologiích. Kód i dokumentace jsou vaše, kdykoliv můžete odejít.',
},
{
icon: Radar,
title: 'Provoz bereme jako součást dodávky',
text: 'Monitoring, alerty a incident management nejsou příplatek. Bez nich automatizace dřív nebo později tiše umře.',
},
{
icon: HeartHandshake,
title: 'Mluvíme lidsky',
text: 'Žádné prezentace plné buzzwordů. Řekneme i to, co se automatizovat nevyplatí.',
},
];
const team = [
{ initials: 'JU', name: 'Jiří Uhlíř', role: 'zakladatel, architektura řešení' },
{ initials: 'MK', name: 'Martin Kříž', role: 'integrace a backend' },
{ initials: 'EN', name: 'Eva Nováková', role: 'voiceboti a konverzační design' },
{ initials: 'PS', name: 'Petr Souček', role: 'provoz, monitoring, podpora' },
];
const milestones = [
{ year: '2018', text: 'Vznik firmy — první integrace e-shopů s účetnictvím.' },
{ year: '2020', text: 'První hlasová linka v produkci, tým se rozrůstá na čtyři lidi.' },
{ year: '2023', text: 'Spouštíme vlastní klientský portál s tickety a incidenty.' },
{ year: '2025', text: 'Provozujeme 120+ automatizací s dostupností 99,98 %.' },
];
export default function About() {
usePageMeta({
title: 'O nás — Automia',
description:
'Malý tým, který firmám staví automatizace, voiceboty a integrace — a pak je i provozuje.',
});
return (
<>
<PageHeader
eyebrow="O nás"
title={
<>
Malý tým, <span className="text-gradient">který za svou práci ručí</span>
</>
}
subtitle={`${brand.name} funguje od roku ${brand.founded}. Nejsme softwarový dům na sto lidí — jsme parta, která staví věci, co musí fungovat v pondělí ráno.`}
/>
<Section>
<div className="grid gap-10 lg:grid-cols-[1.2fr_1fr]">
<div className="space-y-5 text-white/65">
<h2 className="text-2xl font-bold text-white">Jak jsme se k tomu dostali</h2>
<p className="leading-relaxed">
Začínali jsme tím, že jsme jedné velkoobchodní firmě přestali přepisovat objednávky
z e-mailu do skladu. Ukázalo se, že tenhle problém skoro každý jen mu každý ří
jinak. Od doby jsme postavili automatizace pro firmy od pěti do pěti set lidí.
</p>
<p className="leading-relaxed">
Postupně se k automatizacím přidaly hlasové linky, protože telefon je pořád kanál,
kterým přichází nejvíc poptávek. A když jsme začali provozovat věci nonstop, přišly
nutně i dashboardy, tickety a incident management abychom o problémech věděli
dřív než klient.
</p>
<p className="leading-relaxed">
Dnes je náš přístup jednoduchý: dodáme malý funkční celek, změříme, co ušetřil, a
teprve potom stavíme dál.
</p>
</div>
<ol className="relative space-y-6 border-l border-ink-600/70 pl-6">
{milestones.map((milestone) => (
<li key={milestone.year} className="relative">
<span className="absolute top-1.5 -left-[1.9rem] size-3 rounded-full border-2 border-ink-900 bg-brand-400" />
<p className="font-mono text-sm font-bold text-brand-300">{milestone.year}</p>
<p className="mt-1 text-sm leading-relaxed text-white/60">{milestone.text}</p>
</li>
))}
</ol>
</div>
</Section>
<Section className="bg-ink-950/40">
<SectionHeading
eyebrow="Jak pracujeme"
title="Čtyři věci, na kterých si trváme"
subtitle="Nejsou to hodnoty na zeď. Podle nich se rozhodujeme, jaké zakázky bereme."
/>
<div className="grid gap-5 sm:grid-cols-2">
{values.map((value) => {
const Icon = value.icon;
return (
<Card key={value.title} interactive>
<span className="grid size-11 place-items-center rounded-xl bg-gradient-to-br from-brand-500/20 to-accent-500/20 text-brand-300">
<Icon className="size-5" />
</span>
<h3 className="mt-4 font-semibold text-white">{value.title}</h3>
<p className="mt-2 text-sm leading-relaxed text-white/60">{value.text}</p>
</Card>
);
})}
</div>
</Section>
<Section>
<SectionHeading
eyebrow="Tým"
title="Lidé, se kterými budete mluvit"
subtitle="Žádná call centra ani account manažeři mezi vámi a tím, kdo řešení staví."
/>
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
{team.map((member) => (
<Card key={member.initials} interactive className="text-center">
<span className="mx-auto grid size-16 place-items-center rounded-2xl bg-gradient-to-br from-brand-400/25 to-accent-500/25 font-mono text-lg font-bold text-brand-200">
{member.initials}
</span>
<h3 className="mt-4 font-semibold text-white">{member.name}</h3>
<p className="mt-1 text-sm text-white/50">{member.role}</p>
</Card>
))}
</div>
<p className="mt-8 text-center text-xs text-white/30">
Složení týmu je v této verzi webu ukázkové (mockup).
</p>
</Section>
<CallToAction />
</>
);
}
+247
View File
@@ -0,0 +1,247 @@
import { AlertCircle, CheckCircle2, Clock, Mail, MapPin, Phone, Send } from 'lucide-react';
import { useState } from 'react';
import type { FormEvent, ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { PageHeader } from '@/components/ui/PageHeader';
import { Section } from '@/components/ui/Section';
import { brand } from '@/config/brand';
import { apiFetch } from '@/lib/api';
import { usePageMeta } from '@/lib/usePageMeta';
const topics = [
{ value: 'automatizace', label: 'Automatizace procesů' },
{ value: 'voicebot', label: 'Voicebot / hlasová linka' },
{ value: 'integrace', label: 'Integrace systémů' },
{ value: 'dashboard', label: 'Dashboard a reporting' },
{ value: 'podpora', label: 'Podpora / incident' },
{ value: 'jine', label: 'Něco jiného' },
] as const;
type Status = { kind: 'idle' } | { kind: 'sending' } | { kind: 'ok'; message: string } | { kind: 'error'; message: string };
const inputClass =
'w-full rounded-xl border border-ink-600/70 bg-ink-850/70 px-4 py-3 text-sm text-white placeholder:text-white/30 transition-colors focus:border-brand-400/70 focus:outline-none';
export default function Contact() {
usePageMeta({
title: 'Kontakt — Automia',
description: 'Napište nám nebo zavolejte. Ozveme se do jednoho pracovního dne.',
});
const [status, setStatus] = useState<Status>({ kind: 'idle' });
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = event.currentTarget;
const data = Object.fromEntries(new FormData(form).entries());
setStatus({ kind: 'sending' });
try {
const response = await apiFetch<{ message: string }>('/api/contact', {
method: 'POST',
auth: false,
body: data,
});
setStatus({ kind: 'ok', message: response.message });
form.reset();
} catch (err) {
const message = err instanceof Error ? err.message : 'Zprávu se nepodařilo odeslat.';
console.error('[contact] odeslani selhalo:', err);
setStatus({ kind: 'error', message });
}
}
return (
<>
<PageHeader
eyebrow="Kontakt"
title={
<>
Ozvěte se. <span className="text-gradient">Odpovídáme rychle.</span>
</>
}
subtitle="Napište, co řešíte — i když ještě nevíte, jak by to mělo fungovat. Na první hovor stačí popis problému."
/>
<Section>
<div className="grid gap-6 lg:grid-cols-[1.3fr_1fr]">
<Card className="p-6 sm:p-8">
<h2 className="text-xl font-bold text-white">Poptávka nebo dotaz</h2>
<p className="mt-1 text-sm text-white/50">Vyplnění zabere minutu.</p>
<form className="mt-7 space-y-4" onSubmit={handleSubmit}>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Jméno a příjmení" htmlFor="name">
<input id="name" name="name" required minLength={2} className={inputClass} placeholder="Jan Novák" />
</Field>
<Field label="E-mail" htmlFor="email">
<input id="email" name="email" type="email" required className={inputClass} placeholder="jan@firma.cz" />
</Field>
<Field label="Firma" htmlFor="company" optional>
<input id="company" name="company" className={inputClass} placeholder="Firma s.r.o." />
</Field>
<Field label="Telefon" htmlFor="phone" optional>
<input id="phone" name="phone" className={inputClass} placeholder="+420 …" />
</Field>
</div>
<Field label="Téma" htmlFor="topic">
<select id="topic" name="topic" defaultValue="automatizace" className={inputClass}>
{topics.map((topic) => (
<option key={topic.value} value={topic.value} className="bg-ink-850">
{topic.label}
</option>
))}
</select>
</Field>
<Field label="Co řešíte" htmlFor="message">
<textarea
id="message"
name="message"
required
minLength={10}
rows={5}
className={`${inputClass} resize-y`}
placeholder="Např.: Objednávky z e-shopu přepisujeme ručně do skladu, denně asi 60 kusů…"
/>
</Field>
<div className="flex flex-wrap items-center gap-4 pt-2">
<Button type="submit" disabled={status.kind === 'sending'}>
<Send className="size-4" />
{status.kind === 'sending' ? 'Odesílám…' : 'Odeslat poptávku'}
</Button>
<p className="text-xs text-white/35">
Odesláním souhlasíte se zpracováním údajů pro účely odpovědi.
</p>
</div>
{status.kind === 'ok' && (
<p className="flex items-center gap-2 rounded-xl border border-ok-400/30 bg-ok-500/10 px-4 py-3 text-sm text-ok-400">
<CheckCircle2 className="size-4 shrink-0" />
{status.message}
</p>
)}
{status.kind === 'error' && (
<p className="flex items-center gap-2 rounded-xl border border-danger-400/30 bg-danger-500/10 px-4 py-3 text-sm text-danger-400">
<AlertCircle className="size-4 shrink-0" />
{status.message}
</p>
)}
</form>
</Card>
<div className="space-y-4">
<Card>
<h2 className="font-semibold text-white">Přímé kontakty</h2>
<ul className="mt-4 space-y-4 text-sm">
<ContactRow icon={<Mail className="size-4" />} label="E-mail">
<a href={`mailto:${brand.email}`} className="text-white/70 hover:text-brand-300">
{brand.email}
</a>
</ContactRow>
<ContactRow icon={<Phone className="size-4" />} label="Telefon">
<a href={`tel:${brand.phoneHref}`} className="text-white/70 hover:text-brand-300">
{brand.phone}
</a>
</ContactRow>
<ContactRow icon={<Clock className="size-4" />} label="Dostupnost">
<span className="text-white/70">{brand.support.hours}</span>
<span className="mt-0.5 block text-xs text-white/40">{brand.support.sla}</span>
</ContactRow>
<ContactRow icon={<MapPin className="size-4" />} label="Adresa">
<span className="text-white/70">
{brand.address.street}
<br />
{brand.address.zip} {brand.address.city}
<br />
{brand.address.country}
</span>
</ContactRow>
</ul>
</Card>
<Card>
<h2 className="font-semibold text-white">Fakturační údaje</h2>
<dl className="mt-4 space-y-2 font-mono text-sm text-white/60">
<div className="flex justify-between gap-4">
<dt className="text-white/40">Firma</dt>
<dd>{brand.legalName}</dd>
</div>
<div className="flex justify-between gap-4">
<dt className="text-white/40">IČO</dt>
<dd>{brand.ico}</dd>
</div>
<div className="flex justify-between gap-4">
<dt className="text-white/40">DIČ</dt>
<dd>{brand.dic}</dd>
</div>
</dl>
</Card>
<Card className="border-brand-400/25 bg-brand-500/6">
<h2 className="font-semibold text-white">Jste náš klient?</h2>
<p className="mt-2 text-sm leading-relaxed text-white/60">
Požadavky a incidenty zadávejte přímo v klientském portálu mají tam SLA a vidíte
jejich stav.
</p>
<Link
to="/prihlaseni"
className="mt-4 inline-flex text-sm font-semibold text-brand-300 hover:text-brand-200"
>
Přejít do portálu
</Link>
</Card>
</div>
</div>
</Section>
</>
);
}
function Field({
label,
htmlFor,
optional = false,
children,
}: {
label: string;
htmlFor: string;
optional?: boolean;
children: ReactNode;
}) {
return (
<div>
<label htmlFor={htmlFor} className="mb-1.5 block text-sm font-medium text-white/70">
{label}
{optional && <span className="ml-1.5 text-xs text-white/30">nepovinné</span>}
</label>
{children}
</div>
);
}
function ContactRow({
icon,
label,
children,
}: {
icon: ReactNode;
label: string;
children: ReactNode;
}) {
return (
<li className="flex gap-3">
<span className="mt-0.5 grid size-8 shrink-0 place-items-center rounded-lg bg-brand-500/12 text-brand-300">
{icon}
</span>
<div>
<p className="text-xs text-white/40">{label}</p>
<div className="mt-0.5">{children}</div>
</div>
</li>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { CallToAction } from '@/components/home/CallToAction';
import { Hero } from '@/components/home/Hero';
import { LogoCloud } from '@/components/home/LogoCloud';
import { Process } from '@/components/home/Process';
import { Products } from '@/components/home/Products';
import { References } from '@/components/home/References';
import { Stats } from '@/components/home/Stats';
import { usePageMeta } from '@/lib/usePageMeta';
export default function Home() {
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.',
});
return (
<>
<Hero />
<LogoCloud />
<Products />
<Stats />
<References />
<Process />
<CallToAction />
</>
);
}
+147
View File
@@ -0,0 +1,147 @@
import { AlertCircle, ArrowLeft, LockKeyhole, ShieldCheck } from 'lucide-react';
import { useState } from 'react';
import type { FormEvent } from 'react';
import { Link, Navigate, useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '@/auth/AuthContext';
import { Logo } from '@/components/layout/Logo';
import { Button } from '@/components/ui/Button';
import { brand } from '@/config/brand';
import { usePageMeta } from '@/lib/usePageMeta';
const inputClass =
'w-full rounded-xl border border-ink-600/70 bg-ink-850/70 px-4 py-3 text-sm text-white placeholder:text-white/30 transition-colors focus:border-brand-400/70 focus:outline-none';
/** 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.',
});
const { user, loading, login } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
// Kam po prihlaseni - bud puvodni cil z RequireAuth, nebo dashboard.
const redirectTo = (location.state as { from?: string } | null)?.from ?? '/dashboard';
if (!loading && user) return <Navigate to={redirectTo} replace />;
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const email = String(formData.get('email') ?? '');
const password = String(formData.get('password') ?? '');
setError(null);
setSubmitting(true);
try {
await login(email, password);
navigate(redirectTo, { replace: true });
} catch (err) {
const message = err instanceof Error ? err.message : 'Přihlášení se nepodařilo.';
console.warn('[login] neuspesne prihlaseni:', err);
setError(message);
} finally {
setSubmitting(false);
}
}
return (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-ink-900 px-5 py-14">
<div aria-hidden className="pointer-events-none absolute inset-0 -z-10">
<div className="absolute inset-0 bg-grid opacity-25" />
<div className="animate-pulse-slow absolute -top-32 left-1/2 size-[34rem] -translate-x-1/2 rounded-full bg-brand-500/16 blur-[120px]" />
<div className="animate-pulse-slow absolute -bottom-40 right-0 size-[26rem] rounded-full bg-accent-500/14 blur-[120px]" />
</div>
<div className="w-full max-w-md">
<Link
to="/"
className="mb-8 inline-flex items-center gap-2 text-sm text-white/45 transition-colors hover:text-white"
>
<ArrowLeft className="size-4" />
Zpět na web
</Link>
<div className="glass rounded-2xl p-7 shadow-2xl shadow-brand-500/10 sm:p-9">
<Logo />
<h1 className="mt-7 text-2xl font-bold text-white">Klientský portál</h1>
<p className="mt-2 text-sm text-white/55">
Přihlaste se a uvidíte stav automatizací, ticketů i incidentů.
</p>
<form className="mt-7 space-y-4" onSubmit={handleSubmit}>
<div>
<label htmlFor="email" className="mb-1.5 block text-sm font-medium text-white/70">
E-mail
</label>
<input
id="email"
name="email"
type="email"
required
autoComplete="email"
autoFocus
className={inputClass}
placeholder="vas@email.cz"
/>
</div>
<div>
<div className="mb-1.5 flex items-baseline justify-between">
<label htmlFor="password" className="block text-sm font-medium text-white/70">
Heslo
</label>
<span className="text-xs text-white/30">Zapomenuté heslo? Napište nám.</span>
</div>
<input
id="password"
name="password"
type="password"
required
autoComplete="current-password"
className={inputClass}
placeholder="••••••••"
/>
</div>
{error && (
<p className="flex items-center gap-2 rounded-xl border border-danger-400/30 bg-danger-500/10 px-4 py-3 text-sm text-danger-400">
<AlertCircle className="size-4 shrink-0" />
{error}
</p>
)}
<Button type="submit" className="w-full" disabled={submitting}>
<LockKeyhole className="size-4" />
{submitting ? 'Přihlašuji…' : 'Přihlásit se'}
</Button>
</form>
<div className="mt-7 rounded-xl border border-ink-600/60 bg-ink-850/60 p-4">
<p className="flex items-center gap-2 text-xs font-semibold text-brand-300">
<ShieldCheck className="size-3.5" />
Demo přístupy (prototyp)
</p>
<ul className="mt-2.5 space-y-1 font-mono text-xs text-white/50">
<li>admin@automia.cz / demo1234</li>
<li>klient@firma.cz / demo1234</li>
</ul>
</div>
</div>
<p className="mt-6 text-center text-xs text-white/35">
Nemáte účet?{' '}
<Link to="/kontakt" className="font-medium text-brand-300 hover:text-brand-200">
Ozvěte se nám
</Link>{' '}
portál zřizujeme klientům {brand.name}.
</p>
</div>
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { ButtonLink } from '@/components/ui/Button';
import { Container } from '@/components/ui/Container';
import { usePageMeta } from '@/lib/usePageMeta';
export default function NotFound() {
usePageMeta({ title: 'Stránka nenalezena — Automia' });
return (
<Container className="flex min-h-[60vh] flex-col items-center justify-center py-20 text-center">
<p className="font-mono text-6xl font-extrabold text-brand-400/30">404</p>
<h1 className="mt-4 text-2xl font-bold text-white sm:text-3xl">Tuhle stránku nemáme</h1>
<p className="mt-3 max-w-md text-white/55">
Odkaz je nefunkční nebo se stránka přesunula. Zkuste to z rozcestí níž.
</p>
<div className="mt-8 flex flex-wrap justify-center gap-3">
<ButtonLink to="/">Zpět na homepage</ButtonLink>
<ButtonLink to="/kontakt" variant="secondary">
Napsat nám
</ButtonLink>
</div>
</Container>
);
}
+86
View File
@@ -0,0 +1,86 @@
import { Check } from 'lucide-react';
import { CallToAction } from '@/components/home/CallToAction';
import { PageHeader } from '@/components/ui/PageHeader';
import { Section } from '@/components/ui/Section';
import { products } from '@/data/products';
import { usePageMeta } from '@/lib/usePageMeta';
import { cn } from '@/lib/cn';
export default function Services() {
usePageMeta({
title: 'Služby — Automia',
description:
'Automatizace procesů, voiceboti, integrace systémů, dashboardy, tickety a incident management.',
});
return (
<>
<PageHeader
eyebrow="Služby"
title={
<>
Co pro vás <span className="text-gradient">umíme postavit</span>
</>
}
subtitle="Nabídku skládáme podle toho, co firmě reálně chybí. Nejčastěji ale řešíme těchto šest oblastí."
/>
<Section className="py-16! sm:py-20!">
<div className="space-y-6">
{products.map((product, index) => {
const Icon = product.icon;
const reversed = index % 2 === 1;
return (
<article
key={product.slug}
id={product.slug}
className="glass scroll-mt-28 rounded-card p-6 sm:p-8"
>
<div
className={cn(
'grid gap-8 lg:grid-cols-[1.4fr_1fr] lg:items-center',
reversed && 'lg:[&>*:first-child]:order-2',
)}
>
<div>
<div className="flex items-center gap-3">
<span className="grid size-11 place-items-center rounded-xl bg-gradient-to-br from-brand-500/20 to-accent-500/20 text-brand-300">
<Icon className="size-5" />
</span>
<div>
<h2 className="text-xl font-bold text-white">{product.name}</h2>
<p className="text-sm text-brand-300/80">{product.tagline}</p>
</div>
</div>
<p className="mt-5 leading-relaxed text-white/65">{product.description}</p>
<ul className="mt-5 grid gap-2 sm:grid-cols-2">
{product.features.map((feature) => (
<li key={feature} className="flex items-start gap-2 text-sm text-white/55">
<Check className="mt-0.5 size-4 shrink-0 text-ok-400" />
{feature}
</li>
))}
</ul>
</div>
<div className="rounded-2xl border border-ink-600/60 bg-ink-850/60 p-6 text-center">
<p className="text-4xl font-extrabold text-white">{product.metric.value}</p>
<p className="mt-2 text-sm text-white/45">{product.metric.label}</p>
<p className="mt-5 border-t border-ink-600/60 pt-4 font-mono text-xs text-white/30">
ukázková metrika
</p>
</div>
</div>
</article>
);
})}
</div>
</Section>
<CallToAction />
</>
);
}
@@ -0,0 +1,416 @@
import { AlertCircle, ArrowLeft, Check, Info, Pause, Play, Save, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { DataState } from '@/components/dashboard/DataState';
import { FlowCanvas } from '@/components/dashboard/flow/FlowCanvas';
import { StepPicker } from '@/components/dashboard/flow/StepPicker';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { apiFetch } from '@/lib/api';
import {
countSteps,
createActionStep,
createConditionStep,
insertStep,
moveStep,
removeStep,
updateCondition,
type FlowPath,
} from '@/lib/flow';
import { formatDateTime } from '@/lib/format';
import { useApiQuery } from '@/lib/useApiQuery';
import { usePageMeta } from '@/lib/usePageMeta';
import type {
AutomationDetail as Detail,
AutomationFlow,
ConnectorCatalog,
TriggerField,
} from '@/types/dashboard';
/** Kam se ma vlozit dalsi krok - null znamena, ze vyber neni otevreny. */
interface PickerTarget {
mode: 'trigger' | 'action';
path: FlowPath;
index: number;
}
export default function AutomationDetail() {
const { id = '' } = useParams();
const navigate = useNavigate();
const automation = useApiQuery<Detail>(`/api/dashboard/automations/${id}`);
const catalog = useApiQuery<ConnectorCatalog>('/api/dashboard/connectors');
const [name, setName] = useState('');
const [flow, setFlow] = useState<AutomationFlow>({ trigger: null, steps: [] });
const [enabled, setEnabled] = useState(false);
const [picker, setPicker] = useState<PickerTarget | null>(null);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [savedAt, setSavedAt] = useState<string | null>(null);
const [dirty, setDirty] = useState(false);
const [regenerating, setRegenerating] = useState(false);
/** Co podle serveru chybi k zapnuti. Prepocitava ho server pri kazdem ulozeni. */
const [issues, setIssues] = useState<string[]>([]);
usePageMeta({ title: `${name || 'Automatizace'} — portál Automia` });
// Prevzeti dat ze serveru do editovatelneho stavu.
useEffect(() => {
if (!automation.data) return;
setName(automation.data.name);
setFlow(automation.data.flow);
setEnabled(automation.data.enabled);
setIssues(automation.data.issues);
setDirty(false);
}, [automation.data]);
const connectors = catalog.data?.items ?? [];
const categories = catalog.data?.categories ?? [];
const stepCount = useMemo(() => countSteps(flow.steps), [flow.steps]);
/** Kazda zmena stromu jde pres tohle, aby se drzel priznak "neulozeno". */
const changeFlow = useCallback((next: AutomationFlow) => {
setFlow(next);
setDirty(true);
setSavedAt(null);
}, []);
function handlePick(connectorId: string, operationId: string) {
if (!picker) {
console.warn('[builder] vyber potvrzen bez otevreneho cile');
return;
}
if (picker.mode === 'trigger') {
// Pri zmene spoustece drzime uz nadeklarovane parametry, aby se nezahodila prace.
changeFlow({
...flow,
trigger: {
connectorId,
operationId,
fields: flow.trigger?.fields ?? [],
webhookToken: flow.trigger?.webhookToken,
},
});
} else {
changeFlow({
...flow,
steps: insertStep(
flow.steps,
picker.path,
picker.index,
createActionStep(connectorId, operationId),
),
});
}
setPicker(null);
}
function handlePickCondition() {
if (!picker || picker.mode !== 'action') {
console.warn('[builder] podminku lze vlozit jen jako krok');
return;
}
const firstField = flow.trigger?.fields[0];
if (!firstField) {
console.warn('[builder] podminku nelze pridat - spoustec nema zadne parametry');
return;
}
changeFlow({
...flow,
steps: insertStep(flow.steps, picker.path, picker.index, createConditionStep(firstField)),
});
setPicker(null);
}
function handleChangeFields(fields: TriggerField[]) {
if (!flow.trigger) {
console.warn('[builder] zmena parametru bez spoustece');
return;
}
changeFlow({ ...flow, trigger: { ...flow.trigger, fields } });
}
async function handleRegenerateToken() {
setRegenerating(true);
setSaveError(null);
try {
// Token mení server; napřed uložíme rozdělanou práci, ať se nepřepíše.
if (dirty) {
await apiFetch<Detail>(`/api/dashboard/automations/${id}`, {
method: 'PUT',
body: { name, enabled, flow },
});
}
const updated = await apiFetch<Detail>(
`/api/dashboard/automations/${id}/webhook/regenerate`,
{ method: 'POST' },
);
setFlow(updated.flow);
setIssues(updated.issues);
setDirty(false);
setSavedAt(updated.updatedAt);
} catch (err) {
const message = err instanceof Error ? err.message : 'Novou adresu se nepodařilo vytvořit.';
console.error('[builder] regenerace tokenu selhala:', err);
setSaveError(message);
} finally {
setRegenerating(false);
}
}
async function save(nextEnabled = enabled) {
setSaving(true);
setSaveError(null);
try {
const updated = await apiFetch<Detail>(`/api/dashboard/automations/${id}`, {
method: 'PUT',
body: { name, enabled: nextEnabled, flow },
});
setEnabled(updated.enabled);
setFlow(updated.flow);
setIssues(updated.issues);
setDirty(false);
setSavedAt(updated.updatedAt);
// Server muze zapnuti odmitnout - rekneme presne proc, ne obecnou hlasku.
if (nextEnabled && !updated.enabled) {
setSaveError(
updated.issues.length > 0
? `Automatizaci nelze zapnout: ${updated.issues.join(' ')}`
: 'Automatizaci se nepodařilo zapnout.',
);
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Uložení se nepodařilo.';
console.error('[builder] ulozeni selhalo:', err);
setSaveError(message);
} finally {
setSaving(false);
}
}
async function handleDelete() {
if (!window.confirm(`Smazat automatizaci „${name}"? Tuto akci nelze vzít zpět.`)) return;
try {
await apiFetch<void>(`/api/dashboard/automations/${id}`, { method: 'DELETE' });
navigate('/dashboard/automatizace', { replace: true });
} catch (err) {
const message = err instanceof Error ? err.message : 'Smazání se nepodařilo.';
console.error('[builder] smazani selhalo:', err);
setSaveError(message);
}
}
return (
<div className="space-y-6">
<Link
to="/dashboard/automatizace"
className="inline-flex items-center gap-2 text-sm text-white/45 transition-colors hover:text-white"
>
<ArrowLeft className="size-4" />
Zpět na automatizace
</Link>
<DataState
loading={automation.loading || catalog.loading}
error={automation.error ?? catalog.error}
onRetry={() => {
automation.reload();
catalog.reload();
}}
>
<div className="space-y-6">
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<input
value={name}
onChange={(event) => {
setName(event.target.value);
setDirty(true);
setSavedAt(null);
}}
aria-label="Název automatizace"
className="w-full rounded-lg border border-transparent bg-transparent px-2 py-1 -mx-2 text-2xl font-bold text-white transition-colors hover:border-ink-600/70 focus:border-brand-400/70 focus:bg-ink-850/60 focus:outline-none"
/>
<div className="mt-2 flex flex-wrap items-center gap-2 px-0.5">
<span className="font-mono text-xs text-white/35">{id}</span>
{flow.trigger ? (
<Badge tone={enabled ? 'ok' : 'neutral'}>
<span
className={
enabled ? 'size-1.5 rounded-full bg-ok-400' : 'size-1.5 rounded-full bg-white/40'
}
/>
{enabled ? 'Aktivní' : 'Pozastaveno'}
</Badge>
) : (
<Badge tone="warn">Koncept chybí spouštěč</Badge>
)}
<span className="text-xs text-white/40">
{stepCount} {stepCount === 1 ? 'krok' : stepCount >= 2 && stepCount <= 4 ? 'kroky' : 'kroků'}
</span>
{dirty && <Badge tone="warn">Neuložené změny</Badge>}
{!dirty && savedAt && (
<span className="inline-flex items-center gap-1.5 text-xs text-ok-400">
<Check className="size-3.5" />
Uloženo {formatDateTime(savedAt)}
</span>
)}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => void save(!enabled)}
disabled={saving || !flow.trigger}
title={!flow.trigger ? 'Nejdřív vyberte spouštěč' : undefined}
>
{enabled ? <Pause className="size-4" /> : <Play className="size-4" />}
{enabled ? 'Pozastavit' : 'Zapnout'}
</Button>
<Button size="sm" onClick={() => void save()} disabled={saving || !dirty}>
<Save className="size-4" />
{saving ? 'Ukládám…' : 'Uložit'}
</Button>
<button
type="button"
onClick={() => void handleDelete()}
title="Smazat automatizaci"
className="grid size-9 place-items-center rounded-lg text-white/35 transition-colors hover:bg-danger-500/12 hover:text-danger-400"
>
<Trash2 className="size-4" />
<span className="sr-only">Smazat automatizaci</span>
</button>
</div>
</header>
{saveError && (
<p className="flex items-center gap-2 rounded-xl border border-danger-400/30 bg-danger-500/10 px-4 py-3 text-sm text-danger-400">
<AlertCircle className="size-4 shrink-0" />
{saveError}
</p>
)}
{/* Rozdelana prace se ulozi vzdy, jen se nesmi pustit do provozu. */}
{issues.length > 0 && (
<div className="rounded-xl border border-warn-400/30 bg-warn-500/8 px-4 py-3">
<p className="flex items-center gap-2 text-sm font-semibold text-warn-400">
<AlertCircle className="size-4 shrink-0" />
Než půjde zapnout, chybí doplnit
</p>
<ul className="mt-2 space-y-1 pl-6 text-sm text-white/60">
{issues.map((issue) => (
<li key={issue} className="list-disc">
{issue}
</li>
))}
</ul>
</div>
)}
<div className="grid gap-6 xl:grid-cols-[1fr_18rem]">
<section className="glass rounded-card p-5 sm:p-6">
<div className="mb-6 flex items-center justify-between gap-3">
<h2 className="font-semibold text-white">Strom akcí</h2>
<p className="text-xs text-white/35">
Klikněte na <span className="font-mono">+</span> a vyberte, co se stát
</p>
</div>
<FlowCanvas
flow={flow}
connectors={connectors}
webhookBaseUrl={catalog.data?.webhookBaseUrl ?? ''}
regenerating={regenerating}
onPickTrigger={() => setPicker({ mode: 'trigger', path: [], index: 0 })}
onChangeFields={handleChangeFields}
onRegenerateToken={() => void handleRegenerateToken()}
onAddStep={(path, index) => setPicker({ mode: 'action', path, index })}
onRemoveStep={(stepId) =>
changeFlow({ ...flow, steps: removeStep(flow.steps, stepId) })
}
onUpdateCondition={(stepId, patch) =>
changeFlow({ ...flow, steps: updateCondition(flow.steps, stepId, patch) })
}
onMoveStep={(stepId, offset) =>
changeFlow({ ...flow, steps: moveStep(flow.steps, stepId, offset) })
}
/>
</section>
<aside className="space-y-4">
<div className="glass rounded-card p-5">
<h2 className="font-semibold text-white">Jak to funguje</h2>
<ol className="mt-3 space-y-2.5 text-sm text-white/55">
<li className="flex gap-2">
<span className="font-mono text-brand-300">1.</span>
Vyberte spouštěč čím automatizace začne.
</li>
<li className="flex gap-2">
<span className="font-mono text-brand-300">2.</span>
Klikněte na <span className="font-mono">+</span> a přidejte akci nad
napojenou službou.
</li>
<li className="flex gap-2">
<span className="font-mono text-brand-300">3.</span>
Potřebujete rozhodování? Přidejte podmínku běh se rozdělí na větev
ANO a NE.
</li>
<li className="flex gap-2">
<span className="font-mono text-brand-300">4.</span>
Uložte a zapněte.
</li>
</ol>
</div>
{automation.data && (
<div className="glass rounded-card p-5">
<h2 className="font-semibold text-white">Provoz</h2>
<dl className="mt-3 space-y-2.5 text-sm">
<Row label="Vytvořeno" value={formatDateTime(automation.data.createdAt)} />
<Row label="Poslední úprava" value={formatDateTime(automation.data.updatedAt)} />
<Row label="Spuštění dnes" value={String(automation.data.runsToday)} />
<Row label="Úspěšnost" value={`${automation.data.successRate} %`} />
</dl>
</div>
)}
<p className="flex gap-2 rounded-xl border border-ink-600/60 px-4 py-3 text-xs leading-relaxed text-white/40">
<Info className="mt-0.5 size-3.5 shrink-0" />
Prototyp: nastavení jednotlivých polí kroku (mapování dat, filtry) zatím
není součástí builderu. Uložený strom se nespouští.
</p>
</aside>
</div>
</div>
</DataState>
<StepPicker
open={picker !== null}
mode={picker?.mode ?? 'action'}
connectors={connectors}
categories={categories}
onClose={() => setPicker(null)}
onPickOperation={handlePick}
onPickCondition={handlePickCondition}
canAddCondition={(flow.trigger?.fields.length ?? 0) > 0}
/>
</div>
);
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex justify-between gap-3">
<dt className="text-white/40">{label}</dt>
<dd className="text-right text-white/70">{value}</dd>
</div>
);
}
+239
View File
@@ -0,0 +1,239 @@
import {
AlertCircle,
BarChart3,
ChevronRight,
Network,
PhoneCall,
Plus,
Workflow,
type LucideIcon,
} from 'lucide-react';
import { useState } from 'react';
import type { FormEvent } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { DataState } from '@/components/dashboard/DataState';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { apiFetch } from '@/lib/api';
import { formatDuration, formatNumber, formatPercent, formatRelative } from '@/lib/format';
import { useApiQuery } from '@/lib/useApiQuery';
import { usePageMeta } from '@/lib/usePageMeta';
import type { Automation, AutomationDetail, ListResponse } from '@/types/dashboard';
const kindMeta: Record<Automation['kind'], { label: string; icon: LucideIcon }> = {
workflow: { label: 'Workflow', icon: Workflow },
voicebot: { label: 'Voicebot', icon: PhoneCall },
integrace: { label: 'Integrace', icon: Network },
report: { label: 'Report', icon: BarChart3 },
};
export default function Automations() {
usePageMeta({ title: 'Automatizace — portál Automia' });
const { data, loading, error, reload } = useApiQuery<ListResponse<Automation>>(
'/api/dashboard/automations',
{
refetchOn: [
'automation.created',
'automation.updated',
'automation.deleted',
'automation.run',
],
},
);
const [creating, setCreating] = useState(false);
return (
<div className="space-y-6">
<header className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-white">Automatizace</h1>
<p className="mt-1 text-sm text-white/50">
Přehled běžících procesů. Klikněte na automatizaci pro úpravu stromu akcí.
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="secondary" size="sm" onClick={() => setCreating(true)}>
<Plus className="size-4" />
Nová automatizace
</Button>
<Link
to="/dashboard/konektory"
className="rounded-full px-4 py-2 text-sm font-medium text-white/55 transition-colors hover:bg-white/5 hover:text-white"
>
Konektory
</Link>
</div>
</header>
<DataState
loading={loading}
error={error}
empty={data?.items.length === 0}
onRetry={reload}
emptyLabel="Zatím tu nic není. Vytvořte první automatizaci."
>
<div className="grid gap-4 lg:grid-cols-2">
{data?.items.map((automation) => (
<AutomationCard key={automation.id} automation={automation} />
))}
</div>
</DataState>
<CreateAutomationModal
open={creating}
onClose={() => setCreating(false)}
onCreated={reload}
/>
</div>
);
}
function AutomationCard({ automation }: { automation: Automation }) {
const meta = kindMeta[automation.kind];
const Icon = meta.icon;
return (
<Link
to={`/dashboard/automatizace/${automation.id}`}
className="glass group block rounded-card p-5 transition-all hover:-translate-y-0.5 hover:border-brand-400/50"
>
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-3">
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-brand-500/12 text-brand-300">
<Icon className="size-4" />
</span>
<div className="min-w-0">
<h2 className="truncate font-semibold text-white">{automation.name}</h2>
<p className="mt-0.5 font-mono text-xs text-white/35">
{automation.id} · {meta.label} · {automation.stepCount}{' '}
{automation.stepCount === 1 ? 'krok' : 'kroků'}
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
{!automation.configured ? (
<Badge tone="warn">Koncept</Badge>
) : (
<Badge tone={automation.enabled ? 'ok' : 'neutral'}>
<span
className={
automation.enabled
? 'size-1.5 rounded-full bg-ok-400'
: 'size-1.5 rounded-full bg-white/40'
}
/>
{automation.enabled ? 'Aktivní' : 'Pozastaveno'}
</Badge>
)}
<ChevronRight className="size-4 text-white/20 transition-colors group-hover:text-brand-300" />
</div>
</div>
<dl className="mt-5 grid grid-cols-2 gap-4 border-t border-ink-600/50 pt-4 sm:grid-cols-4">
<Metric label="Dnes" value={formatNumber(automation.runsToday)} />
<Metric label="Úspěšnost" value={formatPercent(automation.successRate)} />
<Metric
label="Prům. běh"
value={automation.avgDurationMs === 0 ? '—' : formatDuration(automation.avgDurationMs)}
/>
<Metric label="Naposled" value={formatRelative(automation.lastRunAt)} />
</dl>
</Link>
);
}
function Metric({ label, value }: { label: string; value: string }) {
return (
<div>
<dt className="text-xs text-white/35">{label}</dt>
<dd className="mt-0.5 text-sm font-semibold text-white tabular-nums">{value}</dd>
</div>
);
}
/** Vytvoreni prazdne automatizace - strom se pak sklada na jejim detailu. */
function CreateAutomationModal({
open,
onClose,
onCreated,
}: {
open: boolean;
onClose: () => void;
onCreated: () => void;
}) {
const navigate = useNavigate();
const [name, setName] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitting(true);
setError(null);
try {
const created = await apiFetch<AutomationDetail>('/api/dashboard/automations', {
method: 'POST',
body: { name },
});
onCreated();
setName('');
onClose();
// Rovnou do builderu - uzivatel chce skladat strom, ne cist seznam.
navigate(`/dashboard/automatizace/${created.id}`);
} catch (err) {
const message = err instanceof Error ? err.message : 'Automatizaci nešlo vytvořit.';
console.error('[automations] vytvoreni selhalo:', err);
setError(message);
} finally {
setSubmitting(false);
}
}
return (
<Modal
open={open}
onClose={onClose}
title="Nová automatizace"
description="Pojmenujte ji. Spouštěč a kroky vyberete hned na dalším kroku."
className="max-w-lg"
>
<form onSubmit={handleSubmit} className="space-y-4 p-5">
<div>
<label htmlFor="automation-name" className="mb-1.5 block text-sm font-medium text-white/70">
Název
</label>
<input
id="automation-name"
value={name}
onChange={(event) => setName(event.target.value)}
required
minLength={3}
placeholder="Např.: Poptávka z webu → CRM → e-mail"
className="w-full rounded-xl border border-ink-600/70 bg-ink-850/70 px-4 py-3 text-sm text-white placeholder:text-white/30 focus:border-brand-400/70 focus:outline-none"
/>
<p className="mt-2 text-xs text-white/35">
Doporučení: napište, co automatizace dělá, ne kterou technologii používá.
</p>
</div>
{error && (
<p className="flex items-center gap-2 rounded-xl border border-danger-400/30 bg-danger-500/10 px-4 py-3 text-sm text-danger-400">
<AlertCircle className="size-4 shrink-0" />
{error}
</p>
)}
<div className="flex justify-end gap-2 pt-1">
<Button type="button" variant="ghost" onClick={onClose}>
Zrušit
</Button>
<Button type="submit" disabled={submitting}>
{submitting ? 'Vytvářím…' : 'Vytvořit a pokračovat'}
</Button>
</div>
</form>
</Modal>
);
}
+270
View File
@@ -0,0 +1,270 @@
import { CheckCircle2, Clock, Plug, Search, Zap } from 'lucide-react';
import { useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { DataState } from '@/components/dashboard/DataState';
import { Badge } from '@/components/ui/Badge';
import { brand } from '@/config/brand';
import { cn } from '@/lib/cn';
import { connectorIcon } from '@/lib/connectorIcons';
import { useApiQuery } from '@/lib/useApiQuery';
import { usePageMeta } from '@/lib/usePageMeta';
import type { Connector, ConnectorCatalog, ConnectorCategory, ConnectorStatus } from '@/types/dashboard';
const statusMeta: Record<ConnectorStatus, { label: string; tone: 'ok' | 'neutral' | 'warn' }> = {
connected: { label: 'Napojeno', tone: 'ok' },
available: { label: 'Umíme napojit', tone: 'neutral' },
planned: { label: 'Na roadmapě', tone: 'warn' },
};
export default function Connectors() {
usePageMeta({ title: 'Konektory — portál Automia' });
const { data, loading, error, reload } = useApiQuery<ConnectorCatalog>(
'/api/dashboard/connectors',
);
const [query, setQuery] = useState('');
const [category, setCategory] = useState<ConnectorCategory | 'all'>('all');
const [status, setStatus] = useState<ConnectorStatus | 'all'>('all');
const items = data?.items ?? [];
const categories = data?.categories ?? [];
const filtered = useMemo(() => {
const needle = query.trim().toLowerCase();
return items.filter((connector) => {
if (category !== 'all' && connector.category !== category) return false;
if (status !== 'all' && connector.status !== status) return false;
if (needle.length === 0) return true;
const haystack = [
connector.name,
connector.description,
...connector.triggers.map((t) => t.name),
...connector.actions.map((a) => a.name),
]
.join(' ')
.toLowerCase();
return haystack.includes(needle);
});
}, [items, query, category, status]);
const counts = useMemo(
() => ({
connected: items.filter((c) => c.status === 'connected').length,
available: items.filter((c) => c.status === 'available').length,
planned: items.filter((c) => c.status === 'planned').length,
}),
[items],
);
return (
<div className="space-y-6">
<header>
<h1 className="text-2xl font-bold text-white">Konektory</h1>
<p className="mt-1 text-sm text-white/50">
Služby, které jdou použít v automatizacích. Každý konektor nabízí spouštěče
(čím běh začne) a akce (co se stát).
</p>
</header>
<DataState loading={loading} error={error} onRetry={reload} empty={items.length === 0}>
<div className="space-y-6">
<div className="grid gap-4 sm:grid-cols-3">
<SummaryTile
icon={CheckCircle2}
value={counts.connected}
label="napojeno a připraveno k použití"
tone="ok"
/>
<SummaryTile
icon={Plug}
value={counts.available}
label="umíme napojit na požádání"
tone="brand"
/>
<SummaryTile icon={Clock} value={counts.planned} label="na roadmapě" tone="warn" />
</div>
<div className="glass rounded-card p-4 sm:p-5">
<div className="relative">
<Search className="pointer-events-none absolute top-1/2 left-3.5 size-4 -translate-y-1/2 text-white/35" />
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Hledat službu, spouštěč nebo akci…"
className="w-full rounded-xl border border-ink-600/70 bg-ink-850/70 py-2.5 pr-4 pl-10 text-sm text-white placeholder:text-white/30 focus:border-brand-400/70 focus:outline-none"
/>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Chip active={category === 'all'} onClick={() => setCategory('all')}>
Všechny kategorie
</Chip>
{categories.map((cat) => (
<Chip
key={cat.id}
active={category === cat.id}
onClick={() => setCategory(cat.id)}
>
{cat.label}
</Chip>
))}
</div>
<div className="mt-2 flex flex-wrap gap-2">
<Chip active={status === 'all'} onClick={() => setStatus('all')}>
Vše
</Chip>
{(['connected', 'available', 'planned'] as const).map((value) => (
<Chip key={value} active={status === value} onClick={() => setStatus(value)}>
{statusMeta[value].label}
</Chip>
))}
</div>
</div>
{filtered.length === 0 ? (
<p className="py-10 text-center text-sm text-white/45">
Nic neodpovídá filtru. Chybí vám konektor?{' '}
<a href={`mailto:${brand.email}`} className="text-brand-300 hover:text-brand-200">
Napište nám
</a>
, většinu služeb umíme napojit přes HTTP požadavek.
</p>
) : (
<div className="grid gap-4 lg:grid-cols-2">
{filtered.map((connector) => (
<ConnectorCard key={connector.id} connector={connector} />
))}
</div>
)}
</div>
</DataState>
</div>
);
}
function SummaryTile({
icon: Icon,
value,
label,
tone,
}: {
icon: typeof Plug;
value: number;
label: string;
tone: 'ok' | 'brand' | 'warn';
}) {
const tones = {
ok: 'bg-ok-500/12 text-ok-400',
brand: 'bg-brand-500/12 text-brand-300',
warn: 'bg-warn-500/12 text-warn-400',
} as const;
return (
<div className="glass flex items-center gap-4 rounded-card p-5">
<span className={cn('grid size-10 shrink-0 place-items-center rounded-xl', tones[tone])}>
<Icon className="size-5" />
</span>
<div>
<p className="text-2xl font-extrabold text-white tabular-nums">{value}</p>
<p className="text-xs text-white/45">{label}</p>
</div>
</div>
);
}
function Chip({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
'rounded-full border px-3 py-1.5 text-xs font-medium transition-colors',
active
? 'border-brand-400/50 bg-brand-500/15 text-brand-200'
: 'border-ink-600/70 text-white/50 hover:border-white/20 hover:text-white',
)}
>
{children}
</button>
);
}
function ConnectorCard({ connector }: { connector: Connector }) {
const Icon = connectorIcon(connector.icon);
const meta = statusMeta[connector.status];
return (
<article className="glass flex h-full flex-col rounded-card p-5">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-3">
<span className="grid size-11 shrink-0 place-items-center rounded-xl bg-gradient-to-br from-brand-500/20 to-accent-500/20 text-brand-300">
<Icon className="size-5" />
</span>
<div className="min-w-0">
<h2 className="font-semibold text-white">{connector.name}</h2>
<p className="mt-0.5 text-sm text-white/55">{connector.description}</p>
</div>
</div>
<Badge tone={meta.tone}>{meta.label}</Badge>
</div>
<div className="mt-5 grid flex-1 gap-4 border-t border-ink-600/50 pt-4 sm:grid-cols-2">
<OperationList
title="Spouštěče"
icon={<Zap className="size-3 text-brand-300" />}
names={connector.triggers.map((t) => t.name)}
emptyLabel="Nelze použít jako spouštěč"
/>
<OperationList
title="Akce"
icon={<Plug className="size-3 text-accent-300" />}
names={connector.actions.map((a) => a.name)}
emptyLabel="Žádné akce"
/>
</div>
</article>
);
}
function OperationList({
title,
icon,
names,
emptyLabel,
}: {
title: string;
icon: ReactNode;
names: string[];
emptyLabel: string;
}) {
return (
<div>
<p className="mb-2 flex items-center gap-1.5 text-xs font-semibold tracking-wide text-white/45 uppercase">
{icon}
{title}
</p>
{names.length === 0 ? (
<p className="text-xs text-white/25">{emptyLabel}</p>
) : (
<ul className="space-y-1">
{names.map((name) => (
<li key={name} className="text-sm text-white/60">
{name}
</li>
))}
</ul>
)}
</div>
);
}
+72
View File
@@ -0,0 +1,72 @@
import { DataState } from '@/components/dashboard/DataState';
import { IncidentSeverityBadge, IncidentStatusBadge } from '@/components/dashboard/StatusBadge';
import { formatDateTime, formatRelative } from '@/lib/format';
import { useApiQuery } from '@/lib/useApiQuery';
import { usePageMeta } from '@/lib/usePageMeta';
import type { Incident, ListResponse } from '@/types/dashboard';
export default function Incidents() {
usePageMeta({ title: 'Incidenty — portál Automia' });
const { data, loading, error, reload } = useApiQuery<ListResponse<Incident>>(
'/api/dashboard/incidents',
{ refetchOn: ['incident.started', 'incident.updated', 'incident.resolved'] },
);
return (
<div className="space-y-6">
<header>
<h1 className="text-2xl font-bold text-white">Incidenty</h1>
<p className="mt-1 text-sm text-white/50">
Výpadky a degradace služeb. Timeline zásahů a post-mortem doplníme v další iteraci.
</p>
</header>
<DataState
loading={loading}
error={error}
empty={data?.items.length === 0}
onRetry={reload}
emptyLabel="Žádné incidenty. Všechno běží."
>
<ul className="space-y-4">
{data?.items.map((incident) => (
<li key={incident.id} className="glass rounded-card p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2.5">
<span className="font-mono text-xs text-white/40">{incident.id}</span>
<IncidentSeverityBadge severity={incident.severity} />
</div>
<h2 className="mt-2 font-semibold text-white">{incident.title}</h2>
<p className="mt-1 text-sm text-white/50">Služba: {incident.service}</p>
</div>
<IncidentStatusBadge status={incident.status} />
</div>
<dl className="mt-5 grid gap-4 border-t border-ink-600/50 pt-4 text-sm sm:grid-cols-3">
<div>
<dt className="text-xs text-white/35">Začátek</dt>
<dd className="mt-0.5 text-white/70">{formatDateTime(incident.startedAt)}</dd>
</div>
<div>
<dt className="text-xs text-white/35">Trvání</dt>
<dd className="mt-0.5 text-white/70">
{incident.resolvedAt
? `vyřešeno ${formatRelative(incident.resolvedAt)}`
: `běží ${formatRelative(incident.startedAt).replace('před ', '')}`}
</dd>
</div>
<div>
<dt className="text-xs text-white/35">Post-mortem</dt>
<dd className="mt-0.5 text-white/70">
{incident.status === 'resolved' ? 'k dispozici' : 'po vyřešení'}
</dd>
</div>
</dl>
</li>
))}
</ul>
</DataState>
</div>
);
}
+180
View File
@@ -0,0 +1,180 @@
import { Activity, AlarmClock, Clock, LifeBuoy, RefreshCw, Workflow } from 'lucide-react';
import { Link } from 'react-router-dom';
import { useAuth } from '@/auth/AuthContext';
import { DataState } from '@/components/dashboard/DataState';
import { RunsChart } from '@/components/dashboard/RunsChart';
import { StatTile } from '@/components/dashboard/StatTile';
import { IncidentStatusBadge, TicketStatusBadge } from '@/components/dashboard/StatusBadge';
import { Button } from '@/components/ui/Button';
import { formatNumber, formatPercent, formatRelative } from '@/lib/format';
import { useApiQuery } from '@/lib/useApiQuery';
import { usePageMeta } from '@/lib/usePageMeta';
import type { DashboardSummary, Incident, ListResponse, Ticket } from '@/types/dashboard';
export default function Overview() {
usePageMeta({ title: 'Přehled — portál Automia' });
const { user } = useAuth();
// Prehled se prekresluje na kazdou zmenu, proto sleduje vsechny udalosti.
const summary = useApiQuery<DashboardSummary>('/api/dashboard/summary', {
refetchOn: [
'ticket.created',
'ticket.resolved',
'incident.started',
'incident.resolved',
'automation.updated',
'automation.created',
'automation.deleted',
'automation.run',
'webhook.received',
],
});
const tickets = useApiQuery<ListResponse<Ticket>>('/api/dashboard/tickets', {
refetchOn: ['ticket.created', 'ticket.updated', 'ticket.resolved'],
});
const incidents = useApiQuery<ListResponse<Incident>>('/api/dashboard/incidents', {
refetchOn: ['incident.started', 'incident.updated', 'incident.resolved'],
});
return (
<div className="space-y-7">
<header className="flex flex-wrap items-end justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-white">
Dobrý den, {user?.name?.split(' ')[0] ?? 'vítejte'}
</h1>
<p className="mt-1 text-sm text-white/50">
Tady je stav vašich automatizací a požadavků.
</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => {
summary.reload();
tickets.reload();
incidents.reload();
}}
>
<RefreshCw className="size-4" />
Obnovit
</Button>
</header>
<DataState loading={summary.loading} error={summary.error} onRetry={summary.reload}>
{summary.data && (
<>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatTile
icon={Workflow}
label="Aktivní automatizace"
value={formatNumber(summary.data.activeAutomations)}
hint={`${formatNumber(summary.data.runsToday)} spuštění dnes`}
/>
<StatTile
icon={LifeBuoy}
label="Otevřené tickety"
value={formatNumber(summary.data.openTickets)}
hint="včetně čekajících na vás"
tone={summary.data.openTickets > 3 ? 'warn' : 'ok'}
/>
<StatTile
icon={AlarmClock}
label="Běžící incidenty"
value={formatNumber(summary.data.activeIncidents)}
hint={summary.data.activeIncidents === 0 ? 'vše v pořádku' : 'pracujeme na tom'}
tone={summary.data.activeIncidents > 0 ? 'danger' : 'ok'}
/>
<StatTile
icon={Clock}
label="Ušetřeno tento měsíc"
value={`${formatNumber(summary.data.savedHoursMonth)} h`}
hint={`dostupnost ${formatPercent(summary.data.uptime, 2)}`}
tone="ok"
/>
</div>
<div className="glass rounded-card p-5 sm:p-6">
<RunsChart series={summary.data.series} />
</div>
</>
)}
</DataState>
<div className="grid gap-5 xl:grid-cols-2">
<section className="glass rounded-card p-5 sm:p-6">
<div className="mb-5 flex items-center justify-between gap-3">
<h2 className="font-semibold text-white">Poslední tickety</h2>
<Link
to="/dashboard/tickety"
className="text-sm font-medium text-brand-300 hover:text-brand-200"
>
Všechny
</Link>
</div>
<DataState
loading={tickets.loading}
error={tickets.error}
empty={tickets.data?.items.length === 0}
onRetry={tickets.reload}
emptyLabel="Žádné tickety — dobrá zpráva."
>
<ul className="divide-y divide-ink-600/50">
{tickets.data?.items.slice(0, 4).map((ticket) => (
<li key={ticket.id} className="flex items-start gap-3 py-3.5 first:pt-0 last:pb-0">
<span className="mt-0.5 font-mono text-xs text-white/35">{ticket.id}</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-white/80">{ticket.subject}</p>
<p className="mt-0.5 text-xs text-white/40">
{ticket.requester} · {formatRelative(ticket.updatedAt)}
</p>
</div>
<TicketStatusBadge status={ticket.status} />
</li>
))}
</ul>
</DataState>
</section>
<section className="glass rounded-card p-5 sm:p-6">
<div className="mb-5 flex items-center justify-between gap-3">
<h2 className="font-semibold text-white">Incidenty</h2>
<Link
to="/dashboard/incidenty"
className="text-sm font-medium text-brand-300 hover:text-brand-200"
>
Všechny
</Link>
</div>
<DataState
loading={incidents.loading}
error={incidents.error}
empty={incidents.data?.items.length === 0}
onRetry={incidents.reload}
emptyLabel="Žádné incidenty."
>
<ul className="divide-y divide-ink-600/50">
{incidents.data?.items.slice(0, 4).map((incident) => (
<li key={incident.id} className="flex items-start gap-3 py-3.5 first:pt-0 last:pb-0">
<span className="mt-0.5 grid size-7 shrink-0 place-items-center rounded-lg bg-white/5 text-white/40">
<Activity className="size-3.5" />
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-white/80">{incident.title}</p>
<p className="mt-0.5 text-xs text-white/40">
{incident.service} · začátek {formatRelative(incident.startedAt)}
</p>
</div>
<IncidentStatusBadge status={incident.status} />
</li>
))}
</ul>
</DataState>
</section>
</div>
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
import { Construction } from 'lucide-react';
import { useAuth } from '@/auth/AuthContext';
import { usePageMeta } from '@/lib/usePageMeta';
/** Zamerne jen kostra - obsah nastaveni doresime pri stavbe dashboardu. */
export default function Settings() {
usePageMeta({ title: 'Nastavení — portál Automia' });
const { user } = useAuth();
return (
<div className="space-y-6">
<header>
<h1 className="text-2xl font-bold text-white">Nastavení</h1>
<p className="mt-1 text-sm text-white/50">Údaje o účtu a organizaci.</p>
</header>
<section className="glass rounded-card p-5 sm:p-6">
<h2 className="font-semibold text-white">Váš účet</h2>
<dl className="mt-4 grid gap-4 sm:grid-cols-2">
<Row label="Jméno" value={user?.name ?? '—'} />
<Row label="E-mail" value={user?.email ?? '—'} />
<Row label="Organizace" value={user?.company ?? '—'} />
<Row label="Role" value={user?.role === 'admin' ? 'Interní správce' : 'Klient'} />
</dl>
</section>
<section className="rounded-card border border-dashed border-ink-600/70 p-8 text-center">
<Construction className="mx-auto size-6 text-white/30" />
<h2 className="mt-3 font-semibold text-white">Připravujeme</h2>
<p className="mx-auto mt-2 max-w-md text-sm leading-relaxed text-white/50">
Změna hesla, dvoufaktorové ověření, správa uživatelů organizace, notifikace a API klíče.
Rozsah domluvíme při stavbě dashboardu.
</p>
</section>
</div>
);
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-xl border border-ink-600/50 bg-ink-850/50 px-4 py-3">
<dt className="text-xs text-white/35">{label}</dt>
<dd className="mt-0.5 text-sm text-white/80">{value}</dd>
</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
import { DataState } from '@/components/dashboard/DataState';
import { TicketPriorityBadge, TicketStatusBadge } from '@/components/dashboard/StatusBadge';
import { formatDateTime, formatRelative } from '@/lib/format';
import { useApiQuery } from '@/lib/useApiQuery';
import { usePageMeta } from '@/lib/usePageMeta';
import type { ListResponse, Ticket } from '@/types/dashboard';
export default function Tickets() {
usePageMeta({ title: 'Tickety — portál Automia' });
const { data, loading, error, reload } = useApiQuery<ListResponse<Ticket>>(
'/api/dashboard/tickets',
{ refetchOn: ['ticket.created', 'ticket.updated', 'ticket.resolved'] },
);
return (
<div className="space-y-6">
<header>
<h1 className="text-2xl font-bold text-white">Tickety</h1>
<p className="mt-1 text-sm text-white/50">
Požadavky napříč kanály. Filtrování, komentáře a zakládání ticketů přidáme v další
iteraci.
</p>
</header>
<div className="glass overflow-hidden rounded-card">
<DataState
loading={loading}
error={error}
empty={data?.items.length === 0}
onRetry={reload}
emptyLabel="Žádné tickety."
>
<div className="overflow-x-auto">
<table className="w-full min-w-[52rem] text-left text-sm">
<thead className="border-b border-ink-600/60 text-xs tracking-wide text-white/40 uppercase">
<tr>
<th className="px-5 py-3.5 font-medium">ID</th>
<th className="px-5 py-3.5 font-medium">Předmět</th>
<th className="px-5 py-3.5 font-medium">Zadavatel</th>
<th className="px-5 py-3.5 font-medium">Stav</th>
<th className="px-5 py-3.5 font-medium">Priorita</th>
<th className="px-5 py-3.5 font-medium">Řeší</th>
<th className="px-5 py-3.5 font-medium">Aktualizace</th>
</tr>
</thead>
<tbody className="divide-y divide-ink-600/40">
{data?.items.map((ticket) => (
<tr key={ticket.id} className="transition-colors hover:bg-white/[0.03]">
<td className="px-5 py-4 font-mono text-xs text-white/45">{ticket.id}</td>
<td className="px-5 py-4 text-white/85">{ticket.subject}</td>
<td className="px-5 py-4 text-white/55">{ticket.requester}</td>
<td className="px-5 py-4">
<TicketStatusBadge status={ticket.status} />
</td>
<td className="px-5 py-4">
<TicketPriorityBadge priority={ticket.priority} />
</td>
<td className="px-5 py-4 text-white/55">
{ticket.assignee ?? <span className="text-white/30">nepřiřazeno</span>}
</td>
<td className="px-5 py-4 text-white/45">
<span title={formatDateTime(ticket.updatedAt)}>
{formatRelative(ticket.updatedAt)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</DataState>
</div>
</div>
);
}
+183
View File
@@ -0,0 +1,183 @@
/**
* Typy odpovedi dashboard API. Musi zustat v souladu s apps/api/src/data/mock.ts.
* Az bude API stabilni, vygenerovat je sdilene (viz docs/06-dashboard.md).
*/
export type TicketStatus = 'new' | 'open' | 'waiting' | 'resolved';
export type TicketPriority = 'low' | 'normal' | 'high' | 'critical';
export type IncidentSeverity = 'sev1' | 'sev2' | 'sev3';
export type IncidentStatus = 'investigating' | 'identified' | 'monitoring' | 'resolved';
export interface Ticket {
id: string;
subject: string;
requester: string;
status: TicketStatus;
priority: TicketPriority;
assignee: string | null;
createdAt: string;
updatedAt: string;
}
export interface Incident {
id: string;
title: string;
service: string;
severity: IncidentSeverity;
status: IncidentStatus;
startedAt: string;
resolvedAt: string | null;
}
export type AutomationKind = 'workflow' | 'voicebot' | 'integrace' | 'report';
export interface Automation {
id: string;
name: string;
kind: AutomationKind;
enabled: boolean;
runsToday: number;
successRate: number;
avgDurationMs: number;
lastRunAt: string;
/** Pocet vsech kroku vcetne vnorenych vetvi. */
stepCount: number;
/** false = automatizace jeste nema spoustec (koncept). */
configured: boolean;
/** Co chybi k zapnuti. Prazdne = hotova. */
issues: string[];
}
// ----------------------------------------------------------------- konektory
export type ConnectorCategory =
| 'spoustece'
| 'crm'
| 'ekonomika'
| 'logistika'
| 'komunikace'
| 'analytika'
| 'ai'
| 'nastroje';
export type ConnectorStatus = 'connected' | 'available' | 'planned';
export interface ConnectorOperation {
id: string;
name: string;
description: string;
fields?: string[];
/**
* Jen u triggeru: true = vstupni parametry si definuje uzivatel
* (webhook, formular). false/chybi = data urcuje sluzba.
*/
customPayload?: boolean;
}
export interface Connector {
id: string;
name: string;
category: ConnectorCategory;
description: string;
/** Klic ikony - mapuje se v lib/connectorIcons.ts */
icon: string;
status: ConnectorStatus;
triggers: ConnectorOperation[];
actions: ConnectorOperation[];
}
export interface ConnectorCatalog {
categories: Array<{ id: ConnectorCategory; label: string }>;
items: Connector[];
/** Ktere operatory server povoli pro ktery typ parametru. */
operatorsByType: Record<FieldType, ConditionOperator[]>;
/** Zaklad adresy webhooku, napr. "https://api.automia.cz/webhook". */
webhookBaseUrl: string;
}
// ------------------------------------------------- parametry a podminky
export type FieldType = 'string' | 'number' | 'boolean' | 'date';
export type ConditionOperator =
| 'eq'
| 'neq'
| 'gt'
| 'gte'
| 'lt'
| 'lte'
| 'contains'
| 'startsWith'
| 'isEmpty'
| 'isNotEmpty'
| 'isTrue'
| 'isFalse';
/** Vstupni parametr, ktery spoustec preda do stromu. */
export interface TriggerField {
id: string;
/** Klic v prichozich datech. Podminky se odkazuji na `id`, ne na nej. */
name: string;
type: FieldType;
required: boolean;
}
// ----------------------------------------------------------- strom automatizace
export interface FlowTrigger {
connectorId: string;
operationId: string;
fields: TriggerField[];
/** Generuje vyhradne server, klient ho jen zobrazuje. */
webhookToken?: string;
}
/** Krok stromu: bud akce nad konektorem, nebo podminka se dvema vetvemi. */
export type FlowStep =
| {
id: string;
kind: 'action';
connectorId: string;
operationId: string;
}
| {
id: string;
kind: 'condition';
/** id parametru z trigger.fields */
fieldId: string;
operator: ConditionOperator;
value?: string;
yes: FlowStep[];
no: FlowStep[];
};
export interface AutomationFlow {
trigger: FlowTrigger | null;
steps: FlowStep[];
}
export interface AutomationDetail extends Automation {
flow: AutomationFlow;
createdAt: string;
updatedAt: string;
}
export interface SeriesPoint {
date: string;
runs: number;
failures: number;
}
export interface DashboardSummary {
openTickets: number;
activeIncidents: number;
activeAutomations: number;
runsToday: number;
savedHoursMonth: number;
uptime: number;
series: SeriesPoint[];
}
export interface ListResponse<T> {
items: T[];
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Typy udalosti dashboardu.
* Musi zustat v souladu s apps/api/src/events/bus.ts.
*/
export type DashboardEventType =
| 'ticket.created'
| 'ticket.updated'
| 'ticket.resolved'
| 'incident.started'
| 'incident.updated'
| 'incident.resolved'
| 'automation.created'
| 'automation.updated'
| 'automation.deleted'
| 'automation.run'
| 'webhook.received';
export interface DashboardEvent {
id: string;
type: DashboardEventType;
at: string;
message: string;
payload?: Record<string, unknown>;
}
+13
View File
@@ -0,0 +1,13 @@
/// <reference types="vite/client" />
declare global {
interface Window {
/**
* Prefix reverse proxy vlozeny serverem do index.html podle ROOT_PATH.
* Prazdny retezec znamena, ze aplikace bezi v korenu domeny.
*/
__BASE_PATH__?: string;
}
}
export {};
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"types": ["vite/client"],
"skipLibCheck": true,
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}