This commit is contained in:
Tomas Dvorak
2026-03-13 14:34:19 +01:00
parent 84a8acf944
commit 30d70a6aeb
126 changed files with 27297 additions and 29069 deletions
+2
View File
@@ -0,0 +1,2 @@
VITE_API_BASE_URL=
VITE_SITE_URL=https://example.com
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+46
View File
@@ -0,0 +1,46 @@
# MyClub Landing
Samostatná publikovatelná landing page pro `MyClub` v root složce `/landing`.
## Stack
- React + TypeScript + Vite
- Tailwind CSS v4
- shadcn/ui primitives
- Montserrat variable font
- Form submission přes existující `POST /api/v1/contact`
## Development
```bash
cd landing
npm install
npm run dev
```
Dev server běží na `http://localhost:4174`.
Výchozí proxy posílá `/api/*` na `http://localhost:8080`, takže při lokálním běhu backendu není potřeba nastavovat extra URL.
## Environment
Zkopírujte `.env.example` podle potřeby:
```bash
cp .env.example .env
```
Podporované proměnné:
- `VITE_API_BASE_URL`: volitelné. Pokud je prázdné, používá se same-origin `/api/...`
- `VITE_SITE_URL`: volitelné. Pokud je nastavené, doplní canonical a absolutní OG/Twitter URL
## Build
```bash
cd landing
npm run build
npm run preview
```
Výstup je v `landing/dist`.
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
+32
View File
@@ -0,0 +1,32 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
rules: {
'react-refresh/only-export-components': ['error', { allowConstantExport: true }],
},
},
{
files: ['src/components/ui/button.tsx', 'src/components/ui/badge.tsx'],
rules: {
'react-refresh/only-export-components': 'off',
},
},
])
+34
View File
@@ -0,0 +1,34 @@
<!doctype html>
<html lang="cs">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="rgb(108 56 217)" />
<meta name="color-scheme" content="light" />
<meta
name="description"
content="MyClub spojuje klubový web, MyUIbrix builder, zápasy, obsah, partnery, newslettery i provozní workflow do jednoho přehledného systému pro sportovní kluby."
/>
<meta property="og:type" content="website" />
<meta property="og:site_name" content="MyClub" />
<meta property="og:title" content="MyClub | Web, obsah a provoz klubu v jednom systému" />
<meta
property="og:description"
content="MyClub spojuje klubový web, MyUIbrix builder, zápasy, obsah, partnery, newslettery i provozní workflow do jednoho přehledného systému pro sportovní kluby."
/>
<meta property="og:image" content="/og-cover.svg" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="MyClub | Web, obsah a provoz klubu v jednom systému" />
<meta
name="twitter:description"
content="MyClub spojuje klubový web, MyUIbrix builder, zápasy, obsah, partnery, newslettery i provozní workflow do jednoho přehledného systému pro sportovní kluby."
/>
<meta name="twitter:image" content="/og-cover.svg" />
<title>MyClub | Web, obsah a provoz klubu v jednom systému</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+9068
View File
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
{
"name": "landing",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@fontsource-variable/montserrat": "^5.2.8",
"@hookform/resolvers": "^5.2.2",
"axios": "^1.13.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.577.0",
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-hook-form": "^7.71.2",
"shadcn": "^4.0.6",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@tailwindcss/vite": "^4.2.1",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"tailwindcss": "^4.2.1",
"tailwindcss-animate": "^1.0.7",
"typescript": "~5.9.3",
"typescript-eslint": "^8.56.1",
"vite": "^7.3.1"
}
}
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="MyClub">
<rect width="128" height="128" rx="32" fill="rgb(255 255 255)" />
<rect x="10" y="10" width="108" height="108" rx="28" fill="rgb(255 255 255)" stroke="rgba(17,24,39,0.14)" stroke-width="2" />
<path d="M32 94V34h12l20 30 20-30h12v60H84V58L64 87 44 58v36H32Z" fill="rgb(108 56 217)" />
<circle cx="98" cy="34" r="12" fill="rgba(255,153,51,0.24)" />
<circle cx="98" cy="34" r="6" fill="rgb(255 153 51)" />
</svg>

After

Width:  |  Height:  |  Size: 514 B

