8.2 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/@intecion/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łądslug.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
Next 16: konwencja
middleware.tsjest deprecated na rzeczproxy.ts(plikproxy.ts, funkcjaexport function proxy). Logika pluginu bez zmian —createLocaleMiddlewaredziała tak samo, zmienia się tylko nazwa pliku i funkcji po stronie projektu. Na raziemiddleware.tsdziała z ostrzeżeniem.
// src/middleware.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 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:
import { getConsentTexts } from '@intecion/ipal-kit'
import { ConsentProvider, CookieBanner, CookieButton } from '@intecion/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
layouttypu 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 (@intecion/ipal-kit/client) — siteKey jako prop, wstrzykiwany przez enhanceProps (z SiteIntegrations, publiczny — bezpieczny na kliencie)
- server action → submitForm (@intecion/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 '@intecion/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.