# 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: ```bash pnpm add tailwindcss @tailwindcss/postcss # v4 ``` `postcss.config.mjs` (root): ```js export default { plugins: { '@tailwindcss/postcss': {} } } ``` W globalnym CSS (np. app/(frontend)/styles.css): ```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: ```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 () — 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: ```ts // 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 })), proxy.ts. Layout może czytać locale z payload config (config.localization.locales) — też jedno źródło. ## Proxy (dawniej middleware) > **Next 16:** konwencja `middleware.ts` jest deprecated na rzecz `proxy.ts` > (plik `proxy.ts`, funkcja `export function proxy`). Logika pluginu bez zmian — > `createLocaleMiddleware` działa tak samo, zmienia się tylko nazwa pliku i > funkcji po stronie projektu. Migracja jedną komendą: > `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 } // matcher MUSI być inline (Next analizuje statycznie, nie wykonuje importów — // import DEFAULT_MIDDLEWARE_MATCHER byłby zignorowany → proxy ł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: ```ts import { getConsentTexts } from '@intecion/ipal-kit' import { ConsentProvider, CookieBanner, CookieButton } from '@intecion/ipal-kit/client' const texts = await getConsentTexts({ config, locale, payload, privacyPolicy }) // {children} ``` ## Bloki (RenderBlocks) Klient definiuje bloki (config + komponent) — plugin jest block-agnostic. - `blocks//config.ts` — schemat Payload (Block) - `blocks//Component.tsx` — komponent (dane bloku jako propsy) - `blocks/registry.ts` — mapa blockType → komponent - Pages: pole `layout` typu blocks z listą bloków - page.tsx: `` **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): ```ts 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: ```ts 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: ```ts 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): ```ts const analytics = await getAnalyticsConfig(payload) // tylko publiczne GA4/GTM ID // ... ``` ID z SiteIntegrations. GTM ma priorytet nad GA4, gdy oba ustawione.