Latest updarte

This commit is contained in:
hubaceks
2026-05-18 23:04:50 +02:00
parent 889c84c553
commit 7d63addc7e
59 changed files with 10991 additions and 246 deletions

View File

@@ -0,0 +1,11 @@
{
"permissions": {
"allow": [
"Bash(npx create-next-app@latest . --typescript --tailwind --eslint --app --src-dir --import-alias \"@/*\" --no-turbopack)",
"Read(//Users/sfh/Documents/**)",
"Bash(npx create-next-app@latest eshop_temp --typescript --tailwind --eslint --app --src-dir --import-alias \"@/*\" --no-turbopack)",
"Bash(cp -r /Users/sfh/Documents/eshop_temp/. /Users/sfh/Documents/eshop_bistrousky/)",
"Bash(rm -rf /Users/sfh/Documents/eshop_temp)"
]
}
}

2
.gitignore vendored
View File

@@ -39,3 +39,5 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
/src/generated/prisma

100
CLAUDE.md
View File

@@ -1 +1,99 @@
@AGENTS.md # CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Projekt
BistroUsky e-shop — webová aplikace pro prodej zákusků a cukrovinek.
Produkčně použitelný e-shop s administrací, katalogem, košíkem, objednávkami a platbami.
## Hlavní pravidla
- Neměň architekturu bez vysvětlení.
- Před větší změnou navrhni postup a počkej na souhlas.
- Po změně uveď, co bylo změněno a jak to otestovat.
- Nepřepisuj existující funkčnost, pokud to není nutné.
## Tech stack
| Vrstva | Technologie |
|---|---|
| Framework | Next.js 16 (App Router) |
| Jazyk | TypeScript |
| Styling | Tailwind CSS v4 + shadcn/ui |
| Databáze | PostgreSQL + Prisma ORM |
| Autentizace | NextAuth.js v5 (beta) + @auth/prisma-adapter |
| Platby | Stripe |
| E-mail | Resend + React Email |
| Validace | Zod v4 |
| Testy | Vitest + @testing-library/react (unit), Playwright (E2E) |
## Příkazy
```bash
npm install # instalace závislostí
npm run dev # vývojový server → http://localhost:3000
npm run build # produkční build
npm run lint # ESLint
npm test # Vitest (watch mode)
npx vitest run src/path/to/test.spec.ts # jeden test
npm run test:e2e # Playwright E2E testy
npm run db:migrate # aplikace nových DB migrací
npm run db:generate # regenerace Prisma klienta
npm run db:studio # Prisma Studio GUI
```
## Architektura
```
src/
app/
(shop)/ # veřejná část e-shopu — layout a pages pro zákazníka
(admin)/ # admin sekce — chráněna rolí ADMIN
api/
auth/[...nextauth]/ # NextAuth handler
webhooks/ # Stripe webhook
components/
ui/ # shadcn/ui primitive komponenty (button, input, ...)
shop/ # doménové komponenty (ProductCard, CartItem, ...)
admin/ # admin komponenty (ProductForm, OrderTable, ...)
lib/
db.ts # Prisma client singleton — vždy importuj odtud
auth.ts # NextAuth konfigurace (handlers, auth, signIn, signOut)
stripe.ts # Stripe client
resend.ts # Resend client
emails/ # React Email šablony (OrderConfirmation, ...)
types/ # sdílené TypeScript typy a Zod schémata
prisma/
schema.prisma # datový model
migrations/ # DB migrace (generované, neupravuj ručně)
```
## Klíčové konvence
- **Server Actions** pro mutace (formuláře, košík, objednávky) — ne API routes.
- **API routes** jen pro webhooky (Stripe) a veřejné REST endpointy.
- **Zod schémata** definovat v `src/types/` a sdílet mezi Server Actions a klientem.
- **Prisma client** importovat výhradně ze `src/lib/db.ts` (singleton, zabraňuje memory leaks v dev).
- **Ceny** ukládat vždy v haléřích (integer), zobrazovat konverzí `/100` s formátováním.
- **Route groups** `(shop)` a `(admin)` oddělují layout a middleware ochranu — v `middleware.ts` chránit `/admin/*` rolí ADMIN.
## Doménový model (Prisma)
Klíčové vztahy:
- `Product``Category` (N:1), `ProductVariant[]` (1:N)
- `Cart``CartItem[]``ProductVariant` (guest přes `sessionId`, přihlášený přes `userId`)
- `Order``OrderItem[]` → snapshot ceny (`unitPrice`) v době objednávky
- `User``role: CUSTOMER | ADMIN`
## Prostředí (.env)
```
DATABASE_URL # PostgreSQL connection string
AUTH_SECRET # NextAuth secret (generuj: npx auth secret)
STRIPE_SECRET_KEY
STRIPE_PUBLISHABLE_KEY
STRIPE_WEBHOOK_SECRET
RESEND_API_KEY
NEXT_PUBLIC_APP_URL # http://localhost:3000 ve vývoji
```

25
components.json Normal file
View File

@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.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": {}
}

91
deploy/deploy.sh Normal file
View File

@@ -0,0 +1,91 @@
#!/usr/bin/env bash
# =============================================================================
# BistroUsky — deploy skript (aktualizace běžící instance)
# Spusť ze složky s kódem: bash deploy/deploy.sh
# =============================================================================
set -euo pipefail
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERR]${NC} $*"; exit 1; }
APP_DIR="/var/www/bistrousky"
APP_USER="bistrousky"
PM2_APP="bistrousky"
[[ $EUID -ne 0 ]] && error "Spusť jako root (sudo bash deploy/deploy.sh)"
# ── 0. Záloha databáze před deploym ──────────────────────────────────────
BACKUP_DIR="${APP_DIR}/backups"
mkdir -p "$BACKUP_DIR"
BACKUP_FILE="${BACKUP_DIR}/db_$(date +%Y%m%d_%H%M%S).sql.gz"
info "Záloha DB → ${BACKUP_FILE}..."
sudo -u postgres pg_dump bistrousky | gzip > "$BACKUP_FILE"
# Ponechat pouze posledních 10 záloh
ls -t "${BACKUP_DIR}"/db_*.sql.gz 2>/dev/null | tail -n +11 | xargs -r rm
# ── 1. Synchronizace kódu ────────────────────────────────────────────────
info "Kopírování kódu do ${APP_DIR}..."
rsync -a --delete \
--exclude='.env' \
--exclude='node_modules/' \
--exclude='.git/' \
--exclude='logs/' \
--exclude='backups/' \
./ "$APP_DIR/"
chown -R "$APP_USER:$APP_USER" "$APP_DIR"
# ── 2. Závislosti (všechny — dev i prod, potřebné pro build) ─────────────
info "Instalace závislostí..."
sudo -u "$APP_USER" bash -c "cd $APP_DIR && npm ci --quiet"
# ── 3. Prisma ─────────────────────────────────────────────────────────────
info "Generování Prisma klienta..."
sudo -u "$APP_USER" bash -c "cd $APP_DIR && npx prisma generate"
info "Aplikace DB migrací..."
sudo -u "$APP_USER" bash -c "cd $APP_DIR && npx prisma migrate deploy"
# ── 4. Build ──────────────────────────────────────────────────────────────
info "Produkční build (Next.js)..."
sudo -u "$APP_USER" bash -c "cd $APP_DIR && npm run build"
# ── 4b. Odstranit dev závislosti po buildu ────────────────────────────────
info "Odstraňování dev závislostí..."
sudo -u "$APP_USER" bash -c "cd $APP_DIR && npm prune --omit=dev --quiet"
# ── 4c. Statické soubory a .env do standalone ─────────────────────────────
info "Kopírování statiky a .env do standalone..."
cp -r "${APP_DIR}/.next/static" "${APP_DIR}/.next/standalone/.next/static"
cp -r "${APP_DIR}/public" "${APP_DIR}/.next/standalone/public"
cp "${APP_DIR}/.env" "${APP_DIR}/.next/standalone/.env"
# ── 5. Restart aplikace (zero-downtime přes PM2 reload) ───────────────────
info "Reloading PM2..."
mkdir -p "${APP_DIR}/logs"
chown "$APP_USER:$APP_USER" "${APP_DIR}/logs"
if sudo -u "$APP_USER" pm2 describe "$PM2_APP" &>/dev/null; then
sudo -u "$APP_USER" bash -c "cd $APP_DIR && pm2 reload ecosystem.config.js --env production"
else
sudo -u "$APP_USER" bash -c "cd $APP_DIR && pm2 start ecosystem.config.js --env production"
sudo -u "$APP_USER" pm2 save
fi
# ── 6. Zdravotní kontrola ────────────────────────────────────────────────
info "Čekám na nastartování aplikace..."
sleep 5
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3000 || echo "000")
if [[ "$HTTP_CODE" == "200" || "$HTTP_CODE" == "307" || "$HTTP_CODE" == "301" ]]; then
info "Aplikace běží (HTTP ${HTTP_CODE}) ✓"
else
error "Aplikace neodpovídá (HTTP ${HTTP_CODE}) — zkontroluj: pm2 logs ${PM2_APP}"
fi
echo ""
echo -e "${GREEN}=== Deploy dokončen ===${NC}"
echo -e " Stav: $(sudo -u $APP_USER pm2 jlist | python3 -c "import sys,json; apps=json.load(sys.stdin); [print(a['name'],a['pm2_env']['status']) for a in apps if a['name']=='${PM2_APP}']" 2>/dev/null || echo 'viz pm2 list')"
echo -e " Logy: ${YELLOW}pm2 logs ${PM2_APP}${NC}"
echo ""

183
deploy/install.sh Normal file
View File

