init commit for iPAL-kit plugin
This commit is contained in:
+125
@@ -0,0 +1,125 @@
|
||||
# IPAL — Dokumentacja modułów
|
||||
|
||||
**Stawiasz nowy projekt?** → [getting-started.md](./getting-started.md)
|
||||
|
||||
IPAL (Intecion Payload Advanced Library) to plugin do Payload CMS 3, który
|
||||
dostarcza logikę i konfigurację; projekt klienta zawiera tylko komponenty
|
||||
wizualne i podłączenia do Next.js.
|
||||
|
||||
## Zasada
|
||||
|
||||
- **Plugin** = logika, helpery, konfiguracja, globale.
|
||||
- **Klient (projekt)** = komponenty (wygląd), pliki-podłączenia Next.js
|
||||
(jednolinijkowe re-eksporty), konfiguracja front.
|
||||
|
||||
## Instalacja i wpięcie
|
||||
|
||||
### Wymagane zależności
|
||||
|
||||
Projekt klienta musi mieć (poza payloadem):
|
||||
|
||||
```json
|
||||
"dependencies": {
|
||||
"@payloadcms/plugin-seo": "3.84.1",
|
||||
"@payloadcms/plugin-form-builder": "3.84.1",
|
||||
"nodemailer": "^8.0.1",
|
||||
"lucide-react": "^0.400.0",
|
||||
"slugify": "^1.6.6",
|
||||
"server-only": "^0.0.1"
|
||||
}
|
||||
```
|
||||
|
||||
Wersje `@payloadcms/*` **muszą** być identyczne z wersją `payload`. Wymuś
|
||||
spójność przez `pnpm.overrides` (patrz niżej), inaczej Payload odrzuci wpięcie
|
||||
pluginów (pusty tab SEO, brak kolekcji Forms) albo crashuje.
|
||||
|
||||
```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"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Po wpięciu — wygeneruj importMap
|
||||
|
||||
Plugin dostarcza komponenty admina (pola SEO). Po dodaniu uruchom:
|
||||
|
||||
```bash
|
||||
pnpm payload generate:importmap
|
||||
```
|
||||
|
||||
Bez tego pola SEO nie wyrenderują się (błąd `PayloadComponent not found in
|
||||
importMap`).
|
||||
|
||||
```ts
|
||||
// payload.config.ts
|
||||
import { ipalKit } from 'ipal-kit'
|
||||
|
||||
export default buildConfig({
|
||||
// ...
|
||||
plugins: [
|
||||
ipalKit({
|
||||
i18n: {
|
||||
defaultLocale: 'pl',
|
||||
locales: [
|
||||
{ code: 'pl', label: 'Polski' },
|
||||
{ code: 'en', label: 'English' },
|
||||
],
|
||||
},
|
||||
access: { authCollection: 'users' },
|
||||
pages: { slug: 'pages' },
|
||||
seo: { collections: ['pages', 'posts'] },
|
||||
forms: { redirectRelationships: ['pages'] },
|
||||
}),
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
## Entry pointy pakietu
|
||||
|
||||
| Import | Zawiera | Kontekst |
|
||||
|---|---|---|
|
||||
| `ipal-kit` | logika server-safe, plugin, helpery | server / config |
|
||||
| `ipal-kit/server` | runtime server-only (sendEmail, verifyTurnstile, submitForm) | Server Actions / route handlers |
|
||||
| `ipal-kit/client` | komponenty client (consent, Turnstile, Analytics) | `'use client'` |
|
||||
| `ipal-kit/rsc` | RenderBlocks (RSC) | server component |
|
||||
| `ipal-kit/next/middleware` | locale middleware | middleware.ts |
|
||||
|
||||
## Moduły
|
||||
|
||||
| Moduł | Opis | Dok |
|
||||
|---|---|---|
|
||||
| i18n | Lokalizacja, negocjacja locale, ścieżki URL | [i18n.md](./i18n.md) |
|
||||
| pages | System pages (homepage/privacy/cookies) → ścieżki | [pages.md](./pages.md) |
|
||||
| access | Role admin > editor > user, kontrola dostępu | [access.md](./access.md) |
|
||||
| payload-helpers | getSiteSettings / getSiteIntegrations | [payload-helpers.md](./payload-helpers.md) |
|
||||
| seo | Metadata, hreflang, auto-fill, plugin-seo | [seo.md](./seo.md) |
|
||||
| blocks | RenderBlocks — silnik renderowania bloków | [blocks.md](./blocks.md) |
|
||||
| consent | Banner cookies GDPR, Google Consent Mode | [consent.md](./consent.md) |
|
||||
| turnstile | Cloudflare Turnstile (widget + verify) | [turnstile.md](./turnstile.md) |
|
||||
| email | SMTP z panelu: adapter Payloada + sendEmail | [email.md](./email.md) |
|
||||
| forms | Form-builder + submitForm (Turnstile + zapis) | [forms.md](./forms.md) |
|
||||
| analytics | GA4 / GTM spięte z Consent Mode | [analytics.md](./analytics.md) |
|
||||
| slug | Auto-slug z tytułu, per locale | [slug.md](./slug.md) |
|
||||
| content | Blog/archiwa: kolekcje pod stroną-archiwum, listing, paginacja | [content.md](./content.md) |
|
||||
|
||||
Nowy projekt krok po kroku: [getting-started.md](./getting-started.md)
|
||||
Referencja wdrożenia frontu: [frontend-setup.md](./frontend-setup.md)
|
||||
|
||||
## Zasady dla wszystkich modułów
|
||||
|
||||
1. **Helpery przyjmują `payload` jako argument** — plugin nigdy nie woła
|
||||
`getPayload` sam.
|
||||
2. **Sekrety w panelu** — SMTP, Turnstile secret, R2 w SiteIntegrations
|
||||
(admin-only). Odczyt server-side przez Local API.
|
||||
3. **Client/server split** — kod z sekretami ma `server-only`; komponenty
|
||||
client w `ipal-kit/client`.
|
||||
4. **Generyki na typy klienta** — helpery przyjmują `<T>` (np. wygenerowany
|
||||
`SiteSetting`), bo plugin nie zna typów projektu.
|
||||
@@ -0,0 +1,79 @@
|
||||
# access
|
||||
|
||||
Kontrola dostępu oparta na rolach: hierarchia **admin > editor > user**.
|
||||
Plugin wstrzykuje sztywne pole `roles` do klienckiej kolekcji auth i dostarcza
|
||||
predykaty oraz gotowe funkcje dostępu (collection-level i field-level).
|
||||
|
||||
## Config (payload.config.ts)
|
||||
|
||||
```ts
|
||||
ipalKit({
|
||||
access: {
|
||||
authCollection: 'users', // slug Twojej kolekcji auth
|
||||
// defaultRole: 'user', // rola nowych użytkowników, domyślnie 'user'
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Wstrzykuje pole `roles` (select hasMany, `saveToJWT`, tylko admin może
|
||||
zmieniać role) do wskazanej kolekcji. Kolekcja należy do Ciebie
|
||||
(create-payload-app) — plugin tylko dokłada pole.
|
||||
|
||||
> Po dodaniu: zadbaj, by seed / pierwszy user miał `roles: ['admin']`, inaczej
|
||||
> stracisz dostęp do zasobów admin-only (np. SiteIntegrations).
|
||||
|
||||
## Predykaty (front / hooki / access)
|
||||
|
||||
```ts
|
||||
import { isAdmin, isEditor, hasMinimumRole } from 'ipal-kit'
|
||||
|
||||
isAdmin(user) // admin?
|
||||
isEditor(user) // editor lub admin (hierarchia)
|
||||
hasMinimumRole(user, 'editor') // co najmniej editor
|
||||
```
|
||||
|
||||
Przyjmują luźno typowanego usera (jak `req.user`), czytają `roles` defensywnie.
|
||||
|
||||
## Gotowe funkcje dostępu
|
||||
|
||||
### Collection-level (zwracają boolean | Where)
|
||||
|
||||
```ts
|
||||
import { adminOnly, adminOrEditor, adminOrSelf, requireRole, authenticated } from 'ipal-kit'
|
||||
|
||||
export const Articles = {
|
||||
slug: 'articles',
|
||||
access: {
|
||||
read: authenticated,
|
||||
create: adminOrEditor,
|
||||
update: adminOrEditor,
|
||||
delete: adminOnly,
|
||||
},
|
||||
// ...
|
||||
}
|
||||
|
||||
// Users: każdy widzi siebie, admin widzi wszystkich
|
||||
access: { read: adminOrSelf }
|
||||
|
||||
// dowolny minimalny próg
|
||||
access: { update: requireRole('editor') }
|
||||
```
|
||||
|
||||
### Field-level (zwracają boolean)
|
||||
|
||||
```ts
|
||||
import { adminOnlyField, adminOrEditorField, requireRoleField } from 'ipal-kit'
|
||||
|
||||
{
|
||||
name: 'internalNote',
|
||||
type: 'textarea',
|
||||
access: { read: adminOnlyField, update: adminOnlyField },
|
||||
}
|
||||
```
|
||||
|
||||
## Hierarchia
|
||||
|
||||
```ts
|
||||
import { ROLE_HIERARCHY } from 'ipal-kit'
|
||||
// ['user', 'editor', 'admin'] — wyższa rola spełnia wymóg niższej
|
||||
```
|
||||
@@ -0,0 +1,63 @@
|
||||
# blocks
|
||||
|
||||
`RenderBlocks` — generyczny, serwerowy silnik renderowania bloków. Plugin nie
|
||||
zna Twoich bloków: iteruje po danych, mapuje `blockType` → komponent przez
|
||||
registry (prop), obsługuje zagnieżdżanie i rozszerzenia per-blok. Bloki
|
||||
(schemat + komponenty) definiujesz u siebie.
|
||||
|
||||
## Config
|
||||
|
||||
Brak opcji w payload.config — bloki definiujesz w swoich kolekcjach
|
||||
(pole typu `blocks`). Plugin dostarcza tylko silnik renderujący.
|
||||
|
||||
## Front — RenderBlocks
|
||||
|
||||
Import z `ipal-kit/rsc` (to komponent serwerowy):
|
||||
|
||||
```tsx
|
||||
import { RenderBlocks } from 'ipal-kit/rsc'
|
||||
|
||||
// Twój registry: blockType → komponent (komponenty są Twoje)
|
||||
import { Hero } from '@/blocks/Hero'
|
||||
import { FormBlock } from '@/blocks/FormBlock'
|
||||
|
||||
const registry = { hero: Hero, formBlock: FormBlock }
|
||||
|
||||
export default async function Page() {
|
||||
const page = await payload.findByID({ collection: 'pages', id })
|
||||
return <RenderBlocks blocks={page.layout} components={registry} />
|
||||
}
|
||||
```
|
||||
|
||||
- nieznany `blockType` → pomijany (null), nie crashuje
|
||||
- każdy blok dostaje `_components` (mapę) — do rekursji zagnieżdżonych bloków
|
||||
bez React Context (wymóg RSC)
|
||||
|
||||
## enhanceProps — logika per-blok bez wiedzy pluginu
|
||||
|
||||
Gdy blok potrzebuje danych z innych bloków (np. nawigacja zbierająca kotwice
|
||||
z sekcji), podaj `enhanceProps`. Plugin go wywołuje, nie znając Twoich bloków:
|
||||
|
||||
```tsx
|
||||
const enhanceProps = ({ block, allBlocks }) => {
|
||||
if (block.blockType !== 'sectionNav') return {}
|
||||
const sections = allBlocks
|
||||
.filter((b) => b.blockType === 'anchoredSection')
|
||||
.map((b) => ({ anchor: b.anchor, label: b.navLabel }))
|
||||
return { _sections: sections }
|
||||
}
|
||||
|
||||
<RenderBlocks blocks={page.layout} components={registry} enhanceProps={enhanceProps} />
|
||||
```
|
||||
|
||||
## Komponent bloku
|
||||
|
||||
Każdy komponent dostaje dane bloku jako propsy (plus `_components`, plus to co
|
||||
zwróci `enhanceProps`). Spacing/layout należą do Ciebie — silnik nie owija
|
||||
bloków żadnym markupem.
|
||||
|
||||
```tsx
|
||||
export function Hero(props) {
|
||||
return <section className="py-16">{/* ... */}</section>
|
||||
}
|
||||
```
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
# consent
|
||||
|
||||
Banner zgody na cookies (GDPR): 4 kategorie (necessary / functional /
|
||||
analytics / marketing), zapis w cookie z wersjonowaniem, Google Consent Mode,
|
||||
treść z globala CookieSettings. Domyślny wygląd w czystym Tailwind,
|
||||
nadpisywalny.
|
||||
|
||||
## Zależność
|
||||
|
||||
Banner używa ikony z `lucide-react`:
|
||||
|
||||
```json
|
||||
"dependencies": { "lucide-react": "^0.400.0" }
|
||||
```
|
||||
|
||||
## Config
|
||||
|
||||
Brak opcji — global **CookieSettings** jest zawsze budowany. Edytor zarządza
|
||||
treścią bannera (message, przyciski, kategorie, settingsTitle) w panelu,
|
||||
localized. Link do polityki prywatności bierze się z system pages
|
||||
(`privacyPolicy`), nie z osobnego pola.
|
||||
|
||||
## Front — Provider + banner
|
||||
|
||||
Provider owija aplikację, banner i button renderują się same. Import z
|
||||
`ipal-kit/client`:
|
||||
|
||||
```tsx
|
||||
// app/(frontend)/[locale]/layout.tsx
|
||||
import { ConsentProvider, CookieBanner, CookieButton } from 'ipal-kit/client'
|
||||
import { getConsentTexts } from 'ipal-kit'
|
||||
|
||||
export default async function Layout({ children, params }) {
|
||||
const { locale } = await params
|
||||
const payload = await getPayload({ config })
|
||||
|
||||
// teksty z CookieSettings + link do polityki z system pages
|
||||
const settings = await payload.findGlobal({ slug: 'site-settings', locale: 'all', depth: 1 })
|
||||
const texts = await getConsentTexts({
|
||||
payload, config: i18nConfig, locale,
|
||||
privacyPolicy: { label: 'Polityka prywatności', page: settings.privacyPolicy },
|
||||
})
|
||||
|
||||
return (
|
||||
<ConsentProvider texts={texts}>
|
||||
{children}
|
||||
<CookieBanner />
|
||||
<CookieButton />
|
||||
</ConsentProvider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Nadpisywanie wyglądu (Poziom 2)
|
||||
|
||||
Domyślne klasy Tailwind można nadpisać przez `classNames`:
|
||||
|
||||
```tsx
|
||||
<CookieBanner classNames={{
|
||||
root: 'fixed inset-x-0 bottom-0 ...', // Twój layout
|
||||
primaryButton: 'btn btn-primary', // np. DaisyUI
|
||||
secondaryButton: 'btn btn-ghost',
|
||||
}} />
|
||||
```
|
||||
|
||||
## Gating skryptów wg zgody
|
||||
|
||||
```ts
|
||||
import { updateConsent, setDefaultConsent } from 'ipal-kit'
|
||||
// wysyła sygnały do Google Consent Mode (gtag) na podstawie stanu zgody
|
||||
```
|
||||
|
||||
Logika (kategorie, storage, parsowanie) też jest dostępna server-safe z
|
||||
`ipal-kit`:
|
||||
|
||||
```ts
|
||||
import { parseConsent, CONSENT_COOKIE, CONSENT_CATEGORIES } from 'ipal-kit'
|
||||
// np. gating skryptów server-side na podstawie cookie zgody
|
||||
```
|
||||
|
||||
## Wygląd — nadpisywanie stylów
|
||||
|
||||
Banner i przycisk mają domyślny, neutralny wygląd (light + dark) i działają bez
|
||||
żadnej konfiguracji. Kolory i zaokrąglenia idą przez CSS custom properties z
|
||||
fallbackami — żeby przestylować pod klienta, zadeklaruj zmienne w swoim CSS.
|
||||
Bez importów, bez propsów, bez walki ze specificity:
|
||||
|
||||
```css
|
||||
/* global.css — wszystko opcjonalne, nadpisz tylko to, co chcesz */
|
||||
:root {
|
||||
--ipal-primary: #16a34a;
|
||||
--ipal-primary-hover: #15803d;
|
||||
--ipal-radius: 1rem;
|
||||
}
|
||||
```
|
||||
|
||||
Dostępne tokeny (każdy ma odpowiednik `-dark` używany pod `dark:`):
|
||||
|
||||
| Token | Domyślnie | Co koloruje |
|
||||
|---|---|---|
|
||||
| `--ipal-surface` | `#fff` | tło bannera i przycisku |
|
||||
| `--ipal-border` | `#e5e5e5` | obramowania |
|
||||
| `--ipal-text` | `#404040` | tekst treści |
|
||||
| `--ipal-text-strong` | `#171717` | nagłówki, nazwy kategorii |
|
||||
| `--ipal-text-muted` | `#737373` | opisy kategorii |
|
||||
| `--ipal-primary` | `#2563eb` | przycisk główny, ikona, link, checkbox |
|
||||
| `--ipal-primary-hover` | `#1d4ed8` | hover przycisku głównego |
|
||||
| `--ipal-primary-text` | `#fff` | tekst na przycisku głównym |
|
||||
| `--ipal-hover` | `#f5f5f5` | hover przycisków drugorzędnych |
|
||||
| `--ipal-radius` | `0.375rem` | zaokrąglenie przycisków |
|
||||
|
||||
Wymaga `@source` skanującego pakiet (patrz frontend-setup.md) — inaczej Tailwind
|
||||
nie wygeneruje tych klas.
|
||||
|
||||
### Gdy tokeny nie wystarczą
|
||||
|
||||
Układ (odstępy, pozycja, breakpointy) nie jest tokenizowany — to nie jest coś,
|
||||
co zmienia się per brand, a wystawienie go oznaczałoby wymyślanie CSS od nowa,
|
||||
zmienna po zmiennej. Na większe zmiany są `classNames`:
|
||||
|
||||
```tsx
|
||||
<CookieBanner classNames={{
|
||||
root: 'fixed inset-0 z-50 grid place-items-center bg-black/50', // modal zamiast paska
|
||||
primaryButton: 'btn btn-primary', // np. DaisyUI
|
||||
secondaryButton: 'btn btn-ghost',
|
||||
}} />
|
||||
<CookieButton className="fixed bottom-6 right-6 ..." />
|
||||
```
|
||||
|
||||
Sloty: `root`, `primaryButton`, `secondaryButton`. Podany className zastępuje
|
||||
domyślny (nie dokleja się).
|
||||
|
||||
Elementy mają też `data-ipal="banner"` i `data-ipal="cookie-button"` — stabilne
|
||||
uchwyty do CSS albo testów e2e.
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
# content
|
||||
|
||||
Kolekcje, których wpisy żyją pod stroną-archiwum — blog, realizacje, aktualności,
|
||||
cokolwiek z listingiem. Wpisy trafiają pod adres w rodzaju `/pl/artykuly/moj-post`,
|
||||
a segment `artykuly` nie jest osobną konfiguracją — to slug strony, którą edytor
|
||||
wskazał jako archiwum tej kolekcji.
|
||||
|
||||
## Model — kto czym jest
|
||||
|
||||
```
|
||||
kolekcja posts ──(config)──► „to kolekcja archiwalna”
|
||||
strona „Artykuły” ──(System Pages)──► „jestem archiwum kolekcji posts”
|
||||
wpis ──► należy do posts, i tyle
|
||||
```
|
||||
|
||||
Wpis niczego nie wie o swoim adresie. O adresie decyduje przypisanie strony —
|
||||
jedno dla całej kolekcji. Zmiana tytułu strony „Artykuły" na „Wpisy" przenosi
|
||||
całą sekcję na `/pl/wpisy`, per locale, bez deploya. To jak folder: pliki nie
|
||||
tagują się nazwą folderu, folder ma nazwę.
|
||||
|
||||
**Kolekcji nie da się dodać z panelu** — Payload trzyma schemat w kodzie. Nowy
|
||||
typ treści to zawsze zmiana w kodzie (nowa kolekcja + wpis w configu). Edytor
|
||||
zarządza tylko przypisaniem archiwum i jego adresem.
|
||||
|
||||
## Config
|
||||
|
||||
```ts
|
||||
// content.config.ts — współdzielony przez payload.config i front
|
||||
import type { ContentOption } from 'ipal-kit'
|
||||
|
||||
export const contentConfig: ContentOption = {
|
||||
collections: [
|
||||
{ slug: 'posts', label: 'Artykuły', perPage: 10 },
|
||||
{ slug: 'projects', label: 'Realizacje', perPage: 6 },
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// payload.config.ts
|
||||
ipalKit({
|
||||
pages: { slug: 'pages' }, // wymagane — archiwum jest stroną
|
||||
content: contentConfig,
|
||||
seo: { collections: ['pages', 'posts', 'projects'] }, // wpisy też chcą meta
|
||||
})
|
||||
```
|
||||
|
||||
Każda pozycja dodaje w **Site Settings → System Pages** pole relacji
|
||||
„{label} — archive page". `slug` to kolekcja klienta, `perPage` steruje
|
||||
paginacją listingu.
|
||||
|
||||
Osobny plik `content.config.ts` (jak `i18n.config.ts`) jest potrzebny, bo tę samą
|
||||
deklarację czytają dwa miejsca: payload.config (żeby dodać pola archiwum) i front
|
||||
(router). Jedno źródło prawdy.
|
||||
|
||||
## Rozstrzyganie tras — resolveRoute
|
||||
|
||||
Serce modułu. Catch-all `[[...slug]]` łapie wszystko, a `resolveRoute` mówi, czym
|
||||
dana ścieżka jest:
|
||||
|
||||
```ts
|
||||
import { resolveRoute } from 'ipal-kit'
|
||||
|
||||
const route = await resolveRoute({
|
||||
payload,
|
||||
locale: 'pl',
|
||||
segments: ['artykuly', 'moj-post'],
|
||||
page: 1, // z ?page=
|
||||
content: contentConfig,
|
||||
withEntries: false, // true → dociąga wpisy do archiwum (patrz niżej)
|
||||
})
|
||||
// route.type: 'home' | 'page' | 'archive' | 'entry' | (null gdy 404)
|
||||
```
|
||||
|
||||
Logika: pierwszy segment dopasowywany jest do slugów stron-archiwów z System
|
||||
Pages. Trafienie → wiadomo, której kolekcji dotyczy. `['artykuly']` → listing
|
||||
`posts`; `['artykuly','moj-post']` → wpis w `posts`; brak trafienia → zwykła
|
||||
strona.
|
||||
|
||||
**Archiwum ma pierwszeństwo przed stroną o tym samym slugu** — inaczej strona
|
||||
„artykuly" przesłaniałaby własne wpisy.
|
||||
|
||||
**Głębokość tylko `{archiwum}/{wpis}`** — kategorie w ścieżce robiłyby canonical
|
||||
niejednoznacznym (ten sam wpis pod wieloma URL-ami), więc `/a/b/c` → null.
|
||||
|
||||
**Kolizja slugów:** dwie kolekcje z archiwum o tym samym slugu → wygrywa pierwsza
|
||||
z listy `collections`. Błąd konfiguracji, router nie ostrzega.
|
||||
|
||||
## Helpery frontu — createContentHelpers
|
||||
|
||||
Zamiast pisać cache'owane wrappery w każdym projekcie:
|
||||
|
||||
```ts
|
||||
// src/lib/content.ts
|
||||
import { createContentHelpers } from 'ipal-kit'
|
||||
import config from '@/payload.config'
|
||||
import { contentConfig } from '@/content.config'
|
||||
|
||||
export const { getCachedPayload, getConfiguredLocales, getSettings, resolveRoute } =
|
||||
createContentHelpers({ config, content: contentConfig })
|
||||
```
|
||||
|
||||
Wszystko owinięte w React `cache()`, a instancja Payloada powstaje **raz** w
|
||||
fabryce i jest współdzielona — dlatego to fabryka, nie luźne funkcje. Bez tego
|
||||
`cache()` nie dedupikowałby między helperami, a Next woła generateMetadata i
|
||||
komponent strony osobno.
|
||||
|
||||
`resolveRoute` z fabryki przyjmuje `(locale, segments, page, withEntries?)`.
|
||||
|
||||
## withEntries — wpisy tylko gdy trzeba
|
||||
|
||||
Archiwum potrzebuje listy wpisów; metadane nie. `withEntries` rozstrzyga:
|
||||
|
||||
```ts
|
||||
// generateMetadata — bez wpisów (nie płaci za zapytanie)
|
||||
const route = await resolveRoute(locale, slug, page)
|
||||
|
||||
// komponent strony — z wpisami
|
||||
const route = await resolveRoute(locale, slug, page, true)
|
||||
// route.entries: { docs, page, totalPages, hasPrevPage, hasNextPage, ... }
|
||||
```
|
||||
|
||||
Metadane i strona wołają z różnymi argumentami, więc `cache()` traktuje je jako
|
||||
osobne wywołania — i słusznie, bo metadane wpisów nie potrzebują.
|
||||
|
||||
## Listing
|
||||
|
||||
Strona-archiwum to zwykła strona z blokami, więc listing jest **blokiem** (Twoim
|
||||
— plugin nie decyduje o wyglądzie listy). Blok dostaje wpisy przez enhanceProps:
|
||||
|
||||
```tsx
|
||||
// w page.tsx
|
||||
const enhanceProps = ({ block }) => {
|
||||
if (block.blockType === 'entriesList' && route.type === 'archive') {
|
||||
return { entries: route.entries, locale, archiveSlug: route.doc.slug }
|
||||
}
|
||||
return {}
|
||||
}
|
||||
```
|
||||
|
||||
Blok nie wie, co listuje — wpisy wstrzykuje trasa na podstawie tego, której
|
||||
kolekcji ta strona jest archiwum. Ten sam blok obsługuje `/pl/artykuly` i
|
||||
`/pl/realizacje`.
|
||||
|
||||
## Ścieżki i paginacja
|
||||
|
||||
```ts
|
||||
import { buildEntryPath, buildArchivePath, parsePageParam } from 'ipal-kit'
|
||||
|
||||
buildEntryPath({ locale: 'pl', archiveSlug: 'artykuly', entrySlug: 'moj-post' })
|
||||
// '/pl/artykuly/moj-post'
|
||||
|
||||
buildArchivePath({ locale: 'pl', archiveSlug: 'artykuly', page: 2 })
|
||||
// '/pl/artykuly?page=2' (strona 1 bez query)
|
||||
|
||||
parsePageParam(searchParams.page) // '2' → 2; śmieci/undefined → 1
|
||||
```
|
||||
|
||||
Paginacja przez `?page=`, nie `/2`. Segment ścieżki gryzłby się z wpisem o slugu
|
||||
„2", a `/strona/2` dodawałby kolejny zlokalizowany segment do konfiguracji.
|
||||
Google rozumie `?page=` od lat, a `rel=next/prev` zostało wycofane.
|
||||
|
||||
Canonical strony 2 wskazuje **na siebie** (`?page=2`), nie na stronę 1 — inaczej
|
||||
Google uznałby, że wpisów z dalszych stron nie ma. Hreflangi niosą ten sam numer
|
||||
(`/en/articles?page=2`). To ogarnia `createPageMetadata` automatycznie, gdy
|
||||
przekażesz mu `page` i `content`.
|
||||
|
||||
## Metadane wpisów
|
||||
|
||||
`createPageMetadata` z opcją `content` sam rozpoznaje wpisy i buduje im poprawny
|
||||
canonical/hreflang z prefiksem (patrz seo.md):
|
||||
|
||||
```ts
|
||||
const pageMetadata = createPageMetadata({
|
||||
config: i18nConfig,
|
||||
baseUrl: process.env.NEXT_PUBLIC_SERVER_URL,
|
||||
content: contentConfig, // ← bez tego wpisy dostają zły adres
|
||||
})
|
||||
```
|
||||
|
||||
## Przełącznik języka
|
||||
|
||||
`switchLocalePath` (z i18n) uwzględnia prefiks: polski wpis bez tłumaczenia EN
|
||||
prowadzi do archiwum EN (`/en/articles`), nie na stronę główną — najbliżej tego,
|
||||
czego szuka odwiedzający.
|
||||
|
||||
## Eksport
|
||||
|
||||
```ts
|
||||
import {
|
||||
resolveRoute,
|
||||
getArchiveEntries,
|
||||
buildArchivePath,
|
||||
buildEntryPath,
|
||||
parsePageParam,
|
||||
createContentHelpers,
|
||||
archiveFieldName,
|
||||
} from 'ipal-kit'
|
||||
import type { ContentOption, ResolvedRoute, ArchiveEntries } from 'ipal-kit'
|
||||
```
|
||||
@@ -0,0 +1,51 @@
|
||||
# email
|
||||
|
||||
Wysyłka maili przez SMTP z SiteIntegrations, w runtime (bez Payload email
|
||||
adaptera). Edytor zmienia SMTP w panelu — następny mail idzie z nowymi
|
||||
ustawieniami, bez restartu.
|
||||
|
||||
## Zależność
|
||||
|
||||
```json
|
||||
"dependencies": { "nodemailer": "^6.9.0" }
|
||||
```
|
||||
(+ `@types/nodemailer` w devDependencies)
|
||||
|
||||
## Config
|
||||
|
||||
Brak opcji — SMTP (host, port, user, password, from) jest w SiteIntegrations
|
||||
(tab SMTP). Edytor konfiguruje w panelu.
|
||||
|
||||
## Front — sendEmail (server)
|
||||
|
||||
```ts
|
||||
import { sendEmail } from 'ipal-kit/server'
|
||||
|
||||
const result = await sendEmail({
|
||||
payload,
|
||||
to: '[email protected]',
|
||||
subject: 'Nowa wiadomość',
|
||||
html: '<h1>Cześć</h1><p>Treść…</p>', // gotowy HTML (Twój wygląd)
|
||||
// text: 'wersja plain', from: '...', replyTo: '...'
|
||||
})
|
||||
|
||||
if (result.sent) {
|
||||
// result.messageId
|
||||
} else {
|
||||
// result.error — np. "SMTP is not configured..."
|
||||
}
|
||||
```
|
||||
|
||||
- **HTML to Twój argument** — plugin nie ma templatek, wysyła to co podasz.
|
||||
Wygląd maila składasz na froncie.
|
||||
- Zwraca `{ sent: true, messageId }` albo `{ sent: false, error }` — nie
|
||||
rzuca wyjątku, nie wycieka detali SMTP do klienta.
|
||||
- Port 465 → implicit TLS, inne → STARTTLS.
|
||||
- `server-only` — hasło SMTP nigdy w bundlu przeglądarki.
|
||||
|
||||
## Uwaga: maile systemowe Payloada
|
||||
|
||||
Reset hasła / weryfikacja email idą przez wbudowany mechanizm Payload
|
||||
(`config.email`), którego ten moduł **nie** konfiguruje (celowo — wymagałby
|
||||
SMTP w env). Jeśli ich potrzebujesz, to osobna konfiguracja adaptera przy
|
||||
`buildConfig`.
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
# forms
|
||||
|
||||
Wpina `@payloadcms/plugin-form-builder` (kolekcje forms + form-submissions) i
|
||||
dostarcza `submitForm` — wywoływalną z frontu funkcję, która spina: weryfikację
|
||||
Turnstile → zapis zgłoszenia → wysyłkę maili (naszym senderem).
|
||||
|
||||
## Zależność
|
||||
|
||||
```json
|
||||
"dependencies": { "@payloadcms/plugin-form-builder": "3.84.1" }
|
||||
```
|
||||
|
||||
## Config (payload.config.ts)
|
||||
|
||||
```ts
|
||||
ipalKit({
|
||||
forms: {
|
||||
redirectRelationships: ['pages'], // formularz może przekierować na Page
|
||||
// fields: { text: true, textarea: true, email: true, ... }, // domyślnie włączone sensowne
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Dodaje kolekcje **Forms** (edytor buduje formularze) i **Form Submissions**
|
||||
(zgłoszenia). Wbudowany email form-buildera jest nieużywany — wysyłką zajmuje
|
||||
się `submitForm` przez nasz sender (SMTP z panelu).
|
||||
|
||||
## Front — submitForm (server)
|
||||
|
||||
Zwykle w Server Action wywoływanej przez formularz. HTML obu maili składasz
|
||||
na froncie (Twój wygląd):
|
||||
|
||||
```ts
|
||||
import { submitForm } from 'ipal-kit/server'
|
||||
|
||||
const result = await submitForm({
|
||||
payload,
|
||||
formId, // z kolekcji Forms
|
||||
data: { name, email, message },
|
||||
turnstileToken, // opcjonalny — jeśli podany, weryfikowany
|
||||
ip,
|
||||
emails: {
|
||||
// wiadomość do klienta/admina (np. "nowe zgłoszenie")
|
||||
notification: {
|
||||
to: '[email protected]',
|
||||
subject: 'Nowe zgłoszenie',
|
||||
html: renderAdminEmail(data), // Twój HTML
|
||||
},
|
||||
// potwierdzenie do wysyłającego
|
||||
confirmation: {
|
||||
to: email,
|
||||
subject: 'Dziękujemy za wiadomość',
|
||||
html: renderUserEmail(data), // Twój HTML
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
// result.submissionId
|
||||
// result.emails.notification?.sent / result.emails.confirmation?.sent
|
||||
} else {
|
||||
// result.error — np. Turnstile / zapis
|
||||
}
|
||||
```
|
||||
|
||||
## Flow i gwarancje
|
||||
|
||||
1. **Turnstile** (jeśli token) → nieudany → odrzuć **przed** zapisem (brak
|
||||
spamu w bazie).
|
||||
2. **Zapis** submission (form-submissions).
|
||||
3. **Maile** — oba opcjonalne, HTML z frontu.
|
||||
4. Zwrot: `{ success, submissionId, emails: { notification?, confirmation? } }`.
|
||||
|
||||
**Zapisane zgłoszenie = sukces, nawet gdy mail padnie.** Status wysyłki maili
|
||||
jest osobno w `result.emails`, żeby dane zgłoszenia nie ginęły przez chwilową
|
||||
awarię SMTP. Front może zareagować (ostrzec, ponowić).
|
||||
|
||||
Oba maile opcjonalne — możesz wysłać jeden, drugi, oba lub żaden.
|
||||
`turnstileToken` opcjonalny — brak = pominięcie weryfikacji (decydujesz per
|
||||
formularz).
|
||||
|
||||
## Maile
|
||||
|
||||
Plugin nie składa maili formularzy. Po zapisie submission form-builder wysyła
|
||||
wiadomości skonfigurowane przez edytora (Forms → formularz → Emails), przez
|
||||
`payload.sendEmail`. Żeby wyszły, config musi mieć adapter:
|
||||
|
||||
```ts
|
||||
email: panelSmtpAdapter(), // z 'ipal-kit'
|
||||
```
|
||||
|
||||
Wtedy idą przez SMTP z Site Integrations. Edytor ustawia odbiorców, temat i
|
||||
treść (placeholdery: `{{pole}}`, `{{*}}`, `{{*:table}}`) bez dotykania kodu.
|
||||
|
||||
`submitForm` odpowiada tylko za weryfikację Turnstile i zapis.
|
||||
|
||||
## Własne pola w kolekcji formularzy
|
||||
|
||||
`formOverrides` przechodzi prosto do form-buildera — plugin nie ma opinii, czego
|
||||
formularz potrzebuje poza swoimi polami:
|
||||
|
||||
```ts
|
||||
ipalKit({
|
||||
forms: {
|
||||
redirectRelationships: ['pages'],
|
||||
formOverrides: {
|
||||
fields: ({ defaultFields }) => [
|
||||
...defaultFields,
|
||||
{ name: 'internalNote', type: 'textarea' },
|
||||
],
|
||||
admin: { group: 'Content' },
|
||||
},
|
||||
// formSubmissionOverrides: { ... } // to samo dla zgłoszeń
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
`fields` dostaje domyślne pola kolekcji i zwraca finalną listę — możesz dodawać,
|
||||
usuwać, zmieniać kolejność. Poza `fields` przyjmuje dowolne ustawienia kolekcji
|
||||
(admin, access, hooks).
|
||||
|
||||
## Bezpieczeństwo i wynik zgłoszenia
|
||||
|
||||
`submitForm` przechodzi trzy bramki w kolejności rosnącej po koszcie: rate limit
|
||||
(in-memory, per IP), Turnstile, walidacja względem schematu formularza. Dopiero
|
||||
potem zapis. Flood ginie, zanim dotknie sieci czy bazy.
|
||||
|
||||
Walidacja jest istotna, bo server action to publiczny endpoint — da się go wołać
|
||||
z pominięciem formularza. Plugin ładuje definicję formularza i: odrzuca nieznane
|
||||
klucze, wymusza pola `required`, tnie długość (5000 znaków). Do bazy trafia tylko
|
||||
to, co formularz definiuje.
|
||||
|
||||
Rate limit: 5/min/IP domyślnie, `maxPerMinute` zmienia, `0` wyłącza (gdy limiter
|
||||
jest z przodu). In-memory — przy wielu instancjach licznik jest per-proces, więc
|
||||
efektywny limit to per-instancja. Do formularza kontaktowego wystarcza.
|
||||
|
||||
### Wynik to KOD, nie tekst
|
||||
|
||||
`submitForm` zwraca ustrukturyzowany błąd — plugin mówi CO się stało, projekt
|
||||
decyduje JAK to pokazać (język, brzmienie, obsługa per pole):
|
||||
|
||||
```ts
|
||||
type SubmitFormResult =
|
||||
| { success: true; submissionId: string | number }
|
||||
| { success: false; reason: 'rate_limited' }
|
||||
| { success: false; reason: 'turnstile' }
|
||||
| { success: false; reason: 'validation'; field?: string; kind?: 'required' | 'too_long' | 'unknown_fields' }
|
||||
| { success: false; reason: 'not_found' }
|
||||
| { success: false; reason: 'error' }
|
||||
```
|
||||
|
||||
Front mapuje kody na własne komunikaty:
|
||||
|
||||
```tsx
|
||||
function errorMessage(r) {
|
||||
switch (r.reason) {
|
||||
case 'rate_limited': return 'Zbyt wiele zgłoszeń...'
|
||||
case 'validation':
|
||||
if (r.kind === 'required') return 'Uzupełnij wymagane pola.'
|
||||
// r.field → podświetl konkretne pole
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Ten sam wzorzec co consent: plugin nie zaszywa języka, oddaje dane.
|
||||
@@ -0,0 +1,237 @@
|
||||
# Frontend — wymagane implementacje
|
||||
|
||||
Co projekt klienta musi zrobić na froncie, żeby plugin działał. Zebrane z
|
||||
realnego wdrożenia (ipal-test). W przyszłości → pełny poradnik "first setup".
|
||||
|
||||
## Wymagania środowiska
|
||||
|
||||
### Tailwind CSS (WYMÓG)
|
||||
|
||||
Komponenty pluginu (CookieBanner, Turnstile widget, i inne) są w czystym
|
||||
Tailwind. Klient MUSI mieć Tailwind + skanować pakiet pluginu:
|
||||
|
||||
```bash
|
||||
pnpm add tailwindcss @tailwindcss/postcss # v4
|
||||
```
|
||||
|
||||
`postcss.config.mjs` (root):
|
||||
```js
|
||||
export default { plugins: { '@tailwindcss/postcss': {} } }
|
||||
```
|
||||
|
||||
W globalnym CSS (np. app/(frontend)/styles.css):
|
||||
```css
|
||||
@import "tailwindcss";
|
||||
@source "../../../node_modules/ipal-kit/dist/**/*.js";
|
||||
```
|
||||
|
||||
**@source jest kluczowy** — Tailwind domyślnie NIE skanuje node_modules, więc
|
||||
bez tego klasy komponentów pluginu się nie wygenerują (komponenty renderują
|
||||
się bez stylów). Ścieżka relatywna do pliku CSS.
|
||||
|
||||
### Przestylowanie pod klienta
|
||||
|
||||
Komponenty pluginu (CookieBanner, CookieButton) mają domyślny wygląd i działają
|
||||
bez konfiguracji. Kolory/zaokrąglenia przez CSS custom properties z fallbackami
|
||||
— nadpisz w swoim CSS:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--ipal-primary: #16a34a;
|
||||
--ipal-radius: 1rem;
|
||||
}
|
||||
```
|
||||
|
||||
Pełna lista tokenów + opcja classNames (gdy tokeny nie starczą): docs/consent.md.
|
||||
Bloki są Twoje — plugin ich nie stylizuje, RenderBlocks nie dodaje markupu.
|
||||
|
||||
### Wymagane zależności (transitive)
|
||||
|
||||
Instalacja z npm zaciąga automatycznie. Przy lokalnym tarballu doinstaluj:
|
||||
```
|
||||
@payloadcms/plugin-seo @payloadcms/plugin-form-builder nodemailer
|
||||
lucide-react slugify server-only
|
||||
```
|
||||
|
||||
### Spójność wersji @payloadcms/*
|
||||
|
||||
pnpm.overrides wymuszające jedną wersję (patrz README).
|
||||
|
||||
### generate:importmap
|
||||
|
||||
Po wpięciu pluginu: `pnpm payload generate:importmap` (dla pól SEO w adminie).
|
||||
|
||||
## Struktura tras (lokalizacja)
|
||||
|
||||
```
|
||||
src/app/(frontend)/
|
||||
layout.tsx # root (<html><body>) — istniejący
|
||||
[locale]/
|
||||
layout.tsx # walidacja locale + ConsentProvider
|
||||
[[...slug]]/
|
||||
page.tsx # render strony (bloki)
|
||||
```
|
||||
|
||||
**[[...slug]] MUSI być podwójny nawias** (opcjonalny catch-all):
|
||||
- `[slug]` → string (błąd `slug.join is not a function`)
|
||||
- `[[...slug]]` → tablica (poprawne), łapie /pl (home) i /pl/o-nas jednym plikiem
|
||||
|
||||
## i18n — jedno źródło prawdy
|
||||
|
||||
Wydziel config locale do osobnego pliku, 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: locales nie pasuje do niepustej tuple
|
||||
```
|
||||
|
||||
Importuj w: payload.config (ipalKit({ i18n: i18nConfig })), middleware.ts.
|
||||
Layout może czytać locale z payload config (config.localization.locales) —
|
||||
też jedno źródło.
|
||||
|
||||
## 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
|
||||
}
|
||||
|
||||
// matcher MUSI być inline (Next analizuje statycznie, nie wykonuje importów —
|
||||
// import DEFAULT_MIDDLEWARE_MATCHER byłby zignorowany → middleware łapie
|
||||
// /admin /_next /api → 500)
|
||||
export const config = {
|
||||
matcher: ['/((?!api|admin|_next|.*\\..*).*)'],
|
||||
}
|
||||
```
|
||||
|
||||
## Rozwiązywanie strony (page.tsx)
|
||||
|
||||
- brak slug (/pl) → home przez System Pages (settings.homepage), NIE hardkod slug
|
||||
- slug (/pl/o-nas) → payload.find by slug w danym locale
|
||||
- depth: 2 → żeby relacje w blokach (form) się populowały
|
||||
|
||||
## Consent (layout [locale])
|
||||
|
||||
Layout (server) czyta getConsentTexts, przekazuje jako prop do ConsentProvider
|
||||
(client). Banner + button renderują się same:
|
||||
|
||||
```ts
|
||||
import { getConsentTexts } from 'ipal-kit'
|
||||
import { ConsentProvider, CookieBanner, CookieButton } from 'ipal-kit/client'
|
||||
|
||||
const texts = await getConsentTexts({ config, locale, payload, privacyPolicy })
|
||||
// <ConsentProvider texts={texts}>{children}<CookieBanner/><CookieButton/></ConsentProvider>
|
||||
```
|
||||
|
||||
## Bloki (RenderBlocks)
|
||||
|
||||
Klient definiuje bloki (config + komponent) — plugin jest block-agnostic.
|
||||
|
||||
- `blocks/<Nazwa>/config.ts` — schemat Payload (Block)
|
||||
- `blocks/<Nazwa>/Component.tsx` — komponent (dane bloku jako propsy)
|
||||
- `blocks/registry.ts` — mapa blockType → komponent
|
||||
- Pages: pole `layout` typu blocks z listą bloków
|
||||
- page.tsx: `<RenderBlocks blocks={doc.layout} components={registry} />`
|
||||
|
||||
**Puste blocks: [] crashuje** (traverseFields) — zawsze z co najmniej jednym
|
||||
blokiem.
|
||||
|
||||
enhanceProps — wstrzykiwanie server-side wartości do bloku bez wiedzy pluginu
|
||||
(np. turnstileSiteKey, email do FormBlock):
|
||||
```ts
|
||||
const enhanceProps = ({ block }) => {
|
||||
if (block.blockType === 'formBlock') return { turnstileSiteKey, notificationTo }
|
||||
return {}
|
||||
}
|
||||
```
|
||||
|
||||
## Formularz (FormBlock)
|
||||
|
||||
- FormRenderer (client) — renderuje pola form-buildera (text/email/select/
|
||||
country/checkbox/textarea/number/state/message)
|
||||
- Turnstile widget (ipal-kit/client) — siteKey jako prop, wstrzykiwany przez
|
||||
enhanceProps (z SiteIntegrations, publiczny — bezpieczny na kliencie)
|
||||
- server action → submitForm (ipal-kit/server) — weryfikuje Turnstile i zapisuje
|
||||
|
||||
Maili NIE składa się w kodzie. Po zapisie submission form-builder sam wysyła
|
||||
wiadomości skonfigurowane przez edytora (Forms → dany formularz → Emails:
|
||||
Email To / CC / BCC / Subject / Message z placeholderami {{pole}}, {{*}},
|
||||
{{*:table}}). Idą przez payload.sendEmail → panelSmtpAdapter → SMTP z panelu.
|
||||
|
||||
Wymaga w payload.config:
|
||||
|
||||
```ts
|
||||
import { ipalKit, panelSmtpAdapter } from 'ipal-kit'
|
||||
|
||||
export default buildConfig({
|
||||
email: panelSmtpAdapter(),
|
||||
plugins: [ipalKit({ ... })],
|
||||
})
|
||||
```
|
||||
|
||||
Wymaga w SiteIntegrations: SMTP (host, port, user, password, from) + Turnstile.
|
||||
Turnstile testowe klucze Cloudflare (zawsze pass):
|
||||
site 1x00000000000000000000AA, secret 1x0000000000000000000000000000000AA.
|
||||
|
||||
Walidacja server-side (limit długości pól) w actions.ts — browserowy `required`
|
||||
da się obejść wołając akcję bezpośrednio.
|
||||
|
||||
## Metadata (SEO na froncie)
|
||||
|
||||
createMetadataGenerator zwraca funkcję `({ payload, params, locale })` — Next
|
||||
woła `generateMetadata({ params })` bez payload/locale, a plugin nigdy nie
|
||||
wywołuje getPayload sam, więc klient opakowuje:
|
||||
|
||||
```ts
|
||||
const generate = createMetadataGenerator({
|
||||
config: i18nConfig,
|
||||
baseUrl: process.env.NEXT_PUBLIC_SERVER_URL,
|
||||
homeSlug: 'homepage', // home zwija się do /pl, nie /pl/homepage
|
||||
resolveDocument: async ({ params, locale }) => { ... }, // locale: 'all'!
|
||||
resolveSiteName: async ({ locale }) => { ... },
|
||||
resolveImageUrl: async ({ doc }) => { ... },
|
||||
})
|
||||
|
||||
export async function generateMetadata({ params }) {
|
||||
const { locale, slug } = await params
|
||||
const payload = await getPayload({ config: await config })
|
||||
return generate({ payload, params: { slug: slug ?? [] }, locale })
|
||||
}
|
||||
```
|
||||
|
||||
resolveDocument MUSI pobrać dokument z `locale: 'all'` — wtedy `slug` jest mapą
|
||||
locale→wartość, z której budowane są hreflang alternates. Zwykły fetch (jeden
|
||||
locale) da tylko string i hreflang nie powstanie.
|
||||
|
||||
Next uruchamia generateMetadata i komponent strony niezależnie — bez React
|
||||
cache() każde żądanie odpytuje bazę dwa razy o ten sam dokument.
|
||||
|
||||
## Analytics
|
||||
|
||||
W layoucie [locale], WEWNĄTRZ ConsentProvider (Analytics ustawia Consent Mode
|
||||
ze stored choice, zanim załaduje tag):
|
||||
|
||||
```ts
|
||||
const analytics = await getAnalyticsConfig(payload) // tylko publiczne GA4/GTM ID
|
||||
// <ConsentProvider texts={texts}> ... <Analytics {...analytics} /> </ConsentProvider>
|
||||
```
|
||||
|
||||
ID z SiteIntegrations. GTM ma priorytet nad GA4, gdy oba ustawione.
|
||||
@@ -0,0 +1,494 @@
|
||||
# Nowy projekt — krok po kroku
|
||||
|
||||
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.
|
||||
|
||||
### 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 |
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
# i18n
|
||||
|
||||
Lokalizacja: konfiguracja locali dla Payload, negocjacja języka
|
||||
(cookie / Accept-Language / default), budowanie ścieżek locale-aware,
|
||||
przełączanie języka bez 404.
|
||||
|
||||
## Config (payload.config.ts)
|
||||
|
||||
```ts
|
||||
ipalKit({
|
||||
i18n: {
|
||||
defaultLocale: 'pl',
|
||||
locales: [
|
||||
{ code: 'pl', label: 'Polski' },
|
||||
{ code: 'en', label: 'English' },
|
||||
// { code: 'ar', label: 'العربية', rtl: true },
|
||||
],
|
||||
// fallback: true, // domyślnie true
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Plugin ustawia `config.localization` z tego. Walidacja jest eager (fail-fast
|
||||
przy starcie): pusta lista, duplikaty kodów, `defaultLocale` spoza listy,
|
||||
zły format kodu → błąd `[ipal] i18n: ...`.
|
||||
|
||||
## Front — helpery
|
||||
|
||||
Wszystkie helpery są czyste (przyjmują config jako argument). Trzymaj swój
|
||||
`i18nConfig` w jednym miejscu i importuj gdzie trzeba.
|
||||
|
||||
```ts
|
||||
import {
|
||||
getLocaleCodes, getDefaultLocale, isValidLocale, getLocaleDefinition,
|
||||
negotiateLocale, buildLocalizedPath, switchLocalePath,
|
||||
getLocalizedSlugs, LOCALE_COOKIE_NAME,
|
||||
} from 'ipal-kit'
|
||||
|
||||
const config = { defaultLocale: 'pl', locales: [{code:'pl',label:'Polski'},{code:'en',label:'English'}] }
|
||||
|
||||
getLocaleCodes(config) // ['pl', 'en']
|
||||
isValidLocale('de', config) // false
|
||||
```
|
||||
|
||||
### Budowanie ścieżek
|
||||
|
||||
```ts
|
||||
// dokument pobrany z locale:'all' → slug to mapa { pl, en }
|
||||
const doc = await payload.findByID({ collection: 'pages', id, locale: 'all' })
|
||||
const slugs = getLocalizedSlugs({ slugField: doc.slug, config })
|
||||
// { pl: 'o-nas', en: 'about' }
|
||||
|
||||
buildLocalizedPath({ slugs, locale: 'en', config }) // '/en/about'
|
||||
// home slug ('home') zwija się do roota:
|
||||
buildLocalizedPath({ slugs: { en: 'home' }, locale: 'en', config }) // '/en'
|
||||
```
|
||||
|
||||
### Przełącznik języka (bez 404)
|
||||
|
||||
```ts
|
||||
// /pl/strona-glowna → klik EN → /en/home (albo /en jeśli brak tłumaczenia)
|
||||
const href = switchLocalePath({ slugs, targetLocale: 'en', config })
|
||||
router.push(href)
|
||||
```
|
||||
|
||||
`switchLocalePath` nigdy nie zwraca undefined — brak slug w danym locale →
|
||||
fallback na `/{locale}` (root), zamiast dead-endu na 404.
|
||||
|
||||
## Middleware — patrz osobno
|
||||
|
||||
Negocjacja locale + redirect na wejściu (`domena.com` → `/pl`) jest w
|
||||
`ipal-kit/next/middleware`. Zobacz [middleware w tej sekcji](#middleware)
|
||||
niżej.
|
||||
|
||||
## Middleware
|
||||
|
||||
```ts
|
||||
// next-middleware.ts (projekt klienta) — jedyna logika to podłączenie
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from 'ipal-kit/next/middleware'
|
||||
|
||||
const i18nConfig = {
|
||||
defaultLocale: 'pl',
|
||||
locales: [{ code: 'pl', label: 'Polski' }, { code: 'en', label: 'English' }],
|
||||
}
|
||||
const localeMiddleware = createLocaleMiddleware({ config: i18nConfig })
|
||||
|
||||
export function middleware(req) {
|
||||
const r = localeMiddleware(req)
|
||||
if (r.type === 'next') return NextResponse.next()
|
||||
const res = NextResponse.redirect(r.location)
|
||||
res.cookies.set(r.cookie.name, r.cookie.value)
|
||||
return res
|
||||
}
|
||||
|
||||
export const config = { matcher: DEFAULT_MIDDLEWARE_MATCHER }
|
||||
```
|
||||
|
||||
Zachowanie:
|
||||
- ścieżka z locale (`/pl/...`) → przepuść
|
||||
- root albo ścieżka bez locale (`/`, `/o-nas`) → redirect na `/{locale}...`,
|
||||
locale z: cookie → Accept-Language → default
|
||||
- wybrany locale zapisany w cookie (`LOCALE_COOKIE_NAME`)
|
||||
|
||||
`DEFAULT_MIDDLEWARE_MATCHER` wyklucza `api`, `admin`, `_next`, pliki statyczne.
|
||||
@@ -0,0 +1,55 @@
|
||||
# pages
|
||||
|
||||
System pages: centralne przypisanie ról (homepage / privacyPolicy /
|
||||
cookiePolicy) do dokumentów z klienckiej kolekcji Pages, w SiteSettings.
|
||||
Front rozwiązuje rolę na ścieżkę locale-aware.
|
||||
|
||||
## Config (payload.config.ts)
|
||||
|
||||
```ts
|
||||
ipalKit({
|
||||
pages: {
|
||||
slug: 'pages', // slug Twojej kolekcji Pages
|
||||
// roles: ['homepage', 'privacyPolicy', 'cookiePolicy'], // domyślnie wszystkie
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Dodaje tab **System Pages** w SiteSettings z polami relationship do Twojej
|
||||
kolekcji Pages. Edytor wybiera, który dokument pełni którą rolę. Plugin nie
|
||||
zna Twojej kolekcji — slug podajesz w opcji.
|
||||
|
||||
## Front — rozwiązanie ścieżki roli
|
||||
|
||||
```ts
|
||||
import { getSystemPagePath } from 'ipal-kit'
|
||||
|
||||
// SiteSettings z locale:'all' + depth:1 (żeby relationship był obiektem, nie ID)
|
||||
const settings = await payload.findGlobal({
|
||||
slug: 'site-settings', locale: 'all', depth: 1,
|
||||
})
|
||||
|
||||
// homepage w locale 'pl'
|
||||
getSystemPagePath({ page: settings.homepage, locale: 'pl', config })
|
||||
// → '/pl' (home slug zwija się do roota) lub '/pl/strona-glowna'
|
||||
|
||||
// polityka prywatności w 'en'
|
||||
getSystemPagePath({ page: settings.privacyPolicy, locale: 'en', config })
|
||||
// → '/en/privacy-policy'
|
||||
```
|
||||
|
||||
Zwraca `undefined`, gdy rola nieprzypisana lub dokument nie ma slug w danym
|
||||
locale — caller decyduje o fallbacku (404, redirect).
|
||||
|
||||
## Typowy przypadek: `/{locale}` → strona główna
|
||||
|
||||
W trasie `[locale]/page.tsx` czytasz `settings.homepage`, bierzesz jego slug
|
||||
w bieżącym locale i renderujesz ten dokument. Link do polityki prywatności w
|
||||
stopce / bannerze cookies bierzesz z `getSystemPagePath({ role: privacyPolicy })`.
|
||||
|
||||
## Role
|
||||
|
||||
```ts
|
||||
import { ALL_SYSTEM_PAGE_ROLES } from 'ipal-kit'
|
||||
// ['homepage', 'privacyPolicy', 'cookiePolicy']
|
||||
```
|
||||
@@ -0,0 +1,54 @@
|
||||
# payload-helpers
|
||||
|
||||
Typowany dostęp do globali pluginu (SiteSettings, SiteIntegrations) przez
|
||||
Local API. Zgodnie z zasadą: helpery przyjmują `payload` jako argument —
|
||||
plugin nigdy nie woła `getPayload` sam.
|
||||
|
||||
## Config
|
||||
|
||||
Brak — te globale są zawsze budowane przez plugin. Nie ma osobnej opcji.
|
||||
|
||||
## Front — odczyt globali
|
||||
|
||||
```ts
|
||||
import { getSiteSettings, getSiteIntegrations } from 'ipal-kit'
|
||||
import type { SiteSetting, SiteIntegration } from '@/payload-types'
|
||||
|
||||
const payload = await getPayload({ config })
|
||||
|
||||
// SiteSettings (publiczne — siteName, logo, favicon, theme, system pages)
|
||||
const settings = await getSiteSettings<SiteSetting>(payload, { locale: 'pl' })
|
||||
|
||||
// SiteIntegrations (admin-only; Local API omija access control)
|
||||
const integrations = await getSiteIntegrations<SiteIntegration>(payload)
|
||||
```
|
||||
|
||||
Generyk `<T>` pozwala wstrzyknąć wygenerowany typ klienta. Bez niego zwraca
|
||||
`Record<string, unknown>`.
|
||||
|
||||
## ⚠️ SiteIntegrations zawiera sekrety
|
||||
|
||||
Local API domyślnie omija access control (`overrideAccess: true`), więc
|
||||
`getSiteIntegrations` **zwróci sekrety** (SMTP password, Turnstile secret,
|
||||
R2 keys) mimo bariery admin-only na globalu. To zamierzone — logika serwerowa
|
||||
tego potrzebuje.
|
||||
|
||||
**Nigdy nie przekazuj surowego wyniku do przeglądarki.** Czytaj konkretne
|
||||
wartości server-side, do klienta wysyłaj tylko bezpieczne (np. `turnstileSiteKey`,
|
||||
nie `turnstileSecretKey`):
|
||||
|
||||
```ts
|
||||
// ŹLE — wyciek sekretów do klienta
|
||||
return <Form data={await getSiteIntegrations(payload)} />
|
||||
|
||||
// DOBRZE — tylko publiczna wartość
|
||||
const { turnstileSiteKey } = await getSiteIntegrations(payload)
|
||||
return <Form siteKey={turnstileSiteKey} />
|
||||
```
|
||||
|
||||
## Niższy poziom: getGlobal
|
||||
|
||||
```ts
|
||||
import { getGlobal } from 'ipal-kit'
|
||||
const data = await getGlobal<MyType>(payload, 'moj-global', { locale: 'pl', depth: 1 })
|
||||
```
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
# seo
|
||||
|
||||
Wpina `@payloadcms/plugin-seo` (pola meta w kolekcjach) i dodaje warstwę
|
||||
logiki: składanie tytułów, budowanie `Metadata` dla Next.js z canonical i
|
||||
hreflang, auto-fill pustych meta z treści dokumentu.
|
||||
|
||||
## Zależność
|
||||
|
||||
Dodaj do `dependencies` (pin do wersji payload):
|
||||
|
||||
```json
|
||||
"dependencies": { "@payloadcms/plugin-seo": "3.84.1" }
|
||||
```
|
||||
|
||||
## Config (payload.config.ts)
|
||||
|
||||
```ts
|
||||
ipalKit({
|
||||
seo: {
|
||||
collections: ['pages', 'posts'], // które kolekcje dostają meta
|
||||
// generateTitle: ({ doc }) => `${doc.title}`, // opcjonalne
|
||||
// generateDescription: ({ doc }) => doc.excerpt ?? '',
|
||||
// fields: [...], // extra pola w grupie SEO
|
||||
// autoFill: { title: 'title', description: 'excerpt' }, // mapowanie auto-fill
|
||||
// autoFill: false, // wyłącz auto-fill
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Dodaje tab **SEO** (title, description, image) do wskazanych kolekcji.
|
||||
Auto-fill: hook `beforeChange` wypełnia puste `meta.title` z pola dokumentu
|
||||
(domyślnie `title`). Nigdy nie nadpisuje tego, co edytor wpisał ręcznie.
|
||||
|
||||
## Front — generateMetadata (factory)
|
||||
|
||||
Najprościej: factory redukuje boilerplate. Klient podaje resolvery (bo zna
|
||||
swoje kolekcje/routing), plugin składa metadata.
|
||||
|
||||
```ts
|
||||
// app/(frontend)/[locale]/[[...segments]]/page.tsx
|
||||
import { createMetadataGenerator } from 'ipal-kit'
|
||||
import { getSiteSettings } from 'ipal-kit'
|
||||
|
||||
const gen = createMetadataGenerator({
|
||||
config: i18nConfig,
|
||||
baseUrl: process.env.NEXT_PUBLIC_SERVER_URL,
|
||||
resolveDocument: async ({ payload, params, locale }) => {
|
||||
const slug = /* z params */ ''
|
||||
const res = await payload.find({
|
||||
collection: 'pages',
|
||||
where: { slug: { equals: slug } },
|
||||
locale: 'all', depth: 1, limit: 1,
|
||||
})
|
||||
return res.docs[0] ?? null // musi mieć .meta i .slug (locale:'all')
|
||||
},
|
||||
resolveSiteName: async ({ payload, locale }) =>
|
||||
(await getSiteSettings(payload, { locale })).siteName ?? null,
|
||||
})
|
||||
|
||||
export async function generateMetadata({ params }) {
|
||||
const payload = await getPayload({ config })
|
||||
const { locale } = await params
|
||||
return gen({ payload, params: await params, locale })
|
||||
}
|
||||
```
|
||||
|
||||
## Front — niżej: buildMetadata bezpośrednio
|
||||
|
||||
Jeśli chcesz pełną kontrolę zamiast factory:
|
||||
|
||||
```ts
|
||||
import { buildMetadata, getLocalizedSlugs } from 'ipal-kit'
|
||||
|
||||
const doc = await payload.findByID({ collection: 'pages', id, locale: 'all', depth: 1 })
|
||||
|
||||
return buildMetadata({
|
||||
meta: doc.meta, // z plugin-seo
|
||||
siteName: settings.siteName,
|
||||
imageUrl: /* url OG image */,
|
||||
locale: 'pl',
|
||||
slugs: getLocalizedSlugs({ slugField: doc.slug, config }),
|
||||
config,
|
||||
baseUrl: 'https://example.com',
|
||||
})
|
||||
// → { title, description, openGraph, alternates: { canonical, languages } }
|
||||
```
|
||||
|
||||
## Pomocnicze
|
||||
|
||||
```ts
|
||||
import { composeTitle, buildHreflangAlternates } from 'ipal-kit'
|
||||
|
||||
composeTitle({ pageTitle: 'O nas', siteName: 'Acme' }) // 'O nas | Acme'
|
||||
buildHreflangAlternates({ slugs, config, baseUrl }) // { pl: '...', en: '...' }
|
||||
```
|
||||
@@ -0,0 +1,61 @@
|
||||
# turnstile
|
||||
|
||||
Cloudflare Turnstile: widget (client) + weryfikacja (server). Klucze z
|
||||
SiteIntegrations (panel, nie env). Widget dostaje `siteKey` jako prop; verify
|
||||
czyta secret server-side.
|
||||
|
||||
## Config
|
||||
|
||||
Brak opcji — pola `turnstileSiteKey` / `turnstileSecretKey` są w
|
||||
SiteIntegrations (tab Turnstile). Edytor wpisuje klucze w panelu.
|
||||
|
||||
## Front — widget (client)
|
||||
|
||||
Import z `ipal-kit/client`. `siteKey` pobierz server-side i przekaż jako prop:
|
||||
|
||||
```tsx
|
||||
// server component — pobiera publiczny siteKey z panelu
|
||||
import { getSiteIntegrations } from 'ipal-kit'
|
||||
|
||||
const { turnstileSiteKey } = await getSiteIntegrations(payload)
|
||||
// przekaż do swojego client-formularza → widget
|
||||
```
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
import { Turnstile } from 'ipal-kit/client'
|
||||
import { useState } from 'react'
|
||||
|
||||
function ContactForm({ siteKey }) {
|
||||
const [token, setToken] = useState<string | null>(null)
|
||||
return (
|
||||
<form>
|
||||
{/* pola */}
|
||||
<Turnstile siteKey={siteKey} onToken={setToken} theme="auto" />
|
||||
<button disabled={!token}>Wyślij</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Widget ładuje skrypt Turnstile sam (bez `next/script`), zwraca token przez
|
||||
`onToken` (null przy wygaśnięciu/błędzie).
|
||||
|
||||
## Front — verify (server)
|
||||
|
||||
```ts
|
||||
import { verifyTurnstile } from 'ipal-kit/server'
|
||||
|
||||
const ok = await verifyTurnstile({ token, payload, ip })
|
||||
if (!ok) {
|
||||
// odrzuć zgłoszenie
|
||||
}
|
||||
```
|
||||
|
||||
`verifyTurnstile` czyta secret z SiteIntegrations (Local API), woła Cloudflare.
|
||||
Zwraca `false` na każdy problem (brak klucza, sieć, odrzucenie) — traktuj
|
||||
`false` jako „nie ufaj temu zgłoszeniu". `server-only` gwarantuje, że nie
|
||||
trafi do bundla przeglądarki.
|
||||
|
||||
> W formularzach zwykle nie wołasz `verifyTurnstile` wprost — robi to
|
||||
> `submitForm` (patrz [forms.md](./forms.md)).
|
||||
Reference in New Issue
Block a user