# forms Wpina `@payloadcms/plugin-form-builder` (kolekcje forms + form-submissions) i dostarcza `submitForm` — wywoływalną z frontu funkcję, która spina: weryfikację Turnstile → zapis zgłoszenia → wysyłkę maili (naszym senderem). ## ⚠️ ZASADA: formularz POCHODZI z buildera w panelu (obowiązkowe) **Formularze buduje redaktor w panelu** (kolekcja Forms), NIE deweloper w kodzie. To jest CMS — klient sam definiuje pola, etykiety, komunikaty, odbiorcę. Front tylko RENDERUJE formularz z panelu i wysyła przez `submitForm`. **NIGDY nie twórz własnego, hardkodowanego formularza** — z ręcznie wpisanymi polami, etykietami w JSX, własną walidacją. To łamie „nic na sztywno" (klient nie zmieni pól ani tekstów) i omija cały mechanizm pluginu (Turnstile, rate-limit, consent RODO, powiadomienia). | ŹLE (własny formularz) | DOBRZE (builder pluginu) | |---|---| | `` w JSX | pola z kolekcji Forms (panel) | | etykiety/komunikaty w kodzie | etykiety per język w panelu | | własna walidacja/wysyłka | `submitForm` (Turnstile+consent+mail) | | klient nie zmieni formularza | klient edytuje pola w panelu | **Jak poprawnie:** redaktor tworzy formularz w kolekcji Forms → front pobiera jego definicję → renderuje pola dynamicznie → wysyła przez `submitForm`. Pola, etykiety, komunikaty, odbiorca — wszystko z panelu. Jeśli formularz wymaga pola, którego builder nie ma — dodaj je przez konfigurację `fields` (patrz niżej) albo rozbuduj plugin. NIE hardkoduj własnego formularza. ## Zależność ```json "dependencies": { "@payloadcms/plugin-form-builder": "3.84.1" } ``` ## Config (payload.config.ts) ```ts ipalKit({ forms: { redirectRelationships: ['pages'], // formularz może przekierować na Page // fields: { text: true, textarea: true, email: true, ... }, // domyślnie włączone sensowne }, }) ``` Dodaje kolekcje **Forms** (edytor buduje formularze) i **Form Submissions** (zgłoszenia). Wbudowany email form-buildera jest nieużywany — wysyłką zajmuje się `submitForm` przez nasz sender (SMTP z panelu). ## Front — submitForm (server) Zwykle w Server Action wywoływanej przez formularz. HTML obu maili składasz na froncie (Twój wygląd): ```ts import { submitForm } from '@intecion/ipal-kit/server' const result = await submitForm({ payload, formId, // z kolekcji Forms data: { name, email, message }, turnstileToken, // opcjonalny — jeśli podany, weryfikowany ip, emails: { // wiadomość do klienta/admina (np. "nowe zgłoszenie") notification: { to: 'kontakt@klient.pl', subject: 'Nowe zgłoszenie', html: renderAdminEmail(data), // Twój HTML }, // potwierdzenie do wysyłającego confirmation: { to: email, subject: 'Dziękujemy za wiadomość', html: renderUserEmail(data), // Twój HTML }, }, }) if (result.success) { // result.submissionId // result.emails.notification?.sent / result.emails.confirmation?.sent } else { // result.error — np. Turnstile / zapis } ``` ## Flow i gwarancje 1. **Turnstile** (jeśli token) → nieudany → odrzuć **przed** zapisem (brak spamu w bazie). 2. **Zapis** submission (form-submissions). 3. **Maile** — oba opcjonalne, HTML z frontu. 4. Zwrot: `{ success, submissionId, emails: { notification?, confirmation? } }`. **Zapisane zgłoszenie = sukces, nawet gdy mail padnie.** Status wysyłki maili jest osobno w `result.emails`, żeby dane zgłoszenia nie ginęły przez chwilową awarię SMTP. Front może zareagować (ostrzec, ponowić). Oba maile opcjonalne — możesz wysłać jeden, drugi, oba lub żaden. `turnstileToken` opcjonalny — brak = pominięcie weryfikacji (decydujesz per formularz). ## Maile Plugin nie składa maili formularzy. Po zapisie submission form-builder wysyła wiadomości skonfigurowane przez edytora (Forms → formularz → Emails), przez `payload.sendEmail`. Żeby wyszły, config musi mieć adapter: ```ts email: panelSmtpAdapter(), // z '@intecion/ipal-kit' ``` Wtedy idą przez SMTP z Site Integrations. Edytor ustawia odbiorców, temat i treść (placeholdery: `{{pole}}`, `{{*}}`, `{{*:table}}`) bez dotykania kodu. `submitForm` odpowiada tylko za weryfikację Turnstile i zapis. ## Własne pola w kolekcji formularzy `formOverrides` przechodzi prosto do form-buildera — plugin nie ma opinii, czego formularz potrzebuje poza swoimi polami: ```ts ipalKit({ forms: { redirectRelationships: ['pages'], formOverrides: { fields: ({ defaultFields }) => [ ...defaultFields, { name: 'internalNote', type: 'textarea' }, ], admin: { group: 'Content' }, }, // formSubmissionOverrides: { ... } // to samo dla zgłoszeń }, }) ``` `fields` dostaje domyślne pola kolekcji i zwraca finalną listę — możesz dodawać, usuwać, zmieniać kolejność. Poza `fields` przyjmuje dowolne ustawienia kolekcji (admin, access, hooks). ## Bezpieczeństwo i wynik zgłoszenia `submitForm` przechodzi trzy bramki w kolejności rosnącej po koszcie: rate limit (in-memory, per IP), Turnstile, walidacja względem schematu formularza. Dopiero potem zapis. Flood ginie, zanim dotknie sieci czy bazy. Walidacja jest istotna, bo server action to publiczny endpoint — da się go wołać z pominięciem formularza. Plugin ładuje definicję formularza i: odrzuca nieznane klucze, wymusza pola `required`, tnie długość (5000 znaków). Do bazy trafia tylko to, co formularz definiuje. Rate limit: 5/min/IP domyślnie, `maxPerMinute` zmienia, `0` wyłącza (gdy limiter jest z przodu). In-memory — przy wielu instancjach licznik jest per-proces, więc efektywny limit to per-instancja. Do formularza kontaktowego wystarcza. ### Wynik to KOD, nie tekst `submitForm` zwraca ustrukturyzowany błąd — plugin mówi CO się stało, projekt decyduje JAK to pokazać (język, brzmienie, obsługa per pole): ```ts type SubmitFormResult = | { success: true; submissionId: string | number } | { success: false; reason: 'rate_limited' } | { success: false; reason: 'turnstile' } | { success: false; reason: 'validation'; field?: string; kind?: 'required' | 'too_long' | 'unknown_fields' } | { success: false; reason: 'not_found' } | { success: false; reason: 'consent'; field?: string } | { success: false; reason: 'error' } ``` Front mapuje kody na własne komunikaty: ```tsx function errorMessage(r) { switch (r.reason) { case 'rate_limited': return 'Zbyt wiele zgłoszeń...' case 'validation': if (r.kind === 'required') return 'Uzupełnij wymagane pola.' // r.field → podświetl konkretne pole } } ``` Ten sam wzorzec co consent: plugin nie zaszywa języka, oddaje dane. ## Zgoda RODO (consent field) Pole zgody RODO to checkbox o nazwie `consent` (konfigurowalna przez `FormsOption.consentFieldName`). Plugin WYMUSZA jego zaznaczenie SERVER-SIDE — niezależnie od tego, jak redaktor ustawił pole w panelu. ### Dlaczego server-side Zgoda egzekwowana jest w `validateSubmission`, nie flagą w panelu. To jedyne miejsce, którego redaktor nie osłabi (zapominając `required`) ani nie naruszy (ustawiając `defaultValue: true` — pre-zaznaczenie, którego RODO zabrania), a front nie obejdzie. Jeśli formularz ma pole `consent`, MUSI być zaznaczone, inaczej `submitForm` zwraca `reason: 'consent'`. ### Jak użyć 1. Redaktor dodaje w panelu checkbox o nazwie `consent`, label „Akceptuję politykę prywatności [link]" (link do polityki wpisuje w label — treść zgody należy do panelu). 2. Plugin wymusza zaznaczenie. Niezaznaczony → `reason: 'consent'`. 3. Komunikat z modułu notifications (`notifications.form.consent`, per język). Zmiana nazwy pola: ```ts ipalKit({ forms: { consentFieldName: 'zgoda' } }) ``` ## Komunikaty — resolveFormMessage (zalecane) Zamiast ręcznego switcha po `reason`, użyj `resolveFormMessage` z modułu notifications — mapuje kod na tekst z panelu, per język, z interpolacją `{field}`: ```tsx import { resolveFormMessage, getNotificationTexts } from '@intecion/ipal-kit' const notifications = await getNotificationTexts({ payload, locale }) // w FormRenderer: if (!result.success) { setError(resolveFormMessage(result, notifications.form)) } ``` To obsługuje WSZYSTKIE kody (w tym `consent`, `rate_limited`, `validation` z `{field}`) tekstami z panelu. Ręczny switch (wyżej) zostaw tylko, jeśli nie używasz modułu notifications. Szczegóły: [notifications.md](./notifications.md). ## PUŁAPKA — pola captchy Turnstile wstrzykuje ukryte pole `cf-turnstile-response`. Plugin je toleruje (nie odrzuca jako `unknown_fields`) — bo sam obsługuje Turnstile. Nie musisz go filtrować w kliencie.