Files
ipal-kit/docs/getting-started.md
T
2026-08-01 00:27:07 +02:00

512 lines
15 KiB
Markdown

# Nowy projekt — krok po kroku
> **Instalacja:** najszybciej `pnpm add github:rasm-its/ipal-kit`. Pełne drogi
> (github / rejestr / tarball) i diagnostyka błędów — install.md.
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. Instalacja IPAL
```bash
pnpm add ipal-kit
pnpm add @payloadcms/[email protected] @payloadcms/[email protected] \
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 '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 '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 (
<section className="mx-auto max-w-3xl px-4 py-12">
{heading && <h2 className="mb-4 text-2xl font-bold">{heading}</h2>}
{body && <p className="whitespace-pre-line leading-relaxed">{body}</p>}
</section>
)
}
```
```ts
// src/blocks/registry.ts
import type { BlockComponentMap } from '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/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. Middleware
```ts
// src/middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { createLocaleMiddleware } from 'ipal-kit/next/middleware'
import { i18nConfig } from '@/i18n.config'
const localeMiddleware = createLocaleMiddleware({ config: i18nConfig })
export function middleware(request: NextRequest) {
const result = localeMiddleware(request)
if (result.type === 'next') return NextResponse.next()
const response = NextResponse.redirect(result.location)
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, middleware złapie /admin
// i /_next, i wszystko zwróci 500.
export const config = {
matcher: ['/((?!api|admin|_next|.*\\..*).*)'],
}
```
## 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<string[]> => {
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<Page | null> => {
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 `<html lang>` 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 'ipal-kit'
import { ConsentProvider, CookieBanner, CookieButton, Analytics } from '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 (
<html lang={locale}>
<body>
<ConsentProvider texts={texts}>
<main>{children}</main>
<CookieBanner />
<CookieButton />
<Analytics {...analytics} />
</ConsentProvider>
</body>
</html>
)
}
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 'ipal-kit/rsc'
import { createPageMetadata } from '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<Metadata> {
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 <RenderBlocks blocks={page.layout as never} components={blockRegistry} />
}
```
## 11. Środowisko
```bash
# .env
DATABASE_URL=file:./moj-projekt.db
PAYLOAD_SECRET=<losowy-ciąg>
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 '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/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 <html> and <body>` | 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 |