Add category level and turn the reticle into a background watermark
This commit is contained in:
+12
-5
@@ -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
|
||||
|
||||
|
||||
@@ -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\`;`)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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' },
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
@@ -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".
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user