Files
ipal-kit/docs/getting-started.md
2026-09-09 00:06:18 +02:00

18 KiB

Setup projektu — od zera do wdrożenia

Pełny przewodnik: od pustego katalogu do działającej, wielojęzycznej strony z blokami, consentem, formularzem i SEO. Łączy szkielet projektu (kolejność kroków) z wymaganiami frontendu (Tailwind, trasy, bloki, metadata).

Instalacja pluginu (token Gitea, rejestr vs git) jest w głównym README. Ten przewodnik zakłada, że @intecion/ipal-kit jest zainstalowany, i przeprowadza przez konfigurację.

Zaczynasz wdrożenie produkcyjne? Najpierw WDROZENIE-PLAYBOOK.md — zasady, procedura, pułapki.

Kolejność jest istotna — kilka kroków zależy od poprzednich (schemat bazy, importMap, kolejność wpięcia). Zakłada: pnpm, Node 22, Next 16.


1. Szkielet Payloada

npx create-payload-app@latest moj-projekt
# → Blank, SQLite (dev) / Postgres (prod)
cd moj-projekt

2. Plugin i zależności

Zainstaluj @intecion/ipal-kit zgodnie z README. Dodaj zależności współdzielone z Payloadem, których plugin nie zaciąga sam:

pnpm add @payloadcms/plugin-seo @payloadcms/plugin-form-builder \
         nodemailer lucide-react slugify server-only

