Latest updarte

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,26 +1,131 @@
@import "tailwindcss";
@import "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;
:root {
/* 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);
}
.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 {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}

View File

@@ -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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

22
src/middleware.ts Normal file
View File

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

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

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