# Nowy projekt — krok po kroku > **Instalacja pluginu** (token Gitea, rejestr vs git) jest opisana w głównym > [README](../README.md). Ten przewodnik zakłada, że `@intecion/ipal-kit` jest > już zainstalowany, i przeprowadza przez **konfigurację** projektu. Od pustego katalogu do działającej, wielojęzycznej strony z blokami, consentem i formularzem. Kolejność jest istotna: kilka kroków zależy od poprzednich (schemat bazy, importMap, kolejność wpięcia). Zakłada: pnpm, Node 20+, SQLite (dla Postgres zmienia się tylko adapter). --- ## 1. Szkielet Payloada ```bash npx create-payload-app@latest moj-projekt # → Blank, SQLite cd moj-projekt ``` ## 2. Plugin i zależności Zainstaluj `@intecion/ipal-kit` zgodnie z [README](../README.md) (rejestr Gitea albo bezpośrednio z repozytorium — wymaga tokenu). Następnie dodaj zależności współdzielone z Payloadem, których plugin nie zaciąga sam: ```bash pnpm add @payloadcms/plugin-seo@3.84.1 @payloadcms/plugin-form-builder@3.84.1 \ nodemailer lucide-react slugify server-only ``` 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.84.1", "@payloadcms/ui": "3.84.1", "@payloadcms/next": "3.84.1", "@payloadcms/db-sqlite": "3.84.1", "@payloadcms/richtext-lexical": "3.84.1", "@payloadcms/plugin-seo": "3.84.1", "@payloadcms/plugin-form-builder": "3.84.1" } } ``` ```bash rm -rf node_modules pnpm-lock.yaml && pnpm install ``` ## 3. Konfiguracja locale — jedno źródło Middleware działa przed Payloadem i potrzebuje listy locale synchronicznie, więc nie może jej czytać z gotowego configu. Wydziel osobny plik i importuj w obu miejscach: ```ts // src/i18n.config.ts export const i18nConfig = { defaultLocale: 'pl', locales: [ { code: 'pl', label: 'Polski' }, { code: 'en', label: 'English' }, ], } as const ``` `as const` jest konieczne — bez niego TS nie uzna `locales` za niepustą listę. ## 4. payload.config.ts ```ts import { ipalKit, panelSmtpAdapter } from '@intecion/ipal-kit' import { i18nConfig } from '@/i18n.config' import { Pages } from '@/collections/Pages' export default buildConfig({ // …reszta z template'u collections: [Users, Media, Pages], // SMTP z panelu zamiast env — czyta Site Integrations przy każdym wysłaniu. // Bez tego maile form-buildera nie wyjdą (Payload podstawia mocka). email: panelSmtpAdapter(), 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' }), { name: 'layout', type: 'blocks', blocks: [ContentBlock], // NIGDY pusta lista — Payload się wywala }, ], } ``` ## 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 w rejestrze = `slug` bloku. ## 7. Tailwind Blank template go nie ma, a komponenty pluginu (banner cookies) są w 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ą i banner wyrenderuje się goły. Ścieżka jest relatywna do pliku CSS. ## 8. Proxy (dawniej middleware) > **Next 16:** konwencja `middleware.ts` jest przestarzała — nazwa pliku to teraz > `proxy.ts`, a funkcja `proxy` zamiast `middleware`. Logika pluginu bez zmian: > `createLocaleMiddleware` działa tak samo. Migracja jednej komendy: > `npx @next/codemod@canary middleware-to-proxy .` ```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) if (result.type === 'next') return NextResponse.next() const response = NextResponse.redirect(result.location) // cookie tylko gdy jest zgoda na kategorię functional — inaczej undefined if (result.cookie) response.cookies.set(result.cookie.name, result.cookie.value) return response } // INLINE, nie import — Next analizuje ten obiekt statycznie i nie wykonuje // importów. Importowana stała zostanie zignorowana, proxy złapie /admin // i /_next, i wszystko zwróci 500. export const config = { matcher: ['/((?!api|admin|_next|.*\\..*).*)'], } ``` > Import z pluginu zostaje `@intecion/ipal-kit/next/middleware` — to nazwa > subpath eksportu w pakiecie, niezależna od tego, czy plik projektu nazywa się > `middleware.ts` czy `proxy.ts`. ## 9. Warstwa dostępu do danych Next uruchamia `generateMetadata` i komponent strony niezależnie — `cache()` sprawia, że nie pytają bazy dwa razy o to samo. ```ts // src/lib/payload.ts import { cache } from 'react' import { getPayload } from 'payload' import config from '@/payload.config' export const getCachedPayload = cache(async () => getPayload({ config: await config })) export const getSettings = cache(async (locale: string) => (await getCachedPayload()).findGlobal({ slug: 'site-settings', locale: locale as 'pl' | 'en', depth: 2, }), ) ``` ```ts // src/lib/locales.ts import { cache } from 'react' import config from '@/payload.config' export const getConfiguredLocales = cache(async (): Promise => { const payloadConfig = await config return payloadConfig.localization ? payloadConfig.localization.locales.map((l) => l.code) : [] }) ``` ```ts // src/lib/pages.ts import { cache } from 'react' import type { Page, SiteSetting } from '@/payload-types' import { getCachedPayload, getSettings } from './payload' export const resolvePage = cache( async (locale: string, slugPath: string | null): Promise => { if (!slugPath) { // Strona główna z System Pages — edytor może ją zmienić bez zmiany kodu. const settings = (await getSettings(locale)) as SiteSetting const homepage = settings.homepage return homepage && typeof homepage === 'object' ? homepage : null } const payload = await getCachedPayload() const result = await payload.find({ collection: 'pages', where: { slug: { equals: slugPath } }, locale: locale as 'pl' | 'en', depth: 2, limit: 1, }) return result.docs[0] ?? null }, ) ``` ## 10. Trasy Usuń starter — `(frontend)/layout.tsx` i `(frontend)/page.tsx`. Rootem zostaje layout locale, bo `` musi znać język, a `(frontend)` jest ponad segmentem `[locale]`. Każdy trafia na ścieżkę z locale — middleware przekierowuje. ``` src/app/(frontend)/ styles.css [locale]/ layout.tsx [[...slug]]/ page.tsx ``` `[[...slug]]` — **podwójne** nawiasy. Pojedyncze `[slug]` dają string zamiast tablicy (`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, getSettings } from '@/lib/payload' import { getConfiguredLocales } from '@/lib/locales' 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 (
{children}
) } 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 } from '@/lib/payload' import { resolvePage } from '@/lib/pages' const pageMetadata = createPageMetadata({ config: i18nConfig, baseUrl: process.env.NEXT_PUBLIC_SERVER_URL, }) export async function generateMetadata({ params }): Promise { const { locale, slug } = await params return pageMetadata({ payload: await getCachedPayload(), locale, slug }) } export default async function Page({ params }) { const { locale, slug } = await params const page = await resolvePage(locale, slug?.length ? slug.join('/') : null) if (!page) notFound() return } ``` ## 11. Środowisko ```bash # .env DATABASE_URL=file:./moj-projekt.db PAYLOAD_SECRET= NEXT_PUBLIC_SERVER_URL=http://localhost:3000 ``` Bez `NEXT_PUBLIC_SERVER_URL` canonical i hreflang wyjdą względne. ## 12. Generowanie i start ```bash pnpm generate:types pnpm payload generate:importmap # pola SEO to komponenty admina pnpm dev ``` `generate:importmap` powtarzaj po każdej zmianie, która dokłada komponenty admina. ## 13. Konfiguracja w panelu `http://localhost:3000/admin` 1. **Utwórz pierwszego użytkownika** (dostanie rolę admin). 2. **Site Settings → General** — nazwa witryny, kolejność i separator tytułu. 3. **Pages** — utwórz stronę główną. Wypełnij tytuł **w każdym locale** (przełącznik u góry) — slug generuje się per język, a pusty slug w EN oznacza 404 na `/en/…`. 4. **Site Settings → System Pages** — wskaż Homepage. Bez tego `/pl` da 404. 5. **Cookie Settings** — treść bannera (bez tego lecą angielskie domyślne). Wejdź na `/` — powinno przekierować na `/pl` i pokazać stronę. --- ## Rzeczy opcjonalne ### Formularz z Turnstile Wymaga bloku formularza w projekcie (patrz forms.md) oraz: - **Site Integrations → Turnstile** — site key i secret. Klucze testowe Cloudflare (zawsze przechodzą): site `1x00000000000000000000AA`, secret `1x0000000000000000000000000000000AA`. - **Site Integrations → SMTP** — host, port, user, hasło, adres nadawcy. - **Forms → dany formularz → Emails** — odbiorca, temat, treść (`{{*:table}}` wypisze wszystkie pola tabelką). Maile wysyła form-builder przez `panelSmtpAdapter` — nie pisze się ich w kodzie. ### Blog / archiwum (kolekcja pod stroną-archiwum) Pełny opis: content.md. W skrócie: 1. **Kolekcja** `src/collections/Posts.ts` — tytuł (localized), `buildSlugField`, pola, bloki. Dodaj ją do `collections` w payload.config. 2. **content.config.ts** obok i18n.config.ts: ```ts import type { ContentOption } from '@intecion/ipal-kit' export const contentConfig: ContentOption = { collections: [{ slug: 'posts', label: 'Artykuły', perPage: 10 }], } ``` 3. **payload.config** — `content: contentConfig`, plus `posts` w `seo.collections`. 4. **Front** — `createContentHelpers` w `src/lib/content.ts`, `resolveRoute` w page.tsx (obsługa typów page/archive/entry), blok listy (EntriesList). 5. **Baza + typy** — nowa kolekcja to nowy schemat: ```bash rm -f *.db *.db-shm *.db-wal && pnpm generate:types && pnpm dev ``` 6. **W panelu** — utwórz stronę „Artykuły" (w każdym locale!), dodaj do niej blok listy, w System Pages przypisz ją jako archiwum kolekcji posts. Dodaj wpisy. Adres wpisów = slug strony-archiwum. Zmiana tytułu strony przenosi sekcję. Kolejny typ treści (realizacje) = kolejna kolekcja + kolejna pozycja w content.config. ### Sitemapa i robots.txt `createContentHelpers` oddaje gotowe handlery — dodaj `i18n` i `baseUrl` do jego argumentów (patrz seo.md), potem dwa pliki po jednej linii: ```ts // app/sitemap.ts export { sitemap as default } from '@/lib/content' // app/robots.ts export { robots as default } from '@/lib/content' ``` Sitemapa z hreflangiem per URL, lastmod, wpisami bloga; pomija drafty i noindex. ### Analytics **Site Integrations** → GA4 Measurement ID albo GTM Container ID. Tagi ładują się z Consent Mode: nic nie zapisze ciasteczek, dopóki odwiedzający nie zaakceptuje kategorii Analytics. ### Przestylowanie pod klienta ```css /* styles.css */ :root { --ipal-primary: #16a34a; --ipal-radius: 1rem; } ``` Pełna lista tokenów: consent.md. --- ## Kiedy coś nie działa | Objaw | Przyczyna | |---|---| | Pusty tab SEO / brak kolekcji Forms | rozjazd wersji `@payloadcms/*` — sprawdź `pnpm.overrides` | | `PayloadComponent not found in importMap` | `pnpm payload generate:importmap` | | Banner bez stylów | brak `@source` na `node_modules/@intecion/ipal-kit` albo brak Tailwinda | | `/admin` i `/_next` zwracają 500 | matcher w middleware nie jest inline | | `slug.join is not a function` | katalog `[slug]` zamiast `[[...slug]]` | | `/pl` → 404 | Homepage nieustawiony w System Pages | | `/en/cokolwiek` → 404, `/pl/cokolwiek` działa | pusty tytuł (a więc i slug) w locale EN | | `Missing and ` | root layout usunięty, a `[locale]/layout.tsx` ich nie ma | | `SQLITE_ERROR: index … already exists` | zmiana schematu — usuń `*.db *.db-shm *.db-wal` | | Zmiany w pluginie nie widać | Turbopack cache — `rm -rf .next` | | Maile nie wychodzą | brak `email: panelSmtpAdapter()` w configu albo pusty SMTP w panelu | | GTM ładuje się, brak `_ga` | pusty kontener — GTM sam nie ustawia ciasteczek, potrzebny opublikowany tag GA4 | | `/pl/artykuly` → 404 | strona nieprzypisana jako archiwum w System Pages | | brak pola „archive page" w panelu | brak `content` w configu albo `generate:importmap` po dodaniu | | wpis 404 mimo że istnieje | slug pusty w tym locale — wypełnij tytuł w danym języku |