init commit for iPAL-kit plugin
This commit is contained in:
@@ -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 |
|
||||
Reference in New Issue
Block a user