feat: add internal_component block type with contact form

InternalComponentBlock dispatches on `component` field value.
ContactFormComponent submits via /api/contact (SvelteKit proxy →
CMS /api/forms/kontakt/submit) with honeypot + client_age_ms.
BlockRenderer and block-types.ts updated accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-05-21 11:01:47 +02:00
parent 50d68a17ca
commit 9e05fbb2ba
5 changed files with 157 additions and 0 deletions
+11
View File
@@ -422,6 +422,17 @@ export type CalendarBlockData = Omit<
layout?: BlockLayout;
};
/** Internal component block (_type: "internal_component"). */
export type InternalComponentId = "form-contact";
export interface InternalComponentBlockData {
_type?: "internal_component";
_slug?: string;
id?: string;
component?: InternalComponentId;
layout?: BlockLayout;
}
export interface WindMapBlockData {
_type?: "wind_map";
_slug?: string;
+4
View File
@@ -16,6 +16,7 @@
import CalendarBlock from "./blocks/CalendarBlock.svelte";
import OrganisationsBlock from "./blocks/OrganisationsBlock.svelte";
import OpnFormBlock from "./blocks/OpnFormBlock.svelte";
import InternalComponentBlock from "./blocks/InternalComponentBlock.svelte";
import DeadlineBannerBlock from "./blocks/DeadlineBannerBlock.svelte";
import StrommixBlock from "./blocks/StrommixBlock.svelte";
import WindkarteBlock from "./blocks/WindkarteBlock.svelte";
@@ -39,6 +40,7 @@
CalendarBlockData,
OrganisationsBlockData,
OpnFormBlockData,
InternalComponentBlockData,
DeadlineBannerBlockData,
LiveStrommixBlockData,
WindMapBlockData,
@@ -86,6 +88,8 @@
<OrganisationsBlock block={block as OrganisationsBlockData} />
{:else if type === "opnform"}
<OpnFormBlock block={block as OpnFormBlockData} />
{:else if type === "internal_component"}
<InternalComponentBlock block={block as InternalComponentBlockData} />
{:else if type === "deadline_banner"}
<DeadlineBannerBlock block={block as DeadlineBannerBlockData} />
{:else if type === "live_strommix"}
@@ -0,0 +1,18 @@
<script lang="ts">
import { getBlockLayoutClasses } from "$lib/block-layout";
import type { InternalComponentBlockData } from "$lib/block-types";
import ContactFormComponent from "$lib/components/internal/ContactFormComponent.svelte";
let { block }: { block: InternalComponentBlockData } = $props();
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
const component = $derived(block.component ?? "");
</script>
<div class={layoutClasses} data-block="InternalComponent" data-block-type="internal_component" data-block-slug={block._slug}>
{#if component === "form-contact"}
<ContactFormComponent />
{:else}
<p class="text-sm text-stein-500">Unbekannte interne Komponente: <code>{component}</code></p>
{/if}
</div>
@@ -0,0 +1,92 @@
<script lang="ts">
import Button from "$lib/ui/Button.svelte";
import TextInput from "$lib/ui/TextInput.svelte";
import Textarea from "$lib/ui/Textarea.svelte";
type State = "idle" | "pending" | "success" | "error";
let name = $state("");
let email = $state("");
let message = $state("");
let honeypot = $state("");
let pageLoadedAt = Date.now();
let formState = $state<State>("idle");
let errorMessage = $state("");
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
if (formState === "pending") return;
formState = "pending";
errorMessage = "";
try {
const res = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name,
email,
message,
_honeypot: honeypot,
_client_age_ms: Date.now() - pageLoadedAt,
}),
});
if (res.ok) {
formState = "success";
} else {
const body = await res.json().catch(() => ({}));
errorMessage = body?.error || "Unbekannter Fehler";
formState = "error";
}
} catch {
errorMessage = "Netzwerkfehler bitte erneut versuchen.";
formState = "error";
}
}
</script>
{#if formState === "success"}
<div class="rounded-lg border border-wald-300 bg-wald-50 px-6 py-8 text-center">
<p class="text-lg font-semibold text-wald-700">Nachricht gesendet!</p>
<p class="mt-1 text-sm text-stein-600">Wir melden uns so schnell wie möglich.</p>
</div>
{:else}
<form onsubmit={handleSubmit} novalidate class="space-y-4">
<!-- Honeypot per CSS versteckt, von Bots ausgefüllt -->
<div aria-hidden="true" style="position:absolute;left:-9999px;opacity:0;pointer-events:none;tab-index:-1;">
<label for="contact-hp">Website</label>
<input id="contact-hp" name="_honeypot" tabindex="-1" autocomplete="off" bind:value={honeypot} />
</div>
<TextInput
name="name"
label="Name"
placeholder="Dein Name"
required
bind:value={name}
/>
<TextInput
name="email"
label="E-Mail"
placeholder="deine@email.de"
required
bind:value={email}
/>
<Textarea
name="message"
label="Nachricht"
placeholder="Deine Nachricht …"
required
rows={5}
bind:value={message}
/>
{#if formState === "error"}
<p class="text-sm text-error" role="alert">{errorMessage}</p>
{/if}
<Button type="submit" variant="primary" loading={formState === "pending"} label="Senden" />
</form>
{/if}
+32
View File
@@ -0,0 +1,32 @@
import { json } from "@sveltejs/kit";
import { env } from "$env/dynamic/private";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ request, getClientAddress }) => {
const body = await request.json().catch(() => null);
if (!body || typeof body !== "object") {
return json({ error: "Ungültige Anfrage" }, { status: 400 });
}
const cmsBase = (env.PUBLIC_CMS_URL || "http://localhost:3000").replace(/\/$/, "");
const cmsEnv = env.PUBLIC_CMS_ENV || "";
const url = cmsEnv
? `${cmsBase}/api/forms/kontakt/submit?_environment=${encodeURIComponent(cmsEnv)}`
: `${cmsBase}/api/forms/kontakt/submit`;
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Forwarded-For": getClientAddress(),
},
body: JSON.stringify(body),
});
if (res.ok) {
return json({ ok: true }, { status: 201 });
}
const text = await res.text().catch(() => "");
return json({ error: text || "Fehler beim Senden" }, { status: res.status });
};