Add category level and turn the reticle into a background watermark
CI / test (push) Successful in 1m12s
CI / build-and-push (push) Successful in 2m53s

This commit is contained in:
2026-08-02 19:15:20 +02:00
parent 20beeeb420
commit 87c8f27e73
14 changed files with 464 additions and 63 deletions
+12 -5
View File
@@ -21,11 +21,18 @@ verzování [sémantické](https://semver.org/lang/cs/).
- Proměnná `CSRF_ORIGINS` pro případy, kdy se admin otevírá i z jiné adresy,
než je `NEXT_PUBLIC_SERVER_URL` (typicky po IP v LAN). Bez ní Payload
odmítal veškeré zápisy včetně nahrávání obrázků chybou 403.
- Grafika: silueta lesa přes celou šířku nad patičkou a terč v hlavičce
mezi značkou a navigací. Obojí je vloženo jako CSS maska — zdrojové PNG
nese jen průhlednost a barvu řídí CSS, takže to funguje ve světlém
i tmavém motivu. Výška siluety se odvozuje z poměru stran obrázku, aby
se neořezávaly špičky stromů.
- Kategorie jako vlastní kolekce s názvem, pořadím, úvodním obrázkem
a popisem. Položka na ni odkazuje vazbou místo dřívějšího pevného výběru,
takže rozšíření sortimentu nevyžaduje zásah do kódu.
- Tříúrovňová nabídka: úvodní stránka vypisuje kategorie jako dlaždice
s počtem položek, `/kategorie/<slug>` zboží v kategorii a detail zůstává
na `/nabidka/<slug>`. Plochá adresa detailu je záměr — přeřazení položky
do jiné kategorie tak nerozbije odkazy.
- Grafika: silueta lesa přes celou šířku nad patičkou a terč jako velký
nehybný vodoznak v pozadí stránky. Obojí je vloženo jako CSS maska —
zdrojové PNG nese jen průhlednost a barvu řídí CSS, takže to funguje
ve světlém i tmavém motivu. Výška siluety se odvozuje z poměru stran
obrázku, aby se neořezávaly špičky stromů.
### Opraveno
+73
View File
@@ -0,0 +1,73 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-sqlite'
/**
* Kategorie se z pevného výběru stávají vlastní kolekcí.
*
* Psáno ručně, protože generátor se u přechodu `kategorie` (text) →
* `kategorie_id` (vazba) interaktivně ptá, jestli jde o přejmenování sloupce,
* nebo o nový. Jde o nový — a starý se převede na vazbu, ať položky nepřijdou
* o zařazení.
*/
export async function up({ db }: MigrateUpArgs): Promise<void> {
await db.run(sql`CREATE TABLE \`categories\` (
\`id\` integer PRIMARY KEY NOT NULL,
\`nazev\` text NOT NULL,
\`slug\` text,
\`poradi\` numeric DEFAULT 0,
\`obrazek_id\` integer,
\`popis\` text,
\`updated_at\` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL,
\`created_at\` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL,
FOREIGN KEY (\`obrazek_id\`) REFERENCES \`media\`(\`id\`) ON UPDATE no action ON DELETE set null
);
`)
await db.run(sql`CREATE UNIQUE INDEX \`categories_slug_idx\` ON \`categories\` (\`slug\`);`)
await db.run(sql`CREATE INDEX \`categories_obrazek_idx\` ON \`categories\` (\`obrazek_id\`);`)
await db.run(sql`CREATE INDEX \`categories_updated_at_idx\` ON \`categories\` (\`updated_at\`);`)
await db.run(sql`CREATE INDEX \`categories_created_at_idx\` ON \`categories\` (\`created_at\`);`)
// Tři kategorie, které dosud byly natvrdo v kódu. Na prázdné databázi
// slouží jako výchozí obsah, na existující jako cíl převodu.
await db.run(sql`INSERT INTO \`categories\` (\`nazev\`, \`slug\`, \`poradi\`) VALUES
('Zbraně', 'zbrane', 1),
('Střelivo', 'strelivo', 2),
('Doplňky', 'doplnky', 3);`)
await db.run(sql`ALTER TABLE \`products\` ADD \`kategorie_id\` integer REFERENCES categories(id);`)
await db.run(sql`CREATE INDEX \`products_kategorie_idx\` ON \`products\` (\`kategorie_id\`);`)
// Převod původní textové hodnoty na vazbu.
await db.run(sql`UPDATE \`products\` SET \`kategorie_id\` = (
SELECT \`id\` FROM \`categories\` WHERE \`categories\`.\`slug\` = \`products\`.\`kategorie\`
);`)
await db.run(sql`ALTER TABLE \`products\` DROP COLUMN \`kategorie\`;`)
// Payload si sem ukládá zámky rozepsaných dokumentů; bez sloupce by
// administrace u kategorií hlásila chybu.
await db.run(
sql`ALTER TABLE \`payload_locked_documents_rels\` ADD \`categories_id\` integer REFERENCES categories(id);`,
)
await db.run(
sql`CREATE INDEX \`payload_locked_documents_rels_categories_id_idx\` ON \`payload_locked_documents_rels\` (\`categories_id\`);`,
)
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
await db.run(
sql`ALTER TABLE \`products\` ADD \`kategorie\` text DEFAULT 'zbrane' NOT NULL;`,
)
await db.run(sql`UPDATE \`products\` SET \`kategorie\` = COALESCE((
SELECT \`slug\` FROM \`categories\` WHERE \`categories\`.\`id\` = \`products\`.\`kategorie_id\`
), 'zbrane');`)
await db.run(sql`DROP INDEX IF EXISTS \`products_kategorie_idx\`;`)
await db.run(sql`ALTER TABLE \`products\` DROP COLUMN \`kategorie_id\`;`)
await db.run(sql`DROP INDEX IF EXISTS \`payload_locked_documents_rels_categories_id_idx\`;`)
await db.run(
sql`ALTER TABLE \`payload_locked_documents_rels\` DROP COLUMN \`categories_id\`;`,
)
await db.run(sql`DROP TABLE \`categories\`;`)
}
+6
View File
@@ -1,4 +1,5 @@
import * as migration_20260730_102034_initial from './20260730_102034_initial';
import * as migration_20260731_180000_kategorie from './20260731_180000_kategorie';
export const migrations = [
{
@@ -6,4 +7,9 @@ export const migrations = [
down: migration_20260730_102034_initial.down,
name: '20260730_102034_initial'
},
{
up: migration_20260731_180000_kategorie.up,
down: migration_20260731_180000_kategorie.down,
name: '20260731_180000_kategorie'
},
];
@@ -0,0 +1,75 @@
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { getPayload } from 'payload'
import React from 'react'
import config from '@/payload.config'
import type { Category } from '@/payload-types'
import { KartaPolozky } from '@/components/KartaPolozky'
import '../../styles.css'
export const dynamic = 'force-dynamic'
type Props = { params: Promise<{ slug: string }> }
export async function generateMetadata({ params }: Props) {
const kategorie = await najdiKategorii((await params).slug)
if (!kategorie) return { title: 'Kategorie nenalezena' }
return {
title: `${kategorie.nazev} — Reiner zbraně`,
description: kategorie.popis ?? undefined,
}
}
/** Výpis zboží v jedné kategorii. */
export default async function StrankaKategorie({ params }: Props) {
const kategorie = await najdiKategorii((await params).slug)
if (!kategorie) notFound()
const payload = await getPayload({ config: await config })
const { docs: polozky } = await payload.find({
collection: 'products',
where: {
kategorie: { equals: kategorie.id },
skryto: { not_equals: true },
},
sort: ['stav', '-updatedAt'],
depth: 1,
limit: 100,
})
return (
<div className="stranka">
<p className="zpet">
<Link href="/"> Zpět na nabídku</Link>
</p>
<h1>{kategorie.nazev}</h1>
{kategorie.popis && <p className="uvodni-text">{kategorie.popis}</p>}
{polozky.length === 0 ? (
<p className="prazdno">V této kategorii zatím nic není.</p>
) : (
<ul className="mrizka">
{polozky.map((polozka) => (
<KartaPolozky key={polozka.id} polozka={polozka} />
))}
</ul>
)}
</div>
)
}
async function najdiKategorii(slug: string): Promise<Category | null> {
const payload = await getPayload({ config: await config })
const { docs } = await payload.find({
collection: 'categories',
where: { slug: { equals: slug } },
depth: 0,
limit: 1,
})
return docs[0] ?? null
}
+4 -1
View File
@@ -30,11 +30,14 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
return (
<html lang="cs">
<body>
{/* Terč jako nehybný vodoznak — zůstává na místě i při rolování
a je pod obsahem, takže nepřekáží čtení. */}
<div className="terc-pozadi" aria-hidden="true" />
<header className="hlavicka">
<Link className="znacka" href="/">
Reiner <span>zbraně a střelivo</span>
</Link>
<div className="terc" aria-hidden="true" />
<nav>
<Link href="/">Nabídka</Link>
{stranky.map((stranka) => (
+7 -1
View File
@@ -34,7 +34,13 @@ export default async function DetailPolozky({ params }: Props) {
return (
<article className="stranka detail">
<p className="zpet">
<Link href="/"> Zpět na nabídku</Link>
{typeof polozka.kategorie === 'object' && polozka.kategorie !== null ? (
<Link href={`/kategorie/${polozka.kategorie.slug}`}>
Zpět na {polozka.kategorie.nazev}
</Link>
) : (
<Link href="/"> Zpět na nabídku</Link>
)}
</p>
<h1>{polozka.nazev}</h1>
+57 -33
View File
@@ -4,38 +4,59 @@ import { getPayload } from 'payload'
import React from 'react'
import config from '@/payload.config'
import type { Media, Product } from '@/payload-types'
import { cestaKSouboru, formatujCenu, popisStavu } from '@/lib/format'
import type { Category, Media } from '@/payload-types'
import { cestaKSouboru } from '@/lib/format'
import './styles.css'
// Obsah se čte z CMS při každém požadavku — jinak by se úprava položky
// projevila až po přestavění webu.
export const dynamic = 'force-dynamic'
/** Výpis nabídky. Skryté položky se nezobrazují, prodané se řadí nakonec. */
/** Úvodní stránka: rozcestník po kategoriích nabídky. */
export default async function HomePage() {
const payload = await getPayload({ config: await config })
const { docs: polozky } = await payload.find({
collection: 'products',
where: { skryto: { not_equals: true } },
sort: ['stav', '-updatedAt'],
const { docs: kategorie } = await payload.find({
collection: 'categories',
sort: ['poradi', 'nazev'],
depth: 1,
limit: 100,
limit: 50,
})
// Kolik položek je v které kategorii — ať dlaždice neslibuje prázdný výpis.
const pocty = await Promise.all(
kategorie.map(async (k) => {
const { totalDocs } = await payload.find({
collection: 'products',
where: {
kategorie: { equals: k.id },
skryto: { not_equals: true },
},
limit: 0,
depth: 0,
})
return [k.id, totalDocs] as const
}),
)
const pocetVKategorii = new Map(pocty)
return (
<div className="stranka">
<h1>Nabídka</h1>
{polozky.length === 0 ? (
{kategorie.length === 0 ? (
<p className="prazdno">
Zatím tu nic není. Položky se přidávají v <Link href="/admin">administraci</Link>.
Zatím tu nic není. Kategorie se zakládají v{' '}
<Link href="/admin">administraci</Link>.
</p>
) : (
<ul className="mrizka">
{polozky.map((polozka) => (
<KartaPolozky key={polozka.id} polozka={polozka} />
{kategorie.map((k) => (
<DlazdiceKategorie
key={k.id}
kategorie={k}
pocet={pocetVKategorii.get(k.id) ?? 0}
/>
))}
</ul>
)}
@@ -43,42 +64,39 @@ export default async function HomePage() {
)
}
function KartaPolozky({ polozka }: { polozka: Product }) {
const nahled = prvniObrazek(polozka)
function DlazdiceKategorie({ kategorie, pocet }: { kategorie: Category; pocet: number }) {
const obrazek = nahledKategorie(kategorie)
return (
<li className={`karta${polozka.stav === 'prodano' ? ' karta--prodano' : ''}`}>
<Link href={`/nabidka/${polozka.slug}`}>
<li className="karta">
<Link href={`/kategorie/${kategorie.slug}`}>
<div className="karta__obrazek">
{nahled ? (
{obrazek ? (
<Image
alt={nahled.alt}
src={nahled.url}
width={nahled.width}
height={nahled.height}
alt={obrazek.alt}
src={obrazek.url}
width={obrazek.width}
height={obrazek.height}
sizes="(max-width: 700px) 100vw, 320px"
/>
) : (
<div className="karta__bezobrazku">bez fotky</div>
)}
</div>
<h2>{polozka.nazev}</h2>
<p className="karta__cena">{formatujCenu(polozka.cena)}</p>
<p className="karta__stav">{popisStavu(polozka.stav)}</p>
<h2>{kategorie.nazev}</h2>
<p className="karta__stav">
{pocet === 0 ? 'zatím prázdné' : `${pocet} ${sklonujPolozky(pocet)}`}
</p>
</Link>
</li>
)
}
/**
* Vytáhne z položky první obrázek v podobě použitelné pro `next/image`.
* Vrací `null`, pokud položka fotku nemá nebo se nepodařilo načíst rozměry.
*/
function prvniObrazek(polozka: Product) {
const prvni = polozka.obrazky?.[0]?.obrazek
if (!prvni || typeof prvni === 'number') return null
function nahledKategorie(kategorie: Category) {
const obrazek = kategorie.obrazek
if (!obrazek || typeof obrazek === 'number') return null
const media = prvni as Media
const media = obrazek as Media
const varianta = media.sizes?.nahled
const url = varianta?.url ?? media.url
const width = varianta?.width ?? media.width
@@ -86,5 +104,11 @@ function prvniObrazek(polozka: Product) {
if (!url || !width || !height) return null
return { url: cestaKSouboru(url), width, height, alt: media.alt ?? polozka.nazev }
return { url: cestaKSouboru(url), width, height, alt: media.alt ?? kategorie.nazev }
}
function sklonujPolozky(pocet: number): string {
if (pocet === 1) return 'položka'
if (pocet < 5) return 'položky'
return 'položek'
}
+38 -12
View File
@@ -103,30 +103,48 @@ a {
* PNG nesou jen průhlednost, barvu určuje `background-color` — díky tomu
* fungují ve světlém i tmavém motivu a nekouká z nich bílý obdélník.
*/
.terc {
position: relative;
flex: 0 0 auto;
width: 63px;
height: 63px;
/*
* Terč je vodoznak, ne prvek rozhraní: `fixed` ho drží na místě i při
* rolování, nízké krytí ho odsune do pozadí a `pointer-events: none`
* zajistí, že nepřekáží klikání.
*/
.terc-pozadi {
position: fixed;
top: 7rem;
left: 65%;
transform: translateX(-50%);
width: 246px;
height: 246px;
background-color: var(--barva-tlumena);
opacity: 0.2;
-webkit-mask: url('/terc.png') center / contain no-repeat;
mask: url('/terc.png') center / contain no-repeat;
pointer-events: none;
z-index: 0;
}
/* Červený střed je jediný barevný akcent v hlavičce — maska ho neumí
přenést, tak ho doplníme. */
.terc::after {
/* Červený střed maska nepřenese, tak ho doplníme. */
.terc-pozadi::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 7px;
height: 7px;
width: 21px;
height: 21px;
border-radius: 50%;
background: #c0392b;
transform: translate(-50%, -50%);
}
/* Obsah patří nad vodoznak. */
.hlavicka,
main,
.skyline,
.paticka {
position: relative;
z-index: 1;
}
/*
* Výška se odvozuje z poměru stran obrázku, ne z pevné hodnoty. Předtím tu
* byla pevná výška s `cover`, což ořezávalo špičky stromů. `100% 100%` při
@@ -142,8 +160,10 @@ a {
}
@media (max-width: 620px) {
.terc {
display: none;
.terc-pozadi {
top: 5rem;
width: 195px;
height: 195px;
}
}
@@ -177,6 +197,12 @@ a {
color: var(--barva-tlumena);
}
.uvodni-text {
max-width: 65ch;
margin: -0.75rem 0 2rem;
color: var(--barva-tlumena);
}
/* ---------- výpis nabídky ---------- */
.mrizka {
+76
View File
@@ -0,0 +1,76 @@
import type { CollectionConfig } from 'payload'
import { vytvorSlug } from '../lib/slug'
/**
* Kategorie nabídky — zbraně, střelivo, doplňky a co časem přibude.
*
* Záměrně vlastní kolekce, ne výběr z pevného seznamu: sortiment se bude
* rozšiřovat (optika, trezory) a přidání kategorie tak nevyžaduje zásah
* do kódu ani nasazení.
*/
export const Categories: CollectionConfig = {
slug: 'categories',
labels: {
singular: 'Kategorie',
plural: 'Kategorie',
},
admin: {
useAsTitle: 'nazev',
defaultColumns: ['nazev', 'poradi', 'updatedAt'],
description: 'Rozdělení nabídky. Pořadí určuje, jak se zobrazí na úvodní stránce.',
},
access: {
read: () => true,
},
defaultSort: 'poradi',
fields: [
{
name: 'nazev',
label: 'Název',
type: 'text',
required: true,
},
{
name: 'slug',
label: 'URL adresa',
type: 'text',
unique: true,
index: true,
admin: {
position: 'sidebar',
description: 'Vyplní se samo z názvu, pokud necháte prázdné.',
},
hooks: {
beforeValidate: [({ value, data }) => vytvorSlug(value || data?.nazev || '')],
},
},
{
name: 'poradi',
label: 'Pořadí',
type: 'number',
defaultValue: 0,
admin: {
position: 'sidebar',
description: 'Menší číslo = dřív. Při shodě se řadí podle názvu.',
},
},
{
name: 'obrazek',
label: 'Úvodní obrázek',
type: 'upload',
relationTo: 'media',
admin: {
description: 'Zobrazí se na dlaždici na úvodní stránce.',
},
},
{
name: 'popis',
label: 'Popis',
type: 'textarea',
admin: {
description: 'Krátký text nad výpisem položek v kategorii.',
},
},
],
}
+3 -7
View File
@@ -49,14 +49,10 @@ export const Products: CollectionConfig = {
{
name: 'kategorie',
label: 'Kategorie',
type: 'select',
type: 'relationship',
relationTo: 'categories',
required: true,
defaultValue: 'zbrane',
options: [
{ label: 'Zbraně', value: 'zbrane' },
{ label: 'Střelivo', value: 'strelivo' },
{ label: 'Doplňky', value: 'doplnky' },
],
index: true,
admin: { position: 'sidebar' },
},
{
+53
View File
@@ -0,0 +1,53 @@
import Image from 'next/image'
import Link from 'next/link'
import React from 'react'
import type { Media, Product } from '@/payload-types'
import { cestaKSouboru, formatujCenu, popisStavu } from '@/lib/format'
/** Dlaždice zboží ve výpisu — používá ji úvod kategorie i případné další výpisy. */
export function KartaPolozky({ polozka }: { polozka: Product }) {
const nahled = prvniObrazek(polozka)
return (
<li className={`karta${polozka.stav === 'prodano' ? ' karta--prodano' : ''}`}>
<Link href={`/nabidka/${polozka.slug}`}>
<div className="karta__obrazek">
{nahled ? (
<Image
alt={nahled.alt}
src={nahled.url}
width={nahled.width}
height={nahled.height}
sizes="(max-width: 700px) 100vw, 320px"
/>
) : (
<div className="karta__bezobrazku">bez fotky</div>
)}
</div>
<h2>{polozka.nazev}</h2>
<p className="karta__cena">{formatujCenu(polozka.cena)}</p>
<p className="karta__stav">{popisStavu(polozka.stav)}</p>
</Link>
</li>
)
}
/**
* Vytáhne z položky první obrázek v podobě použitelné pro `next/image`.
* Vrací `null`, pokud položka fotku nemá nebo se nepodařilo načíst rozměry.
*/
function prvniObrazek(polozka: Product) {
const prvni = polozka.obrazky?.[0]?.obrazek
if (!prvni || typeof prvni === 'number') return null
const media = prvni as Media
const varianta = media.sizes?.nahled
const url = varianta?.url ?? media.url
const width = varianta?.width ?? media.width
const height = varianta?.height ?? media.height
if (!url || !width || !height) return null
return { url: cestaKSouboru(url), width, height, alt: media.alt ?? polozka.nazev }
}
+48 -1
View File
@@ -68,6 +68,7 @@ export interface Config {
blocks: {};
collections: {
products: Product;
categories: Category;
media: Media;
pages: Page;
users: User;
@@ -79,6 +80,7 @@ export interface Config {
collectionsJoins: {};
collectionsSelect: {
products: ProductsSelect<false> | ProductsSelect<true>;
categories: CategoriesSelect<false> | CategoriesSelect<true>;
media: MediaSelect<false> | MediaSelect<true>;
pages: PagesSelect<false> | PagesSelect<true>;
users: UsersSelect<false> | UsersSelect<true>;
@@ -134,7 +136,7 @@ export interface Product {
* Vyplní se samo z názvu, pokud necháte prázdné.
*/
slug?: string | null;
kategorie: 'zbrane' | 'strelivo' | 'doplnky';
kategorie: number | Category;
cena: number;
stav: 'skladem' | 'na-objednavku' | 'prodano';
/**
@@ -167,6 +169,34 @@ export interface Product {
updatedAt: string;
createdAt: string;
}
/**
* Rozdělení nabídky. Pořadí určuje, jak se zobrazí na úvodní stránce.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "categories".
*/
export interface Category {
id: number;
nazev: string;
/**
* Vyplní se samo z názvu, pokud necháte prázdné.
*/
slug?: string | null;
/**
* Menší číslo = dřív. Při shodě se řadí podle názvu.
*/
poradi?: number | null;
/**
* Zobrazí se na dlaždici na úvodní stránce.
*/
obrazek?: (number | null) | Media;
/**
* Krátký text nad výpisem položek v kategorii.
*/
popis?: string | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media".
@@ -287,6 +317,10 @@ export interface PayloadLockedDocument {
relationTo: 'products';
value: number | Product;
} | null)
| ({
relationTo: 'categories';
value: number | Category;
} | null)
| ({
relationTo: 'media';
value: number | Media;
@@ -369,6 +403,19 @@ export interface ProductsSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "categories_select".
*/
export interface CategoriesSelect<T extends boolean = true> {
nazev?: T;
slug?: T;
poradi?: T;
obrazek?: T;
popis?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media_select".
+2 -1
View File
@@ -5,6 +5,7 @@ import { buildConfig } from 'payload'
import { fileURLToPath } from 'url'
import sharp from 'sharp'
import { Categories } from './collections/Categories'
import { Media } from './collections/Media'
import { Pages } from './collections/Pages'
import { Products } from './collections/Products'
@@ -34,7 +35,7 @@ export default buildConfig({
titleSuffix: ' — Reiner zbraně',
},
},
collections: [Products, Media, Pages, Users],
collections: [Products, Categories, Media, Pages, Users],
editor: lexicalEditor(),
secret: process.env.PAYLOAD_SECRET || '',
serverURL: process.env.NEXT_PUBLIC_SERVER_URL,
+10 -2
View File
@@ -13,15 +13,23 @@ describe('Payload API', () => {
it('má nadefinované všechny kolekce', () => {
const slugy = Object.keys(payload.collections)
expect(slugy).toEqual(expect.arrayContaining(['products', 'media', 'pages', 'users']))
expect(slugy).toEqual(
expect.arrayContaining(['products', 'categories', 'media', 'pages', 'users']),
)
})
it('umí založit a najít položku nabídky', async () => {
const kategorie = await payload.create({
collection: 'categories',
data: { nazev: 'Zbraně' },
})
expect(kategorie.slug).toBe('zbrane')
const vytvorena = await payload.create({
collection: 'products',
data: {
nazev: 'CZ 75 SP-01 Shadow',
kategorie: 'zbrane',
kategorie: kategorie.id,
cena: 28900,
stav: 'skladem',
},