Spójność wersji @payloadcms/* (KRYTYCZNE)

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:

"pnpm": {
  "overrides": {
    "payload": "3.88.0",
    "@payloadcms/ui": "3.88.0",
    "@payloadcms/next": "3.88.0",
    "@payloadcms/db-postgres": "3.88.0",
    "@payloadcms/richtext-lexical": "3.88.0",
    "@payloadcms/plugin-seo": "3.88.0",
    "@payloadcms/plugin-form-builder": "3.88.0"
  }
}
rm -rf node_modules pnpm-lock.yaml && pnpm install

Build script z --webpack (Next 16)

Next 16 domyślnie Turbopack, który konfliktuje z withPayload. W package.json:

"build": "cross-env NODE_OPTIONS=\"--max-old-space-size=3072\" next build --webpack"

3. Konfiguracja locale — jedno źródło

Proxy działa przed Payloadem i potrzebuje listy locale synchronicznie, więc nie może jej czytać z gotowego configu. Wydziel osobny plik, importuj wszędzie:

// src/i18n.config.ts
export const i18nConfig = {
  defaultLocale: 'pl',
  locales: [
    { code: 'pl', label: 'Polski' },
    { code: 'en', label: 'English' },
  ],
} as const   // as const — inaczej TS nie uzna locales za niepustą tuple

Importuj w: payload.config (ipalKit({ i18n: i18nConfig })) i proxy.ts.

4. payload.config.ts

import { ipalKit, mailAdapter } from '@intecion/ipal-kit'
import { i18nConfig } from '@/i18n.config'
import { Pages } from '@/collections/Pages'

export default buildConfig({
  collections: [Users, Media, Pages],

  // Dyspozytor email: czyta transport (SMTP/Graph) z panelu przy każdej wysyłce.
  // Bez tego maile form-buildera nie wyjdą (Payload podstawia mocka).
  email: mailAdapter(),

  plugins: [
    ipalKit({
      i18n: i18nConfig,
      access: { authCollection: 'users' },
      pages: { slug: 'pages' },
      seo: { collections: ['pages'] },
      forms: { redirectRelationships: ['pages'] },
    }),
  ],
})

5. Kolekcja Pages

// 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' }),        // NIGDY ręczny slug — plugin to ma
    {
      name: 'layout',
      type: 'blocks',
      blocks: [ContentBlock],                  // NIGDY pusta lista — Payload crashuje
    },
  ],
}

6. Pierwszy blok

Bloki należą do projektu — plugin ich nie zna i nie stylizuje.

// 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 },
  ],
}
// 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>
  )
}
// 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 = slug bloku
}

Jak budować treść, żeby klient mógł wszystko edytować (filozofia CMS, kolejność komponent→blok→strona): architektura-tresci.md.

Puste blocks: [] crashuje (traverseFields) — zawsze co najmniej jeden blok.

enhanceProps — wstrzykiwanie danych server-side do bloków

Bloki NIE importują lib/* (cykl importów). Wartości server-side (turnstileSiteKey, odbiorca formularza) wstrzykuje się przez enhanceProps — bez wiedzy pluginu:

const enhanceProps = ({ block }) => {
  if (block.blockType === 'formBlock') return { turnstileSiteKey, notificationTo }
  return {}
}

7. Tailwind (WYMÓG)

Blank template go nie ma, a komponenty pluginu (banner cookies, Turnstile) są w czystym Tailwindzie.

pnpm add tailwindcss @tailwindcss/postcss
// postcss.config.mjs (root)
export default { plugins: { '@tailwindcss/postcss': {} } }
/* 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ą (banner wyrenderuje się goły). Ścieżka relatywna do pliku CSS.

Przestylowanie pod klienta

Komponenty pluginu mają domyślny wygląd. Kolory/zaokrąglenia przez CSS custom properties (fallbacki wbudowane):

:root {
  --ipal-primary: #16a34a;
  --ipal-radius: 1rem;
}

Pełna lista tokenów + opcja classNames: consent.md.

8. Proxy (routing locale) — NIGDY middleware.ts

Next 16 używa proxy.ts, NIE middleware.ts. Plik proxy.ts, funkcja proxy. middleware.ts jest przestarzały — jeśli istnieje, USUŃ go. Nigdy obu naraz. Migracja starego: npx @next/codemod@canary middleware-to-proxy .

Import z pluginu zostaje @intecion/ipal-kit/next/middleware — to nazwa subpath eksportu, NIE nazwa pliku. Nie myl ich.

// 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)

  // Cookie zapisywany w OBU wynikach (redirect na '/' i next przy zmianie
  // języka), TYLKO gdy jest zgoda na functional.
  const response =
    result.type === 'next'
      ? NextResponse.next()
      : NextResponse.redirect(result.location)

  if (result.cookie) {
    response.cookies.set(result.cookie.name, result.cookie.value)
  }
  return response
}

// Matcher INLINE (nie import) — Next analizuje statycznie, nie wykonuje importów.
// Import stałej byłby zignorowany → proxy złapałby /admin /_next /api → 500.
// Ten wzorzec łapie root '/' (negocjacja locale), pomija api/admin/_next/pliki.
export const config = {
  matcher: ['/((?!api|admin|_next|.*\\..*).*)'],
}

Zlokalizowane ścieżki — getLocalizedSlugs (NIGDY zaszyta mapa)

Do przełącznika języka / budowania ścieżek NIE twórz zaszytej mapy slugów. Slugi są w bazie (pole slug localized):

import { getLocalizedSlugs, switchLocalePath } from '@intecion/ipal-kit'

const doc = await payload.findByID({ collection: 'pages', id, locale: 'all' })
const slugs = getLocalizedSlugs({ slugField: doc.slug, config: i18nConfig })
switchLocalePath({ slugs, targetLocale: 'en', config: i18nConfig })  // → '/en/about'

9. Warstwa dostępu do danych — lib/ (jedno źródło)

// src/lib/content.ts — JEDYNE źródło helperów pluginu
import { createContentHelpers } from '@intecion/ipal-kit'
import payloadConfig from '@/payload.config'
import { i18nConfig } from '@/i18n.config'

export const {
  getCachedPayload, getSettings, getConfiguredLocales, resolveRoute, getEntries, robots,
} = createContentHelpers({
  config: payloadConfig,          // PAYLOAD config (nie i18n!)
  content: { collections: [] },
  i18n: i18nConfig,               // i18n OSOBNO
})
// src/lib/payload.ts — funkcje projektu, typowane
import { cache } from 'react'
import { getSiteSettings } from '@intecion/ipal-kit'
import { getCachedPayload } from './content'      // z content, nie osobny getPayload
import type { SiteSetting } from '@/payload-types'

export const getSettings = cache(async (locale: string) =>
  getSiteSettings<SiteSetting>(await getCachedPayload(), { locale: locale as never, depth: 2 }),
)

NIE twórz lib/pages.ts (resolvePage) ani lib/locales.ts — plugin ma resolveRoute i getConfiguredLocales. Duplikaty = rozjazd.

10. Trasy

Usuń starter — (frontend)/layout.tsx i (frontend)/page.tsx. Rootem zostaje layout locale (bo <html lang> musi znać język).

src/app/(frontend)/
  styles.css
  [locale]/
    layout.tsx                  # walidacja locale + ConsentProvider + Analytics
    [[...slug]]/
      page.tsx                  # render bloków

[[...slug]] — PODWÓJNE nawiasy (opcjonalny catch-all). Pojedyncze [slug] dają string (slug.join is not a function) i nie łapią samego /pl.

// 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, getConfiguredLocales } from '@/lib/content'
import { getSettings } from '@/lib/payload'
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}>
      {/* BEZ jawnego <head>! Sztywny <head> wypycha metadata do <body>
          (canonical/title poza head → crawlery ich nie widzą). Next zarządza
          <head> sam; MediaPreconnect w body, React 19 hoistuje link do head. */}
      <body>
        <MediaPreconnect />                 {/* preconnect CDN, jeśli R2 */}
        <ConsentProvider texts={texts}>
          <main>{children}</main>
          <CookieBanner />
          <CookieButton />
          <Analytics {...analytics} />        {/* WEWNĄTRZ ConsentProvider */}
        </ConsentProvider>
      </body>
    </html>
  )
}

