Latest updarte
This commit is contained in:
11
.claude/settings.local.json
Normal file
11
.claude/settings.local.json
Normal 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
2
.gitignore
vendored
@@ -39,3 +39,5 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
/src/generated/prisma
|
||||
|
||||
100
CLAUDE.md
100
CLAUDE.md
@@ -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` má `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
25
components.json
Normal 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
91
deploy/deploy.sh
Normal 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
183
deploy/install.sh
Normal 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 ""
|
||||
95
deploy/nginx/bistrousky.conf
Normal file
95
deploy/nginx/bistrousky.conf
Normal 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
37
ecosystem.config.js
Normal 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,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,7 +1,14 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
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;
|
||||
|
||||
7213
package-lock.json
generated
7213
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
44
package.json
44
package.json
@@ -1,26 +1,56 @@
|
||||
{
|
||||
"name": "eshop_temp",
|
||||
"name": "eshop-bistrousky",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"build": "NODE_OPTIONS='--max-old-space-size=384' next build",
|
||||
"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": {
|
||||
"@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-auth": "^5.0.0-beta.31",
|
||||
"prisma": "^6.19.3",
|
||||
"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": {
|
||||
"@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/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.4",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
"react-email": "^6.0.5",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
19
prisma.config.ts
Normal file
19
prisma.config.ts
Normal 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
187
prisma/schema.prisma
Normal 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
148
prisma/seed.ts
Normal 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
80
src/actions/auth.ts
Normal 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
40
src/actions/cart.ts
Normal 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
102
src/actions/categories.ts
Normal 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
89
src/actions/orders.ts
Normal 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
163
src/actions/products.ts
Normal 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");
|
||||
}
|
||||
67
src/app/(admin)/admin/kategorie/page.tsx
Normal file
67
src/app/(admin)/admin/kategorie/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
140
src/app/(admin)/admin/objednavky/[id]/page.tsx
Normal file
140
src/app/(admin)/admin/objednavky/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
88
src/app/(admin)/admin/objednavky/page.tsx
Normal file
88
src/app/(admin)/admin/objednavky/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
src/app/(admin)/admin/page.tsx
Normal file
72
src/app/(admin)/admin/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
src/app/(admin)/admin/produkty/[id]/upravit/page.tsx
Normal file
50
src/app/(admin)/admin/produkty/[id]/upravit/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
src/app/(admin)/admin/produkty/novy/page.tsx
Normal file
16
src/app/(admin)/admin/produkty/novy/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
99
src/app/(admin)/admin/produkty/page.tsx
Normal file
99
src/app/(admin)/admin/produkty/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
src/app/(admin)/layout.tsx
Normal file
60
src/app/(admin)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
173
src/app/(shop)/checkout/page.tsx
Normal file
173
src/app/(shop)/checkout/page.tsx
Normal 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 1–2 pracovních dnů.
|
||||
O odeslání tě 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>
|
||||
);
|
||||
}
|
||||
88
src/app/(shop)/katalog/page.tsx
Normal file
88
src/app/(shop)/katalog/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
132
src/app/(shop)/kosik/page.tsx
Normal file
132
src/app/(shop)/kosik/page.tsx
Normal 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
16
src/app/(shop)/layout.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
108
src/app/(shop)/objednavka/[id]/page.tsx
Normal file
108
src/app/(shop)/objednavka/[id]/page.tsx
Normal 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 1–2 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
50
src/app/(shop)/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
src/app/(shop)/prihlaseni/page.tsx
Normal file
64
src/app/(shop)/prihlaseni/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
96
src/app/(shop)/produkt/[slug]/page.tsx
Normal file
96
src/app/(shop)/produkt/[slug]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
src/app/(shop)/registrace/page.tsx
Normal file
86
src/app/(shop)/registrace/page.tsx
Normal 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">
|
||||
Už 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>
|
||||
);
|
||||
}
|
||||
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { handlers } from "@/lib/auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
@@ -1,26 +1,131 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-sans: var(--font-sans);
|
||||
--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 {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
/* Teplá krémová paleta — cukrárna BistroUsky */
|
||||
--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 {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--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;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,18 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Geist } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
const geist = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
subsets: ["latin", "latin-ext"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: {
|
||||
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({
|
||||
@@ -23,11 +21,10 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
<html lang="cs" className={`${geist.variable} h-full antialiased`}>
|
||||
<body className="min-h-full flex flex-col bg-background text-foreground">
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
41
src/components/admin/CategoryInlineForm.tsx
Normal file
41
src/components/admin/CategoryInlineForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
src/components/admin/DeleteButton.tsx
Normal file
39
src/components/admin/DeleteButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
223
src/components/admin/ProductForm.tsx
Normal file
223
src/components/admin/ProductForm.tsx
Normal 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 (Kč)</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>
|
||||
);
|
||||
}
|
||||
60
src/components/shop/CategoryFilter.tsx
Normal file
60
src/components/shop/CategoryFilter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
src/components/shop/Footer.tsx
Normal file
48
src/components/shop/Footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
85
src/components/shop/Header.tsx
Normal file
85
src/components/shop/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
src/components/shop/ProductCard.tsx
Normal file
86
src/components/shop/ProductCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
src/components/shop/VariantSelector.tsx
Normal file
110
src/components/shop/VariantSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
src/components/ui/button.tsx
Normal file
59
src/components/ui/button.tsx
Normal 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
56
src/lib/auth.ts
Normal 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
76
src/lib/cart.ts
Normal 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
8
src/lib/db.ts
Normal 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
3
src/lib/resend.ts
Normal 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
5
src/lib/stripe.ts
Normal 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
14
src/lib/utils.ts
Normal 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
22
src/middleware.ts
Normal 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
10
src/types/next-auth.d.ts
vendored
Normal 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
16
vitest.config.ts
Normal 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
1
vitest.setup.ts
Normal file
@@ -0,0 +1 @@
|
||||
import "@testing-library/jest-dom";
|
||||
Reference in New Issue
Block a user