Latest updarte
This commit is contained in:
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;
|
||||
: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;
|
||||
}
|
||||
}
|
||||
@@ -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"];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user