export async function generateStaticParams() {
  const locales = await getConfiguredLocales()
  return locales.map((locale) => ({ locale }))
}
// 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, resolveRoute } from '@/lib/content'

const pageMetadata = createPageMetadata({
  config: i18nConfig,
  baseUrl: process.env.NEXT_PUBLIC_SERVER_URL,
})

// ISR — cache strony z gotowym <head>. Eliminuje race condition streamingu
// metadata (canonical/title zawsze w head, nie w body). TTFB ~20ms, brak 503.
// Redaktor widzi zmiany po rewalidacji — patrz seo.md (ISR a treść z panelu).
export const revalidate = 3600

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, searchParams }) {
  const { locale, slug } = await params
  const { page } = await searchParams
  const route = await resolveRoute(locale, slug ?? [], page)   // 3 args
  if (!route) notFound()
  return <RenderBlocks blocks={route.doc.layout as never} components={blockRegistry} />
}
  • brak slug (/pl) → home przez System Pages (nie hardkod slug)
  • slug (/pl/o-nas) → resolveRoute po slug w danym locale
  • ISR (revalidate) → metadata zawsze w <head> (nie body), szybki TTFB. KRYTYCZNE dla SEO — patrz seo.md (metadata w head).
  • depth: 2 → relacje w blokach (form) się populują

11. Metadata / SEO (szczegóły)

createPageMetadata obsługuje hreflang. Kluczowe: resolveDocument pobiera dokument z locale: 'all' — wtedy slug jest mapą locale→wartość, z której budują się hreflang alternates. Zwykły fetch (jeden locale) → tylko string, hreflang nie powstanie.

Bez NEXT_PUBLIC_SERVER_URL canonical i hreflang wyjdą względne.

12. Nagłówki bezpieczeństwa

