245 lines
8.3 KiB
Markdown
245 lines
8.3 KiB
Markdown
# 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 (<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:
|
|
|
|
```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 })
|
|
// <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):
|
|
```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
|
|
// <ConsentProvider texts={texts}> ... <Analytics {...analytics} /> </ConsentProvider>
|
|
```
|
|
|
|
ID z SiteIntegrations. GTM ma priorytet nad GA4, gdy oba ustawione. |