Files

8.6 KiB

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)
<input name="email" placeholder="Email" /> 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ść

"dependencies": { "@payloadcms/plugin-form-builder": "3.84.1" }

Config (payload.config.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):

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: '[email protected]',
      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:

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:

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

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:

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.

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:

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

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.

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.