@@ -0,0 +1,183 @@
#!/usr/bin/env bash
# =============================================================================
# BistroUsky — instalační skript pro LXC kontejner (Debian/Ubuntu)
# Spusť jako root: bash install.sh
# =============================================================================
set -euo pipefail
# ── Barvy pro výstup ──────────────────────────────────────────────────────
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERR]${NC} $*"; exit 1; }
# ── Konfigurace (uprav dle potřeby) ──────────────────────────────────────
APP_USER="bistrousky"
APP_DIR="/var/www/bistrousky"
NODE_VERSION="22"
DOMAIN="" # např. shop.bistrousky.cz — nech prázdné pro přeskočení Nginx SSL
DB_NAME="bistrousky"
DB_USER="bistrousky"
DB_PASS="" # bude vygenerováno automaticky, pokud prázdné
# ─────────────────────────────────────────────────────────────────────────────
[[ $EUID -ne 0 ]] && error "Spusť skript jako root (sudo bash install.sh)"
# ── Vygenerovat DB heslo pokud není zadáno ────────────────────────────────
if [[ -z "$DB_PASS" ]]; then
DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 32)
fi
info "=== BistroUsky instalace ==="
info "Systém: $(lsb_release -ds 2>/dev/null || cat /etc/os-release | grep PRETTY_NAME | cut -d= -f2)"
# ── 1. Systémové balíčky ──────────────────────────────────────────────────
info "Aktualizace systému a instalace závislostí..."
apt-get update -qq
apt-get install -y -qq \
curl wget git build-essential \
nginx certbot python3-certbot-nginx \
postgresql postgresql-contrib \
openssl ufw
# ── 2. Node.js ────────────────────────────────────────────────────────────
if ! command -v node &>/dev/null || [[ "$(node -v | cut -d. -f1 | tr -d v)" -lt "$NODE_VERSION" ]]; then
info "Instalace Node.js ${NODE_VERSION}..."
curl -fsSL "https://deb.nodesource.com/setup_${NODE_VERSION}.x" | bash -
apt-get install -y nodejs
else
info "Node.js $(node -v) již nainstalován."
fi
# ── 3. PM2 ────────────────────────────────────────────────────────────────
info "Instalace PM2..."
npm install -g pm2 --quiet
# ── 4. Systémový uživatel ─────────────────────────────────────────────────
if ! id "$APP_USER" &>/dev/null; then
info "Vytváření uživatele ${APP_USER}..."
useradd --system --shell /bin/bash --create-home --home-dir "$APP_DIR" "$APP_USER"
else
info "Uživatel ${APP_USER} již existuje."
fi
mkdir -p "$APP_DIR"
chown -R "$APP_USER:$APP_USER" "$APP_DIR"
# ── 5. PostgreSQL ─────────────────────────────────────────────────────────
info "Konfigurace PostgreSQL..."
systemctl enable postgresql --quiet
systemctl start postgresql
sudo -u postgres psql <<SQL
DO \$\$
BEGIN
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${DB_USER}') THEN
CREATE USER ${DB_USER} WITH PASSWORD '${DB_PASS}';
ELSE
ALTER USER ${DB_USER} WITH PASSWORD '${DB_PASS}';
END IF;
END
\$\$;
CREATE DATABASE IF NOT EXISTS ${DB_NAME} OWNER ${DB_USER};
GRANT ALL PRIVILEGES ON DATABASE ${DB_NAME} TO ${DB_USER};
SQL
# pozn: CREATE DATABASE IF NOT EXISTS není platná syntaxe v Postgres —
# použijeme || true
sudo -u postgres createdb --owner="$DB_USER" "$DB_NAME" 2>/dev/null || true
sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE ${DB_NAME} TO ${DB_USER};" 2>/dev/null || true
# ── 6. Aplikace ───────────────────────────────────────────────────────────
info "Nastavení aplikace v ${APP_DIR}..."
# .env soubor
ENV_FILE="${APP_DIR}/.env"
if [[ ! -f "$ENV_FILE" ]]; then
AUTH_SECRET=$(openssl rand -base64 32)
cat > "$ENV_FILE" <<ENV
DATABASE_URL="postgresql://${DB_USER}:${DB_PASS}@localhost:5432/${DB_NAME}?schema=public"
AUTH_SECRET="${AUTH_SECRET}"
STRIPE_SECRET_KEY=""
STRIPE_PUBLISHABLE_KEY=""
STRIPE_WEBHOOK_SECRET=""
RESEND_API_KEY=""
NEXT_PUBLIC_APP_URL="https://${DOMAIN:-localhost}"
ENV
chmod 600 "$ENV_FILE"
chown "$APP_USER:$APP_USER" "$ENV_FILE"
info ".env vytvořen: ${ENV_FILE}"
else
warn ".env již existuje, přeskakuji."
fi
# ── 7. Nginx ──────────────────────────────────────────────────────────────
info "Konfigurace Nginx..."
NGINX_CONF="/etc/nginx/sites-available/bistrousky"
cat > "$NGINX_CONF" <<NGINX
server {
listen 80;
server_name ${DOMAIN:-_};
# Přesměrování na HTTPS (aktivuje se po certbot)
# return 301 https://\$host\$request_uri;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_cache_bypass \$http_upgrade;
}
# Statické soubory servírovat přímo (volitelné, Next.js to zvládne samo)
location /_next/static/ {
proxy_pass http://127.0.0.1:3000;
add_header Cache-Control "public, max-age=31536000, immutable";
}
}
NGINX
ln -sf "$NGINX_CONF" /etc/nginx/sites-enabled/bistrousky
rm -f /etc/nginx/sites-enabled/default
nginx -t && systemctl enable nginx --quiet && systemctl restart nginx
# ── 8. Firewall ───────────────────────────────────────────────────────────
info "Konfigurace UFW firewallu..."
ufw --force enable
ufw allow OpenSSH
ufw allow "Nginx Full"
ufw reload
# ── 9. PM2 startup (automatický start po rebootu) ─────────────────────────
info "Nastavení PM2 startupu..."
env PATH="$PATH:/usr/bin" pm2 startup systemd -u "$APP_USER" --hp "$APP_DIR" | tail -1 | bash || true
# ── Výpis výsledků ────────────────────────────────────────────────────────
echo ""
echo -e "${GREEN}╔══════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ Instalace dokončena! ║${NC}"
echo -e "${GREEN}╚══════════════════════════════════════════════════╝${NC}"
echo ""
echo -e " DB uživatel : ${YELLOW}${DB_USER}${NC}"
echo -e " DB heslo : ${YELLOW}${DB_PASS}${NC} ← ulož si to!"
echo -e " DB jméno : ${YELLOW}${DB_NAME}${NC}"
echo -e " App složka : ${YELLOW}${APP_DIR}${NC}"
echo -e " .env soubor : ${YELLOW}${ENV_FILE}${NC}"
echo ""
echo -e " Další kroky:"
echo -e " 1. Nahraj kód do ${YELLOW}${APP_DIR}${NC}"
echo -e " 2. Doplň API klíče do ${YELLOW}${ENV_FILE}${NC}"
echo -e " 3. Spusť ${YELLOW}bash deploy.sh${NC}"
if [[ -n "$DOMAIN" ]]; then
echo -e " 4. SSL: ${YELLOW}certbot --nginx -d ${DOMAIN}${NC}"
fi
echo ""

View File

@@ -0,0 +1,95 @@
# /etc/nginx/sites-available/bistrousky
# Generováno install.sh — pro produkci uprav DOMAIN a aktivuj SSL blok
upstream bistrousky_app {
server 127.0.0.1:3000;
keepalive 64;
}
# ── HTTP → HTTPS přesměrování ─────────────────────────────────────────────
server {
listen 80;
listen [::]:80;
server_name shop.bistrousky.cz; # ← uprav na svou doménu
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
# ── HTTPS (aktivuj po certbot) ────────────────────────────────────────────
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name shop.bistrousky.cz; # ← uprav na svou doménu
# SSL certifikáty (generuje certbot)
ssl_certificate /etc/letsencrypt/live/shop.bistrousky.cz/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/shop.bistrousky.cz/privkey.pem;
ssl_trusted_certificate /etc/letsencrypt/live/shop.bistrousky.cz/chain.pem;
# Moderní SSL nastavení
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_stapling on;
ssl_stapling_verify on;
# Security headers
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Frame-Options SAMEORIGIN always;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Limity
client_max_body_size 10M;
# Logy
access_log /var/log/nginx/bistrousky.access.log;
error_log /var/log/nginx/bistrousky.error.log;
# ── Statické soubory Next.js (cache navždy — hash v názvu) ────────────
location /_next/static/ {
proxy_pass http://bistrousky_app;
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
}
# ── Veřejné soubory ───────────────────────────────────────────────────
location /favicon.ico {
proxy_pass http://bistrousky_app;
add_header Cache-Control "public, max-age=86400";
access_log off;
}
# ── Stripe webhook — zvýšený timeout ─────────────────────────────────
location /api/webhooks/ {
proxy_pass http://bistrousky_app;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 30s;
}
# ── Aplikace ─────────────────────────────────────────────────────────
location / {
proxy_pass http://bistrousky_app;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 15s;
}
}

37
ecosystem.config.js Normal file
View File

@@ -0,0 +1,37 @@
/** @type {import('pm2').StartOptions} */
module.exports = {
apps: [
{
name: "bistrousky",
script: ".next/standalone/server.js",
cwd: "/opt/eshop_bistrousky",
// Prostředí
env_production: {
NODE_ENV: "production",
PORT: 3000,
HOSTNAME: "127.0.0.1",
},
// Clustering — využije všechna jádra CPU
instances: "max",
exec_mode: "cluster",
// Automatický restart při pádu
autorestart: true,
watch: false,
max_restarts: 10,
restart_delay: 3000,
// Logy
out_file: "/opt/eshop_bistrousky/logs/out.log",
error_file: "/opt/eshop_bistrousky/logs/error.log",
merge_logs: true,
log_date_format: "YYYY-MM-DD HH:mm:ss",
// Graceful shutdown (čeká na dokončení požadavků)
kill_timeout: 5000,
listen_timeout: 10000,
},
],
};

View File

@@ -1,7 +1,14 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ output: "standalone",
// Na serveru s malou RAM přeskočíme TS kontrolu při buildu.
// Typy kontroluj lokálně: npx tsc --noEmit
typescript: {
ignoreBuildErrors: true,
},
}; };
export default nextConfig; export default nextConfig;

7213
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,26 +1,56 @@
{ {
"name": "eshop_temp", "name": "eshop-bistrousky",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "NODE_OPTIONS='--max-old-space-size=384' next build",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "eslint",
"test": "vitest",
"test:e2e": "playwright test",
"db:migrate": "prisma migrate dev",
"db:studio": "prisma studio",
"db:generate": "prisma generate",
"db:seed": "prisma db seed"
}, },
"dependencies": { "dependencies": {
"@auth/prisma-adapter": "^2.11.2",
"@prisma/client": "^6.19.3",
"@radix-ui/react-slot": "^1.1.1",
"@stripe/stripe-js": "^9.4.0",
"@tailwindcss/postcss": "^4",
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.14.0",
"next": "16.2.4", "next": "16.2.4",
"next-auth": "^5.0.0-beta.31",
"prisma": "^6.19.3",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4" "react-dom": "19.2.4",
"resend": "^6.12.2",
"shadcn": "^4.6.0",
"stripe": "^22.1.0",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"zod": "^4.4.2"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@playwright/test": "^1.59.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@vitejs/plugin-react": "^4.7.0",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.2.4", "eslint-config-next": "16.2.4",
"tailwindcss": "^4", "react-email": "^6.0.5",
"typescript": "^5" "ts-node": "^10.9.2",
"typescript": "^5",
"vitest": "^4.1.5"
} }
} }

19
prisma.config.ts Normal file
View File

@@ -0,0 +1,19 @@
// This file was generated by Prisma and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
engine: "classic",
datasource: {
url: env("DATABASE_URL"),
},
seed: {
run: "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts",
},
});