+18
View File
@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630" role="img" aria-label="MyClub Open Graph Cover">
<rect width="1200" height="630" fill="rgb(255 255 255)" />
<rect x="42" y="42" width="1116" height="546" rx="36" fill="rgb(255 255 255)" stroke="rgba(17,24,39,0.1)" stroke-width="2" />
<circle cx="180" cy="126" r="88" fill="rgba(108,56,217,0.14)" />
<circle cx="1032" cy="128" r="72" fill="rgba(255,153,51,0.18)" />
<path d="M110 454V182h68l112 168 112-168h68v272h-68V291L290 429 178 291v163h-68Z" fill="rgb(108 56 217)" />
<rect x="552" y="170" width="520" height="290" rx="30" fill="rgba(108,56,217,0.05)" stroke="rgba(17,24,39,0.08)" />
<rect x="590" y="210" width="230" height="132" rx="24" fill="rgb(255 255 255)" stroke="rgba(17,24,39,0.08)" />
<rect x="848" y="210" width="186" height="60" rx="20" fill="rgba(255,153,51,0.18)" />
<rect x="848" y="286" width="186" height="56" rx="20" fill="rgb(255 255 255)" stroke="rgba(17,24,39,0.08)" />
<rect x="590" y="362" width="444" height="58" rx="20" fill="rgb(255 255 255)" stroke="rgba(17,24,39,0.08)" />
<text x="552" y="520" fill="rgb(17 24 39)" font-family="Montserrat, sans-serif" font-size="58" font-weight="700" letter-spacing="-2">
MyClub
</text>
<text x="552" y="574" fill="rgb(75 85 99)" font-family="Montserrat, sans-serif" font-size="28">
Web, obsah a provoz klubu v jednom systému
</text>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+429
View File
@@ -0,0 +1,429 @@
import {
ArrowRight,
Check,
ChevronRight,
LayoutDashboard,
MailPlus,
Sparkles,
Image as ImageIcon,
} from 'lucide-react'
import {
contactChecklist,
faqItems,
featureCards,
navItems,
operationalPillars,
proofPoints,
workflowSteps,
} from '@/content'
import { useLandingSeo } from '@/hooks/useLandingSeo'
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { ContactForm } from '@/components/landing/ContactForm'
import { HeroVisual } from '@/components/landing/HeroVisual'
import { SectionHeading } from '@/components/landing/SectionHeading'
import { SiteHeader } from '@/components/landing/SiteHeader'
import { VideoText } from '@/components/ui/video-text'
useLandingSeo()
export default function App() {
return (
<div id="top" className="landing-shell">
<SiteHeader items={navItems} />
<main className="pb-16 pt-24 sm:pt-28">
<section className="relative overflow-hidden pb-14 pt-8 sm:pb-18">
<div className="section-shell grid gap-12 lg:grid-cols-[1.02fr_0.98fr] lg:items-center">
<div className="relative z-10 flex flex-col gap-7">
<Badge className="eyebrow-pill max-w-max">
MyClub SaaS pro sportovní kluby
</Badge>
<div className="flex flex-col gap-5">
<div className="relative h-[120px] w-full overflow-hidden rounded-2xl">
<VideoText
src="https://cdn.magicui.design/ocean-small.webm"
className="absolute inset-0"
fontSize={18}
fontWeight="800"
>
Jeden systém pro web, obsah a provoz vašeho klubu.
</VideoText>
</div>
<p className="max-w-2xl text-lg leading-7 text-[rgb(75_85_99)] sm:text-xl sm:leading-8 break-words">
MyClub spojuje MyUIbrix builder, zápasy, týmy, partnery, newslettery a
klubovou administraci do jedné čisté vrstvy připravené na každodenní provoz.
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row">
<Button
asChild
className="h-12 rounded-full px-6 text-sm shadow-[0_18px_34px_rgba(17,24,39,0.12)]"
>
<a href="#kontakt">
Domluvit ukázku
<ArrowRight className="size-4" />
</a>
</Button>
<Button
asChild
variant="outline"
className="h-12 rounded-full border-[rgba(17,24,39,0.12)] bg-white px-6 text-sm hover:bg-[rgba(108,56,217,0.06)]"
>
<a href="#ukazka">
Projít produktovou vrstvu
<ChevronRight className="size-4" />
</a>
</Button>
</div>
<div className="grid gap-4 sm:grid-cols-3">
{[
'MyUIbrix jako vestavěný editor homepage.',
'Klubová data, obsah a komunikace bez pluginové skládačky.',
'Připravené fungovat vedle stávajícího backendu a publikace.',
].map((item) => (
<div key={item} className="rounded-[1.55rem] border border-[rgba(17,24,39,0.08)] bg-white/88 p-5 backdrop-blur-sm transition-all duration-300 hover:shadow-lg hover:bg-white">
<div className="mb-3 flex items-center gap-2 text-sm font-semibold text-[rgb(17_24_39)]">
<Check className="size-4 text-[rgb(108_56_217)]" />
Přímý benefit
</div>
<p className="text-sm leading-6 text-[rgb(75_85_99)]">{item}</p>
</div>
))}
</div>
</div>
<HeroVisual />
</div>
</section>
<section className="pb-16 pt-8">
<div className="section-shell">
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{proofPoints.map((item) => (
<Card key={item.title} className="surface-panel-soft border-0 p-6 transition-all duration-300 hover:shadow-xl hover:scale-[1.02]">
<CardHeader className="p-0 pb-4">
<div className="mb-4 flex size-14 items-center justify-center rounded-2xl border border-[rgba(17,24,39,0.08)] bg-white shadow-sm">
<item.icon className="size-6 text-[rgb(108_56_217)]" />
</div>
<CardDescription className="section-label mb-2">{item.eyebrow}</CardDescription>
<CardTitle className="text-xl tracking-[-0.04em] leading-7 text-[rgb(17_24_39)]">{item.title}</CardTitle>
</CardHeader>
<CardContent className="p-0">
<p className="text-sm leading-6 text-[rgb(75_85_99)]">{item.description}</p>
</CardContent>
</Card>
))}
</div>
</div>
</section>
<section id="platforma" className="scroll-mt-28 py-20">
<div className="section-shell flex flex-col gap-12">
<SectionHeading
kicker="Platforma"
title="Navržené pro klub, který nechce skládat pět různých systémů."
description="Každá vrstva landingu vychází z reálných schopností projektu. Žádná obecná SaaS omáčka, ale konkrétní modulární klubový stack."
/>
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{featureCards.map((item, index) => (
<Card
key={item.title}
className={`
${index % 3 === 1 ? 'surface-panel-secondary border-0' : 'surface-panel border-0'}
p-6 transition-all duration-300 hover:shadow-xl hover:scale-[1.02]
`}
>
<CardHeader className="p-0 pb-4">
<div className="mb-4 flex size-14 items-center justify-center rounded-2xl border border-[rgba(17,24,39,0.08)] bg-white shadow-sm">
<item.icon
className={`
${index % 3 === 1 ? 'size-6 text-[rgb(255_153_51)]' : 'size-6 text-[rgb(108_56_217)]'}
`}
/>
</div>
<CardDescription className="section-label mb-2">{item.eyebrow}</CardDescription>
<CardTitle className="text-xl tracking-[-0.04em] leading-7 text-[rgb(17_24_39)]">{item.title}</CardTitle>
</CardHeader>
<CardContent className="p-0">
<p className="text-sm leading-6 text-[rgb(75_85_99)]">{item.description}</p>
</CardContent>
</Card>
))}
</div>
</div>
</section>
<section id="ukazka" className="scroll-mt-28 py-20">
<div className="section-shell grid gap-12 xl:grid-cols-[1.08fr_0.92fr]">
<div className="flex flex-col gap-12">
<SectionHeading
kicker="Ukázka produktu"
title="Editorial sports-tech bez generické šablony."
description="Landing používá bílý mód, výraznou typografii a kontrolovanou barevnost. Stejný přístup může nést i veřejný klubový web vystavěný v MyClubu."
/>
<Card className="surface-panel border-0 py-0 transition-all duration-300 hover:shadow-xl">
<CardHeader className="border-b border-[rgba(17,24,39,0.08)] pb-6">
<CardDescription className="section-label">ukázková kompozice</CardDescription>
<CardTitle className="text-2xl tracking-[-0.05em] leading-8">
Homepage builder, který ří rytmus celého klubu
</CardTitle>
</CardHeader>
<CardContent className="grid gap-6 px-6 py-8 sm:grid-cols-[0.95fr_1.05fr]">
<div className="rounded-[1.5rem] border border-[rgba(17,24,39,0.08)] bg-white p-6">
<div className="mb-4 flex items-center justify-between">
<span className="section-label">bloky stránky</span>
<Sparkles className="size-5 text-[rgb(108_56_217)]" />
</div>
<div className="space-y-4">
{[
'Hero s hlavní kampaní',
'Novinky a články',
'Příští zápas + tabulka',
'Sponzoři a bannery',
'Kontakty a mapa',
].map((label, index) => (
<div
key={label}
className="flex items-center gap-4 rounded-[1.1rem] border border-[rgba(17,24,39,0.06)] bg-[rgba(17,24,39,0.02)] px-5 py-4 transition-all duration-200 hover:bg-[rgba(17,24,39,0.04)] hover:shadow-sm"
>
<span className="flex size-8 items-center justify-center rounded-full bg-[rgba(108,56,217,0.08)] text-xs font-semibold text-[rgb(108_56_217)]">
0{index + 1}
</span>
<span className="font-medium text-[rgb(17_24_39)] leading-6">{label}</span>
</div>
))}
</div>
</div>
<div className="rounded-[1.5rem] border border-[rgba(17,24,39,0.08)] bg-[linear-gradient(180deg,rgba(108,56,217,0.08),rgba(255,255,255,0.96))] p-6">
<div className="mb-4 flex items-center justify-between">
<span className="section-label">preview</span>
<LayoutDashboard className="size-5 text-[rgb(17_24_39)]" />
</div>
<div className="grid gap-4">
<div className="rounded-[1.4rem] bg-white p-5">
<div className="h-3 w-2/3 rounded-full bg-[rgba(17,24,39,0.12)] mb-4" />
<div className="grid grid-cols-3 gap-3">
<div className="h-20 rounded-2xl bg-[rgba(108,56,217,0.09)] flex items-center justify-center">
<ImageIcon className="size-6 text-[rgb(108_56_217)/20]" />
</div>
<div className="h-20 rounded-2xl bg-[rgba(255,153,51,0.16)] flex items-center justify-center">
<ImageIcon className="size-6 text-[rgb(255_153_51)/20]" />
</div>
<div className="h-20 rounded-2xl bg-[rgba(17,24,39,0.06)] flex items-center justify-center">
<ImageIcon className="size-6 text-[rgb(17_24_39)/15]" />
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="rounded-[1.25rem] bg-white p-4">
<div className="h-2.5 w-16 rounded-full bg-[rgba(17,24,39,0.12)] mb-4" />
<div className="h-20 rounded-[1rem] bg-[rgba(108,56,217,0.08)] flex items-center justify-center">
<ImageIcon className="size-5 text-[rgb(108_56_217)/15]" />
</div>
</div>
<div className="rounded-[1.25rem] bg-white p-4">
<div className="h-2.5 w-16 rounded-full bg-[rgba(17,24,39,0.12)] mb-4" />
<div className="h-20 rounded-[1rem] bg-[rgba(255,153,51,0.16)] flex items-center justify-center">
<ImageIcon className="size-5 text-[rgb(255_153_51)/15]" />
</div>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
<div className="flex flex-col gap-6">
{operationalPillars.map((item, index) => (
<Card
key={item.title}
className={`
${index === 1 ? 'surface-panel-secondary border-0' : 'surface-panel-soft border-0'}
transition-all duration-300 hover:shadow-xl hover:scale-[1.02]
`}
>
<CardHeader>
<div className="mb-4 flex size-14 items-center justify-center rounded-2xl border border-[rgba(17,24,39,0.08)] bg-white shadow-sm">
<item.icon
className={`
${index === 1 ? 'size-6 text-[rgb(255_153_51)]' : 'size-6 text-[rgb(108_56_217)]'}
`}
/>
</div>
<CardDescription className="section-label mb-2">{item.eyebrow}</CardDescription>
<CardTitle className="text-xl tracking-[-0.04em] leading-7 text-[rgb(17_24_39)]">{item.title}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm leading-6 text-[rgb(75_85_99)]">{item.description}</p>
</CardContent>
</Card>
))}
</div>
</div>
</section>
<section id="workflow" className="scroll-mt-28 py-20">
<div className="section-shell flex flex-col gap-12">
<SectionHeading
kicker="Jak to funguje"
title="Od první identity klubu po každodenní publikaci."
description="Workflow je navržené tak, aby produkt dával smysl vedení klubu, redakci i operativě okolo zápasového provozu."
align="center"
/>
<div className="grid gap-6 lg:grid-cols-4">
{workflowSteps.map((step, index) => (
<Card
key={step.step}
className={`
${index === 1 || index === 3 ? 'surface-panel-secondary border-0' : 'surface-panel border-0'}
transition-all duration-300 hover:shadow-xl hover:scale-[1.02] relative overflow-hidden
`}
>
<div className="absolute inset-0 bg-gradient-to-br from-transparent via-white/5 to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100" />
<CardHeader className="relative z-10">
<div className="flex items-center justify-between gap-4">
<span className="rounded-full bg-white px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-[rgb(75_85_99)] shadow-sm border border-[rgba(17,24,39,0.08)]">
krok {step.step}
</span>
<span className="text-3xl font-semibold tracking-[-0.05em] text-[rgba(17,24,39,0.2)]">
{step.step}
</span>
</div>
<CardTitle className="text-xl tracking-[-0.04em] leading-7 text-[rgb(17_24_39)] mt-4">{step.title}</CardTitle>
</CardHeader>
<CardContent className="relative z-10">
<p className="text-sm leading-6 text-[rgb(75_85_99)]">{step.description}</p>
</CardContent>
</Card>
))}
</div>
</div>
</section>
<section id="faq" className="scroll-mt-28 py-20">
<div className="section-shell grid gap-12 xl:grid-cols-[0.95fr_1.05fr]">
<SectionHeading
kicker="FAQ"
title="Krátké odpovědi na věci, které padnou nejdřív."
description="Landing je postavený jako publikovatelná poptávková vrstva. Níže jsou odpovědi, které drží směr produktu i technického nasazení."
/>
<Card className="surface-panel border-0 transition-all duration-300 hover:shadow-xl">
<CardContent className="px-8 py-6">
<Accordion type="single" collapsible className="space-y-4">
{faqItems.map((item) => (
<AccordionItem
key={item.question}
value={item.question}
className="border border-[rgba(17,24,39,0.08)] rounded-xl px-6 transition-all duration-200 hover:bg-[rgba(17,24,39,0.02]"
>
<AccordionTrigger className="text-base font-semibold text-[rgb(17_24_39)] hover:text-[rgb(108_56_217)] transition-colors duration-200">
{item.question}
</AccordionTrigger>
<AccordionContent className="text-sm leading-6 text-[rgb(75_85_99)] pt-4">
{item.answer}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</CardContent>
</Card>
</div>
</section>
<section id="kontakt" className="scroll-mt-28 py-20">
<div className="section-shell">
<div className="grid gap-12 rounded-[2rem] border border-[rgba(17,24,39,0.08)] bg-[linear-gradient(135deg,rgba(108,56,217,0.08),rgba(255,255,255,0.98),rgba(255,153,51,0.14))] p-8 shadow-[0_28px_80px_rgba(17,24,39,0.08)] lg:grid-cols-[0.9fr_1.1fr] lg:p-10">
<div className="flex flex-col gap-8">
<Badge className="eyebrow-pill max-w-max">CTA a poptávka</Badge>
<div className="flex flex-col gap-6">
<h2 className="section-title text-[clamp(2.2rem,4vw,4rem)] max-w-lg">
Chcete si projít, jak bude MyClub fungovat pro váš klub?
</h2>
<p className="section-copy max-w-xl">
Pošlete stručný kontext a landing použije existující veřejný endpoint projektu. Tím je hotová
první publikovatelná vrstva bez dalších backend zásahů.
</p>
</div>
<div className="grid gap-4">
{contactChecklist.map((item) => (
<div
key={item}
className="flex items-start gap-4 rounded-[1.35rem] border border-[rgba(17,24,39,0.08)] bg-white/86 px-5 py-5 transition-all duration-300 hover:bg-white hover:shadow-md"
>
<span className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-full bg-[rgba(108,56,217,0.08)] text-[rgb(108_56_217)]">
<Check className="size-5" />
</span>
<p className="text-sm leading-7 text-[rgb(17_24_39)]">{item}</p>
</div>
))}
</div>
<div className="rounded-[1.45rem] border border-[rgba(17,24,39,0.08)] bg-white/88 p-6 transition-all duration-300 hover:bg-white hover:shadow-md">
<div className="mb-3 flex items-center gap-3 text-sm font-semibold text-[rgb(17_24_39)]">
<MailPlus className="size-5 text-[rgb(255_153_51)]" />
Technická poznámka
</div>
<p className="text-sm leading-7 text-[rgb(75_85_99)]">
V developmentu funguje formulář přes Vite proxy na <code className="px-2 py-1 bg-[rgba(108,56,217,0.08)] rounded-md">localhost:8080</code>. V produkci
může zůstat same-origin nebo využít <code className="px-2 py-1 bg-[rgba(108,56,217,0.08)] rounded-md">VITE_API_BASE_URL</code>.
</p>
</div>
</div>
<Card className="surface-panel border-0 bg-white/92 transition-all duration-300 hover:shadow-xl">
<CardHeader>
<CardDescription className="section-label">pracující formulář</CardDescription>
<CardTitle className="text-2xl tracking-[-0.05em] leading-8">
Pošlete poptávku přes stávající backend
</CardTitle>
</CardHeader>
<CardContent>
<ContactForm />
</CardContent>
</Card>
</div>
</div>
</section>
</main>
<footer className="border-t border-[rgba(17,24,39,0.08)] py-12 bg-gradient-to-b from-white to-[rgba(108,56,217,0.02)]">
<div className="section-shell flex flex-col gap-8 md:flex-row md:items-center md:justify-between">
<div className="flex flex-col gap-3">
<p className="text-xl font-bold tracking-[-0.03em] text-[rgb(17_24_39)]">
MyClub
</p>
<p className="text-sm leading-6 text-[rgb(75_85_99)] max-w-md">
Web, obsah, partneři a klubový provoz bez generické CMS skládačky.
</p>
</div>
<div className="flex flex-wrap items-center gap-6 text-sm font-medium text-[rgb(75_85_99)]">
{navItems.map((item) => (
<a
key={item.href}
href={item.href}
className="transition-all duration-200 hover:text-[rgb(108_56_217)] hover:translate-y-[-1px]"
>
{item.label}
</a>
))}
</div>
</div>
</footer>
</div>
)
}
@@ -0,0 +1,190 @@
import { useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import axios from 'axios'
import { ArrowRight, LoaderCircle } from 'lucide-react'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { submitContactForm } from '@/lib/api'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
const contactSchema = z.object({
name: z.string().min(2, 'Zadejte prosím jméno nebo název klubu.'),
email: z.email('Zadejte prosím platný e-mail.'),
subject: z.string().min(3, 'Napište stručné téma poptávky.'),
message: z.string().min(24, 'Doplňte prosím alespoň krátký kontext, co chcete řešit.'),
})
type ContactValues = z.infer<typeof contactSchema>
const defaultValues: ContactValues = {
name: '',
email: '',
subject: 'Mám zájem o ukázku MyClubu',
message: '',
}
type FormStatus =
| { type: 'success'; message: string }
| { type: 'error'; message: string }
| null
export function ContactForm() {
const [status, setStatus] = useState<FormStatus>(null)
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm<ContactValues>({
resolver: zodResolver(contactSchema),
defaultValues,
})
async function onSubmit(values: ContactValues) {
setStatus(null)
try {
await submitContactForm(values)
reset(defaultValues)
setStatus({
type: 'success',
message:
'Poptávka byla odeslána. Pokud je backend správně nakonfigurovaný, zpráva právě dorazila do klubové kontaktní vrstvy.',
})
} catch (error) {
let message =
'Odeslání se nepodařilo. Zkontrolujte, že běží backend a že endpoint /api/v1/contact přijímá veřejné formuláře.'
if (axios.isAxiosError(error)) {
const serverMessage =
typeof error.response?.data?.error === 'string'
? error.response.data.error
: undefined
if (serverMessage) {
if (/failed to save message/i.test(serverMessage)) {
message =
'Server zprávu přijal, ale nepodařilo se ji uložit. Zkontrolujte databázi a nastavení kontaktního formuláře na backendu.'
} else if (/invalid payload/i.test(serverMessage)) {
message = 'Server odmítl odeslaná data. Zkontrolujte prosím vyplněná pole.'
} else if (/valid email is required/i.test(serverMessage)) {
message = 'Zadejte prosím platný e-mail.'
} else {
message = serverMessage
}
} else if (error.code === 'ECONNABORTED') {
message = 'Server neodpověděl včas. Zkuste odeslání znovu nebo zkontrolujte backend.'
} else if (!error.response) {
message = 'Nepodařilo se spojit s API. V developmentu ověřte proxy nebo VITE_API_BASE_URL.'
}
}
setStatus({ type: 'error', message })
}
}
return (
<form className="flex flex-col gap-5" onSubmit={handleSubmit(onSubmit)} noValidate>
<div className="grid gap-5 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<label className="text-sm font-semibold text-[rgb(17_24_39)]" htmlFor="name">
Jméno / klub
</label>
<Input
id="name"
placeholder="Například FK Example / Jan Novák"
aria-invalid={errors.name ? true : undefined}
className="h-12 rounded-2xl border-[rgba(17,24,39,0.1)] bg-white px-4 text-[rgb(17_24_39)] placeholder:text-[rgba(75,85,99,0.7)]"
{...register('name')}
/>
{errors.name ? <p className="form-feedback">{errors.name.message}</p> : null}
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-semibold text-[rgb(17_24_39)]" htmlFor="email">
E-mail
</label>
<Input
id="email"
type="email"
placeholder="vas@email.cz"
aria-invalid={errors.email ? true : undefined}
className="h-12 rounded-2xl border-[rgba(17,24,39,0.1)] bg-white px-4 text-[rgb(17_24_39)] placeholder:text-[rgba(75,85,99,0.7)]"
{...register('email')}
/>
{errors.email ? <p className="form-feedback">{errors.email.message}</p> : null}
</div>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-semibold text-[rgb(17_24_39)]" htmlFor="subject">
Co chcete řešit
</label>
<Input
id="subject"
aria-invalid={errors.subject ? true : undefined}
className="h-12 rounded-2xl border-[rgba(17,24,39,0.1)] bg-white px-4 text-[rgb(17_24_39)] placeholder:text-[rgba(75,85,99,0.7)]"
{...register('subject')}
/>
{errors.subject ? <p className="form-feedback">{errors.subject.message}</p> : null}
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-semibold text-[rgb(17_24_39)]" htmlFor="message">
Krátký kontext
</label>
<Textarea
id="message"
rows={6}
placeholder="Popište klub, stávající web, jak dnes funguje obsah a co má nová platforma zjednodušit."
aria-invalid={errors.message ? true : undefined}
className="min-h-36 rounded-[1.35rem] border-[rgba(17,24,39,0.1)] bg-white px-4 py-3 text-[rgb(17_24_39)] placeholder:text-[rgba(75,85,99,0.7)]"
{...register('message')}
/>
{errors.message ? <p className="form-feedback">{errors.message.message}</p> : null}
</div>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<Button
type="submit"
className="h-12 rounded-full px-6 text-sm shadow-[0_16px_32px_rgba(17,24,39,0.12)]"
disabled={isSubmitting}
>
{isSubmitting ? (
<>
<LoaderCircle className="size-4 animate-spin" />
Odesílám
</>
) : (
<>
Domluvit ukázku
<ArrowRight className="size-4" />
</>
)}
</Button>
<p className="max-w-xs text-sm leading-6 text-[rgb(75_85_99)]">
Formulář používá stávající veřejný endpoint projektu. V produkci stačí stejné origin API nebo
nastavit <code>VITE_API_BASE_URL</code>.
</p>
</div>
<div aria-live="polite" className="min-h-6">
{status ? (
<p
className={
status.type === 'success'
? 'rounded-3xl border border-[rgba(108,56,217,0.15)] bg-[rgba(108,56,217,0.07)] px-4 py-3 text-sm leading-6 text-[rgb(17_24_39)]'
: 'rounded-3xl border border-[rgba(255,153,51,0.24)] bg-[rgba(255,153,51,0.12)] px-4 py-3 text-sm leading-6 text-[rgb(17_24_39)]'
}
>
{status.message}
</p>
) : null}
</div>
</form>
)
}
@@ -0,0 +1,205 @@
import {
ArrowUpRight,
CalendarRange,
ChartColumnIncreasing,
Layers3,
MailPlus,
Trophy,
Image as ImageIcon,
} from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { VideoText } from '@/components/ui/video-text'
export function HeroVisual() {
return (
<div className="relative mx-auto w-full max-w-[680px]">
<div className="hero-halo hero-halo-primary absolute -left-16 top-16 size-56" />
<div className="hero-halo hero-halo-secondary absolute -right-12 top-4 size-52" />
<Card className="surface-panel relative overflow-hidden rounded-[2rem] border-0 py-0 transition-all duration-500 hover:shadow-2xl">
<CardHeader className="border-b border-[rgba(17,24,39,0.08)] px-8 py-6">
<div className="flex items-center justify-between gap-4">
<div className="flex-1">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-[rgb(108_56_217)] mb-3">
produktová ukázka
</p>
<CardTitle className="text-2xl tracking-[-0.04em] leading-8">
Řídicí vrstva pro klubový web
</CardTitle>
</div>
<Badge
variant="outline"
className="hidden border-[rgba(17,24,39,0.08)] bg-white px-4 py-2 text-[rgb(75_85_99)] sm:inline-flex shadow-sm"
>
white mode only
</Badge>
</div>
<CardDescription className="max-w-xl text-[rgb(75_85_99)] mt-3 leading-6">
MyUIbrix, klubový obsah, partneři i matchday přehled v jedné kompozici.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-6 px-8 py-8 lg:grid-cols-[1.08fr_0.92fr]">
<div className="space-y-5">
<div className="mock-window dashboard-grid rounded-[1.75rem] p-5">
<div className="mb-5 flex items-center justify-between gap-4">
<div className="flex gap-3">
<span className="size-3 rounded-full bg-[rgba(108,56,217,0.45)] shadow-sm" />
<span className="size-3 rounded-full bg-[rgba(255,153,51,0.45)] shadow-sm" />
<span className="size-3 rounded-full bg-[rgba(17,24,39,0.18)] shadow-sm" />
</div>
<div className="rounded-full bg-white px-4 py-2 text-[11px] font-semibold uppercase tracking-[0.2em] text-[rgb(75_85_99)] shadow-sm border border-[rgba(17,24,39,0.08)]">
homepage builder
</div>
</div>
<div className="grid gap-4">
<div className="rounded-[1.3rem] border border-[rgba(108,56,217,0.16)] bg-[rgba(108,56,217,0.07)] p-5">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-3 text-sm font-semibold text-[rgb(17_24_39)]">
<Layers3 className="size-5 text-[rgb(108_56_217)]" />
Hero + novinky + zápasy
</div>
<span className="rounded-full bg-white px-3 py-1.5 text-[11px] font-semibold text-[rgb(108_56_217)] shadow-sm border border-[rgba(108,56,217,0.16)]">
live preview
</span>
</div>
<div className="space-y-3">
<div className="h-3 w-11/12 rounded-full bg-white/90 shadow-sm" />
<div className="h-3 w-8/12 rounded-full bg-white/75 shadow-sm" />
<div className="grid grid-cols-3 gap-3 pt-2">
<div className="h-20 rounded-2xl bg-white/85 shadow-sm flex items-center justify-center relative overflow-hidden group">
<VideoText
src="https://cdn.magicui.design/ocean-small.webm"
className="absolute inset-0"
fontSize={8}
>
<ImageIcon className="size-8 text-white/80" />
</VideoText>
</div>
<div className="h-20 rounded-2xl bg-white/70 shadow-sm flex items-center justify-center relative overflow-hidden group">
<VideoText
src="https://cdn.magicui.design/ocean-small.webm"
className="absolute inset-0"
fontSize={8}
>
<ImageIcon className="size-8 text-white/80" />
</VideoText>
</div>
<div className="h-20 rounded-2xl bg-white/85 shadow-sm flex items-center justify-center relative overflow-hidden group">
<VideoText
src="https://cdn.magicui.design/ocean-small.webm"
className="absolute inset-0"
fontSize={8}
>
<ImageIcon className="size-8 text-white/80" />
</VideoText>
</div>
</div>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-[1.2rem] border border-[rgba(17,24,39,0.08)] bg-white p-4 transition-all duration-300 hover:shadow-md hover:scale-[1.02]">
<div className="text-xs font-semibold uppercase tracking-[0.2em] text-[rgb(75_85_99)] mb-3">
články
</div>
<div className="mt-2 text-xl font-semibold tracking-[-0.04em] text-[rgb(17_24_39)]">
redakce
</div>
</div>
<div className="rounded-[1.2rem] border border-[rgba(17,24,39,0.08)] bg-white p-4 transition-all duration-300 hover:shadow-md hover:scale-[1.02]">
<div className="text-xs font-semibold uppercase tracking-[0.2em] text-[rgb(75_85_99)] mb-3">
partneři
</div>
<div className="mt-2 text-xl font-semibold tracking-[-0.04em] text-[rgb(17_24_39)]">
bannery
</div>
</div>
<div className="rounded-[1.2rem] border border-[rgba(17,24,39,0.08)] bg-white p-4 transition-all duration-300 hover:shadow-md hover:scale-[1.02]">
<div className="text-xs font-semibold uppercase tracking-[0.2em] text-[rgb(75_85_99)] mb-3">
komunikace
</div>
<div className="mt-2 text-xl font-semibold tracking-[-0.04em] text-[rgb(17_24_39)]">
newsletter
</div>
</div>
</div>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="metric-chip transition-all duration-300 hover:shadow-md hover:scale-[1.02]">
<CalendarRange className="size-5 text-[rgb(108_56_217)]" />
<span className="text-sm leading-5">Zápasy a tabulky v klubovém layoutu</span>
</div>
<div className="metric-chip transition-all duration-300 hover:shadow-md hover:scale-[1.02]">
<MailPlus className="size-5 text-[rgb(255_153_51)]" />
<span className="text-sm leading-5">Kontakty a newsletter bez externí skládačky</span>
</div>
</div>
</div>
<div className="space-y-5">
<div className="rounded-[1.75rem] border border-[rgba(17,24,39,0.08)] bg-white p-6 shadow-[0_20px_40px_rgba(17,24,39,0.06)] transition-all duration-300 hover:shadow-xl">
<div className="mb-5 flex items-center justify-between">
<div className="flex items-center gap-3 text-sm font-semibold text-[rgb(17_24_39)]">
<Trophy className="size-5 text-[rgb(255_153_51)]" />
Match center
</div>
<span className="text-xs font-semibold uppercase tracking-[0.18em] text-[rgb(75_85_99)]">
připraveno na víkend
</span>
</div>
<div className="space-y-3">
{[
['1. tým', 'sobota 16:30'],
['Dorost U19', 'neděle 10:15'],
['Ženy', 'neděle 14:00'],
].map(([label, time]) => (
<div
key={label}
className="flex items-center justify-between rounded-[1.1rem] border border-[rgba(17,24,39,0.07)] bg-[rgba(17,24,39,0.02)] px-4 py-3 transition-all duration-200 hover:bg-[rgba(17,24,39,0.04)] hover:shadow-sm"
>
<span className="font-medium text-[rgb(17_24_39)]">{label}</span>
<span className="text-sm text-[rgb(75_85_99)]">{time}</span>
</div>
))}
</div>
</div>
<div className="rounded-[1.75rem] border border-[rgba(17,24,39,0.08)] bg-[linear-gradient(180deg,rgba(255,153,51,0.12),rgba(255,255,255,0.98))] p-6 shadow-[0_20px_40px_rgba(17,24,39,0.06)] transition-all duration-300 hover:shadow-xl">
<div className="mb-5 flex items-center justify-between">
<div className="flex items-center gap-3 text-sm font-semibold text-[rgb(17_24_39)]">
<ChartColumnIncreasing className="size-5 text-[rgb(108_56_217)]" />
Přehled vedení
</div>
<ArrowUpRight className="size-4 text-[rgb(17_24_39)]" />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="rounded-[1.2rem] bg-white/80 p-4 transition-all duration-300 hover:bg-white hover:shadow-sm min-h-[100px] flex flex-col justify-between">
<div className="text-xs font-semibold uppercase tracking-[0.2em] text-[rgb(75_85_99)] mb-2">
obsah
</div>
<div className="text-lg font-bold tracking-[-0.02em] text-[rgb(17_24_39)] leading-tight">
pod kontrolou
</div>
</div>
<div className="rounded-[1.2rem] bg-white/80 p-4 transition-all duration-300 hover:bg-white hover:shadow-sm min-h-[100px] flex flex-col justify-between">
<div className="text-xs font-semibold uppercase tracking-[0.2em] text-[rgb(75_85_99)] mb-2">
partneři
</div>
<div className="text-lg font-bold tracking-[-0.02em] text-[rgb(17_24_39)] leading-tight">
v publikaci
</div>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
)
}
@@ -0,0 +1,28 @@
import { cn } from '@/lib/utils'
type SectionHeadingProps = {
kicker: string
title: string
description: string
align?: 'left' | 'center'
}
export function SectionHeading({
kicker,
title,
description,
align = 'left',
}: SectionHeadingProps) {
return (
<div
className={cn(
'flex flex-col gap-4',
align === 'center' && 'mx-auto max-w-3xl text-center'
)}
>
<span className="section-kicker">{kicker}</span>
<h2 className="section-title text-[clamp(2rem,4vw,3.55rem)]">{title}</h2>
<p className="section-copy max-w-3xl">{description}</p>
</div>
)
}
@@ -0,0 +1,109 @@
import { ArrowRight, Menu } from 'lucide-react'
import type { NavItem } from '@/content'
import { Button } from '@/components/ui/button'
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet'
type SiteHeaderProps = {
items: NavItem[]
}
export function SiteHeader({ items }: SiteHeaderProps) {
return (
<header className="fixed inset-x-0 top-0 z-50 border-b border-[rgba(17,24,39,0.08)] bg-white/85 backdrop-blur-xl">
<div className="section-shell flex h-18 items-center justify-between gap-6">
<a href="#top" className="flex items-center gap-3 text-sm font-semibold text-[rgb(17_24_39)]">
<span className="flex size-10 items-center justify-center rounded-2xl border border-[rgba(17,24,39,0.08)] bg-[rgba(108,56,217,0.08)] text-base font-bold text-[rgb(108_56_217)]">
M
</span>
<span className="flex flex-col leading-none">
<span className="text-base tracking-[-0.03em]">MyClub</span>
<span className="text-xs font-medium text-[rgb(75_85_99)]">
klubový web a provoz v jednom
</span>
</span>
</a>
<nav className="hidden items-center gap-1 rounded-full border border-[rgba(17,24,39,0.08)] bg-white/80 p-1 md:flex">
{items.map((item) => (
<a
key={item.href}
href={item.href}
className="rounded-full px-4 py-2 text-sm font-medium text-[rgb(75_85_99)] transition-colors hover:bg-[rgba(108,56,217,0.08)] hover:text-[rgb(17_24_39)]"
>
{item.label}
</a>
))}
</nav>
<div className="hidden md:block">
<Button
asChild
className="h-11 rounded-full px-5 text-sm shadow-[0_16px_32px_rgba(17,24,39,0.12)]"
>
<a href="#kontakt">
Domluvit ukázku
<ArrowRight className="size-4" />
</a>
</Button>
</div>
<div className="md:hidden">
<Sheet>
<SheetTrigger asChild>
<Button
variant="outline"
size="icon-lg"
className="rounded-full border-[rgba(17,24,39,0.12)] bg-white"
aria-label="Otevřít navigaci"
>
<Menu className="size-5" />
</Button>
</SheetTrigger>
<SheetContent
side="right"
className="w-[86vw] border-l border-[rgba(17,24,39,0.08)] bg-white"
>
<SheetHeader className="px-6 pt-6">
<SheetTitle>MyClub</SheetTitle>
<SheetDescription>
Klubový web, obsah a provoz v jednom editoru.
</SheetDescription>
</SheetHeader>
<div className="flex flex-1 flex-col gap-2 px-6 pb-6">
{items.map((item) => (
<SheetClose asChild key={item.href}>
<a
href={item.href}
className="rounded-3xl border border-[rgba(17,24,39,0.08)] px-4 py-3 text-base font-medium text-[rgb(17_24_39)] transition-colors hover:bg-[rgba(108,56,217,0.06)]"
>
{item.label}
</a>
</SheetClose>
))}
<SheetClose asChild>
<Button asChild className="mt-4 h-11 rounded-full">
<a href="#kontakt">
Domluvit ukázku
<ArrowRight className="size-4" />
</a>
</Button>
</SheetClose>
</div>
</SheetContent>
</Sheet>
</div>
</div>
</header>
)
}
+79
View File
@@ -0,0 +1,79 @@
import * as React from "react"
import { Accordion as AccordionPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
function Accordion({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return (
<AccordionPrimitive.Root
data-slot="accordion"
className={cn("flex w-full flex-col", className)}
{...props}
/>
)
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("not-last:border-b", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"group/accordion-trigger relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring disabled:pointer-events-none disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
className
)}
{...props}
>
{children}
<ChevronDownIcon data-slot="accordion-trigger-icon" className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
<ChevronUpIcon data-slot="accordion-trigger-icon" className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="overflow-hidden text-sm data-open:animate-accordion-down data-closed:animate-accordion-up"
{...props}
>
<div
className={cn(
"h-(--radix-accordion-content-height) pt-0 pb-2.5 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
>
{children}
</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
+49
View File
@@ -0,0 +1,49 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
+67
View File
@@ -0,0 +1,67 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+103
View File
@@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
+144
View File
@@ -0,0 +1,144 @@
"use client"
import * as React from "react"
import { Dialog as SheetPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-[rgba(17,24,39,0.16)] duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-background bg-clip-padding text-sm shadow-[0_24px_80px_rgba(17,24,39,0.14)] transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close data-slot="sheet-close" asChild>
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
>
<XIcon
/>
<span className="sr-only">Close</span>
</Button>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-0.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-base font-medium text-foreground", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }
+136
View File
@@ -0,0 +1,136 @@
"use client"
import React, { useEffect, useState } from "react"
import type { ElementType, ReactNode } from "react"
import { cn } from "@/lib/utils"
export interface VideoTextProps {
/**
* The video source URL
*/
src: string
/**
* Additional className for the container
*/
className?: string
/**
* Whether to autoplay the video
*/
autoPlay?: boolean
/**
* Whether to mute the video
*/
muted?: boolean
/**
* Whether to loop the video
*/
loop?: boolean
/**
* Whether to preload the video
*/
preload?: "auto" | "metadata" | "none"
/**
* The content to display (will have the video "inside" it)
*/
children: ReactNode
/**
* Font size for the text mask (in viewport width units)
* @default 10
*/
fontSize?: string | number
/**
* Font weight for the text mask
* @default "bold"
*/
fontWeight?: string | number
/**
* Text anchor for the text mask
* @default "middle"
*/
textAnchor?: string
/**
* Dominant baseline for the text mask
* @default "middle"
*/
dominantBaseline?: string
/**
* Font family for the text mask
* @default "sans-serif"
*/
fontFamily?: string
/**
* The element type to render for the text
* @default "div"
*/
as?: ElementType
}
export function VideoText({
src,
children,
className = "",
autoPlay = true,
muted = true,
loop = true,
preload = "auto",
fontSize = 15,
fontWeight = "bold",
textAnchor = "middle",
dominantBaseline = "middle",
fontFamily = "'Montserrat Variable', 'Montserrat', sans-serif",
as: Component = "div",
}: VideoTextProps) {
const [svgMask, setSvgMask] = useState("")
const content = React.Children.toArray(children).join("")
useEffect(() => {
const updateSvgMask = () => {
const responsiveFontSize =
typeof fontSize === "number" ? `${fontSize}vw` : fontSize
const newSvgMask = `<svg xmlns='http://www.w3.org/2000/svg' width='100%' height='100%'><text x='50%' y='50%' font-size='${responsiveFontSize}' font-weight='${fontWeight}' text-anchor='${textAnchor}' dominant-baseline='${dominantBaseline}' font-family='${fontFamily}'>${content}</text></svg>`
setSvgMask(newSvgMask)
}
updateSvgMask()
window.addEventListener("resize", updateSvgMask)
return () => window.removeEventListener("resize", updateSvgMask)
}, [content, fontSize, fontWeight, textAnchor, dominantBaseline, fontFamily])
const dataUrlMask = `url("data:image/svg+xml,${encodeURIComponent(svgMask)}")`
return (
<Component className={cn(`relative size-full`, className)}>
{/* Create a container that masks the video to only show within text */}
<div
className="absolute inset-0 flex items-center justify-center"
style={{
maskImage: dataUrlMask,
WebkitMaskImage: dataUrlMask,
maskSize: "contain",
WebkitMaskSize: "contain",
maskRepeat: "no-repeat",
WebkitMaskRepeat: "no-repeat",
maskPosition: "center",
WebkitMaskPosition: "center",
}}
>
<video
className="h-full w-full object-cover"
autoPlay={autoPlay}
muted={muted}
loop={loop}
preload={preload}
playsInline
>
<source src={src} type="video/mp4" />
<source src={src} type="video/webm" />
Your browser does not support the video tag.
</video>
</div>
{/* Add a backup text element for SEO/accessibility */}
<span className="sr-only">{content}</span>
</Component>
)
}
+196
View File
@@ -0,0 +1,196 @@
import type { LucideIcon } from 'lucide-react'
import {
BarChart3,
Blocks,
ChartColumnIncreasing,
Flag,
Images,
MailPlus,
Megaphone,
Newspaper,
ShieldCheck,
Trophy,
Users2,
} from 'lucide-react'
export type NavItem = {
label: string
href: string
}
export type ContentCard = {
icon: LucideIcon
eyebrow: string
title: string
description: string
}
export type WorkflowStep = {
step: string
title: string
description: string
}
export type FaqItem = {
question: string
answer: string
}
export const navItems: NavItem[] = [
{ label: 'Platforma', href: '#platforma' },
{ label: 'Ukázka', href: '#ukazka' },
{ label: 'Jak to funguje', href: '#workflow' },
{ label: 'FAQ', href: '#faq' },
{ label: 'Kontakt', href: '#kontakt' },
]
export const proofPoints: ContentCard[] = [
{
icon: Blocks,
eyebrow: 'Web + CMS',
title: 'Jedno prostředí pro veřejný web i správu obsahu',
description: 'Stránky, články, galerie, bannery a homepage builder bez přepínání mezi nástroji.',
},
{
icon: Trophy,
eyebrow: 'Sportovní data',
title: 'Zápasy, výsledky a tabulky v klubovém kontextu',
description: 'Připravené pro klubový provoz, včetně hráčů, týmů, rozpisů a matchday obsahu.',
},
{
icon: Users2,
eyebrow: 'Klubová agenda',
title: 'Týmy, hráči, kontakty a role přehledně pohromadě',
description: 'Obsahový tým, vedení klubu i operativa pracují nad jedním zdrojem pravdy.',
},
{
icon: MailPlus,
eyebrow: 'Komunikace',
title: 'Newsletter, kontaktní formuláře a engagement bez doplňků',
description: 'Komunikace s fanoušky, členy a partnery vychází přímo z klubového obsahu.',
},
{
icon: Megaphone,
eyebrow: 'Partneři',
title: 'Sponzoři, bannery a promo plochy bez ručního chaosu',
description: 'Klubová monetizace se řídí stejně snadno jako redakční obsah.',
},
{
icon: ChartColumnIncreasing,
eyebrow: 'Přehled',
title: 'Analytika a provozní kontrola v jednom dashboardu',
description: 'Vidíte, co klub publikuje, co funguje a co má smysl dál posílit.',
},
]
export const featureCards: ContentCard[] = [
{
icon: Blocks,
eyebrow: 'MyUIbrix uvnitř',
title: 'Skládání homepage bez front-end sprintu',
description: 'Hero sekce, novinky, zápasy, bannery, kontakty i další bloky poskládáte vizuálně a s okamžitým náhledem.',
},
{
icon: Newspaper,
eyebrow: 'Obsah',
title: 'Redakce, články, galerie a videa v jednotném workflow',
description: 'Klubový obsah má společná média, kategorie, vyhledávání i publikaci napříč veřejným webem.',
},
{
icon: Trophy,
eyebrow: 'Matchday',
title: 'Sportovní sekce, které nejsou jen statická tabulka',
description: 'Matches, standings, hráči a soutěže vypadají jako součást produktu, ne jako externí widget.',
},
{
icon: Megaphone,
eyebrow: 'Marketing',
title: 'Sponzoři, bannery a kampaně navázané na klubový obsah',
description: 'Promo plochy i partnerské sekce spravujete ve stejném systému jako články a homepage.',
},
{
icon: MailPlus,
eyebrow: 'Komunikace',
title: 'Newslettery, kontakty, komentáře a ankety bez lepení pluginů',
description: 'Fan engagement a klubové formuláře jsou součást platformy, ne další externí vrstva.',
},
{
icon: BarChart3,
eyebrow: 'Vedení klubu',
title: 'Výsledky, aktivita a provozní signály na jednom místě',
description: 'Obsah, návštěvnost a klubová agenda zůstávají čitelné i pro tým, který nechce složitou enterprise správu.',
},
]
export const operationalPillars: ContentCard[] = [
{
icon: ShieldCheck,
eyebrow: 'Pro management',
title: 'Méně nástrojů, méně překlepů, méně ruční koordinace',
description: 'Jedna platforma pro veřejný web, provozní data i komunikaci s partnery a komunitou.',
},
{
icon: Flag,
eyebrow: 'Pro operativu',
title: 'Rychlá reakce na zápasový víkend i změny v klubu',
description: 'Obsah, bannery, kontakty a homepage bloky upravíte bez čekání na nový deploy celé prezentace.',
},
{
icon: Images,
eyebrow: 'Pro obsahový tým',
title: 'Média, články a vizuální skladba webu pod jednou střechou',
description: 'Redaktor i admin pracují nad stejným systémem a neřeší, kam co ručně kopírovat.',
},
]
export const workflowSteps: WorkflowStep[] = [
{
step: '01',
title: 'Nastavíte klubovou identitu',
description: 'Barvy, navigaci, kontakty, role, mapu a základní klubový obsah připravíte bez ohýbání obecného CMS.',
},
{
step: '02',
title: 'Poskládáte veřejný web v MyUIbrixu',
description: 'Homepage builder drží vzhled webu pod kontrolou i ve chvíli, kdy potřebujete rychle změnit pořadí bloků a akcenty.',
},
{
step: '03',
title: 'Napojíte sportovní a obsahové vrstvy',
description: 'Zprávy, zápasy, tabulky, hráče, partnery i newsletter propojujete v jednom toku práce.',
},
{
step: '04',
title: 'Publikujete a dál ladíte provoz',
description: 'Obsahový tým publikuje, vedení sleduje přehled a klub má jednu platformu připravenou pro další růst.',
},
]
export const faqItems: FaqItem[] = [
{
question: 'Je MyClub jen pro fotbalový klub?',
answer: 'Ne. Repo sice vznikal ve fotbalovém kontextu, ale produktová vrstva je postavená jako klubová platforma pro sportovní organizace obecně. Fotbalové integrace jsou výhoda, ne omezení.',
},
{
question: 'Musí každou změnu řešit vývojář?',
answer: 'Nemusí. Právě proto je součástí produktu MyUIbrix, tedy vizuální builder pro homepage a obsahové sekce. Vývojář není potřeba pro běžný denní provoz a obsahové změny.',
},
{
question: 'Co všechno v landingu myslíte pojmem provoz klubu?',
answer: 'Obsah, veřejný web, kontakty, newslettery, bannery, sponzory, zápasy, tabulky, týmy, hráče a administrativní tok práce mezi lidmi v klubu.',
},
{
question: 'Dá se MyClub nasadit vedle stávajícího backendu?',
answer: 'Ano. Tato landing page je už připravená fungovat vedle současného backendu a odesílá poptávky přes existující endpoint. Stejný princip lze použít i pro širší produktové nasazení.',
},
{
question: 'Jak vypadá první krok po odeslání formuláře?',
answer: 'Ozvete se přes poptávkový formulář, projdou se vaše současné potřeby, veřejný web a provozní procesy, a podle toho se připraví produktová ukázka nebo návrh dalšího kroku.',
},
]
export const contactChecklist = [
'Ukázka podle typu klubu a vašeho současného webu.',
'Průchod obsahem, MyUIbrix builderem i provozní agendou.',
'Doporučení, jak navázat na stávající backend a publikaci.',
]
+81
View File
@@ -0,0 +1,81 @@
import { useEffect } from 'react'
import { siteUrl } from '@/lib/api'
const seo = {
title: 'MyClub | Web, obsah a provoz klubu v jednom systému',
description:
'MyClub spojuje klubový web, MyUIbrix builder, zápasy, obsah, partnery, newslettery i provozní workflow do jednoho přehledného systému pro sportovní kluby.',
}
function ensureMeta(
selector: string,
attribute: 'name' | 'property',
key: string,
content: string
) {
let meta = document.head.querySelector(selector) as HTMLMetaElement | null
if (!meta) {
meta = document.createElement('meta')
meta.setAttribute(attribute, key)
document.head.appendChild(meta)
}
meta.setAttribute('content', content)
}
export function useLandingSeo() {
useEffect(() => {
document.title = seo.title
ensureMeta('meta[name="description"]', 'name', 'description', seo.description)
ensureMeta('meta[name="theme-color"]', 'name', 'theme-color', 'rgb(108 56 217)')
ensureMeta('meta[property="og:type"]', 'property', 'og:type', 'website')
ensureMeta('meta[property="og:site_name"]', 'property', 'og:site_name', 'MyClub')
ensureMeta('meta[property="og:title"]', 'property', 'og:title', seo.title)
ensureMeta(
'meta[property="og:description"]',
'property',
'og:description',
seo.description
)
ensureMeta('meta[name="twitter:card"]', 'name', 'twitter:card', 'summary')
ensureMeta('meta[name="twitter:title"]', 'name', 'twitter:title', seo.title)
ensureMeta(
'meta[name="twitter:description"]',
'name',
'twitter:description',
seo.description
)
if (!siteUrl) {
return
}
const canonicalHref = `${siteUrl}/`
const ogImage = `${siteUrl}/og-cover.svg`
let canonical = document.head.querySelector(
'link[rel="canonical"]'
) as HTMLLinkElement | null
if (!canonical) {
canonical = document.createElement('link')
canonical.setAttribute('rel', 'canonical')
document.head.appendChild(canonical)
}
canonical.setAttribute('href', canonicalHref)
ensureMeta('meta[property="og:url"]', 'property', 'og:url', canonicalHref)
ensureMeta('meta[property="og:image"]', 'property', 'og:image', ogImage)
ensureMeta(
'meta[property="og:image:type"]',
'property',
'og:image:type',
'image/svg+xml'
)
ensureMeta('meta[name="twitter:image"]', 'name', 'twitter:image', ogImage)
}, [])
}
+394
View File
@@ -0,0 +1,394 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@import '@fontsource-variable/montserrat';
@custom-variant dark (&:is(.dark *));
:root {
--background: rgb(255 255 255);
--foreground: rgb(17 24 39);
--card: rgb(255 255 255);
--card-foreground: rgb(17 24 39);
--popover: rgb(255 255 255);
--popover-foreground: rgb(17 24 39);
--primary: rgb(108 56 217);
--primary-foreground: rgb(255 255 255);
--secondary: rgba(255, 153, 51, 0.14);
--secondary-foreground: rgb(17 24 39);
--muted: rgba(17, 24, 39, 0.04);
--muted-foreground: rgb(75 85 99);
--accent: rgba(108, 56, 217, 0.08);
--accent-foreground: rgb(17 24 39);
--destructive: rgb(255 153 51);
--border: rgba(17, 24, 39, 0.12);
--input: rgba(17, 24, 39, 0.1);
--ring: rgba(108, 56, 217, 0.28);
--chart-1: rgb(108 56 217);
--chart-2: rgba(108, 56, 217, 0.7);
--chart-3: rgb(255 153 51);
--chart-4: rgba(255 153 51, 0.7);
--chart-5: rgba(17, 24, 39, 0.7);
--radius: 1.5rem;
--sidebar: rgb(255 255 255);
--sidebar-foreground: rgb(17 24 39);
--sidebar-primary: rgb(108 56 217);
--sidebar-primary-foreground: rgb(255 255 255);
--sidebar-accent: rgba(108, 56, 217, 0.08);
--sidebar-accent-foreground: rgb(17 24 39);
--sidebar-border: rgba(17, 24, 39, 0.12);
--sidebar-ring: rgba(108, 56, 217, 0.28);
/* Enhanced typography scale */
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.25rem;
--text-2xl: 1.5rem;
--text-3xl: 1.875rem;
--text-4xl: 2.25rem;
--text-5xl: 3rem;
--text-6xl: 3.75rem;
/* Line heights */
--leading-tight: 1.25;
--leading-normal: 1.5;
--leading-relaxed: 1.625;
--leading-loose: 2;
/* Spacing scale */
--space-xs: 0.5rem;
--space-sm: 0.75rem;
--space-md: 1rem;
--space-lg: 1.25rem;
--space-xl: 1.5rem;
--space-2xl: 2rem;
--space-3xl: 3rem;
--space-4xl: 4rem;
}
@theme inline {
--font-sans: 'Montserrat Variable', 'Montserrat', sans-serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--color-brand-primary: rgb(108 56 217);
--color-brand-secondary: rgb(255 153 51);
--color-brand-ink: rgb(17 24 39);
--color-brand-muted: rgb(75 85 99);
--radius-sm: calc(var(--radius) * 0.55);
--radius-md: calc(var(--radius) * 0.76);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.28);
--radius-2xl: calc(var(--radius) * 1.7);
--radius-3xl: calc(var(--radius) * 2.15);
--radius-4xl: calc(var(--radius) * 2.6);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
html {
@apply bg-background font-sans text-foreground;
scroll-behavior: smooth;
}
body {
@apply bg-background text-foreground;
font-family: 'Montserrat Variable', 'Montserrat', sans-serif;
min-height: 100vh;
background-image:
linear-gradient(180deg, rgba(108, 56, 217, 0.08), rgba(255, 255, 255, 0) 18%),
linear-gradient(120deg, rgba(255, 153, 51, 0.12), rgba(255, 255, 255, 0) 28%),
linear-gradient(180deg, rgba(17, 24, 39, 0.02), rgba(255, 255, 255, 0) 55%);
background-color: rgb(255 255 255);
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
}
body::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
background-image:
linear-gradient(rgba(17, 24, 39, 0.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(17, 24, 39, 0.035) 1px, transparent 1px);
background-size: 48px 48px;
mask-image: linear-gradient(180deg, rgba(17, 24, 39, 0.28), transparent 70%);
z-index: -1;
}
a {
color: inherit;
text-decoration: none;
}
code {
font-family: 'Montserrat Variable', 'Montserrat', sans-serif;
border-radius: 999px;
background: rgba(108, 56, 217, 0.08);
color: rgb(17 24 39);
padding: 0.2rem 0.55rem;
font-size: 0.8rem;
font-weight: 600;
}
::selection {
background: rgba(108, 56, 217, 0.18);
color: rgb(17 24 39);
}
#root {
min-height: 100vh;
}
}
@layer components {
.landing-shell {
@apply min-h-screen overflow-x-clip;
}
.section-shell {
width: min(1280px, calc(100% - 3rem));
margin-inline: auto;
padding-inline: var(--space-lg);
}
.section-kicker {
@apply text-xs font-bold uppercase tracking-[0.28em];
color: rgb(108 56 217);
font-size: var(--text-xs);
line-height: var(--leading-tight);
letter-spacing: 0.28em;
margin-bottom: var(--space-sm);
}
.section-title {
@apply font-semibold leading-[1.1] tracking-[-0.03em] text-[rgb(17_24_39)];
font-size: clamp(2rem, 4vw, 3.5rem);
line-height: 1.1;
letter-spacing: -0.03em;
margin-bottom: var(--space-lg);
max-width: none;
word-wrap: break-word;
overflow-wrap: break-word;
hyphens: auto;
}
.section-copy {
@apply text-base leading-7 text-[rgb(75_85_99)] sm:text-lg sm:leading-8;
font-size: var(--text-base);
line-height: var(--leading-relaxed);
color: rgb(75 85 99);
max-width: 70ch;
word-wrap: break-word;
overflow-wrap: break-word;
}
@media (min-width: 640px) {
.section-copy {
font-size: var(--text-lg);
line-height: var(--leading-relaxed);
}
}
.section-label {
@apply text-xs font-semibold uppercase tracking-[0.2em];
color: rgb(75 85 99);
font-size: var(--text-xs);
line-height: var(--leading-normal);
letter-spacing: 0.2em;
margin-bottom: var(--space-xs);
word-wrap: break-word;
overflow-wrap: break-word;
}
.eyebrow-pill {
@apply inline-flex w-fit items-center rounded-full border px-4 py-2 text-xs font-semibold uppercase tracking-[0.24em];
border-color: rgba(17, 24, 39, 0.08);
background: rgba(108, 56, 217, 0.08);
color: rgb(108 56 217);
}
.surface-panel {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(108, 56, 217, 0.06)),
rgb(255 255 255);
border: 1px solid rgba(17, 24, 39, 0.1);
box-shadow: 0 30px 70px rgba(17, 24, 39, 0.08);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.surface-panel:hover {
transform: translateY(-4px);
box-shadow: 0 40px 80px rgba(17, 24, 39, 0.12);
}
.surface-panel-soft {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(17, 24, 39, 0.02)),
rgb(255 255 255);
border: 1px solid rgba(17, 24, 39, 0.08);
box-shadow: 0 24px 54px rgba(17, 24, 39, 0.06);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.surface-panel-soft:hover {
transform: translateY(-2px);
box-shadow: 0 32px 64px rgba(17, 24, 39, 0.1);
}
.surface-panel-secondary {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(255, 153, 51, 0.14)),
rgb(255 255 255);
border: 1px solid rgba(17, 24, 39, 0.1);
box-shadow: 0 28px 62px rgba(17, 24, 39, 0.07);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.surface-panel-secondary:hover {
transform: translateY(-3px);
box-shadow: 0 36px 72px rgba(255, 153, 51, 0.15);
}
/* Enhanced animations and micro-interactions */
.hero-halo {
@apply pointer-events-none rounded-full blur-3xl;
animation: float 6s ease-in-out infinite;
}
.hero-halo-primary {
background: radial-gradient(circle, rgba(108, 56, 217, 0.22) 0%, rgba(108, 56, 217, 0) 72%);
animation-delay: 0s;
}
.hero-halo-secondary {
background: radial-gradient(circle, rgba(255, 153, 51, 0.22) 0%, rgba(255, 153, 51, 0) 72%);
animation-delay: 3s;
}
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-20px); }
}
.metric-chip {
@apply flex items-center gap-2 rounded-full border px-4 py-3 text-sm font-medium;
border-color: rgba(17, 24, 39, 0.08);
background: rgba(255, 255, 255, 0.86);
color: rgb(17, 24, 39);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.metric-chip:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(17, 24, 39, 0.15);
background: rgba(255, 255, 255, 0.95);
}
.form-feedback {
@apply text-sm font-medium;
color: rgb(255 153 51);
}
/* Scroll animations */
.fade-in-up {
opacity: 0;
transform: translateY(30px);
transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
.fade-in-up.visible {
opacity: 1;
transform: translateY(0);
}
/* Enhanced button interactions */
.btn-primary {
background: linear-gradient(135deg, rgb(108, 56, 217), rgb(139, 92, 246));
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 20px 40px rgba(108, 56, 217, 0.3);
}
.btn-primary:active {
transform: translateY(0);
}
.mock-window {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(17, 24, 39, 0.02)),
rgb(255 255 255);
border: 1px solid rgba(17, 24, 39, 0.08);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9);
}
.dashboard-grid {
background-image:
linear-gradient(rgba(17, 24, 39, 0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(17, 24, 39, 0.04) 1px, transparent 1px);
background-size: 28px 28px;
}
.metric-chip {
@apply flex items-center gap-2 rounded-full border px-4 py-3 text-sm font-medium;
border-color: rgba(17, 24, 39, 0.08);
background: rgba(255, 255, 255, 0.86);
color: rgb(17 24 39);
}
.form-feedback {
@apply text-sm font-medium;
color: rgb(255 153 51);
}
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
+23
View File
@@ -0,0 +1,23 @@
import axios from 'axios'
export type ContactPayload = {
name: string
email: string
subject: string
message: string
}
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL?.replace(/\/$/, '') ?? ''
export const siteUrl = import.meta.env.VITE_SITE_URL?.replace(/\/$/, '') ?? ''
export async function submitContactForm(payload: ContactPayload) {
const response = await axios.post(`${apiBaseUrl}/api/v1/contact`, payload, {
headers: {
'Content-Type': 'application/json',
},
timeout: 15000,
})
return response.data
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+32
View File
@@ -0,0 +1,32 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2023",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+24
View File
@@ -0,0 +1,24 @@
import path from 'node:path'
import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 4174,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
})