# Setup projektu — od zera do wdrożenia Pełny przewodnik: od pustego katalogu do działającej, wielojęzycznej strony z blokami, consentem, formularzem i SEO. Łączy szkielet projektu (kolejność kroków) z wymaganiami frontendu (Tailwind, trasy, bloki, metadata). > **Instalacja pluginu** (token Gitea, rejestr vs git) jest w głównym > [README](../README.md). Ten przewodnik zakłada, że `@intecion/ipal-kit` jest > zainstalowany, i przeprowadza przez konfigurację. > > **Zaczynasz wdrożenie produkcyjne?** Najpierw [WDROZENIE-PLAYBOOK.md](./WDROZENIE-PLAYBOOK.md) > — zasady, procedura, pułapki. Kolejność jest istotna — kilka kroków zależy od poprzednich (schemat bazy, importMap, kolejność wpięcia). Zakłada: pnpm, Node 22, Next 16. --- ## 1. Szkielet Payloada ```bash npx create-payload-app@latest moj-projekt # → Blank, SQLite (dev) / Postgres (prod) cd moj-projekt ``` ## 2. Plugin i zależności Zainstaluj `@intecion/ipal-kit` zgodnie z [README](../README.md). Dodaj zależności współdzielone z Payloadem, których plugin nie zaciąga sam: ```bash pnpm add @payloadcms/plugin-seo @payloadcms/plugin-form-builder \ nodemailer lucide-react slugify server-only ``` ### Spójność wersji @payloadcms/* (KRYTYCZNE) Wersje `@payloadcms/*` **muszą** zgadzać się z wersją `payload` — inaczej zagnieżdżone pluginy się nie wpinają (pusty tab SEO, brak kolekcji Forms) albo projekt się wywala. Wymuś w `package.json`: ```json "pnpm": { "overrides": { "payload": "3.88.0", "@payloadcms/ui": "3.88.0", "@payloadcms/next": "3.88.0", "@payloadcms/db-postgres": "3.88.0", "@payloadcms/richtext-lexical": "3.88.0", "@payloadcms/plugin-seo": "3.88.0", "@payloadcms/plugin-form-builder": "3.88.0" } } ``` ```bash rm -rf node_modules pnpm-lock.yaml && pnpm install ``` ### Build script z --webpack (Next 16) Next 16 domyślnie Turbopack, który konfliktuje z withPayload. W `package.json`: ```json "build": "cross-env NODE_OPTIONS=\"--max-old-space-size=3072\" next build --webpack" ``` ## 3. Konfiguracja locale — jedno źródło Proxy działa przed Payloadem i potrzebuje listy locale synchronicznie, więc nie może jej czytać z gotowego configu. Wydziel osobny plik, importuj wszędzie: ```ts // src/i18n.config.ts export const i18nConfig = { defaultLocale: 'pl', locales: [ { code: 'pl', label: 'Polski' }, { code: 'en', label: 'English' }, ], } as const // as const — inaczej TS nie uzna locales za niepustą tuple ``` Importuj w: `payload.config` (ipalKit({ i18n: i18nConfig })) i `proxy.ts`. ## 4. payload.config.ts ```ts import { ipalKit, mailAdapter } from '@intecion/ipal-kit' import { i18nConfig } from '@/i18n.config' import { Pages } from '@/collections/Pages' export default buildConfig({ collections: [Users, Media, Pages], // Dyspozytor email: czyta transport (SMTP/Graph) z panelu przy każdej wysyłce. // Bez tego maile form-buildera nie wyjdą (Payload podstawia mocka). email: mailAdapter(), plugins: [ ipalKit({ i18n: i18nConfig, access: { authCollection: 'users' }, pages: { slug: 'pages' }, seo: { collections: ['pages'] }, forms: { redirectRelationships: ['pages'] }, }), ], }) ``` ## 5. Kolekcja Pages ```ts // src/collections/Pages.ts import type { CollectionConfig } from 'payload' import { buildSlugField } from '@intecion/ipal-kit' import { ContentBlock } from '@/blocks/Content/config' export const Pages: CollectionConfig = { slug: 'pages', admin: { useAsTitle: 'title' }, access: { read: () => true }, fields: [ { name: 'title', type: 'text', required: true, localized: true }, buildSlugField({ from: 'title' }), // NIGDY ręczny slug — plugin to ma { name: 'layout', type: 'blocks', blocks: [ContentBlock], // NIGDY pusta lista — Payload crashuje }, ], } ``` ## 6. Pierwszy blok Bloki należą do projektu — plugin ich nie zna i nie stylizuje. ```ts // src/blocks/Content/config.ts import type { Block } from 'payload' export const ContentBlock: Block = { slug: 'content', fields: [ { name: 'heading', type: 'text', localized: true }, { name: 'body', type: 'textarea', localized: true, required: true }, ], } ``` ```tsx // src/blocks/Content/Component.tsx export function ContentBlockComponent({ heading, body }: { heading?: string; body?: string }) { return (
{heading &&

{heading}

} {body &&

{body}

}
) } ``` ```ts // src/blocks/registry.ts import type { BlockComponentMap } from '@intecion/ipal-kit/rsc' import { ContentBlockComponent } from '@/blocks/Content/Component' export const blockRegistry: BlockComponentMap = { content: ContentBlockComponent, // klucz = slug bloku } ``` > **Jak budować treść, żeby klient mógł wszystko edytować** (filozofia > CMS, kolejność komponent→blok→strona): [architektura-tresci.md](./architektura-tresci.md). **Puste `blocks: []` crashuje** (traverseFields) — zawsze co najmniej jeden blok. ### enhanceProps — wstrzykiwanie danych server-side do bloków Bloki NIE importują `lib/*` (cykl importów). Wartości server-side (turnstileSiteKey, odbiorca formularza) wstrzykuje się przez enhanceProps — bez wiedzy pluginu: ```ts const enhanceProps = ({ block }) => { if (block.blockType === 'formBlock') return { turnstileSiteKey, notificationTo } return {} } ``` ## 7. Tailwind (WYMÓG) Blank template go nie ma, a komponenty pluginu (banner cookies, Turnstile) są w czystym Tailwindzie. ```bash pnpm add tailwindcss @tailwindcss/postcss ``` ```js // postcss.config.mjs (root) export default { plugins: { '@tailwindcss/postcss': {} } } ``` ```css /* src/app/(frontend)/styles.css — na górze */ @import "tailwindcss"; @source "../../../node_modules/@intecion/ipal-kit/dist/**/*.js"; ``` **`@source` jest KONIECZNY** — Tailwind nie skanuje `node_modules`, więc bez niego klasy komponentów pluginu nie powstaną (banner wyrenderuje się goły). Ścieżka relatywna do pliku CSS. ### Przestylowanie pod klienta Komponenty pluginu mają domyślny wygląd. Kolory/zaokrąglenia przez CSS custom properties (fallbacki wbudowane): ```css :root { --ipal-primary: #16a34a; --ipal-radius: 1rem; } ``` Pełna lista tokenów + opcja classNames: [consent.md](./consent.md). ## 8. Proxy (routing locale) — NIGDY middleware.ts > **Next 16 używa `proxy.ts`, NIE `middleware.ts`.** Plik `proxy.ts`, funkcja > `proxy`. `middleware.ts` jest przestarzały — jeśli istnieje, USUŃ go. Nigdy > obu naraz. Migracja starego: `npx @next/codemod@canary middleware-to-proxy .` > > Import z pluginu zostaje `@intecion/ipal-kit/next/middleware` — to nazwa > subpath eksportu, NIE nazwa pliku. Nie myl ich. ```ts // src/proxy.ts import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' import { createLocaleMiddleware } from '@intecion/ipal-kit/next/middleware' import { i18nConfig } from '@/i18n.config' const localeMiddleware = createLocaleMiddleware({ config: i18nConfig }) export function proxy(request: NextRequest) { const result = localeMiddleware(request) // Cookie zapisywany w OBU wynikach (redirect na '/' i next przy zmianie // języka), TYLKO gdy jest zgoda na functional. const response = result.type === 'next' ? NextResponse.next() : NextResponse.redirect(result.location) if (result.cookie) { response.cookies.set(result.cookie.name, result.cookie.value) } return response } // Matcher INLINE (nie import) — Next analizuje statycznie, nie wykonuje importów. // Import stałej byłby zignorowany → proxy złapałby /admin /_next /api → 500. // Ten wzorzec łapie root '/' (negocjacja locale), pomija api/admin/_next/pliki. export const config = { matcher: ['/((?!api|admin|_next|.*\\..*).*)'], } ``` ### Zlokalizowane ścieżki — getLocalizedSlugs (NIGDY zaszyta mapa) Do przełącznika języka / budowania ścieżek NIE twórz zaszytej mapy slugów. Slugi są w bazie (pole `slug` localized): ```ts import { getLocalizedSlugs, switchLocalePath } from '@intecion/ipal-kit' const doc = await payload.findByID({ collection: 'pages', id, locale: 'all' }) const slugs = getLocalizedSlugs({ slugField: doc.slug, config: i18nConfig }) switchLocalePath({ slugs, targetLocale: 'en', config: i18nConfig }) // → '/en/about' ``` ## 9. Warstwa dostępu do danych — lib/ (jedno źródło) ```ts // src/lib/content.ts — JEDYNE źródło helperów pluginu import { createContentHelpers } from '@intecion/ipal-kit' import payloadConfig from '@/payload.config' import { i18nConfig } from '@/i18n.config' export const { getCachedPayload, getSettings, getConfiguredLocales, resolveRoute, getEntries, robots, } = createContentHelpers({ config: payloadConfig, // PAYLOAD config (nie i18n!) content: { collections: [] }, i18n: i18nConfig, // i18n OSOBNO }) ``` ```ts // src/lib/payload.ts — funkcje projektu, typowane import { cache } from 'react' import { getSiteSettings } from '@intecion/ipal-kit' import { getCachedPayload } from './content' // z content, nie osobny getPayload import type { SiteSetting } from '@/payload-types' export const getSettings = cache(async (locale: string) => getSiteSettings(await getCachedPayload(), { locale: locale as never, depth: 2 }), ) ``` > NIE twórz `lib/pages.ts` (resolvePage) ani `lib/locales.ts` — plugin ma > `resolveRoute` i `getConfiguredLocales`. Duplikaty = rozjazd. ## 10. Trasy Usuń starter — `(frontend)/layout.tsx` i `(frontend)/page.tsx`. Rootem zostaje layout locale (bo `` musi znać język). ``` src/app/(frontend)/ styles.css [locale]/ layout.tsx # walidacja locale + ConsentProvider + Analytics [[...slug]]/ page.tsx # render bloków ``` **`[[...slug]]` — PODWÓJNE nawiasy** (opcjonalny catch-all). Pojedyncze `[slug]` dają string (`slug.join is not a function`) i nie łapią samego `/pl`. ```tsx // src/app/(frontend)/[locale]/layout.tsx import { notFound } from 'next/navigation' import { getConsentTexts, getAnalyticsConfig } from '@intecion/ipal-kit' import { ConsentProvider, CookieBanner, CookieButton, Analytics } from '@intecion/ipal-kit/client' import { i18nConfig } from '@/i18n.config' import { getCachedPayload, getConfiguredLocales } from '@/lib/content' import { getSettings } from '@/lib/payload' import '../styles.css' export default async function LocaleLayout({ children, params }) { const { locale } = await params const locales = await getConfiguredLocales() if (!locales.includes(locale)) notFound() const payload = await getCachedPayload() const settings = await getSettings(locale) const privacyPage = (settings as { privacyPolicy?: unknown }).privacyPolicy const [texts, analytics] = await Promise.all([ getConsentTexts({ config: i18nConfig, locale, payload, privacyPolicy: privacyPage && typeof privacyPage === 'object' ? { page: privacyPage, label: 'Polityka prywatności' } : undefined, }), getAnalyticsConfig(payload), ]) return ( {/* BEZ jawnego ! Sztywny wypycha metadata do (canonical/title poza head → crawlery ich nie widzą). Next zarządza sam; MediaPreconnect w body, React 19 hoistuje link do head. */} {/* preconnect CDN, jeśli R2 */}
{children}
{/* WEWNĄTRZ ConsentProvider */}
) } export async function generateStaticParams() { const locales = await getConfiguredLocales() return locales.map((locale) => ({ locale })) } ``` ```tsx // src/app/(frontend)/[locale]/[[...slug]]/page.tsx import { notFound } from 'next/navigation' import type { Metadata } from 'next' import { RenderBlocks } from '@intecion/ipal-kit/rsc' import { createPageMetadata } from '@intecion/ipal-kit' import { i18nConfig } from '@/i18n.config' import { blockRegistry } from '@/blocks/registry' import { getCachedPayload, resolveRoute } from '@/lib/content' const pageMetadata = createPageMetadata({ config: i18nConfig, baseUrl: process.env.NEXT_PUBLIC_SERVER_URL, }) // ISR — cache strony z gotowym . Eliminuje race condition streamingu // metadata (canonical/title zawsze w head, nie w body). TTFB ~20ms, brak 503. // Redaktor widzi zmiany po rewalidacji — patrz seo.md (ISR a treść z panelu). export const revalidate = 3600 export async function generateMetadata({ params }): Promise { const { locale, slug } = await params return pageMetadata({ payload: await getCachedPayload(), locale, slug }) } export default async function Page({ params, searchParams }) { const { locale, slug } = await params const { page } = await searchParams const route = await resolveRoute(locale, slug ?? [], page) // 3 args if (!route) notFound() return } ``` - brak slug (`/pl`) → home przez System Pages (nie hardkod slug) - slug (`/pl/o-nas`) → resolveRoute po slug w danym locale - **ISR (`revalidate`)** → metadata zawsze w `` (nie body), szybki TTFB. KRYTYCZNE dla SEO — patrz seo.md (metadata w head). - `depth: 2` → relacje w blokach (form) się populują ## 11. Metadata / SEO (szczegóły) `createPageMetadata` obsługuje hreflang. Kluczowe: resolveDocument pobiera dokument z **`locale: 'all'`** — wtedy `slug` jest mapą locale→wartość, z której budują się hreflang alternates. Zwykły fetch (jeden locale) → tylko string, hreflang nie powstanie. Bez `NEXT_PUBLIC_SERVER_URL` canonical i hreflang wyjdą względne. ## 12. Nagłówki bezpieczeństwa ```ts // next.config.ts import { buildSecurityHeaders } from '@intecion/ipal-kit' const securityHeaders = buildSecurityHeaders({ hsts: process.env.NODE_ENV === 'production', // off w dev (http) additional: [ /* CSP projektu — zna swoje domeny */ ], }) // async headers() { return [{ source: '/:path*', headers: securityHeaders }] } ``` Szczegóły: [security.md](./security.md). ## 13. Środowisko ```bash # .env DATABASE_URI= PAYLOAD_SECRET= NEXT_PUBLIC_SERVER_URL=http://localhost:3000 # Email przez Graph (opcjonalnie — sekrety agencyjne): # GRAPH_TENANT_ID=... GRAPH_CLIENT_ID=... GRAPH_CLIENT_SECRET=... GRAPH_SENDER=... ``` ## 14. Generowanie i start ```bash pnpm generate:types pnpm payload generate:importmap # pola SEO + custom komponenty (MaskedField...) pnpm dev ``` `generate:importmap` powtarzaj po każdej zmianie dokładającej komponenty admina. ## 15. Konfiguracja w panelu `http://localhost:3000/admin` 1. **Utwórz pierwszego użytkownika** (rola admin). 2. **Site Settings → General** — nazwa witryny, tytuł. 3. **Pages** — strona główna. Tytuł **w każdym locale** (slug per język; pusty slug EN = 404 na `/en/…`). 4. **Site Settings → System Pages** — wskaż Homepage (bez tego `/pl` → 404). 5. **Cookie Settings** — treść bannera per język. 6. **Notifications** — teksty wyników formularza per język (opcjonalne, ma fallback). 7. **Site Integrations → SMTP** — transport (SMTP/Graph), From Name, From Address. Wejdź na `/` — powinno przekierować na `/pl`. --- ## Opcjonalne ### Formularz z Turnstile Wymaga bloku formularza (patrz [forms.md](./forms.md)) + Site Integrations → Turnstile (klucze testowe Cloudflare: site `1x00000000000000000000AA`, secret `1x0000000000000000000000000000000AA`). Maile wysyła form-builder przez mailAdapter — nie pisze się ich w kodzie. Zgoda RODO: checkbox o nazwie `consent`. ### Blog / archiwum Pełny opis: [content.md](./content.md). Kolekcja + content.config.ts + przypisanie strony-archiwum w System Pages. ### Sitemapa i robots ```ts // app/sitemap.ts export { sitemap as default } from '@/lib/content' export const dynamic = 'force-dynamic' // KONIECZNE dla deployu kontenerowego // app/robots.ts export { robots as default } from '@/lib/content' ``` `force-dynamic` w sitemap.ts jest wymagane przy deployu w kontenerze (Coolify/ Docker) — bez niego build próbuje prerenderować sitemap i łączy się z bazą, której kontener budujący nie widzi → build pada. Szczegóły: [deployment.md](./deployment.md). --- ## Kiedy coś nie działa | Objaw | Przyczyna | |---|---| | Pusty tab SEO / brak Forms | rozjazd wersji `@payloadcms/*` — sprawdź `pnpm.overrides` | | `PayloadComponent not found in importMap` | `pnpm payload generate:importmap` | | `Cannot destructure property 'config'` (custom pole) | dublet `@payloadcms/ui` — peerDependency (playbook D) | | Banner bez stylów | brak `@source` na node_modules albo brak Tailwinda | | `/admin` i `/_next` → 500 | matcher w proxy nie jest inline | | `slug.join is not a function` | `[slug]` zamiast `[[...slug]]` | | `/pl` → 404 | Homepage nieustawiony w System Pages | | `/en/*` → 404, `/pl/*` działa | pusty tytuł/slug w locale EN | | `Missing and ` | root layout usunięty, a `[locale]/layout.tsx` ich nie ma | | Zmiany w pluginie nie widać | `rm -rf .next`; sprawdź czy wciągnięto wersję (grep node_modules) | | Maile nie wychodzą | brak `email: mailAdapter()` albo pusty SMTP/Graph | | istnieje `middleware.ts` | USUŃ — Next 16 to `proxy.ts` | | zaszyta mapa `localizedRoutes` | antywzorzec — `getLocalizedSlugs` z bazy |