187
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,187 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ─── Auth (NextAuth v5) ────────────────────────────────────────────────────
model User {
id String @id @default(cuid())
name String?
email String @unique
emailVerified DateTime?
image String?
role Role @default(CUSTOMER)
password String?
accounts Account[]
sessions Session[]
orders Order[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String?
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model VerificationToken {
identifier String
token String
expires DateTime
@@unique([identifier, token])
}
enum Role {
CUSTOMER
ADMIN
}
// ─── Katalog ──────────────────────────────────────────────────────────────
model Category {
id String @id @default(cuid())
name String
slug String @unique
description String?
image String?
products Product[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Product {
id String @id @default(cuid())
name String
slug String @unique
description String?
image String?
published Boolean @default(false)
categoryId String
category Category @relation(fields: [categoryId], references: [id])
variants ProductVariant[]
orderItems OrderItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model ProductVariant {
id String @id @default(cuid())
name String
price Int // cena v haléřích (CZK)
stock Int @default(0)
sku String? @unique
productId String
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
cartItems CartItem[]
orderItems OrderItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// ─── Košík ────────────────────────────────────────────────────────────────
model Cart {
id String @id @default(cuid())
sessionId String? @unique // pro guest uživatele
userId String? @unique // pro přihlášené
items CartItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model CartItem {
id String @id @default(cuid())
cartId String
variantId String
quantity Int
cart Cart @relation(fields: [cartId], references: [id], onDelete: Cascade)
variant ProductVariant @relation(fields: [variantId], references: [id])
@@unique([cartId, variantId])
}
// ─── Objednávky ───────────────────────────────────────────────────────────
model Order {
id String @id @default(cuid())
userId String?
guestEmail String?
status OrderStatus @default(PENDING)
totalAmount Int // v haléřích
stripeSessionId String? @unique
shippingName String
shippingEmail String
shippingPhone String?
shippingStreet String
shippingCity String
shippingZip String
shippingCountry String @default("CZ")
user User? @relation(fields: [userId], references: [id])
items OrderItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model OrderItem {
id String @id @default(cuid())
orderId String
productId String
variantId String
quantity Int
unitPrice Int // cena v době objednávky (snapshot)
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
product Product @relation(fields: [productId], references: [id])
variant ProductVariant @relation(fields: [variantId], references: [id])
}
enum OrderStatus {
PENDING
PAID
PROCESSING
SHIPPED
DELIVERED
CANCELLED
REFUNDED
}

148
prisma/seed.ts Normal file
View File

@@ -0,0 +1,148 @@
import { PrismaClient } from "@prisma/client";
const db = new PrismaClient();
async function main() {
console.log("🌱 Seeding databáze...");
// Kategorie
const dorty = await db.category.upsert({
where: { slug: "dorty" },
update: {},
create: { name: "Dorty", slug: "dorty", description: "Celé dorty na objednávku i jako dárek" },
});
const zakusky = await db.category.upsert({
where: { slug: "zakusky" },
update: {},
create: { name: "Zákusky", slug: "zakusky", description: "Čerstvé zákusky pečené každý den" },
});
const pralinky = await db.category.upsert({
where: { slug: "pralinky" },
update: {},
create: { name: "Pralinky", slug: "pralinky", description: "Ručně vyráběné čokoládové pralinky" },
});
// Produkty
await db.product.upsert({
where: { slug: "cokoladovy-dort" },
update: {},
create: {
name: "Čokoládový dort",
slug: "cokoladovy-dort",
description: "Tříposchoďový dort z belgické čokolády s ganache polevou a čokoládovými hoblinami.",
published: true,
categoryId: dorty.id,
variants: {
create: [
{ name: "Malý (6 porcí)", price: 59000, stock: 5 },
{ name: "Střední (10 porcí)", price: 89000, stock: 3 },
{ name: "Velký (16 porcí)", price: 129000, stock: 2 },
],
},
},
});
await db.product.upsert({
where: { slug: "jahodovy-dort" },
update: {},
create: {
name: "Jahodový dort",
slug: "jahodovy-dort",
description: "Lehký piškotový dort s šlehačkou a čerstvými jahodami. Sezónní specialita.",
published: true,
categoryId: dorty.id,
variants: {
create: [
{ name: "Malý (6 porcí)", price: 65000, stock: 4 },
{ name: "Velký (12 porcí)", price: 119000, stock: 2 },
],
},
},
});
await db.product.upsert({
where: { slug: "kremovy-zakusek" },
update: {},
create: {
name: "Krémový zákusek",
slug: "kremovy-zakusek",
description: "Klasický kremrole s vanilkovým krémem a cukrovou polevou.",
published: true,
categoryId: zakusky.id,
variants: {
create: [
{ name: "1 kus", price: 4500, stock: 20 },
{ name: "Balení 6 kusů", price: 24000, stock: 10 },
],
},
},
});
await db.product.upsert({
where: { slug: "eclair-cokoladovy" },
update: {},
create: {
name: "Éclair čokoládový",
slug: "eclair-cokoladovy",
description: "Francouzský éclair z odpalovaného těsta s čokoládovým krémem.",
published: true,
categoryId: zakusky.id,
variants: {
create: [
{ name: "1 kus", price: 5500, stock: 15 },
{ name: "Balení 4 kusy", price: 20000, stock: 8 },
],
},
},
});
await db.product.upsert({
where: { slug: "pralinky-belgicke" },
update: {},
create: {
name: "Belgické pralinky",
slug: "pralinky-belgicke",
description: "Ručně vyráběné pralinky z hořké, mléčné a bílé belgické čokolády s různými náplněmi.",
published: true,
categoryId: pralinky.id,
variants: {
create: [
{ name: "Krabička 9 kusů", price: 29000, stock: 12 },
{ name: "Krabička 18 kusů", price: 55000, stock: 8 },
{ name: "Krabička 36 kusů", price: 99000, stock: 5 },
],
},
},
});
await db.product.upsert({
where: { slug: "makronky-mix" },
update: {},
create: {
name: "Makronky mix",
slug: "makronky-mix",
description: "Francouzské makronky v 6 příchutích: vanilka, malina, pistácie, citron, karamel, čokoláda.",
published: true,
categoryId: pralinky.id,
variants: {
create: [
{ name: "6 kusů (mix)", price: 22000, stock: 10 },
{ name: "12 kusů (mix)", price: 40000, stock: 6 },
],
},
},
});
console.log("✅ Seed dokončen!");
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await db.$disconnect();
});

80
src/actions/auth.ts Normal file
View File

@@ -0,0 +1,80 @@
"use server";
import { redirect } from "next/navigation";
import { z } from "zod";
import bcrypt from "bcryptjs";
import { db } from "@/lib/db";
import { signIn, signOut } from "@/lib/auth";
import { AuthError } from "next-auth";
// ── Registrace ────────────────────────────────────────────────────────────
const RegisterSchema = z.object({
name: z.string().min(2, "Jméno musí mít alespoň 2 znaky"),
email: z.string().email("Neplatný e-mail"),
password: z.string().min(8, "Heslo musí mít alespoň 8 znaků"),
});
export type AuthFormState = {
error?: string;
fieldErrors?: Record<string, string[]>;
};
export async function register(
_prev: AuthFormState,
formData: FormData
): Promise<AuthFormState> {
const parsed = RegisterSchema.safeParse({
name: formData.get("name"),
email: formData.get("email"),
password: formData.get("password"),
});
if (!parsed.success) {
return { fieldErrors: parsed.error.flatten().fieldErrors };
}
const { name, email, password } = parsed.data;
const existing = await db.user.findUnique({ where: { email } });
if (existing) {
return { fieldErrors: { email: ["Tento e-mail je již registrován"] } };
}
const hashedPassword = await bcrypt.hash(password, 12);
await db.user.create({
data: { name, email, password: hashedPassword },
});
// Automaticky přihlásit po registraci
await signIn("credentials", { email, password, redirectTo: "/" });
redirect("/");
}
// ── Přihlášení ────────────────────────────────────────────────────────────
export async function login(
_prev: AuthFormState,
formData: FormData
): Promise<AuthFormState> {
try {
await signIn("credentials", {
email: formData.get("email"),
password: formData.get("password"),
redirectTo: "/",
});
} catch (error) {
if (error instanceof AuthError) {
return { error: "Nesprávný e-mail nebo heslo" };
}
throw error;
}
redirect("/");
}
// ── Odhlášení ─────────────────────────────────────────────────────────────
export async function logout() {
await signOut({ redirectTo: "/" });
}

40
src/actions/cart.ts Normal file
View File

@@ -0,0 +1,40 @@
"use server";
import { revalidatePath } from "next/cache";
import { getCartItems, setCartItems } from "@/lib/cart";
export async function addToCart(variantId: string, quantity = 1) {
const items = await getCartItems();
const existing = items.find((i) => i.variantId === variantId);
if (existing) {
existing.quantity += quantity;
} else {
items.push({ variantId, quantity });
}
await setCartItems(items);
revalidatePath("/kosik");
}
export async function removeFromCart(variantId: string) {
const items = await getCartItems();
await setCartItems(items.filter((i) => i.variantId !== variantId));
revalidatePath("/kosik");
}
export async function updateQuantity(variantId: string, quantity: number) {
if (quantity < 1) {
return removeFromCart(variantId);
}
const items = await getCartItems();
const item = items.find((i) => i.variantId === variantId);
if (item) item.quantity = quantity;
await setCartItems(items);
revalidatePath("/kosik");
}
export async function clearCart() {
await setCartItems([]);
revalidatePath("/kosik");
}

102
src/actions/categories.ts Normal file
View File

@@ -0,0 +1,102 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { db } from "@/lib/db";
function slugify(text: string) {
return text
.toLowerCase()
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
const CategorySchema = z.object({
name: z.string().min(1, "Název je povinný"),
description: z.string().optional(),
image: z.string().url("Neplatná URL").optional().or(z.literal("")),
});
export type CategoryFormState = {
error?: string;
fieldErrors?: Record<string, string[]>;
};
export async function createCategory(
_prev: CategoryFormState,
formData: FormData
): Promise<CategoryFormState> {
const parsed = CategorySchema.safeParse({
name: formData.get("name"),
description: formData.get("description"),
image: formData.get("image"),
});
if (!parsed.success) {
return { fieldErrors: parsed.error.flatten().fieldErrors };
}
const { name, description, image } = parsed.data;
try {
await db.category.create({
data: {
name,
slug: slugify(name),
description: description || null,
image: image || null,
},
});
} catch {
return { error: "Kategorie se nepodařila vytvořit. Zkontroluj unikátnost názvu." };
}
revalidatePath("/admin/kategorie");
revalidatePath("/katalog");
redirect("/admin/kategorie");
}
export async function updateCategory(
id: string,
_prev: CategoryFormState,
formData: FormData
): Promise<CategoryFormState> {
const parsed = CategorySchema.safeParse({
name: formData.get("name"),
description: formData.get("description"),
image: formData.get("image"),
});
if (!parsed.success) {
return { fieldErrors: parsed.error.flatten().fieldErrors };
}
const { name, description, image } = parsed.data;
try {
await db.category.update({
where: { id },
data: {
name,
slug: slugify(name),
description: description || null,
image: image || null,
},
});
} catch {
return { error: "Kategorii se nepodařilo uložit." };
}
revalidatePath("/admin/kategorie");
revalidatePath("/katalog");
redirect("/admin/kategorie");
}
export async function deleteCategory(id: string) {
await db.category.delete({ where: { id } });
revalidatePath("/admin/kategorie");
revalidatePath("/katalog");
}

89
src/actions/orders.ts Normal file
View File

@@ -0,0 +1,89 @@
"use server";
import { redirect } from "next/navigation";
import { z } from "zod";
import { db } from "@/lib/db";
import { auth } from "@/lib/auth";
import { getCart, cartTotal, setCartItems } from "@/lib/cart";
const CheckoutSchema = z.object({
name: z.string().min(2, "Zadej jméno a příjmení"),
email: z.string().email("Neplatný e-mail"),
phone: z.string().optional(),
street: z.string().min(3, "Zadej ulici a číslo popisné"),
city: z.string().min(2, "Zadej město"),
zip: z.string().regex(/^\d{3}\s?\d{2}$/, "Neplatné PSČ (např. 110 00)"),
country: z.string().default("CZ"),
note: z.string().optional(),
});
export type CheckoutFormState = {
error?: string;
fieldErrors?: Record<string, string[]>;
};
export async function createOrder(
_prev: CheckoutFormState,
formData: FormData
): Promise<CheckoutFormState> {
const parsed = CheckoutSchema.safeParse({
name: formData.get("name"),
email: formData.get("email"),
phone: formData.get("phone"),
street: formData.get("street"),
city: formData.get("city"),
zip: formData.get("zip"),
country: formData.get("country") || "CZ",
note: formData.get("note"),
});
if (!parsed.success) {
return { fieldErrors: parsed.error.flatten().fieldErrors };
}
const items = await getCart();
if (items.length === 0) {
return { error: "Košík je prázdný." };
}
const session = await auth();
const total = cartTotal(items);
const order = await db.order.create({
data: {
userId: session?.user?.id ?? null,
guestEmail: session ? null : parsed.data.email,
status: "PENDING",
totalAmount: total,
shippingName: parsed.data.name,
shippingEmail: parsed.data.email,
shippingPhone: parsed.data.phone || null,
shippingStreet: parsed.data.street,
shippingCity: parsed.data.city,
shippingZip: parsed.data.zip.replace(/\s/g, ""),
shippingCountry: parsed.data.country,
items: {
create: items.map((item) => ({
productId: item.variant.product.id,
variantId: item.variantId,
quantity: item.quantity,
unitPrice: item.variant.price,
})),
},
},
});
// Vyprázdnit košík
await setCartItems([]);
redirect(`/objednavka/${order.id}`);
}
// ── Admin: změna stavu objednávky ─────────────────────────────────────────
export async function updateOrderStatus(id: string, status: string) {
await db.order.update({
where: { id },
data: { status: status as never },
});
}

163
src/actions/products.ts Normal file
View File

@@ -0,0 +1,163 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { db } from "@/lib/db";
// ── Helpers ───────────────────────────────────────────────────────────────
function slugify(text: string) {
return text
.toLowerCase()
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
// ── Schémata ──────────────────────────────────────────────────────────────
const VariantSchema = z.object({
id: z.string().optional(),
name: z.string().min(1, "Název varianty je povinný"),
price: z.coerce.number().min(0, "Cena musí být kladná"),
stock: z.coerce.number().int().min(0).default(0),
sku: z.string().optional(),
});
const ProductSchema = z.object({
name: z.string().min(1, "Název je povinný"),
description: z.string().optional(),
categoryId: z.string().min(1, "Kategorie je povinná"),
published: z.coerce.boolean().default(false),
image: z.string().url("Neplatná URL obrázku").optional().or(z.literal("")),
variants: z.array(VariantSchema).min(1, "Přidej alespoň jednu variantu"),
});
export type ProductFormState = {
error?: string;
fieldErrors?: Record<string, string[]>;
};
// ── Vytvořit produkt ──────────────────────────────────────────────────────
export async function createProduct(
_prev: ProductFormState,
formData: FormData
): Promise<ProductFormState> {
const raw = {
name: formData.get("name"),
description: formData.get("description"),
categoryId: formData.get("categoryId"),
published: formData.get("published") === "on",
image: formData.get("image"),
variants: JSON.parse((formData.get("variants") as string) ?? "[]"),
};
const parsed = ProductSchema.safeParse(raw);
if (!parsed.success) {
return { fieldErrors: parsed.error.flatten().fieldErrors };
}
const { name, description, categoryId, published, image, variants } = parsed.data;
const slug = slugify(name);
try {
await db.product.create({
data: {
name,
slug,
description: description || null,
categoryId,
published,
image: image || null,
variants: {
create: variants.map((v) => ({
name: v.name,
price: Math.round(v.price * 100), // Kč → haléře
stock: v.stock,
sku: v.sku || null,
})),
},
},
});
} catch {
return { error: "Produkt se nepodařilo vytvořit. Zkontroluj unikátnost názvu." };
}
revalidatePath("/katalog");
revalidatePath("/admin/produkty");
redirect("/admin/produkty");
}
// ── Upravit produkt ───────────────────────────────────────────────────────
export async function updateProduct(
id: string,
_prev: ProductFormState,
formData: FormData
): Promise<ProductFormState> {
const raw = {
name: formData.get("name"),
description: formData.get("description"),
categoryId: formData.get("categoryId"),
published: formData.get("published") === "on",
image: formData.get("image"),
variants: JSON.parse((formData.get("variants") as string) ?? "[]"),
};
const parsed = ProductSchema.safeParse(raw);
if (!parsed.success) {
return { fieldErrors: parsed.error.flatten().fieldErrors };
}
const { name, description, categoryId, published, image, variants } = parsed.data;
try {
// Smazat staré varianty a vytvořit nové
await db.$transaction([
db.productVariant.deleteMany({ where: { productId: id } }),
db.product.update({
where: { id },
data: {
name,
description: description || null,
categoryId,
published,
image: image || null,
variants: {
create: variants.map((v) => ({
name: v.name,
price: Math.round(v.price * 100),
stock: v.stock,
sku: v.sku || null,
})),
},
},
}),
]);
} catch {
return { error: "Produkt se nepodařilo uložit." };
}
revalidatePath("/katalog");
revalidatePath("/admin/produkty");
redirect("/admin/produkty");
}
// ── Smazat produkt ────────────────────────────────────────────────────────
export async function deleteProduct(id: string) {
await db.product.delete({ where: { id } });
revalidatePath("/katalog");
revalidatePath("/admin/produkty");
}
// ── Přepnout publikování ──────────────────────────────────────────────────
export async function togglePublished(id: string, published: boolean) {
await db.product.update({ where: { id }, data: { published } });
revalidatePath("/katalog");
revalidatePath("/admin/produkty");
}

View File

@@ -0,0 +1,67 @@
import { db } from "@/lib/db";
import { deleteCategory, createCategory } from "@/actions/categories";
import { CategoryInlineForm } from "@/components/admin/CategoryInlineForm";
import { DeleteButton } from "@/components/admin/DeleteButton";
export const metadata = { title: "Kategorie" };
export default async function AdminKategoriePage() {
const categories = await db.category.findMany({
include: { _count: { select: { products: true } } },
orderBy: { name: "asc" },
});
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-primary">Kategorie</h1>
{/* Formulář pro novou kategorii */}
<div className="p-5 rounded-xl border border-border bg-card space-y-3">
<h2 className="font-semibold text-sm">Nová kategorie</h2>
<CategoryInlineForm action={createCategory} />
</div>
{/* Seznam */}
<div className="rounded-xl border border-border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50 text-muted-foreground">
<tr>
<th className="text-left px-4 py-3 font-medium">Název</th>
<th className="text-left px-4 py-3 font-medium hidden sm:table-cell">Slug</th>
<th className="text-left px-4 py-3 font-medium">Produkty</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-border">
{categories.length === 0 && (
<tr>
<td colSpan={4} className="text-center py-10 text-muted-foreground">
Žádné kategorie.
</td>
</tr>
)}
{categories.map((cat) => (
<tr key={cat.id} className="hover:bg-muted/30 transition-colors">
<td className="px-4 py-3 font-medium">{cat.name}</td>
<td className="px-4 py-3 text-muted-foreground font-mono text-xs hidden sm:table-cell">
{cat.slug}
</td>
<td className="px-4 py-3 text-muted-foreground">{cat._count.products}</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
<DeleteButton
action={deleteCategory.bind(null, cat.id)}
confirm={`Smazat kategorii "${cat.name}"?`}
disabled={cat._count.products > 0}
title={cat._count.products > 0 ? "Nelze smazat kategorii s produkty" : "Smazat"}
/>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,140 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { ChevronLeft } from "lucide-react";
import { db } from "@/lib/db";
import { formatPrice } from "@/lib/utils";
import { updateOrderStatus } from "@/actions/orders";
import { revalidatePath } from "next/cache";
interface Props {
params: Promise<{ id: string }>;
}
export const metadata = { title: "Detail objednávky" };
const statusConfig: Record<string, { label: string; className: string }> = {
PENDING: { label: "Nová", className: "bg-amber-50 text-amber-700 border-amber-200" },
PAID: { label: "Zaplacena", className: "bg-blue-50 text-blue-700 border-blue-200" },
PROCESSING: { label: "Připravuje se", className: "bg-purple-50 text-purple-700 border-purple-200" },
SHIPPED: { label: "Odesláno", className: "bg-indigo-50 text-indigo-700 border-indigo-200" },
DELIVERED: { label: "Doručeno", className: "bg-green-50 text-green-700 border-green-200" },
CANCELLED: { label: "Zrušena", className: "bg-red-50 text-red-700 border-red-200" },
REFUNDED: { label: "Refundována", className: "bg-gray-50 text-gray-700 border-gray-200" },
};
const allStatuses = ["PENDING", "PROCESSING", "SHIPPED", "DELIVERED", "CANCELLED"];
export default async function AdminObjednavkaDetailPage({ params }: Props) {
const { id } = await params;
const order = await db.order.findUnique({
where: { id },
include: {
items: {
include: {
product: { select: { name: true, slug: true } },
variant: { select: { name: true } },
},
},
user: { select: { name: true, email: true } },
},
});
if (!order) notFound();
const status = statusConfig[order.status] ?? { label: order.status, className: "" };
async function changeStatus(formData: FormData) {
"use server";
const newStatus = formData.get("status") as string;
await updateOrderStatus(id, newStatus);
revalidatePath(`/admin/objednavky/${id}`);
revalidatePath("/admin/objednavky");
}
return (
<div className="space-y-6 max-w-3xl">
<div className="flex items-center gap-3">
<Link href="/admin/objednavky" className="text-muted-foreground hover:text-foreground transition-colors">
<ChevronLeft className="h-5 w-5" />
</Link>
<h1 className="text-2xl font-bold text-primary">
Objednávka #{order.id.slice(-8).toUpperCase()}
</h1>
<span className={`text-xs px-2.5 py-1 rounded-full font-medium border ${status.className}`}>
{status.label}
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Zákazník */}
<div className="p-5 rounded-xl border border-border bg-card space-y-2">
<h2 className="font-semibold text-sm text-muted-foreground uppercase tracking-wide">Zákazník</h2>
<p className="font-medium">{order.shippingName}</p>
<p className="text-sm text-muted-foreground">{order.shippingEmail}</p>
{order.shippingPhone && <p className="text-sm text-muted-foreground">{order.shippingPhone}</p>}
</div>
{/* Adresa */}
<div className="p-5 rounded-xl border border-border bg-card space-y-2">
<h2 className="font-semibold text-sm text-muted-foreground uppercase tracking-wide">Doručovací adresa</h2>
<p className="text-sm">{order.shippingStreet}</p>
<p className="text-sm">{order.shippingZip} {order.shippingCity}</p>
<p className="text-sm text-muted-foreground">{order.shippingCountry}</p>
</div>
</div>
{/* Položky */}
<div className="p-5 rounded-xl border border-border bg-card space-y-4">
<h2 className="font-semibold">Položky</h2>
<div className="divide-y divide-border">
{order.items.map((item) => (
<div key={item.id} className="flex justify-between py-3 text-sm">
<div>
<p className="font-medium">{item.product.name}</p>
<p className="text-xs text-muted-foreground">{item.variant.name} × {item.quantity}</p>
</div>
<span className="font-medium">{formatPrice(item.unitPrice * item.quantity)}</span>
</div>
))}
</div>
<div className="flex justify-between font-semibold border-t border-border pt-3">
<span>Celkem</span>
<span className="text-primary text-lg">{formatPrice(order.totalAmount)}</span>
</div>
<p className="text-sm text-muted-foreground">🚚 Platba dobírkou</p>
</div>
{/* Změna stavu */}
<div className="p-5 rounded-xl border border-border bg-card space-y-4">
<h2 className="font-semibold">Změna stavu</h2>
<form action={changeStatus} className="flex gap-3 flex-wrap items-end">
<div className="space-y-1 flex-1 min-w-40">
<label className="text-sm text-muted-foreground">Nový stav</label>
<select
name="status"
defaultValue={order.status}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
{allStatuses.map((s) => (
<option key={s} value={s}>{statusConfig[s]?.label ?? s}</option>
))}
</select>
</div>
<button
type="submit"
className="px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
>
Uložit stav
</button>
</form>
</div>
<div className="text-xs text-muted-foreground">
Vytvořena: {new Date(order.createdAt).toLocaleString("cs-CZ")}
{" · "}
Aktualizována: {new Date(order.updatedAt).toLocaleString("cs-CZ")}
</div>
</div>
);
}

View File

@@ -0,0 +1,88 @@
import Link from "next/link";
import { db } from "@/lib/db";
import { formatPrice } from "@/lib/utils";
export const metadata = { title: "Objednávky" };
const statusConfig: Record<string, { label: string; className: string }> = {
PENDING: { label: "Nová", className: "bg-amber-50 text-amber-700 border-amber-200" },
PAID: { label: "Zaplacena", className: "bg-blue-50 text-blue-700 border-blue-200" },
PROCESSING: { label: "Připravuje se", className: "bg-purple-50 text-purple-700 border-purple-200" },
SHIPPED: { label: "Odesláno", className: "bg-indigo-50 text-indigo-700 border-indigo-200" },
DELIVERED: { label: "Doručeno", className: "bg-green-50 text-green-700 border-green-200" },
CANCELLED: { label: "Zrušena", className: "bg-red-50 text-red-700 border-red-200" },
REFUNDED: { label: "Refundována", className: "bg-gray-50 text-gray-700 border-gray-200" },
};
export default async function AdminObjednavkyPage() {
const orders = await db.order.findMany({
include: {
items: { select: { id: true } },
user: { select: { name: true, email: true } },
},
orderBy: { createdAt: "desc" },
});
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-primary">Objednávky</h1>
<div className="rounded-xl border border-border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50 text-muted-foreground">
<tr>
<th className="text-left px-4 py-3 font-medium">Číslo</th>
<th className="text-left px-4 py-3 font-medium hidden md:table-cell">Zákazník</th>
<th className="text-left px-4 py-3 font-medium hidden sm:table-cell">Datum</th>
<th className="text-left px-4 py-3 font-medium">Celkem</th>
<th className="text-left px-4 py-3 font-medium">Stav</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-border">
{orders.length === 0 && (
<tr>
<td colSpan={6} className="text-center py-10 text-muted-foreground">
Zatím žádné objednávky.
</td>
</tr>
)}
{orders.map((order) => {
const status = statusConfig[order.status] ?? { label: order.status, className: "" };
return (
<tr key={order.id} className="hover:bg-muted/30 transition-colors">
<td className="px-4 py-3 font-mono text-xs">
#{order.id.slice(-8).toUpperCase()}
</td>
<td className="px-4 py-3 text-muted-foreground hidden md:table-cell">
{order.user?.name ?? order.shippingName}
<span className="block text-xs">{order.shippingEmail}</span>
</td>
<td className="px-4 py-3 text-muted-foreground hidden sm:table-cell">
{new Date(order.createdAt).toLocaleDateString("cs-CZ")}
</td>
<td className="px-4 py-3 font-medium">
{formatPrice(order.totalAmount)}
</td>
<td className="px-4 py-3">
<span className={`text-xs px-2 py-0.5 rounded-full font-medium border ${status.className}`}>
{status.label}
</span>
</td>
<td className="px-4 py-3 text-right">
<Link
href={`/admin/objednavky/${order.id}`}
className="text-xs text-primary underline underline-offset-4 hover:no-underline"
>
Detail
</Link>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,72 @@
import Link from "next/link";
import { db } from "@/lib/db";
import { Package, Tags, ShoppingBag, TrendingUp } from "lucide-react";
export const metadata = { title: "Admin — přehled" };
export default async function AdminDashboard() {
const [productCount, categoryCount, orderCount, revenue] = await Promise.all([
db.product.count(),
db.category.count(),
db.order.count(),
db.order.aggregate({
_sum: { totalAmount: true },
where: { status: { in: ["PAID", "PROCESSING", "SHIPPED", "DELIVERED"] } },
}),
]);
const totalRevenue = revenue._sum.totalAmount ?? 0;
const stats = [
{ label: "Produkty", value: productCount, icon: Package, href: "/admin/produkty" },
{ label: "Kategorie", value: categoryCount, icon: Tags, href: "/admin/kategorie" },
{ label: "Objednávky", value: orderCount, icon: ShoppingBag, href: "/admin/objednavky" },
{
label: "Tržby",
value: new Intl.NumberFormat("cs-CZ", { style: "currency", currency: "CZK", minimumFractionDigits: 0 }).format(totalRevenue / 100),
icon: TrendingUp,
href: "/admin/objednavky",
},
];
return (
<div className="space-y-8">
<h1 className="text-2xl font-bold text-primary">Přehled</h1>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
{stats.map(({ label, value, icon: Icon, href }) => (
<Link
key={label}
href={href}
className="flex items-center gap-4 p-5 rounded-xl border border-border bg-card hover:border-primary/30 hover:shadow-sm transition-all"
>
<div className="p-2.5 rounded-lg bg-primary/10">
<Icon className="h-5 w-5 text-primary" />
</div>
<div>
<p className="text-xs text-muted-foreground">{label}</p>
<p className="text-xl font-semibold">{value}</p>
</div>
</Link>
))}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Link
href="/admin/produkty/novy"
className="flex items-center gap-3 p-4 rounded-xl border border-dashed border-primary/40 text-primary hover:bg-primary/5 transition-colors"
>
<Package className="h-5 w-5" />
<span className="font-medium">Přidat nový produkt</span>
</Link>
<Link
href="/admin/kategorie"
className="flex items-center gap-3 p-4 rounded-xl border border-dashed border-primary/40 text-primary hover:bg-primary/5 transition-colors"
>
<Tags className="h-5 w-5" />
<span className="font-medium">Spravovat kategorie</span>
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,50 @@
import { notFound } from "next/navigation";
import { db } from "@/lib/db";
import { ProductForm } from "@/components/admin/ProductForm";
import { updateProduct } from "@/actions/products";
interface Props {
params: Promise<{ id: string }>;
}
export const metadata = { title: "Upravit produkt" };
export default async function UpravitProduktPage({ params }: Props) {
const { id } = await params;
const [product, categories] = await Promise.all([
db.product.findUnique({
where: { id },
include: { variants: { orderBy: { price: "asc" } } },
}),
db.category.findMany({ orderBy: { name: "asc" } }),
]);
if (!product) notFound();
const action = updateProduct.bind(null, id);
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-primary">Upravit produkt</h1>
<ProductForm
action={action}
categories={categories}
defaultValues={{
name: product.name,
description: product.description ?? "",
categoryId: product.categoryId,
published: product.published,
image: product.image ?? "",
variants: product.variants.map((v) => ({
id: v.id,
name: v.name,
price: v.price / 100, // haléře → Kč
stock: v.stock,
sku: v.sku ?? "",
})),
}}
/>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import { db } from "@/lib/db";
import { ProductForm } from "@/components/admin/ProductForm";
import { createProduct } from "@/actions/products";
export const metadata = { title: "Nový produkt" };
export default async function NovyProduktPage() {
const categories = await db.category.findMany({ orderBy: { name: "asc" } });
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-primary">Nový produkt</h1>
<ProductForm action={createProduct} categories={categories} />
</div>
);
}

View File

@@ -0,0 +1,99 @@
import Link from "next/link";
import { db } from "@/lib/db";
import { Plus, Pencil } from "lucide-react";
import { Button } from "@/components/ui/button";
import { togglePublished, deleteProduct } from "@/actions/products";
import { DeleteButton } from "@/components/admin/DeleteButton";
export const metadata = { title: "Produkty" };
function formatPrice(halers: number) {
return new Intl.NumberFormat("cs-CZ", {
style: "currency", currency: "CZK", minimumFractionDigits: 0,
}).format(halers / 100);
}
export default async function AdminProduktyPage() {
const products = await db.product.findMany({
include: {
category: { select: { name: true } },
variants: { select: { price: true }, orderBy: { price: "asc" } },
},
orderBy: { createdAt: "desc" },
});
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-primary">Produkty</h1>
<Button asChild>
<Link href="/admin/produkty/novy">
<Plus className="h-4 w-4" />
Nový produkt
</Link>
</Button>
</div>
<div className="rounded-xl border border-border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50 text-muted-foreground">
<tr>
<th className="text-left px-4 py-3 font-medium">Název</th>
<th className="text-left px-4 py-3 font-medium hidden md:table-cell">Kategorie</th>
<th className="text-left px-4 py-3 font-medium hidden sm:table-cell">Cena od</th>
<th className="text-left px-4 py-3 font-medium">Stav</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-border">
{products.length === 0 && (
<tr>
<td colSpan={5} className="text-center py-10 text-muted-foreground">
Žádné produkty. <Link href="/admin/produkty/novy" className="text-primary underline">Přidat první.</Link>
</td>
</tr>
)}
{products.map((product) => (
<tr key={product.id} className="hover:bg-muted/30 transition-colors">
<td className="px-4 py-3 font-medium">{product.name}</td>
<td className="px-4 py-3 text-muted-foreground hidden md:table-cell">
{product.category.name}
</td>
<td className="px-4 py-3 text-muted-foreground hidden sm:table-cell">
{product.variants[0] ? formatPrice(product.variants[0].price) : "—"}
</td>
<td className="px-4 py-3">
<form action={togglePublished.bind(null, product.id, !product.published)}>
<button
type="submit"
className={`text-xs px-2 py-0.5 rounded-full font-medium border transition-colors ${
product.published
? "bg-green-50 text-green-700 border-green-200 hover:bg-green-100"
: "bg-muted text-muted-foreground border-border hover:bg-muted/80"
}`}
>
{product.published ? "Publikováno" : "Skrytý"}
</button>
</form>
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
<Button variant="ghost" size="sm" asChild>
<Link href={`/admin/produkty/${product.id}/upravit`}>
<Pencil className="h-4 w-4" />
</Link>
</Button>
<DeleteButton
action={deleteProduct.bind(null, product.id)}
confirm={`Smazat "${product.name}"?`}
/>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,60 @@
import Link from "next/link";
import {
LayoutDashboard,
Package,
ShoppingBag,
Tags,
LogOut,
} from "lucide-react";
const navItems = [
{ href: "/admin", label: "Přehled", icon: LayoutDashboard },
{ href: "/admin/produkty", label: "Produkty", icon: Package },
{ href: "/admin/kategorie", label: "Kategorie", icon: Tags },
{ href: "/admin/objednavky", label: "Objednávky", icon: ShoppingBag },
];
export default function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen flex">
{/* Sidebar */}
<aside className="w-56 shrink-0 border-r border-border bg-sidebar flex flex-col">
<div className="h-16 flex items-center px-4 border-b border-border">
<Link href="/" className="flex items-center gap-2 font-semibold text-primary">
<span>🧁</span>
<span className="text-sm">BistroUsky</span>
</Link>
</div>
<nav className="flex-1 p-3 space-y-1">
{navItems.map(({ href, label, icon: Icon }) => (
<Link
key={href}
href={href}
className="flex items-center gap-3 px-3 py-2 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors"
>
<Icon className="h-4 w-4 shrink-0" />
{label}
</Link>
))}
</nav>
<div className="p-3 border-t border-border">
<button className="flex items-center gap-3 px-3 py-2 w-full rounded-md text-sm text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors">
<LogOut className="h-4 w-4" />
Odhlásit se
</button>
</div>
</aside>
{/* Obsah */}
<div className="flex-1 flex flex-col min-w-0">
<main className="flex-1 p-6">{children}</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,173 @@
"use client";
import { useActionState } from "react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { createOrder } from "@/actions/orders";
export default function CheckoutPage() {
const [state, formAction, pending] = useActionState(createOrder, {});
return (
<div className="max-w-4xl mx-auto px-4 py-10">
<h1 className="text-2xl font-bold text-primary mb-8">Dokončení objednávky</h1>
<form action={formAction} className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Adresní formulář */}
<div className="lg:col-span-2 space-y-5">
{state.error && (
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
{state.error}
</div>
)}
{/* Kontaktní údaje */}
<div className="p-5 rounded-xl border border-border bg-card space-y-4">
<h2 className="font-semibold">Kontaktní údaje</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Field
label="Jméno a příjmení *"
name="name"
autoComplete="name"
placeholder="Jan Novák"
error={state.fieldErrors?.name?.[0]}
/>
<Field
label="E-mail *"
name="email"
type="email"
autoComplete="email"
placeholder="jan@example.cz"
error={state.fieldErrors?.email?.[0]}
/>
</div>
<Field
label="Telefon"
name="phone"
type="tel"
autoComplete="tel"
placeholder="+420 123 456 789"
error={state.fieldErrors?.phone?.[0]}
/>
</div>
{/* Doručovací adresa */}
<div className="p-5 rounded-xl border border-border bg-card space-y-4">
<h2 className="font-semibold">Doručovací adresa</h2>
<Field
label="Ulice a číslo popisné *"
name="street"
autoComplete="street-address"
placeholder="Václavské náměstí 1"
error={state.fieldErrors?.street?.[0]}
/>
<div className="grid grid-cols-2 gap-4">
<Field
label="Město *"
name="city"
autoComplete="address-level2"
placeholder="Praha"
error={state.fieldErrors?.city?.[0]}
/>
<Field
label="PSČ *"
name="zip"
autoComplete="postal-code"
placeholder="110 00"
error={state.fieldErrors?.zip?.[0]}
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Stát</label>
<select
name="country"
defaultValue="CZ"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="CZ">Česká republika</option>
<option value="SK">Slovensko</option>
</select>
</div>
</div>
{/* Poznámka */}
<div className="p-5 rounded-xl border border-border bg-card space-y-3">
<h2 className="font-semibold">Poznámka k objednávce</h2>
<textarea
name="note"
rows={3}
placeholder="Např. zavolejte před doručením..."
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring resize-none"
/>
</div>
</div>
{/* Shrnutí + odeslat */}
<div className="space-y-4">
<div className="p-5 rounded-xl border border-border bg-card space-y-4 sticky top-20">
<h2 className="font-semibold">Platba a doprava</h2>
<div className="flex items-center gap-3 p-3 rounded-lg border-2 border-primary bg-primary/5">
<span className="text-xl">🚚</span>
<div>
<p className="text-sm font-medium">Dobírka</p>
<p className="text-xs text-muted-foreground">Platba při převzetí</p>
</div>
</div>
<p className="text-xs text-muted-foreground">
Objednávku zpracujeme do 12 pracovních dnů.
O odeslání budeme informovat e-mailem.
</p>
<Button type="submit" className="w-full" size="lg" disabled={pending}>
{pending ? "Odesílám..." : "Odeslat objednávku"}
</Button>
<Link
href="/kosik"
className="block text-center text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Zpět do košíku
</Link>
</div>
</div>
</form>
</div>
);
}
// ── Helper komponenta ─────────────────────────────────────────────────────
function Field({
label,
name,
type = "text",
placeholder,
autoComplete,
error,
}: {
label: string;
name: string;
type?: string;
placeholder?: string;
autoComplete?: string;
error?: string;
}) {
return (
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor={name}>{label}</label>
<input
id={name}
name={name}
type={type}
placeholder={placeholder}
autoComplete={autoComplete}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
);
}

View File

@@ -0,0 +1,88 @@
import { Suspense } from "react";
import { db } from "@/lib/db";
import { ProductCard } from "@/components/shop/ProductCard";
import { CategoryFilter } from "@/components/shop/CategoryFilter";
interface KatalogPageProps {
searchParams: Promise<{ kategorie?: string }>;
}
async function getProducts(categorySlug?: string) {
return db.product.findMany({
where: {
published: true,
...(categorySlug
? { category: { slug: categorySlug } }
: {}),
},
include: {
variants: {
select: { id: true, price: true },
orderBy: { price: "asc" },
},
},
orderBy: { createdAt: "desc" },
});
}
async function getCategories() {
return db.category.findMany({
orderBy: { name: "asc" },
});
}
export async function generateMetadata({ searchParams }: KatalogPageProps) {
const { kategorie } = await searchParams;
return {
title: kategorie ? `Kategorie: ${kategorie}` : "Katalog",
};
}
export default async function KatalogPage({ searchParams }: KatalogPageProps) {
const { kategorie } = await searchParams;
const [products, categories] = await Promise.all([
getProducts(kategorie),
getCategories(),
]);
return (
<div className="max-w-6xl mx-auto px-4 py-10">
{/* Nadpis */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-primary mb-1">Katalog</h1>
<p className="text-muted-foreground">
{products.length === 0
? "Žádné produkty k zobrazení"
: `${products.length} ${products.length === 1 ? "produkt" : products.length < 5 ? "produkty" : "produktů"}`}
</p>
</div>
{/* Filtr kategorií */}
{categories.length > 0 && (
<div className="mb-8">
<Suspense>
<CategoryFilter categories={categories} />
</Suspense>
</div>
)}
{/* Mřížka produktů */}
{products.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24 text-center gap-4">
<span className="text-6xl">🧁</span>
<p className="text-muted-foreground">
{kategorie
? "V této kategorii zatím nejsou žádné produkty."
: "Katalog je zatím prázdný."}
</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,132 @@
import Link from "next/link";
import Image from "next/image";
import { ShoppingCart, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { getCart, cartTotal } from "@/lib/cart";
import { formatPrice } from "@/lib/utils";
import { removeFromCart, updateQuantity } from "@/actions/cart";
export const metadata = { title: "Košík" };
export default async function KosikPage() {
const items = await getCart();
const total = cartTotal(items);
if (items.length === 0) {
return (
<div className="max-w-2xl mx-auto px-4 py-20 text-center space-y-4">
<ShoppingCart className="h-16 w-16 mx-auto text-muted-foreground/40" />
<h1 className="text-2xl font-bold">Košík je prázdný</h1>
<p className="text-muted-foreground">Přidej si něco dobrého 🧁</p>
<Button asChild>
<Link href="/katalog">Přejít do katalogu</Link>
</Button>
</div>
);
}
return (
<div className="max-w-4xl mx-auto px-4 py-10">
<h1 className="text-2xl font-bold text-primary mb-8">Košík</h1>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Položky */}
<div className="lg:col-span-2 space-y-3">
{items.map((item) => (
<div
key={item.variantId}
className="flex gap-4 p-4 rounded-xl border border-border bg-card"
>
{/* Obrázek */}
<Link
href={`/produkt/${item.variant.product.slug}`}
className="relative h-20 w-20 shrink-0 rounded-lg overflow-hidden bg-secondary/30"
>
{item.variant.product.image ? (
<Image
src={item.variant.product.image}
alt={item.variant.product.name}
fill
className="object-cover"
sizes="80px"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center text-2xl">🧁</div>
)}
</Link>
{/* Info */}
<div className="flex-1 min-w-0">
<Link
href={`/produkt/${item.variant.product.slug}`}
className="font-medium text-sm hover:text-primary transition-colors line-clamp-1"
>
{item.variant.product.name}
</Link>
<p className="text-xs text-muted-foreground mt-0.5">{item.variant.name}</p>
<p className="text-sm font-semibold text-primary mt-1">
{formatPrice(item.variant.price)}
</p>
</div>
{/* Množství + smazat */}
<div className="flex flex-col items-end gap-2">
<form action={removeFromCart.bind(null, item.variantId)}>
<button className="text-muted-foreground hover:text-destructive transition-colors p-1">
<Trash2 className="h-4 w-4" />
</button>
</form>
<div className="flex items-center border border-border rounded-lg overflow-hidden text-sm">
<form action={updateQuantity.bind(null, item.variantId, item.quantity - 1)}>
<button className="px-2 py-1 hover:bg-muted transition-colors"></button>
</form>
<span className="px-3 py-1 font-medium">{item.quantity}</span>
<form action={updateQuantity.bind(null, item.variantId, item.quantity + 1)}>
<button className="px-2 py-1 hover:bg-muted transition-colors">+</button>
</form>
</div>
<p className="text-xs text-muted-foreground">
{formatPrice(item.variant.price * item.quantity)}
</p>
</div>
</div>
))}
</div>
{/* Shrnutí */}
<div className="space-y-4">
<div className="p-5 rounded-xl border border-border bg-card space-y-4 sticky top-20">
<h2 className="font-semibold">Shrnutí objednávky</h2>
<div className="space-y-2 text-sm">
{items.map((item) => (
<div key={item.variantId} className="flex justify-between gap-2">
<span className="text-muted-foreground truncate">
{item.variant.product.name} × {item.quantity}
</span>
<span className="shrink-0">{formatPrice(item.variant.price * item.quantity)}</span>
</div>
))}
</div>
<div className="border-t border-border pt-3 flex justify-between font-semibold">
<span>Celkem</span>
<span className="text-primary text-lg">{formatPrice(total)}</span>
</div>
<Button asChild className="w-full" size="lg">
<Link href="/checkout">Pokračovat k objednávce</Link>
</Button>
<Link
href="/katalog"
className="block text-center text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Pokračovat v nákupu
</Link>
</div>
</div>
</div>
</div>
);
}

16
src/app/(shop)/layout.tsx Normal file
View File

@@ -0,0 +1,16 @@
import { Header } from "@/components/shop/Header";
import { Footer } from "@/components/shop/Footer";
export default function ShopLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
<Header />
<main className="flex-1">{children}</main>
<Footer />
</>
);
}

View File

@@ -0,0 +1,108 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { CheckCircle } from "lucide-react";
import { db } from "@/lib/db";
import { Button } from "@/components/ui/button";
import { formatPrice } from "@/lib/utils";
interface Props {
params: Promise<{ id: string }>;
}
const statusLabel: Record<string, string> = {
PENDING: "Přijata — čeká na zpracování",
PAID: "Zaplacena",
PROCESSING: "Připravuje se",
SHIPPED: "Odesláno",
DELIVERED: "Doručeno",
CANCELLED: "Zrušena",
REFUNDED: "Refundována",
};
export default async function ObjednavkaPage({ params }: Props) {
const { id } = await params;
const order = await db.order.findUnique({
where: { id },
include: {
items: {
include: {
product: { select: { name: true, slug: true } },
variant: { select: { name: true } },
},
},
},
});
if (!order) notFound();
return (
<div className="max-w-2xl mx-auto px-4 py-16 space-y-8">
{/* Potvrzení */}
<div className="text-center space-y-3">
<CheckCircle className="h-16 w-16 text-green-500 mx-auto" />
<h1 className="text-2xl font-bold text-primary">Děkujeme za objednávku!</h1>
<p className="text-muted-foreground">
Potvrzení jsme odeslali na <strong>{order.shippingEmail}</strong>.
<br />
Objednávku zpracujeme do 12 pracovních dnů.
</p>
</div>
{/* Detail objednávky */}
<div className="p-5 rounded-xl border border-border bg-card space-y-5">
<div className="flex items-center justify-between">
<h2 className="font-semibold">Objednávka</h2>
<span className="text-xs font-mono text-muted-foreground">#{order.id.slice(-8).toUpperCase()}</span>
</div>
<div className="text-sm">
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-amber-50 text-amber-700 border border-amber-200 text-xs font-medium">
{statusLabel[order.status] ?? order.status}
</span>
</div>
{/* Položky */}
<div className="divide-y divide-border">
{order.items.map((item) => (
<div key={item.id} className="flex justify-between py-3 text-sm">
<div>
<p className="font-medium">{item.product.name}</p>
<p className="text-xs text-muted-foreground">
{item.variant.name} × {item.quantity}
</p>
</div>
<span className="font-medium">{formatPrice(item.unitPrice * item.quantity)}</span>
</div>
))}
</div>
<div className="flex justify-between font-semibold border-t border-border pt-3">
<span>Celkem</span>
<span className="text-primary">{formatPrice(order.totalAmount)}</span>
</div>
{/* Adresa */}
<div className="text-sm text-muted-foreground space-y-0.5 border-t border-border pt-3">
<p className="font-medium text-foreground">Doručení na adresu</p>
<p>{order.shippingName}</p>
<p>{order.shippingStreet}</p>
<p>{order.shippingZip} {order.shippingCity}</p>
</div>
<div className="text-sm p-3 rounded-lg bg-secondary/50">
🚚 <strong>Platba dobírkou</strong> zaplatíš při převzetí zásilky.
</div>
</div>
<div className="flex gap-3 justify-center">
<Button asChild>
<Link href="/katalog">Pokračovat v nákupu</Link>
</Button>
<Button variant="outline" asChild>
<Link href="/">Domů</Link>
</Button>
</div>
</div>
);
}

50
src/app/(shop)/page.tsx Normal file
View File

@@ -0,0 +1,50 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
export default function HomePage() {
return (
<div className="max-w-6xl mx-auto px-4">
{/* Hero sekce */}
<section className="py-20 text-center space-y-6">
<h1 className="text-4xl md:text-5xl font-bold text-primary leading-tight">
Zákusky a cukrovinky<br />
<span className="text-accent-foreground">plné lásky</span>
</h1>
<p className="text-lg text-muted-foreground max-w-xl mx-auto">
Čerstvě pečené každý den. Dorty, pralinky a zákusky z těch nejlepších surovin.
</p>
<div className="flex gap-3 justify-center flex-wrap">
<Button asChild size="lg">
<Link href="/katalog">Prohlédnout nabídku</Link>
</Button>
<Button asChild size="lg" variant="outline">
<Link href="/katalog?kategorie=dorty">Naše dorty</Link>
</Button>
</div>
</section>
{/* Kategorie — placeholder, nahradíme reálnými daty */}
<section className="py-12">
<h2 className="text-2xl font-semibold mb-6">Kategorie</h2>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{[
{ name: "Dorty", emoji: "🎂", slug: "dorty" },
{ name: "Zákusky", emoji: "🧁", slug: "zakusky" },
{ name: "Pralinky", emoji: "🍫", slug: "pralinky" },
].map((cat) => (
<Link
key={cat.slug}
href={`/katalog?kategorie=${cat.slug}`}
className="group flex flex-col items-center justify-center gap-3 p-8 rounded-xl border border-border bg-card hover:bg-secondary/60 hover:border-primary/30 transition-all"
>
<span className="text-4xl">{cat.emoji}</span>
<span className="font-medium group-hover:text-primary transition-colors">
{cat.name}
</span>
</Link>
))}
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,64 @@
"use client";
import Link from "next/link";
import { useActionState } from "react";
import { Button } from "@/components/ui/button";
import { login } from "@/actions/auth";
export default function PrihlaseniPage() {
const [state, formAction, pending] = useActionState(login, {});
return (
<div className="min-h-[60vh] flex items-center justify-center px-4 py-16">
<div className="w-full max-w-sm space-y-6">
<div className="text-center space-y-1">
<h1 className="text-2xl font-bold text-primary">Přihlášení</h1>
<p className="text-sm text-muted-foreground">
Nemáš účet?{" "}
<Link href="/registrace" className="text-primary underline underline-offset-4">
Zaregistruj se
</Link>
</p>
</div>
<form action={formAction} className="space-y-4">
{state.error && (
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm text-center">
{state.error}
</div>
)}
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="email">E-mail</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="jan@example.cz"
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="password">Heslo</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="••••••••"
/>
</div>
<Button type="submit" className="w-full" disabled={pending}>
{pending ? "Přihlašuji..." : "Přihlásit se"}
</Button>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,96 @@
import Image from "next/image";
import Link from "next/link";
import { notFound } from "next/navigation";
import { ChevronLeft } from "lucide-react";
import { db } from "@/lib/db";
import { VariantSelector } from "@/components/shop/VariantSelector";
interface Props {
params: Promise<{ slug: string }>;
}
export async function generateMetadata({ params }: Props) {
const { slug } = await params;
const product = await db.product.findUnique({ where: { slug } });
if (!product) return {};
return {
title: product.name,
description: product.description ?? undefined,
};
}
export default async function ProduktDetailPage({ params }: Props) {
const { slug } = await params;
const product = await db.product.findUnique({
where: { slug, published: true },
include: {
category: { select: { name: true, slug: true } },
variants: { orderBy: { price: "asc" } },
},
});
if (!product) notFound();
return (
<div className="max-w-6xl mx-auto px-4 py-10">
{/* Drobečková navigace */}
<nav className="flex items-center gap-2 text-sm text-muted-foreground mb-8">
<Link href="/katalog" className="hover:text-foreground transition-colors">
Katalog
</Link>
<span>/</span>
<Link
href={`/katalog?kategorie=${product.category.slug}`}
className="hover:text-foreground transition-colors"
>
{product.category.name}
</Link>
<span>/</span>
<span className="text-foreground">{product.name}</span>
</nav>
<div className="grid grid-cols-1 md:grid-cols-2 gap-10 lg:gap-16">
{/* Obrázek */}
<div className="relative aspect-square rounded-2xl overflow-hidden bg-secondary/30">
{product.image ? (
<Image
src={product.image}
alt={product.name}
fill
className="object-cover"
sizes="(max-width: 768px) 100vw, 50vw"
priority
/>
) : (
<div className="absolute inset-0 flex items-center justify-center text-8xl">
🧁
</div>
)}
</div>
{/* Info + selector */}
<div className="space-y-6">
<div>
<p className="text-sm text-muted-foreground mb-1">{product.category.name}</p>
<h1 className="text-3xl font-bold text-primary">{product.name}</h1>
</div>
{product.description && (
<p className="text-muted-foreground leading-relaxed">{product.description}</p>
)}
<VariantSelector variants={product.variants} />
<Link
href="/katalog"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronLeft className="h-4 w-4" />
Zpět do katalogu
</Link>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,86 @@
"use client";
import Link from "next/link";
import { useActionState } from "react";
import { Button } from "@/components/ui/button";
import { register } from "@/actions/auth";
export default function RegistracePage() {
const [state, formAction, pending] = useActionState(register, {});
return (
<div className="min-h-[60vh] flex items-center justify-center px-4 py-16">
<div className="w-full max-w-sm space-y-6">
<div className="text-center space-y-1">
<h1 className="text-2xl font-bold text-primary">Registrace</h1>
<p className="text-sm text-muted-foreground">
máš účet?{" "}
<Link href="/prihlaseni" className="text-primary underline underline-offset-4">
Přihlás se
</Link>
</p>
</div>
<form action={formAction} className="space-y-4">
{state.error && (
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm text-center">
{state.error}
</div>
)}
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="name">Jméno</label>
<input
id="name"
name="name"
type="text"
autoComplete="name"
required
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="Jan Novák"
/>
{state.fieldErrors?.name && (
<p className="text-xs text-destructive">{state.fieldErrors.name[0]}</p>
)}
</div>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="email">E-mail</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="jan@example.cz"
/>
{state.fieldErrors?.email && (
<p className="text-xs text-destructive">{state.fieldErrors.email[0]}</p>
)}
</div>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="password">Heslo</label>
<input
id="password"
name="password"
type="password"
autoComplete="new-password"
required
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="Alespoň 8 znaků"
/>
{state.fieldErrors?.password && (
<p className="text-xs text-destructive">{state.fieldErrors.password[0]}</p>
)}
</div>
<Button type="submit" className="w-full" disabled={pending}>
{pending ? "Registruji..." : "Vytvořit účet"}
</Button>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,3 @@
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;

View File

@@ -1,26 +1,131 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
:root { @custom-variant dark (&:is(.dark *));
--background: #ffffff;
--foreground: #171717;
}
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans); --font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono); --font-mono: var(--font-geist-mono);
--font-heading: var(--font-sans);
--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);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
} }
@media (prefers-color-scheme: dark) {
:root { :root {
--background: #0a0a0a; /* Teplá krémová paleta — cukrárna BistroUsky */
--foreground: #ededed; --background: oklch(0.99 0.005 80); /* teplá bílá */
} --foreground: oklch(0.2 0.02 55); /* tmavě hnědá */
--card: oklch(1 0 0);
--card-foreground: oklch(0.2 0.02 55);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.2 0.02 55);
--primary: oklch(0.38 0.09 55); /* čokoládová hnědá */
--primary-foreground: oklch(0.98 0.005 80);
--secondary: oklch(0.95 0.015 75); /* teplá krémová */
--secondary-foreground: oklch(0.3 0.04 55);
--muted: oklch(0.95 0.01 75);
--muted-foreground: oklch(0.52 0.04 55);
--accent: oklch(0.82 0.08 20); /* růžová — jako glazura */
--accent-foreground: oklch(0.2 0.02 55);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.9 0.015 75);
--input: oklch(0.93 0.01 75);
--ring: oklch(0.38 0.09 55);
--chart-1: oklch(0.7 0.12 55);
--chart-2: oklch(0.6 0.1 20);
--chart-3: oklch(0.5 0.08 80);
--chart-4: oklch(0.65 0.09 140);
--chart-5: oklch(0.55 0.1 300);
--radius: 0.625rem;
--sidebar: oklch(0.97 0.01 75);
--sidebar-foreground: oklch(0.2 0.02 55);
--sidebar-primary: oklch(0.38 0.09 55);
--sidebar-primary-foreground: oklch(0.98 0.005 80);
--sidebar-accent: oklch(0.93 0.015 75);
--sidebar-accent-foreground: oklch(0.2 0.02 55);
--sidebar-border: oklch(0.9 0.015 75);
--sidebar-ring: oklch(0.38 0.09 55);
} }
body { .dark {
background: var(--background); --background: oklch(0.145 0 0);
color: var(--foreground); --foreground: oklch(0.985 0 0);
font-family: Arial, Helvetica, sans-serif; --card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
} }

View File

@@ -1,20 +1,18 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist } from "next/font/google";
import "./globals.css"; import "./globals.css";
const geistSans = Geist({ const geist = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
subsets: ["latin"], subsets: ["latin", "latin-ext"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: {
description: "Generated by create next app", default: "BistroUsky — zákusky a cukrovinky",
template: "%s | BistroUsky",
},
description: "Čerstvé zákusky, dorty a cukrovinky s láskou vyráběné každý den.",
}; };
export default function RootLayout({ export default function RootLayout({
@@ -23,11 +21,10 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html <html lang="cs" className={`${geist.variable} h-full antialiased`}>
lang="en" <body className="min-h-full flex flex-col bg-background text-foreground">
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} {children}
> </body>
<body className="min-h-full flex flex-col">{children}</body>
</html> </html>
); );
} }

View File

@@ -1,65 +0,0 @@
import Image from "next/image";
export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,41 @@
"use client";
import { useActionState } from "react";
import { Button } from "@/components/ui/button";
import type { CategoryFormState } from "@/actions/categories";
interface Props {
action: (prev: CategoryFormState, formData: FormData) => Promise<CategoryFormState>;
}
export function CategoryInlineForm({ action }: Props) {
const [state, formAction, pending] = useActionState(action, {});
return (
<form action={formAction} className="space-y-3">
{state.error && (
<p className="text-xs text-destructive">{state.error}</p>
)}
<div className="flex gap-3 flex-wrap">
<div className="flex-1 min-w-48 space-y-1">
<input
name="name"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="Název kategorie *"
/>
{state.fieldErrors?.name && (
<p className="text-xs text-destructive">{state.fieldErrors.name[0]}</p>
)}
</div>
<input
name="description"
className="flex-1 min-w-48 rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="Popis (volitelný)"
/>
<Button type="submit" disabled={pending} size="sm">
{pending ? "Ukládám..." : "Přidat"}
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,39 @@
"use client";
import { Button } from "@/components/ui/button";
interface DeleteButtonProps {
action: () => Promise<void>;
label?: string;
confirm?: string;
disabled?: boolean;
title?: string;
}
export function DeleteButton({
action,
label = "✕",
confirm: confirmMsg = "Opravdu smazat?",
disabled,
title,
}: DeleteButtonProps) {
return (
<form
action={async () => {
if (!window.confirm(confirmMsg)) return;
await action();
}}
>
<Button
type="submit"
variant="ghost"
size="sm"
disabled={disabled}
title={title}
className="text-destructive hover:text-destructive hover:bg-destructive/10 disabled:opacity-40"
>
{label}
</Button>
</form>
);
}

View File

@@ -0,0 +1,223 @@
"use client";
import { useActionState, useState } from "react";
import { Button } from "@/components/ui/button";
import { Plus, Trash2 } from "lucide-react";
import type { ProductFormState } from "@/actions/products";
interface Variant {
id?: string;
name: string;
price: number; // v Kč (ne haléře)
stock: number;
sku: string;
}
interface Category {
id: string;
name: string;
}
interface ProductFormProps {
action: (prev: ProductFormState, formData: FormData) => Promise<ProductFormState>;
categories: Category[];
defaultValues?: {
name?: string;
description?: string;
categoryId?: string;
published?: boolean;
image?: string;
variants?: Variant[];
};
}
const emptyVariant = (): Variant => ({ name: "", price: 0, stock: 0, sku: "" });
export function ProductForm({ action, categories, defaultValues }: ProductFormProps) {
const [state, formAction, pending] = useActionState(action, {});
const [variants, setVariants] = useState<Variant[]>(
defaultValues?.variants ?? [emptyVariant()]
);
function addVariant() {
setVariants((v) => [...v, emptyVariant()]);
}
function removeVariant(i: number) {
setVariants((v) => v.filter((_, idx) => idx !== i));
}
function updateVariant(i: number, field: keyof Variant, value: string | number) {
setVariants((v) => v.map((variant, idx) => idx === i ? { ...variant, [field]: value } : variant));
}
return (
<form action={formAction} className="space-y-6 max-w-2xl">
{/* Skrytý input pro varianty (JSON) */}
<input type="hidden" name="variants" value={JSON.stringify(variants)} />
{state.error && (
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
{state.error}
</div>
)}
{/* Základní informace */}
<div className="space-y-4 p-5 rounded-xl border border-border bg-card">
<h2 className="font-semibold">Základní informace</h2>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="name">Název *</label>
<input
id="name"
name="name"
defaultValue={defaultValues?.name}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="Čokoládový dort"
/>
{state.fieldErrors?.name && (
<p className="text-xs text-destructive">{state.fieldErrors.name[0]}</p>
)}
</div>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="description">Popis</label>
<textarea
id="description"
name="description"
defaultValue={defaultValues?.description ?? ""}
rows={3}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring resize-none"
placeholder="Krátký popis produktu..."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="categoryId">Kategorie *</label>
<select
id="categoryId"
name="categoryId"
defaultValue={defaultValues?.categoryId}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value=""> Vyber kategorii </option>
{categories.map((cat) => (
<option key={cat.id} value={cat.id}>{cat.name}</option>
))}
</select>
{state.fieldErrors?.categoryId && (
<p className="text-xs text-destructive">{state.fieldErrors.categoryId[0]}</p>
)}
</div>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="image">URL obrázku</label>
<input
id="image"
name="image"
type="url"
defaultValue={defaultValues?.image ?? ""}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="https://..."
/>
</div>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="published"
name="published"
defaultChecked={defaultValues?.published ?? false}
className="rounded"
/>
<label htmlFor="published" className="text-sm font-medium cursor-pointer">
Publikováno (viditelné v katalogu)
</label>
</div>
</div>
{/* Varianty */}
<div className="space-y-4 p-5 rounded-xl border border-border bg-card">
<div className="flex items-center justify-between">
<h2 className="font-semibold">Varianty *</h2>
<Button type="button" variant="outline" size="sm" onClick={addVariant}>
<Plus className="h-4 w-4" />
Přidat variantu
</Button>
</div>
{state.fieldErrors?.variants && (
<p className="text-xs text-destructive">{state.fieldErrors.variants[0]}</p>
)}
<div className="space-y-3">
{variants.map((variant, i) => (
<div key={i} className="grid grid-cols-12 gap-2 items-end p-3 rounded-lg bg-muted/40">
<div className="col-span-4 space-y-1">
<label className="text-xs text-muted-foreground">Název</label>
<input
value={variant.name}
onChange={(e) => updateVariant(i, "name", e.target.value)}
className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="Malý (6 porcí)"
/>
</div>
<div className="col-span-3 space-y-1">
<label className="text-xs text-muted-foreground">Cena ()</label>
<input
type="number"
min="0"
step="0.01"
value={variant.price}
onChange={(e) => updateVariant(i, "price", parseFloat(e.target.value) || 0)}
className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div className="col-span-2 space-y-1">
<label className="text-xs text-muted-foreground">Sklad</label>
<input
type="number"
min="0"
value={variant.stock}
onChange={(e) => updateVariant(i, "stock", parseInt(e.target.value) || 0)}
className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div className="col-span-2 space-y-1">
<label className="text-xs text-muted-foreground">SKU</label>
<input
value={variant.sku}
onChange={(e) => updateVariant(i, "sku", e.target.value)}
className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="opt."
/>
</div>
<div className="col-span-1 flex justify-end">
<button
type="button"
onClick={() => removeVariant(i)}
disabled={variants.length === 1}
className="p-1.5 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 disabled:opacity-30 transition-colors"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
))}
</div>
</div>
{/* Akce */}
<div className="flex gap-3">
<Button type="submit" disabled={pending}>
{pending ? "Ukládám..." : "Uložit produkt"}
</Button>
<Button type="button" variant="outline" onClick={() => history.back()}>
Zrušit
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,60 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { cn } from "@/lib/utils";
interface Category {
id: string;
name: string;
slug: string;
}
interface CategoryFilterProps {
categories: Category[];
}
export function CategoryFilter({ categories }: CategoryFilterProps) {
const router = useRouter();
const searchParams = useSearchParams();
const activeSlug = searchParams.get("kategorie");
function handleSelect(slug: string | null) {
const params = new URLSearchParams(searchParams.toString());
if (slug) {
params.set("kategorie", slug);
} else {
params.delete("kategorie");
}
router.push(`/katalog?${params.toString()}`);
}
return (
<div className="flex flex-wrap gap-2">
<button
onClick={() => handleSelect(null)}
className={cn(
"px-4 py-1.5 rounded-full text-sm font-medium border transition-colors",
!activeSlug
? "bg-primary text-primary-foreground border-primary"
: "bg-background border-border text-muted-foreground hover:border-primary/40 hover:text-foreground"
)}
>
Vše
</button>
{categories.map((cat) => (
<button
key={cat.id}
onClick={() => handleSelect(cat.slug)}
className={cn(
"px-4 py-1.5 rounded-full text-sm font-medium border transition-colors",
activeSlug === cat.slug
? "bg-primary text-primary-foreground border-primary"
: "bg-background border-border text-muted-foreground hover:border-primary/40 hover:text-foreground"
)}
>
{cat.name}
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,48 @@
import Link from "next/link";
export function Footer() {
return (
<footer className="mt-auto border-t border-border/60 bg-secondary/40">
<div className="max-w-6xl mx-auto px-4 py-10">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{/* Brand */}
<div className="space-y-2">
<div className="flex items-center gap-2 font-semibold text-lg text-primary">
<span>🧁</span>
<span>BistroUsky</span>
</div>
<p className="text-sm text-muted-foreground">
Čerstvé zákusky a cukrovinky<br />vyráběné s láskou každý den.
</p>
</div>
{/* Navigace */}
<div className="space-y-3">
<h3 className="text-sm font-semibold">Nabídka</h3>
<ul className="space-y-2 text-sm text-muted-foreground">
<li><Link href="/katalog" className="hover:text-foreground transition-colors">Celý katalog</Link></li>
<li><Link href="/katalog?kategorie=dorty" className="hover:text-foreground transition-colors">Dorty</Link></li>
<li><Link href="/katalog?kategorie=zakusky" className="hover:text-foreground transition-colors">Zákusky</Link></li>
<li><Link href="/katalog?kategorie=pralinky" className="hover:text-foreground transition-colors">Pralinky</Link></li>
</ul>
</div>
{/* Info */}
<div className="space-y-3">
<h3 className="text-sm font-semibold">Informace</h3>
<ul className="space-y-2 text-sm text-muted-foreground">
<li><Link href="/obchodni-podminky" className="hover:text-foreground transition-colors">Obchodní podmínky</Link></li>
<li><Link href="/ochrana-osobnich-udaju" className="hover:text-foreground transition-colors">Ochrana osobních údajů</Link></li>
<li><Link href="/doprava-a-platba" className="hover:text-foreground transition-colors">Doprava a platba</Link></li>
</ul>
</div>
</div>
<div className="mt-8 pt-6 border-t border-border/40 text-center text-xs text-muted-foreground">
© {new Date().getFullYear()} BistroUsky. Všechna práva vyhrazena.
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,85 @@
import Link from "next/link";
import { ShoppingCart, User, Menu, LogOut, LayoutDashboard } from "lucide-react";
import { Button } from "@/components/ui/button";
import { getCartItems } from "@/lib/cart";
import { auth } from "@/lib/auth";
import { logout } from "@/actions/auth";
export async function Header() {
const [cartItems, session] = await Promise.all([
getCartItems(),
auth(),
]);
const cartCount = cartItems.reduce((sum, i) => sum + i.quantity, 0);
const user = session?.user;
const isAdmin = user?.role === "ADMIN";
return (
<header className="sticky top-0 z-50 w-full border-b border-border/60 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80">
<div className="max-w-6xl mx-auto px-4 h-16 flex items-center justify-between gap-4">
{/* Logo */}
<Link href="/" className="flex items-center gap-2 font-semibold text-xl text-primary shrink-0">
<span className="text-2xl">🧁</span>
<span>BistroUsky</span>
</Link>
{/* Navigace — desktop */}
<nav className="hidden md:flex items-center gap-6 text-sm font-medium">
<Link href="/katalog" className="text-muted-foreground hover:text-foreground transition-colors">Katalog</Link>
<Link href="/katalog?kategorie=dorty" className="text-muted-foreground hover:text-foreground transition-colors">Dorty</Link>
<Link href="/katalog?kategorie=zakusky" className="text-muted-foreground hover:text-foreground transition-colors">Zákusky</Link>
<Link href="/katalog?kategorie=pralinky" className="text-muted-foreground hover:text-foreground transition-colors">Pralinky</Link>
</nav>
{/* Akce */}
<div className="flex items-center gap-1">
{/* Košík */}
<Button variant="ghost" size="icon" asChild className="relative">
<Link href="/kosik" aria-label={`Košík (${cartCount} položek)`}>
<ShoppingCart className="h-5 w-5" />
{cartCount > 0 && (
<span className="absolute -top-1 -right-1 h-4 w-4 rounded-full bg-primary text-primary-foreground text-[10px] font-bold flex items-center justify-center">
{cartCount > 9 ? "9+" : cartCount}
</span>
)}
</Link>
</Button>
{/* Přihlášený uživatel */}
{user ? (
<div className="hidden md:flex items-center gap-1">
{isAdmin && (
<Button variant="ghost" size="icon" asChild title="Admin">
<Link href="/admin">
<LayoutDashboard className="h-5 w-5" />
</Link>
</Button>
)}
<span className="text-sm text-muted-foreground px-2 max-w-[120px] truncate">
{user.name ?? user.email}
</span>
<form action={logout}>
<Button variant="ghost" size="icon" type="submit" title="Odhlásit se">
<LogOut className="h-5 w-5" />
</Button>
</form>
</div>
) : (
<Button variant="ghost" size="icon" asChild className="hidden md:inline-flex">
<Link href="/prihlaseni" aria-label="Přihlásit se">
<User className="h-5 w-5" />
</Link>
</Button>
)}
{/* Mobilní menu */}
<Button variant="ghost" size="icon" className="md:hidden" aria-label="Menu">
<Menu className="h-5 w-5" />
</Button>
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,86 @@
import Link from "next/link";
import Image from "next/image";
import { ShoppingCart } from "lucide-react";
import { Button } from "@/components/ui/button";
interface ProductCardProps {
product: {
id: string;
name: string;
slug: string;
image: string | null;
description: string | null;
variants: {
id: string;
price: number;
}[];
};
}
function formatPrice(halers: number) {
return new Intl.NumberFormat("cs-CZ", {
style: "currency",
currency: "CZK",
minimumFractionDigits: 0,
}).format(halers / 100);
}
export function ProductCard({ product }: ProductCardProps) {
const lowestPrice = product.variants.length > 0
? Math.min(...product.variants.map((v) => v.price))
: null;
return (
<div className="group flex flex-col rounded-xl border border-border bg-card overflow-hidden hover:shadow-md hover:border-primary/20 transition-all">
{/* Obrázek */}
<Link href={`/produkt/${product.slug}`} className="block relative aspect-square overflow-hidden bg-secondary/30">
{product.image ? (
<Image
src={product.image}
alt={product.name}
fill
className="object-cover group-hover:scale-105 transition-transform duration-300"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center text-5xl">
🧁
</div>
)}
</Link>
{/* Obsah */}
<div className="flex flex-col flex-1 p-4 gap-3">
<div className="flex-1">
<Link
href={`/produkt/${product.slug}`}
className="font-medium text-sm leading-tight hover:text-primary transition-colors line-clamp-2"
>
{product.name}
</Link>
{product.description && (
<p className="mt-1 text-xs text-muted-foreground line-clamp-2">
{product.description}
</p>
)}
</div>
<div className="flex items-center justify-between gap-2">
<span className="font-semibold text-primary">
{lowestPrice !== null ? (
<>od {formatPrice(lowestPrice)}</>
) : (
<span className="text-muted-foreground text-sm">Cena na dotaz</span>
)}
</span>
<Button size="sm" variant="outline" asChild>
<Link href={`/produkt/${product.slug}`}>
<ShoppingCart className="h-4 w-4" />
Vybrat
</Link>
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,110 @@
"use client";
import { useState } from "react";
import { ShoppingCart } from "lucide-react";
import { Button } from "@/components/ui/button";
import { addToCart } from "@/actions/cart";
import { formatPrice } from "@/lib/utils";
interface Variant {
id: string;
name: string;
price: number;
stock: number;
}
interface VariantSelectorProps {
variants: Variant[];
}
export function VariantSelector({ variants }: VariantSelectorProps) {
const [selectedId, setSelectedId] = useState(variants[0]?.id ?? "");
const [quantity, setQuantity] = useState(1);
const [adding, setAdding] = useState(false);
const [added, setAdded] = useState(false);
const selected = variants.find((v) => v.id === selectedId);
async function handleAddToCart() {
if (!selectedId) return;
setAdding(true);
await addToCart(selectedId, quantity);
setAdding(false);
setAdded(true);
setTimeout(() => setAdded(false), 2000);
}
return (
<div className="space-y-5">
{/* Varianty */}
{variants.length > 1 && (
<div className="space-y-2">
<p className="text-sm font-medium">Varianta</p>
<div className="flex flex-wrap gap-2">
{variants.map((v) => (
<button
key={v.id}
onClick={() => setSelectedId(v.id)}
disabled={v.stock === 0}
className={`px-4 py-2 rounded-lg border text-sm font-medium transition-all ${
selectedId === v.id
? "border-primary bg-primary text-primary-foreground"
: v.stock === 0
? "border-border text-muted-foreground opacity-50 cursor-not-allowed"
: "border-border hover:border-primary/50 hover:bg-secondary/50"
}`}
>
{v.name}
{v.stock === 0 && " (vyprodáno)"}
</button>
))}
</div>
</div>
)}
{/* Cena */}
{selected && (
<div className="text-3xl font-bold text-primary">
{formatPrice(selected.price)}
</div>
)}
{/* Množství + přidat */}
<div className="flex items-center gap-3">
<div className="flex items-center border border-border rounded-lg overflow-hidden">
<button
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
className="px-3 py-2 text-lg hover:bg-muted transition-colors"
>
</button>
<span className="px-4 py-2 text-sm font-medium min-w-[2.5rem] text-center">
{quantity}
</span>
<button
onClick={() => setQuantity((q) => Math.min(selected?.stock ?? 99, q + 1))}
className="px-3 py-2 text-lg hover:bg-muted transition-colors"
>
+
</button>
</div>
<Button
onClick={handleAddToCart}
disabled={adding || !selected || selected.stock === 0}
className="flex-1"
size="lg"
>
<ShoppingCart className="h-5 w-5" />
{added ? "Přidáno! ✓" : adding ? "Přidávám..." : "Přidat do košíku"}
</Button>
</div>
{selected && selected.stock > 0 && selected.stock <= 5 && (
<p className="text-sm text-amber-600">
Poslední {selected.stock} {selected.stock === 1 ? "kus" : "kusy"} skladem
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,59 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground hover:bg-primary/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
link:
"text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

56
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,56 @@
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { PrismaAdapter } from "@auth/prisma-adapter";
import bcrypt from "bcryptjs";
import { z } from "zod";
import { db } from "@/lib/db";
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(db),
session: { strategy: "jwt" },
trustHost: true,
pages: {
signIn: "/prihlaseni",
},
providers: [
Credentials({
credentials: {
email: { label: "E-mail", type: "email" },
password: { label: "Heslo", type: "password" },
},
async authorize(credentials) {
const parsed = z.object({
email: z.string().email(),
password: z.string().min(1),
}).safeParse(credentials);
if (!parsed.success) return null;
const user = await db.user.findUnique({
where: { email: parsed.data.email },
});
if (!user || !user.password) return null;
const valid = await bcrypt.compare(parsed.data.password, user.password);
if (!valid) return null;
return { id: user.id, email: user.email, name: user.name, role: user.role };
},
}),
],
callbacks: {
jwt({ token, user }) {
if (user) {
token.id = user.id;
token.role = (user as { role?: string }).role;
}
return token;
},
session({ session, token }) {
session.user.id = token.id as string;
session.user.role = token.role as string;
return session;
},
},
});

76
src/lib/cart.ts Normal file
View File

@@ -0,0 +1,76 @@
import { cookies } from "next/headers";
import { db } from "@/lib/db";
export const CART_COOKIE = "bistrousky_cart";
export interface CartItem {
variantId: string;
quantity: number;
}
// ── Čtení / zápis cookie ──────────────────────────────────────────────────
export async function getCartItems(): Promise<CartItem[]> {
const store = await cookies();
const raw = store.get(CART_COOKIE)?.value;
if (!raw) return [];
try {
return JSON.parse(raw) as CartItem[];
} catch {
return [];
}
}
export async function setCartItems(items: CartItem[]) {
const store = await cookies();
store.set(CART_COOKIE, JSON.stringify(items), {
httpOnly: true,
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 30, // 30 dní
});
}
// ── Košík s daty z DB ─────────────────────────────────────────────────────
export interface CartItemFull {
variantId: string;
quantity: number;
variant: {
id: string;
name: string;
price: number;
stock: number;
product: {
id: string;
name: string;
slug: string;
image: string | null;
};
};
}
export async function getCart(): Promise<CartItemFull[]> {
const items = await getCartItems();
if (items.length === 0) return [];
const variantIds = items.map((i) => i.variantId);
const variants = await db.productVariant.findMany({
where: { id: { in: variantIds } },
include: {
product: { select: { id: true, name: true, slug: true, image: true } },
},
});
return items
.map((item) => {
const variant = variants.find((v) => v.id === item.variantId);
if (!variant) return null;
return { variantId: item.variantId, quantity: item.quantity, variant };
})
.filter(Boolean) as CartItemFull[];
}
export function cartTotal(items: CartItemFull[]): number {
return items.reduce((sum, item) => sum + item.variant.price * item.quantity, 0);
}

8
src/lib/db.ts Normal file
View File

@@ -0,0 +1,8 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const db =
globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;

3
src/lib/resend.ts Normal file
View File

@@ -0,0 +1,3 @@
import { Resend } from "resend";
export const resend = new Resend(process.env.RESEND_API_KEY!);

5
src/lib/stripe.ts Normal file
View File

@@ -0,0 +1,5 @@
import Stripe from "stripe";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2026-04-22.dahlia",
});

14
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,14 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatPrice(halers: number): string {
return new Intl.NumberFormat("cs-CZ", {
style: "currency",
currency: "CZK",
minimumFractionDigits: 0,
}).format(halers / 100)
}

22
src/middleware.ts Normal file
View File

@@ -0,0 +1,22 @@
import { auth } from "@/lib/auth";
import { NextResponse } from "next/server";
export default auth((req) => {
const { pathname } = req.nextUrl;
// Ochrana admin sekce
if (pathname.startsWith("/admin")) {
if (!req.auth) {
return NextResponse.redirect(new URL("/prihlaseni", req.url));
}
if (req.auth.user?.role !== "ADMIN") {
return NextResponse.redirect(new URL("/", req.url));
}
}
return NextResponse.next();
});
export const config = {
matcher: ["/admin/:path*"],
};

10
src/types/next-auth.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
import { DefaultSession } from "next-auth";
declare module "next-auth" {
interface Session {
user: {
id: string;
role: string;
} & DefaultSession["user"];
}
}

16
vitest.config.ts Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import { resolve } from "path";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
setupFiles: ["./vitest.setup.ts"],
},
resolve: {
alias: {
"@": resolve(__dirname, "./src"),
},
},
});

1
vitest.setup.ts Normal file
View File

@@ -0,0 +1 @@
import "@testing-library/jest-dom";