Files
ipal-kit/docs/frontend-setup.md
T

7.8 KiB

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:

pnpm add tailwindcss @tailwindcss/postcss   # v4

postcss.config.mjs (root):

export default { plugins: { '@tailwindcss/postcss': {} } }

W globalnym CSS (np. app/(frontend)/styles.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:

: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:

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

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

Layout (server) czyta getConsentTexts, przekazuje jako prop do ConsentProvider (client). Banner + button renderują się same:

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

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:

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:

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

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.