525 lines
16 KiB
Markdown
525 lines
16 KiB
Markdown
# Nowy projekt — krok po kroku
|
|
|
|
> **Instalacja pluginu** (token Gitea, rejestr vs git) jest opisana w głównym
|
|
> [README](../README.md). Ten przewodnik zakłada, że `@intecion/ipal-kit` jest
|
|
> już zainstalowany, i przeprowadza przez **konfigurację** projektu.
|
|
|
|
Od pustego katalogu do działającej, wielojęzycznej strony z blokami, consentem i
|
|
formularzem. Kolejność jest istotna: kilka kroków zależy od poprzednich (schemat
|
|
bazy, importMap, kolejność wpięcia).
|
|
|
|
Zakłada: pnpm, Node 20+, SQLite (dla Postgres zmienia się tylko adapter).
|
|
|
|
---
|
|
|
|
## 1. Szkielet Payloada
|
|
|
|
```bash
|
|
npx create-payload-app@latest moj-projekt
|
|
# → Blank, SQLite
|
|
cd moj-projekt
|
|
```
|
|
|
|
## 2. Plugin i zależności
|
|
|
|
Zainstaluj `@intecion/ipal-kit` zgodnie z [README](../README.md) (rejestr Gitea
|
|
albo bezpośrednio z repozytorium — wymaga tokenu). Następnie dodaj zależności
|
|
współdzielone z Payloadem, których plugin nie zaciąga sam:
|
|
|
|
```bash
|
|
pnpm add @payloadcms/[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 '@intecion/ipal-kit'
|
|
import { i18nConfig } from '@/i18n.config'
|
|
import { Pages } from '@/collections/Pages'
|
|
|
|
export default buildConfig({
|
|
// …reszta z template'u
|
|
collections: [Users, Media, Pages],
|
|
|
|
// SMTP z panelu zamiast env — czyta Site Integrations przy każdym wysłaniu.
|
|
// Bez tego maile form-buildera nie wyjdą (Payload podstawia mocka).
|
|
email: panelSmtpAdapter(),
|
|
|
|
plugins: [
|
|
ipalKit({
|
|
i18n: i18nConfig,
|
|
access: { authCollection: 'users' },
|
|
pages: { slug: 'pages' },
|
|
seo: { collections: ['pages'] },
|
|
forms: { redirectRelationships: ['pages'] },
|
|
}),
|
|
],
|
|
})
|
|
```
|
|
|
|
## 5. Kolekcja Pages
|
|
|
|
```ts
|
|
// src/collections/Pages.ts
|
|
import type { CollectionConfig } from 'payload'
|
|
import { buildSlugField } from '@intecion/ipal-kit'
|
|
import { ContentBlock } from '@/blocks/Content/config'
|
|
|
|
export const Pages: CollectionConfig = {
|
|
slug: 'pages',
|
|
admin: { useAsTitle: 'title' },
|
|
access: { read: () => true },
|
|
fields: [
|
|
{ name: 'title', type: 'text', required: true, localized: true },
|
|
buildSlugField({ from: 'title' }),
|
|
{
|
|
name: 'layout',
|
|
type: 'blocks',
|
|
blocks: [ContentBlock], // NIGDY pusta lista — Payload się wywala
|
|
},
|
|
],
|
|
}
|
|
```
|
|
|
|
## 6. Pierwszy blok
|
|
|
|
Bloki należą do projektu — plugin ich nie zna i nie stylizuje.
|
|
|
|
```ts
|
|
// src/blocks/Content/config.ts
|
|
import type { Block } from 'payload'
|
|
|
|
export const ContentBlock: Block = {
|
|
slug: 'content',
|
|
fields: [
|
|
{ name: 'heading', type: 'text', localized: true },
|
|
{ name: 'body', type: 'textarea', localized: true, required: true },
|
|
],
|
|
}
|
|
```
|
|
|
|
```tsx
|
|
// src/blocks/Content/Component.tsx
|
|
export function ContentBlockComponent({ heading, body }: { heading?: string; body?: string }) {
|
|
return (
|
|
<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 '@intecion/ipal-kit/rsc'
|
|
import { ContentBlockComponent } from '@/blocks/Content/Component'
|
|
|
|
export const blockRegistry: BlockComponentMap = {
|
|
content: ContentBlockComponent,
|
|
}
|
|
```
|
|
|
|
Klucz w rejestrze = `slug` bloku.
|
|
|
|
## 7. Tailwind
|
|
|
|
Blank template go nie ma, a komponenty pluginu (banner cookies) są w Tailwindzie.
|
|
|
|
```bash
|
|
pnpm add tailwindcss @tailwindcss/postcss
|
|
```
|
|
|
|
```js
|
|
// postcss.config.mjs (root)
|
|
export default { plugins: { '@tailwindcss/postcss': {} } }
|
|
```
|
|
|
|
```css
|
|
/* src/app/(frontend)/styles.css — na górze */
|
|
@import "tailwindcss";
|
|
@source "../../../node_modules/@intecion/ipal-kit/dist/**/*.js";
|
|
```
|
|
|
|
`@source` jest **konieczny** — Tailwind nie skanuje `node_modules`, więc bez
|
|
niego klasy komponentów pluginu nie powstaną i banner wyrenderuje się goły.
|
|
Ścieżka jest relatywna do pliku CSS.
|
|
|
|
## 8. Proxy (dawniej middleware)
|
|
|
|
> **Next 16:** konwencja `middleware.ts` jest przestarzała — nazwa pliku to teraz
|
|
> `proxy.ts`, a funkcja `proxy` zamiast `middleware`. Logika pluginu bez zmian:
|
|
> `createLocaleMiddleware` działa tak samo. Migracja jednej komendy:
|
|
> `npx @next/codemod@canary middleware-to-proxy .`
|
|
|
|
```ts
|
|
// src/proxy.ts
|
|
import { NextResponse } from 'next/server'
|
|
import type { NextRequest } from 'next/server'
|
|
import { createLocaleMiddleware } from '@intecion/ipal-kit/next/middleware'
|
|
import { i18nConfig } from '@/i18n.config'
|
|
|
|
const localeMiddleware = createLocaleMiddleware({ config: i18nConfig })
|
|
|
|
export function proxy(request: NextRequest) {
|
|
const result = localeMiddleware(request)
|
|
if (result.type === 'next') return NextResponse.next()
|
|
|
|
const response = NextResponse.redirect(result.location)
|
|
// cookie tylko gdy jest zgoda na kategorię functional — inaczej undefined
|
|
if (result.cookie) response.cookies.set(result.cookie.name, result.cookie.value)
|
|
return response
|
|
}
|
|
|
|
// INLINE, nie import — Next analizuje ten obiekt statycznie i nie wykonuje
|
|
// importów. Importowana stała zostanie zignorowana, proxy złapie /admin
|
|
// i /_next, i wszystko zwróci 500.
|
|
export const config = {
|
|
matcher: ['/((?!api|admin|_next|.*\\..*).*)'],
|
|
}
|
|
```
|
|
|
|
> Import z pluginu zostaje `@intecion/ipal-kit/next/middleware` — to nazwa
|
|
> subpath eksportu w pakiecie, niezależna od tego, czy plik projektu nazywa się
|
|
> `middleware.ts` czy `proxy.ts`.
|
|
|
|
## 9. Warstwa dostępu do danych
|
|
|
|
Next uruchamia `generateMetadata` i komponent strony niezależnie — `cache()`
|
|
sprawia, że nie pytają bazy dwa razy o to samo.
|
|
|
|
```ts
|
|
// src/lib/payload.ts
|
|
import { cache } from 'react'
|
|
import { getPayload } from 'payload'
|
|
import config from '@/payload.config'
|
|
|
|
export const getCachedPayload = cache(async () => getPayload({ config: await config }))
|
|
|
|
export const getSettings = cache(async (locale: string) =>
|
|
(await getCachedPayload()).findGlobal({
|
|
slug: 'site-settings',
|
|
locale: locale as 'pl' | 'en',
|
|
depth: 2,
|
|
}),
|
|
)
|
|
```
|
|
|
|
```ts
|
|
// src/lib/locales.ts
|
|
import { cache } from 'react'
|
|
import config from '@/payload.config'
|
|
|
|
export const getConfiguredLocales = cache(async (): Promise<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 '@intecion/ipal-kit'
|
|
import { ConsentProvider, CookieBanner, CookieButton, Analytics } from '@intecion/ipal-kit/client'
|
|
import { i18nConfig } from '@/i18n.config'
|
|
import { getCachedPayload, getSettings } from '@/lib/payload'
|
|
import { getConfiguredLocales } from '@/lib/locales'
|
|
import '../styles.css'
|
|
|
|
export default async function LocaleLayout({ children, params }) {
|
|
const { locale } = await params
|
|
const locales = await getConfiguredLocales()
|
|
if (!locales.includes(locale)) notFound()
|
|
|
|
const payload = await getCachedPayload()
|
|
const settings = await getSettings(locale)
|
|
const privacyPage = (settings as { privacyPolicy?: unknown }).privacyPolicy
|
|
|
|
const [texts, analytics] = await Promise.all([
|
|
getConsentTexts({
|
|
config: i18nConfig,
|
|
locale,
|
|
payload,
|
|
privacyPolicy:
|
|
privacyPage && typeof privacyPage === 'object'
|
|
? { page: privacyPage, label: 'Polityka prywatności' }
|
|
: undefined,
|
|
}),
|
|
getAnalyticsConfig(payload),
|
|
])
|
|
|
|
return (
|
|
<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 '@intecion/ipal-kit/rsc'
|
|
import { createPageMetadata } from '@intecion/ipal-kit'
|
|
import { i18nConfig } from '@/i18n.config'
|
|
import { blockRegistry } from '@/blocks/registry'
|
|
import { getCachedPayload } from '@/lib/payload'
|
|
import { resolvePage } from '@/lib/pages'
|
|
|
|
const pageMetadata = createPageMetadata({
|
|
config: i18nConfig,
|
|
baseUrl: process.env.NEXT_PUBLIC_SERVER_URL,
|
|
})
|
|
|
|
export async function generateMetadata({ params }): Promise<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 '@intecion/ipal-kit'
|
|
export const contentConfig: ContentOption = {
|
|
collections: [{ slug: 'posts', label: 'Artykuły', perPage: 10 }],
|
|
}
|
|
```
|
|
|
|
3. **payload.config** — `content: contentConfig`, plus `posts` w `seo.collections`.
|
|
|
|
4. **Front** — `createContentHelpers` w `src/lib/content.ts`, `resolveRoute`
|
|
w page.tsx (obsługa typów page/archive/entry), blok listy (EntriesList).
|
|
|
|
5. **Baza + typy** — nowa kolekcja to nowy schemat:
|
|
```bash
|
|
rm -f *.db *.db-shm *.db-wal && pnpm generate:types && pnpm dev
|
|
```
|
|
|
|
6. **W panelu** — utwórz stronę „Artykuły" (w każdym locale!), dodaj do niej blok
|
|
listy, w System Pages przypisz ją jako archiwum kolekcji posts. Dodaj wpisy.
|
|
|
|
Adres wpisów = slug strony-archiwum. Zmiana tytułu strony przenosi sekcję. Kolejny
|
|
typ treści (realizacje) = kolejna kolekcja + kolejna pozycja w content.config.
|
|
|
|
### Sitemapa i robots.txt
|
|
|
|
`createContentHelpers` oddaje gotowe handlery — dodaj `i18n` i `baseUrl` do jego
|
|
argumentów (patrz seo.md), potem dwa pliki po jednej linii:
|
|
|
|
```ts
|
|
// app/sitemap.ts
|
|
export { sitemap as default } from '@/lib/content'
|
|
// app/robots.ts
|
|
export { robots as default } from '@/lib/content'
|
|
```
|
|
|
|
Sitemapa z hreflangiem per URL, lastmod, wpisami bloga; pomija drafty i noindex.
|
|
|
|
### Analytics
|
|
|
|
**Site Integrations** → GA4 Measurement ID albo GTM Container ID. Tagi ładują
|
|
się z Consent Mode: nic nie zapisze ciasteczek, dopóki odwiedzający nie
|
|
zaakceptuje kategorii Analytics.
|
|
|
|
### Przestylowanie pod klienta
|
|
|
|
```css
|
|
/* styles.css */
|
|
:root {
|
|
--ipal-primary: #16a34a;
|
|
--ipal-radius: 1rem;
|
|
}
|
|
```
|
|
|
|
Pełna lista tokenów: consent.md.
|
|
|
|
---
|
|
|
|
## Kiedy coś nie działa
|
|
|
|
| Objaw | Przyczyna |
|
|
|---|---|
|
|
| Pusty tab SEO / brak kolekcji Forms | rozjazd wersji `@payloadcms/*` — sprawdź `pnpm.overrides` |
|
|
| `PayloadComponent not found in importMap` | `pnpm payload generate:importmap` |
|
|
| Banner bez stylów | brak `@source` na `node_modules/@intecion/ipal-kit` albo brak Tailwinda |
|
|
| `/admin` i `/_next` zwracają 500 | matcher w middleware nie jest inline |
|
|
| `slug.join is not a function` | katalog `[slug]` zamiast `[[...slug]]` |
|
|
| `/pl` → 404 | Homepage nieustawiony w System Pages |
|
|
| `/en/cokolwiek` → 404, `/pl/cokolwiek` działa | pusty tytuł (a więc i slug) w locale EN |
|
|
| `Missing <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 | |