Poradnik

Jak stworzyc blok formularza kontaktowego

Stworz formularz kontaktowy w Form Builderze Cmssy, a nastepnie wyrenderuj go we wlasnej headless stronie Next.js z @cmssy/react. Walidacja, zgloszenia i email po stronie Cmssy.

Z
Zespol Cmssy
12 min read

Jak stworzyc blok formularza kontaktowego

Zbuduj headless formularz kontaktowy: stworz go w Form Builderze Cmssy, a nastepnie wyrenderuj we wlasnej stronie Next.js przez SDK.

Co budujemy

Blok formularza kontaktowego: formularz definiujesz raz w Form Builderze Cmssy, a Twój blok renderuje go we własnej aplikacji Next.js. Cmssy zajmuje się walidacją, zapisuje zgłoszenie i wysyła maile do odbiorców; Twoja aplikacja włada markupem i stylami.

Ten podział ma znaczenie. Definicja formularza - pola, etykiety, walidacja, komunikat sukcesu, wszystko przetłumaczone - to treść, więc żyje w CMS-ie i redaktor może ją zmienić bez deployu. Renderowanie to kod, więc żyje w Twoim repo.

Wymagania wstępne

  • Aplikacja Next.js (App Router) podpięta do workspace'u przez @cmssy/react i @cmssy/next - zobacz przewodnik instalacji
  • Skonfigurowany codegen GraphQL, żeby SubmitFormDocument wygenerował się sam

Krok 1: Utwórz formularz w Cmssy

W panelu Cmssy wejdź w Formularze i utwórz jeden:

  1. Dodaj potrzebne pola - name (text), email (email), message (textarea) - każde z przetłumaczoną etykietą i regułami walidacji
  2. Ustaw typ akcji na contact i dodaj adresy odbiorców
  3. Wpisz etykietę przycisku i komunikat sukcesu, per język
  4. Ustaw status na published, żeby formularz przyjmował zgłoszenia

Krok 2: Zdefiniuj blok

Utwórz blocks/contact/block.ts. Builder fields.form daje edytorowi wybór formularza; umieszczenie go na zakładce advanced trzyma go z dala od codziennej edycji tekstów:

import { defineBlock, fields } from "@cmssy/react";
import Contact from "./Contact";

export const contactProps = {
  heading: fields.text({ label: "Heading" }),
  description: fields.textarea({ label: "Description" }),
  formId: fields.form({ label: "Form", tab: "advanced" }),
  submitLoadingText: fields.text({
    label: "Submit Loading Text",
    defaultValue: "Sending...",
  }),
  successHeading: fields.text({
    label: "Success Heading",
    defaultValue: "Message Sent!",
  }),
};

export const contactBlock = defineBlock({
  type: "contact",
  category: "Forms",
  label: "Contact",
  description:
    "Contact details and/or contact form; near the end of a page or on a dedicated contact page.",
  component: Contact,
  props: contactProps,
});

Krok 3: Zbuduj komponent

Blok nie pobiera formularza. SDK rozwiązuje każdy formularz użyty na stronie i podaje definicje Twojemu komponentowi w context.forms, kluczowane po id. Ponieważ pole siedzi na zakładce advanced, jego wartość przychodzi w propie advanced:

import type { BlockProps } from "@cmssy/react";
import type { contactProps } from "./block";
import { ContactForm } from "./ContactForm";

export default function Contact({
  content,
  context,
  advanced = {},
}: BlockProps<typeof contactProps>) {
  const { heading, description, successHeading, submitLoadingText } = content;
  const { formId } = advanced as { formId?: string };
  const formDef = formId ? (context?.forms?.[formId] ?? null) : null;

  return (
    <section className="py-24">
      <div className="max-w-lg mx-auto px-6">
        {heading && <h2 className="text-3xl font-bold">{heading}</h2>}
        {description && (
          <p className="mt-3 text-muted-foreground">{description}</p>
        )}

        {formDef?.fields?.length && formId ? (
          <ContactForm
            formDef={formDef}
            formId={formId}
            successHeading={successHeading}
            submitLoadingText={submitLoadingText}
          />
        ) : null}
      </div>
    </section>
  );
}

Zwróć uwagę na strażnika: brak wybranego formularza oznacza brak wyrenderowanego formularza. Blok pokazuje to, co da mu CMS, i nic więcej - nigdy zahardkodowanego nagłówka, który przelewałby angielski na polską stronę.

Krok 4: Wyślij przez server action

Wysłanie to mutacja GraphQL i należy do serwera, żeby Twoje poświadczenia delivery nigdy nie trafiły do przeglądarki. Umieść wywołanie w services/forms.ts:

import { print } from "graphql";
import { createCmssyClient } from "@cmssy/react";
import { cmssy } from "@/cmssy/config";
import {
  SubmitFormDocument,
  type SubmitFormMutation,
} from "@/graphql/generated/graphql";

const client = createCmssyClient(cmssy);

export async function submitForm(
  formId: string,
  data: Record<string, string>,
) {
  const res = await client.queryScoped<SubmitFormMutation>(
    print(SubmitFormDocument),
    { formId, input: { data } },
  );
  const result = res.public.form.submit;
  return { success: result.success, message: result.message };
}

Następnie opakuj to w server action w blocks/contact/actions.ts. Pole website to honeypot: jest ukryte przed ludźmi, więc cokolwiek je wypełnia jest botem i dostaje fikcyjny sukces:

"use server";

import { submitForm } from "@/services/forms";
import type { ContactState } from "./types";

export async function submitContact(
  formId: string,
  _prevState: ContactState,
  formData: FormData,
): Promise<ContactState> {
  if (formData.get("website")) {
    return { status: "success", message: null };
  }

  const data: Record<string, string> = {};
  for (const [key, value] of formData.entries()) {
    if (key === "website") continue;
    if (typeof value === "string" && value) data[key] = value;
  }

  try {
    const result = await submitForm(formId, data);
    return {
      status: result.success ? "success" : "error",
      message: result.message,
    };
  } catch {
    return { status: "error", message: null };
  }
}

Krok 5: Wyrenderuj pola

Część kliencka renderuje te pola, które deklaruje definicja formularza, i woła akcję przez useActionState. Nic w liście pól nie jest zahardkodowane - dodaj pole w Form Builderze, a pojawi się tutaj bez deployu:

"use client";

import { useActionState } from "react";
import type { CmssyFormDefinition } from "@cmssy/react";
import { submitContact } from "./actions";
import type { ContactState } from "./types";

const INITIAL_STATE: ContactState = { status: "idle", message: null };

export function ContactForm({
  formDef,
  formId,
  successHeading,
  submitLoadingText,
}: {
  formDef: CmssyFormDefinition;
  formId: string;
  successHeading: string;
  submitLoadingText: string;
}) {
  const [state, formAction, isPending] = useActionState(
    submitContact.bind(null, formId),
    INITIAL_STATE,
  );

  if (state.status === "success") {
    return <p>{successHeading}</p>;
  }

  return (
    <form action={formAction} className="mt-10 space-y-5">
      <input type="text" name="website" tabIndex={-1} className="hidden" />

      {formDef.fields.map((field) => (
        <div key={field.id}>
          <label className="block text-sm font-medium mb-1.5">
            {field.label}
          </label>
          {field.fieldType === "textarea" ? (
            <textarea
              name={field.name}
              rows={5}
              required={field.validation?.required}
              className="w-full px-4 py-2.5 border rounded-lg"
            />
          ) : (
            <input
              type={field.fieldType}
              name={field.name}
              required={field.validation?.required}
              className="w-full px-4 py-2.5 border rounded-lg"
            />
          )}
        </div>
      ))}

      <button type="submit" disabled={isPending}>
        {isPending ? submitLoadingText : "Send"}
      </button>

      {state.status === "error" && (
        <p className="text-sm text-red-500">{state.message}</p>
      )}
    </form>
  );
}

Etykieta jest przetłumaczona w CMS-ie, więc każdy język dostaje swoją bez żadnego ifa w Twoim kodzie.

Krok 6: Zarejestruj blok

Dodaj go do tablicy w cmssy/blocks.ts - tej samej, którą podajesz do createCmssyPage:

import { contactBlock } from "@/blocks/contact/block";
// ...pozostałe bloki

export const blocks = [contactBlock];

Krok 7: Wdróż i użyj

Nie ma osobnego kroku wdrożenia bloku - blok jedzie razem z Twoją aplikacją Next.js:

git push   # potem deploy przez Vercel lub Twoje CI

Potem otwórz stronę w edytorze Cmssy, upuść blok Contact, wybierz swój formularz na zakładce Advanced i opublikuj. Zgłoszenia lądują w sekcji Formularze w panelu i idą do skonfigurowanych odbiorców.

Kluczowe wzorce

  • Definicja formularza to treść - pola, etykiety i walidacja żyją w CMS-ie, więc redaktorzy zmieniają je bez deployu
  • Wysyłka po stronie serwera - server action trzyma poświadczenia delivery poza przeglądarką
  • Honeypot zamiast captchy - ukryte pole nic nie kosztuje i zatrzymuje większość botów
  • Pomijaj realne wysyłki w edytorze - sprawdź context.isPreview, zanim podepniesz cokolwiek destrukcyjnego
  • Renderuj nic zamiast domyślnej wartości - brak wybranego formularza to brak formularza, nie placeholder

Następne kroki