// next.config.ts
import { buildSecurityHeaders } from '@intecion/ipal-kit'
const securityHeaders = buildSecurityHeaders({
  hsts: process.env.NODE_ENV === 'production',    // off w dev (http)
  additional: [ /* CSP projektu — zna swoje domeny */ ],
})
// async headers() { return [{ source: '/:path*', headers: securityHeaders }] }

Szczegóły: security.md.

13. Środowisko

# .env
DATABASE_URI=<postgres albo file:./dev.db>
PAYLOAD_SECRET=<losowy-ciąg>
NEXT_PUBLIC_SERVER_URL=http://localhost:3000
# Email przez Graph (opcjonalnie — sekrety agencyjne):
# GRAPH_TENANT_ID=... GRAPH_CLIENT_ID=... GRAPH_CLIENT_SECRET=... GRAPH_SENDER=...

14. Generowanie i start

pnpm generate:types
pnpm payload generate:importmap   # pola SEO + custom komponenty (MaskedField...)
pnpm dev

generate:importmap powtarzaj po każdej zmianie dokładającej komponenty admina.

15. Konfiguracja w panelu

http://localhost:3000/admin

  1. Utwórz pierwszego użytkownika (rola admin).
  2. Site Settings → General — nazwa witryny, tytuł.
  3. Pages — strona główna. Tytuł w każdym locale (slug per język; pusty slug EN = 404 na /en/…).
  4. Site Settings → System Pages — wskaż Homepage (bez tego /pl → 404).
  5. Cookie Settings — treść bannera per język.
  6. Notifications — teksty wyników formularza per język (opcjonalne, ma fallback).
  7. Site Integrations → SMTP — transport (SMTP/Graph), From Name, From Address.

Wejdź na / — powinno przekierować na /pl.


Opcjonalne

Formularz z Turnstile

Wymaga bloku formularza (patrz forms.md) + Site Integrations → Turnstile (klucze testowe Cloudflare: site 1x00000000000000000000AA, secret 1x0000000000000000000000000000000AA). Maile wysyła form-builder przez mailAdapter — nie pisze się ich w kodzie. Zgoda RODO: checkbox o nazwie consent.

Blog / archiwum

Pełny opis: content.md. Kolekcja + content.config.ts + przypisanie strony-archiwum w System Pages.

Sitemapa i robots

// app/sitemap.ts
export { sitemap as default } from '@/lib/content'
export const dynamic = 'force-dynamic' // KONIECZNE dla deployu kontenerowego
// app/robots.ts
export { robots as default } from '@/lib/content'

force-dynamic w sitemap.ts jest wymagane przy deployu w kontenerze (Coolify/ Docker) — bez niego build próbuje prerenderować sitemap i łączy się z bazą, której kontener budujący nie widzi → build pada. Szczegóły: deployment.md.


Kiedy coś nie działa

Objaw Przyczyna
Pusty tab SEO / brak Forms rozjazd wersji @payloadcms/* — sprawdź pnpm.overrides
PayloadComponent not found in importMap pnpm payload generate:importmap
Cannot destructure property 'config' (custom pole) dublet @payloadcms/ui — peerDependency (playbook D)
Banner bez stylów brak @source na node_modules albo brak Tailwinda
/admin i /_next → 500 matcher w proxy nie jest inline
slug.join is not a function [slug] zamiast [[...slug]]
/pl → 404 Homepage nieustawiony w System Pages
/en/* → 404, /pl/* działa pusty tytuł/slug w locale EN
Missing <html> and <body> root layout usunięty, a [locale]/layout.tsx ich nie ma
Zmiany w pluginie nie widać rm -rf .next; sprawdź czy wciągnięto wersję (grep node_modules)
Maile nie wychodzą brak email: mailAdapter() albo pusty SMTP/Graph
istnieje middleware.ts USUŃ — Next 16 to proxy.ts
zaszyta mapa localizedRoutes antywzorzec — getLocalizedSlugs z bazy