Compare commits
3 Commits
50d68a17ca
...
94a8feeb67
| Author | SHA1 | Date | |
|---|---|---|---|
| 94a8feeb67 | |||
| 8575572183 | |||
| 9e05fbb2ba |
@@ -422,6 +422,16 @@ export type CalendarBlockData = Omit<
|
|||||||
layout?: BlockLayout;
|
layout?: BlockLayout;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Internal component block (_type: "internal_component"). */
|
||||||
|
export type InternalComponentId = "form-contact";
|
||||||
|
|
||||||
|
export interface InternalComponentBlockData {
|
||||||
|
_type?: "internal_component";
|
||||||
|
_slug?: string;
|
||||||
|
component?: InternalComponentId;
|
||||||
|
layout?: BlockLayout;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WindMapBlockData {
|
export interface WindMapBlockData {
|
||||||
_type?: "wind_map";
|
_type?: "wind_map";
|
||||||
_slug?: string;
|
_slug?: string;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
import CalendarBlock from "./blocks/CalendarBlock.svelte";
|
import CalendarBlock from "./blocks/CalendarBlock.svelte";
|
||||||
import OrganisationsBlock from "./blocks/OrganisationsBlock.svelte";
|
import OrganisationsBlock from "./blocks/OrganisationsBlock.svelte";
|
||||||
import OpnFormBlock from "./blocks/OpnFormBlock.svelte";
|
import OpnFormBlock from "./blocks/OpnFormBlock.svelte";
|
||||||
|
import InternalComponentBlock from "./blocks/InternalComponentBlock.svelte";
|
||||||
import DeadlineBannerBlock from "./blocks/DeadlineBannerBlock.svelte";
|
import DeadlineBannerBlock from "./blocks/DeadlineBannerBlock.svelte";
|
||||||
import StrommixBlock from "./blocks/StrommixBlock.svelte";
|
import StrommixBlock from "./blocks/StrommixBlock.svelte";
|
||||||
import WindkarteBlock from "./blocks/WindkarteBlock.svelte";
|
import WindkarteBlock from "./blocks/WindkarteBlock.svelte";
|
||||||
@@ -39,6 +40,7 @@
|
|||||||
CalendarBlockData,
|
CalendarBlockData,
|
||||||
OrganisationsBlockData,
|
OrganisationsBlockData,
|
||||||
OpnFormBlockData,
|
OpnFormBlockData,
|
||||||
|
InternalComponentBlockData,
|
||||||
DeadlineBannerBlockData,
|
DeadlineBannerBlockData,
|
||||||
LiveStrommixBlockData,
|
LiveStrommixBlockData,
|
||||||
WindMapBlockData,
|
WindMapBlockData,
|
||||||
@@ -86,6 +88,8 @@
|
|||||||
<OrganisationsBlock block={block as OrganisationsBlockData} />
|
<OrganisationsBlock block={block as OrganisationsBlockData} />
|
||||||
{:else if type === "opnform"}
|
{:else if type === "opnform"}
|
||||||
<OpnFormBlock block={block as OpnFormBlockData} />
|
<OpnFormBlock block={block as OpnFormBlockData} />
|
||||||
|
{:else if type === "internal_component"}
|
||||||
|
<InternalComponentBlock block={block as InternalComponentBlockData} />
|
||||||
{:else if type === "deadline_banner"}
|
{:else if type === "deadline_banner"}
|
||||||
<DeadlineBannerBlock block={block as DeadlineBannerBlockData} />
|
<DeadlineBannerBlock block={block as DeadlineBannerBlockData} />
|
||||||
{:else if type === "live_strommix"}
|
{:else if type === "live_strommix"}
|
||||||
|
|||||||
@@ -1,51 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import MarkdownBlock from "./blocks/MarkdownBlock.svelte";
|
import BlockRenderer from "./BlockRenderer.svelte";
|
||||||
import HeadlineBlock from "./blocks/HeadlineBlock.svelte";
|
|
||||||
import HtmlBlock from "./blocks/HtmlBlock.svelte";
|
|
||||||
import IframeBlock from "./blocks/IframeBlock.svelte";
|
|
||||||
import ImageBlock from "./blocks/ImageBlock.svelte";
|
|
||||||
import ImageGalleryBlock from "./blocks/ImageGalleryBlock.svelte";
|
|
||||||
import FilesBlock from "./blocks/FilesBlock.svelte";
|
|
||||||
import YoutubeVideoBlock from "./blocks/YoutubeVideoBlock.svelte";
|
|
||||||
import YoutubeVideoGalleryBlock from "./blocks/YoutubeVideoGalleryBlock.svelte";
|
|
||||||
import QuoteBlock from "./blocks/QuoteBlock.svelte";
|
|
||||||
import QuoteCarouselBlock from "./blocks/QuoteCarouselBlock.svelte";
|
|
||||||
import LinkListBlock from "./blocks/LinkListBlock.svelte";
|
|
||||||
import PostOverviewBlock from "./blocks/PostOverviewBlock.svelte";
|
|
||||||
import SearchableTextBlock from "./blocks/SearchableTextBlock.svelte";
|
|
||||||
import CalendarBlock from "./blocks/CalendarBlock.svelte";
|
|
||||||
import OrganisationsBlock from "./blocks/OrganisationsBlock.svelte";
|
|
||||||
import OpnFormBlock from "./blocks/OpnFormBlock.svelte";
|
|
||||||
import DeadlineBannerBlock from "./blocks/DeadlineBannerBlock.svelte";
|
|
||||||
import StrommixBlock from "./blocks/StrommixBlock.svelte";
|
|
||||||
import WindkarteBlock from "./blocks/WindkarteBlock.svelte";
|
|
||||||
import { isBlockLayoutBreakout, getSpaceBottomClass } from "$lib/block-layout";
|
import { isBlockLayoutBreakout, getSpaceBottomClass } from "$lib/block-layout";
|
||||||
import type { BlockLayout } from "$lib/block-layout";
|
import type { BlockLayout } from "$lib/block-layout";
|
||||||
import type { Translations } from "$lib/translations";
|
import type { Translations } from "$lib/translations";
|
||||||
import type {
|
import type { RowContentLayout, ResolvedBlock } from "$lib/block-types";
|
||||||
RowContentLayout,
|
|
||||||
ResolvedBlock,
|
|
||||||
MarkdownBlockData,
|
|
||||||
HeadlineBlockData,
|
|
||||||
HtmlBlockData,
|
|
||||||
IframeBlockData,
|
|
||||||
ImageBlockData,
|
|
||||||
ImageGalleryBlockData,
|
|
||||||
FilesBlockData,
|
|
||||||
YoutubeVideoBlockData,
|
|
||||||
YoutubeVideoGalleryBlockData,
|
|
||||||
QuoteBlockData,
|
|
||||||
QuoteCarouselBlockData,
|
|
||||||
LinkListBlockData,
|
|
||||||
PostOverviewBlockData,
|
|
||||||
SearchableTextBlockData,
|
|
||||||
CalendarBlockData,
|
|
||||||
OrganisationsBlockData,
|
|
||||||
OpnFormBlockData,
|
|
||||||
DeadlineBannerBlockData,
|
|
||||||
LiveStrommixBlockData,
|
|
||||||
WindMapBlockData,
|
|
||||||
} from "$lib/block-types";
|
|
||||||
|
|
||||||
let { layout, class: className = "", translations = {} }: { layout: RowContentLayout; class?: string; translations?: Translations | null } = $props();
|
let { layout, class: className = "", translations = {} }: { layout: RowContentLayout; class?: string; translations?: Translations | null } = $props();
|
||||||
|
|
||||||
@@ -107,72 +65,25 @@
|
|||||||
>
|
>
|
||||||
{#each row.content as item}
|
{#each row.content as item}
|
||||||
{#if isResolvedBlock(item)}
|
{#if isResolvedBlock(item)}
|
||||||
{#snippet blockContent()}
|
|
||||||
{#if blockType(item) === "markdown"}
|
|
||||||
<MarkdownBlock block={item as MarkdownBlockData} />
|
|
||||||
{:else if blockType(item) === "headline"}
|
|
||||||
<HeadlineBlock block={item as HeadlineBlockData} />
|
|
||||||
{:else if blockType(item) === "html"}
|
|
||||||
<HtmlBlock block={item as HtmlBlockData} />
|
|
||||||
{:else if blockType(item) === "iframe"}
|
|
||||||
<IframeBlock block={item as IframeBlockData} translations={translations} />
|
|
||||||
{:else if blockType(item) === "image"}
|
|
||||||
<ImageBlock block={item as ImageBlockData} />
|
|
||||||
{:else if blockType(item) === "image_gallery"}
|
|
||||||
<ImageGalleryBlock block={item as ImageGalleryBlockData} />
|
|
||||||
{:else if blockType(item) === "files"}
|
|
||||||
<FilesBlock block={item as FilesBlockData} />
|
|
||||||
{:else if blockType(item) === "youtube_video"}
|
|
||||||
<YoutubeVideoBlock block={item as YoutubeVideoBlockData} />
|
|
||||||
{:else if blockType(item) === "youtube_video_gallery"}
|
|
||||||
<YoutubeVideoGalleryBlock block={item as YoutubeVideoGalleryBlockData} />
|
|
||||||
{:else if blockType(item) === "quote"}
|
|
||||||
<QuoteBlock block={item as QuoteBlockData} />
|
|
||||||
{:else if blockType(item) === "quote_carousel"}
|
|
||||||
<QuoteCarouselBlock block={item as QuoteCarouselBlockData} />
|
|
||||||
{:else if blockType(item) === "link_list"}
|
|
||||||
<LinkListBlock block={item as LinkListBlockData} />
|
|
||||||
{:else if blockType(item) === "post_overview"}
|
|
||||||
<PostOverviewBlock block={item as PostOverviewBlockData} />
|
|
||||||
{:else if blockType(item) === "searchable_text"}
|
|
||||||
<SearchableTextBlock block={item as SearchableTextBlockData} translations={translations} />
|
|
||||||
{:else if blockType(item) === "calendar"}
|
|
||||||
<CalendarBlock block={item as CalendarBlockData} translations={translations} />
|
|
||||||
{:else if blockType(item) === "organisations"}
|
|
||||||
<OrganisationsBlock block={item as OrganisationsBlockData} />
|
|
||||||
{:else if blockType(item) === "opnform"}
|
|
||||||
<OpnFormBlock block={item as OpnFormBlockData} />
|
|
||||||
{:else if blockType(item) === "deadline_banner"}
|
|
||||||
<DeadlineBannerBlock block={item as DeadlineBannerBlockData} />
|
|
||||||
{:else if blockType(item) === "live_strommix"}
|
|
||||||
<StrommixBlock block={item as LiveStrommixBlockData} />
|
|
||||||
{:else if blockType(item) === "wind_map"}
|
|
||||||
<WindkarteBlock block={item as WindMapBlockData} />
|
|
||||||
{:else}
|
|
||||||
<div class="col-span-12 rounded border border-erde-200 bg-erde-50 px-3 py-2 text-sm text-erde-800">
|
|
||||||
Unbekannter Block: <code>{blockType(item)}</code>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/snippet}
|
|
||||||
{#if isBlockLayoutBreakout((item as { layout?: BlockLayout }).layout)}
|
{#if isBlockLayoutBreakout((item as { layout?: BlockLayout }).layout)}
|
||||||
{@const rowBlockType = blockType(item)}
|
{@const type = blockType(item)}
|
||||||
<div class="col-span-12">
|
<div class="col-span-12">
|
||||||
<div
|
<div
|
||||||
class="component-breakout--{rowBlockType} {getSpaceBottomClass((item as { layout?: BlockLayout }).layout)} w-screen relative left-1/2 -translate-x-1/2 max-w-none {rowBlockType === 'quote'
|
class="component-breakout--{type} {getSpaceBottomClass((item as { layout?: BlockLayout }).layout)} w-screen relative left-1/2 -translate-x-1/2 max-w-none {type === 'quote'
|
||||||
? 'bg-wald-50'
|
? 'bg-wald-50'
|
||||||
: '[background:var(--color-container-breakout)]'}"
|
: '[background:var(--color-container-breakout)]'}"
|
||||||
>
|
>
|
||||||
{#if rowBlockType === 'deadline_banner'}
|
{#if type === 'deadline_banner'}
|
||||||
{@render blockContent()}
|
<BlockRenderer block={item} {translations} />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="container-custom py-8">
|
<div class="container-custom py-8">
|
||||||
{@render blockContent()}
|
<BlockRenderer block={item} {translations} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{@render blockContent()}
|
<BlockRenderer block={item} {translations} />
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -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,210 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Button from "$lib/ui/Button.svelte";
|
||||||
|
import TextInput from "$lib/ui/TextInput.svelte";
|
||||||
|
import Textarea from "$lib/ui/Textarea.svelte";
|
||||||
|
import Checkbox from "$lib/ui/Checkbox.svelte";
|
||||||
|
import { useTranslate, T } from "$lib/translations";
|
||||||
|
import {
|
||||||
|
LIMITS,
|
||||||
|
validateContact,
|
||||||
|
type ContactErrors,
|
||||||
|
type ContactFields,
|
||||||
|
} from "./contact-form-validation";
|
||||||
|
|
||||||
|
type FormState = "idle" | "pending" | "success" | "error";
|
||||||
|
|
||||||
|
const t = useTranslate();
|
||||||
|
|
||||||
|
let name = $state("");
|
||||||
|
let email = $state("");
|
||||||
|
let subject = $state("");
|
||||||
|
let message = $state("");
|
||||||
|
let consent = $state(false);
|
||||||
|
|
||||||
|
// Honeypots — three with realistic names. Real users never see them.
|
||||||
|
let honeypot = $state("");
|
||||||
|
let website = $state("");
|
||||||
|
let company = $state("");
|
||||||
|
|
||||||
|
const pageLoadedAt = Date.now();
|
||||||
|
|
||||||
|
let formState = $state<FormState>("idle");
|
||||||
|
let serverError = $state("");
|
||||||
|
let submitAttempted = $state(false);
|
||||||
|
|
||||||
|
const fields = $derived<ContactFields>({ name, email, subject, message, consent });
|
||||||
|
const errors = $derived<ContactErrors>(validateContact(fields));
|
||||||
|
const isValid = $derived(Object.keys(errors).length === 0);
|
||||||
|
|
||||||
|
function showError(field: keyof ContactFields): string {
|
||||||
|
if (!submitAttempted) return "";
|
||||||
|
const e = errors[field];
|
||||||
|
return e ? t(e.key, e.params) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageRemaining = $derived(LIMITS.MESSAGE_MAX - message.length);
|
||||||
|
const messageHelp = $derived(
|
||||||
|
messageRemaining >= 0
|
||||||
|
? t(T.contact_message_remaining, { n: messageRemaining })
|
||||||
|
: t(T.contact_message_overflow, { n: -messageRemaining }),
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleSubmit(e: SubmitEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
submitAttempted = true;
|
||||||
|
if (formState === "pending") return;
|
||||||
|
if (!isValid) {
|
||||||
|
const first = Object.keys(errors)[0];
|
||||||
|
const el = document.querySelector<HTMLElement>(`[data-field="${first}"]`);
|
||||||
|
el?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
formState = "pending";
|
||||||
|
serverError = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/contact", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
subject,
|
||||||
|
message,
|
||||||
|
consent,
|
||||||
|
_honeypot: honeypot,
|
||||||
|
website,
|
||||||
|
company,
|
||||||
|
_client_age_ms: Date.now() - pageLoadedAt,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
formState = "success";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
if (res.status === 422) {
|
||||||
|
serverError = t(T.contact_server_error_validation);
|
||||||
|
} else if (res.status === 429) {
|
||||||
|
serverError = t(T.contact_server_error_throttle);
|
||||||
|
} else if (res.status === 403) {
|
||||||
|
serverError = t(T.contact_server_error_blocked);
|
||||||
|
} else {
|
||||||
|
serverError = body?.error || t(T.contact_server_error_generic);
|
||||||
|
}
|
||||||
|
formState = "error";
|
||||||
|
} catch {
|
||||||
|
serverError = t(T.contact_server_error_network);
|
||||||
|
formState = "error";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if formState === "success"}
|
||||||
|
<div class="rounded-sm border border-wald-300 bg-wald-50 px-4 py-5 text-center shadow-sm">
|
||||||
|
<p class="text-base font-semibold text-wald-700">{t(T.contact_success_title)}</p>
|
||||||
|
<p class="mt-1 text-sm text-stein-600">{t(T.contact_success_body)}</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<form
|
||||||
|
onsubmit={handleSubmit}
|
||||||
|
novalidate
|
||||||
|
class="space-y-3"
|
||||||
|
aria-busy={formState === "pending"}
|
||||||
|
>
|
||||||
|
<!-- Honeypots: visually hidden, off the keyboard, off the a11y tree -->
|
||||||
|
<div aria-hidden="true" class="pointer-events-none absolute left-[-9999px] top-auto h-px w-px overflow-hidden opacity-0">
|
||||||
|
<label for="contact-hp-1">leer lassen</label>
|
||||||
|
<input id="contact-hp-1" name="_honeypot" type="text" tabindex="-1" autocomplete="off" bind:value={honeypot} />
|
||||||
|
<label for="contact-hp-2">Website</label>
|
||||||
|
<input id="contact-hp-2" name="website" type="url" tabindex="-1" autocomplete="off" bind:value={website} />
|
||||||
|
<label for="contact-hp-3">Firma</label>
|
||||||
|
<input id="contact-hp-3" name="company" type="text" tabindex="-1" autocomplete="off" bind:value={company} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div data-field="name">
|
||||||
|
<TextInput
|
||||||
|
size="sm"
|
||||||
|
name="name"
|
||||||
|
label={t(T.contact_label_name)}
|
||||||
|
placeholder={t(T.contact_placeholder_name)}
|
||||||
|
required
|
||||||
|
bind:value={name}
|
||||||
|
error={showError("name")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div data-field="email">
|
||||||
|
<TextInput
|
||||||
|
size="sm"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
label={t(T.contact_label_email)}
|
||||||
|
placeholder={t(T.contact_placeholder_email)}
|
||||||
|
required
|
||||||
|
bind:value={email}
|
||||||
|
error={showError("email")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div data-field="subject">
|
||||||
|
<TextInput
|
||||||
|
size="sm"
|
||||||
|
name="subject"
|
||||||
|
label={t(T.contact_label_subject)}
|
||||||
|
placeholder={t(T.contact_placeholder_subject)}
|
||||||
|
bind:value={subject}
|
||||||
|
error={showError("subject")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div data-field="message">
|
||||||
|
<Textarea
|
||||||
|
size="sm"
|
||||||
|
name="message"
|
||||||
|
label={t(T.contact_label_message)}
|
||||||
|
placeholder={t(T.contact_placeholder_message)}
|
||||||
|
required
|
||||||
|
rows={5}
|
||||||
|
bind:value={message}
|
||||||
|
error={showError("message")}
|
||||||
|
helpText={messageHelp}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div data-field="consent" class="pt-1">
|
||||||
|
<Checkbox
|
||||||
|
name="consent"
|
||||||
|
bind:checked={consent}
|
||||||
|
label={t(T.contact_label_consent)}
|
||||||
|
/>
|
||||||
|
{#if showError("consent")}
|
||||||
|
<p class="mt-1 text-[0.75rem] text-error" role="alert">{showError("consent")}</p>
|
||||||
|
{/if}
|
||||||
|
<p class="mt-1 text-xs text-stein-500">{t(T.contact_privacy_note)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if serverError}
|
||||||
|
<div class="rounded-sm border border-error bg-error-subtle px-3 py-2 text-sm text-error shadow-sm" role="alert">
|
||||||
|
{serverError}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3 pt-2">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
loading={formState === "pending"}
|
||||||
|
disabled={!isValid && submitAttempted}
|
||||||
|
label={formState === "pending" ? t(T.contact_submitting) : t(T.contact_submit)}
|
||||||
|
/>
|
||||||
|
{#if !isValid && submitAttempted && !serverError}
|
||||||
|
<span class="text-xs text-error">{t(T.contact_fix_errors)}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* Shared validation for ContactFormComponent + /api/contact server route.
|
||||||
|
* Single source of truth for limits and rules.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const LIMITS = {
|
||||||
|
NAME_MIN: 2,
|
||||||
|
NAME_MAX: 80,
|
||||||
|
EMAIL_MAX: 200,
|
||||||
|
SUBJECT_MAX: 120,
|
||||||
|
MESSAGE_MIN: 10,
|
||||||
|
MESSAGE_MAX: 4000,
|
||||||
|
MAX_URLS_IN_MESSAGE: 2,
|
||||||
|
MIN_CLIENT_AGE_MS: 3000,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const HONEYPOT_FIELDS = ["_honeypot", "website", "company"] as const;
|
||||||
|
|
||||||
|
export type ContactFields = {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
subject: string;
|
||||||
|
message: string;
|
||||||
|
consent: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Error as translation-key + optional params (resolved in the UI). */
|
||||||
|
export type ContactError = {
|
||||||
|
key: string;
|
||||||
|
params?: Record<string, string | number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ContactErrors = Partial<Record<keyof ContactFields, ContactError>>;
|
||||||
|
|
||||||
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
|
||||||
|
const URL_RE = /\b(?:https?:\/\/|www\.)\S+/gi;
|
||||||
|
const CONTROL_CHAR_RE = /[\x00-\x08\x0B\x0C\x0E-\x1F]/;
|
||||||
|
|
||||||
|
function trim(s: unknown): string {
|
||||||
|
return typeof s === "string" ? s.trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateContact(input: Partial<ContactFields>): ContactErrors {
|
||||||
|
const errors: ContactErrors = {};
|
||||||
|
const name = trim(input.name);
|
||||||
|
const email = trim(input.email);
|
||||||
|
const subject = trim(input.subject);
|
||||||
|
const message = trim(input.message);
|
||||||
|
|
||||||
|
if (name.length < LIMITS.NAME_MIN) {
|
||||||
|
errors.name = { key: "contact_error_name_min", params: { n: LIMITS.NAME_MIN } };
|
||||||
|
} else if (name.length > LIMITS.NAME_MAX) {
|
||||||
|
errors.name = { key: "contact_error_name_max", params: { n: LIMITS.NAME_MAX } };
|
||||||
|
} else if (URL_RE.test(name)) {
|
||||||
|
errors.name = { key: "contact_error_name_no_links" };
|
||||||
|
} else if (CONTROL_CHAR_RE.test(name)) {
|
||||||
|
errors.name = { key: "contact_error_name_invalid_chars" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
errors.email = { key: "contact_error_email_required" };
|
||||||
|
} else if (email.length > LIMITS.EMAIL_MAX) {
|
||||||
|
errors.email = { key: "contact_error_email_max", params: { n: LIMITS.EMAIL_MAX } };
|
||||||
|
} else if (!EMAIL_RE.test(email)) {
|
||||||
|
errors.email = { key: "contact_error_email_invalid" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subject.length > LIMITS.SUBJECT_MAX) {
|
||||||
|
errors.subject = { key: "contact_error_subject_max", params: { n: LIMITS.SUBJECT_MAX } };
|
||||||
|
} else if (CONTROL_CHAR_RE.test(subject)) {
|
||||||
|
errors.subject = { key: "contact_error_subject_invalid_chars" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.length < LIMITS.MESSAGE_MIN) {
|
||||||
|
errors.message = { key: "contact_error_message_min", params: { n: LIMITS.MESSAGE_MIN } };
|
||||||
|
} else if (message.length > LIMITS.MESSAGE_MAX) {
|
||||||
|
errors.message = { key: "contact_error_message_max", params: { n: LIMITS.MESSAGE_MAX } };
|
||||||
|
} else {
|
||||||
|
const urlMatches = message.match(URL_RE) ?? [];
|
||||||
|
if (urlMatches.length > LIMITS.MAX_URLS_IN_MESSAGE) {
|
||||||
|
errors.message = { key: "contact_error_message_too_many_links", params: { n: LIMITS.MAX_URLS_IN_MESSAGE } };
|
||||||
|
} else if (CONTROL_CHAR_RE.test(message)) {
|
||||||
|
errors.message = { key: "contact_error_message_invalid_chars" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.consent !== true) {
|
||||||
|
errors.consent = { key: "contact_error_consent_required" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
@@ -276,6 +276,44 @@ const TRANSLATION_KEYS = [
|
|||||||
"strommix_trend_flat",
|
"strommix_trend_flat",
|
||||||
"strommix_sparkline_aria", // {{from}}, {{to}}
|
"strommix_sparkline_aria", // {{from}}, {{to}}
|
||||||
"strommix_stale_badge",
|
"strommix_stale_badge",
|
||||||
|
// Contact form (ContactFormComponent.svelte).
|
||||||
|
"contact_label_name",
|
||||||
|
"contact_placeholder_name",
|
||||||
|
"contact_label_email",
|
||||||
|
"contact_placeholder_email",
|
||||||
|
"contact_label_subject",
|
||||||
|
"contact_placeholder_subject",
|
||||||
|
"contact_label_message",
|
||||||
|
"contact_placeholder_message",
|
||||||
|
"contact_message_remaining", // {{n}}
|
||||||
|
"contact_message_overflow", // {{n}}
|
||||||
|
"contact_label_consent",
|
||||||
|
"contact_privacy_note",
|
||||||
|
"contact_submit",
|
||||||
|
"contact_submitting",
|
||||||
|
"contact_success_title",
|
||||||
|
"contact_success_body",
|
||||||
|
"contact_fix_errors",
|
||||||
|
"contact_server_error_generic",
|
||||||
|
"contact_server_error_network",
|
||||||
|
"contact_server_error_blocked",
|
||||||
|
"contact_server_error_throttle",
|
||||||
|
"contact_server_error_validation",
|
||||||
|
// Per-field validation errors (params: n).
|
||||||
|
"contact_error_name_min", // {{n}}
|
||||||
|
"contact_error_name_max", // {{n}}
|
||||||
|
"contact_error_name_no_links",
|
||||||
|
"contact_error_name_invalid_chars",
|
||||||
|
"contact_error_email_required",
|
||||||
|
"contact_error_email_max", // {{n}}
|
||||||
|
"contact_error_email_invalid",
|
||||||
|
"contact_error_subject_max", // {{n}}
|
||||||
|
"contact_error_subject_invalid_chars",
|
||||||
|
"contact_error_message_min", // {{n}}
|
||||||
|
"contact_error_message_max", // {{n}}
|
||||||
|
"contact_error_message_too_many_links", // {{n}}
|
||||||
|
"contact_error_message_invalid_chars",
|
||||||
|
"contact_error_consent_required",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type TranslationKey = (typeof TRANSLATION_KEYS)[number];
|
export type TranslationKey = (typeof TRANSLATION_KEYS)[number];
|
||||||
|
|||||||
@@ -11,18 +11,25 @@
|
|||||||
error = '',
|
error = '',
|
||||||
helpText = '',
|
helpText = '',
|
||||||
invalid = false,
|
invalid = false,
|
||||||
|
size = 'md' as 'sm' | 'md',
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const inputId = $derived(id || `input-${Math.random().toString(36).slice(2)}`);
|
const inputId = $derived(id || `input-${Math.random().toString(36).slice(2)}`);
|
||||||
const errorId = $derived(`${inputId}-error`);
|
const errorId = $derived(`${inputId}-error`);
|
||||||
const helpId = $derived(`${inputId}-help`);
|
const helpId = $derived(`${inputId}-help`);
|
||||||
|
|
||||||
|
const sizeClasses = $derived(
|
||||||
|
size === 'sm'
|
||||||
|
? 'h-10 rounded-sm px-3 py-2 text-sm shadow-sm'
|
||||||
|
: 'h-12 rounded-lg px-4 py-3 text-base'
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
{#if label}
|
{#if label}
|
||||||
<label
|
<label
|
||||||
for={inputId}
|
for={inputId}
|
||||||
class="mb-1.5 block text-sm font-medium text-stein-700"
|
class="mb-1.5 block {size === 'sm' ? 'text-xs' : 'text-sm'} font-medium text-stein-700"
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
{#if required}
|
{#if required}
|
||||||
@@ -41,16 +48,16 @@
|
|||||||
aria-required={required}
|
aria-required={required}
|
||||||
aria-invalid={invalid || !!error}
|
aria-invalid={invalid || !!error}
|
||||||
aria-describedby={[error ? errorId : '', helpText ? helpId : ''].filter(Boolean).join(' ')}
|
aria-describedby={[error ? errorId : '', helpText ? helpId : ''].filter(Boolean).join(' ')}
|
||||||
class="h-12 w-full rounded-lg border bg-white px-4 py-3 text-base text-stein-800 outline-none placeholder:text-stein-400 {error || invalid
|
class="{sizeClasses} w-full border bg-white text-stein-800 outline-none placeholder:text-stein-400 {error || invalid
|
||||||
? 'border-error ring-2 ring-error-subtle'
|
? 'border-error ring-2 ring-error-subtle'
|
||||||
: 'border-stein-300 hover:border-stein-400 focus:border-wald-500 focus:ring-2 focus:ring-wald-100'} disabled:bg-stein-100 disabled:opacity-60"
|
: 'border-stein-300 hover:border-stein-400 focus:border-wald-500 focus:ring-2 focus:ring-wald-100'} disabled:bg-stein-100 disabled:opacity-60"
|
||||||
/>
|
/>
|
||||||
{#if error}
|
{#if error}
|
||||||
<p id={errorId} class="mt-1.5 text-[0.8125rem] text-error" role="alert">
|
<p id={errorId} class="mt-1 text-[0.75rem] text-error" role="alert">
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
{:else if helpText}
|
{:else if helpText}
|
||||||
<p id={helpId} class="mt-1.5 text-[0.8125rem] text-stein-500">
|
<p id={helpId} class="mt-1 text-[0.75rem] text-stein-500">
|
||||||
{helpText}
|
{helpText}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -11,18 +11,25 @@
|
|||||||
helpText = '',
|
helpText = '',
|
||||||
invalid = false,
|
invalid = false,
|
||||||
rows = 4,
|
rows = 4,
|
||||||
|
size = 'md' as 'sm' | 'md',
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const inputId = $derived(id || `textarea-${Math.random().toString(36).slice(2)}`);
|
const inputId = $derived(id || `textarea-${Math.random().toString(36).slice(2)}`);
|
||||||
const errorId = $derived(`${inputId}-error`);
|
const errorId = $derived(`${inputId}-error`);
|
||||||
const helpId = $derived(`${inputId}-help`);
|
const helpId = $derived(`${inputId}-help`);
|
||||||
|
|
||||||
|
const sizeClasses = $derived(
|
||||||
|
size === 'sm'
|
||||||
|
? 'min-h-[80px] rounded-sm px-3 py-2 text-sm shadow-sm'
|
||||||
|
: 'min-h-[120px] rounded-lg px-4 py-3 text-base'
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
{#if label}
|
{#if label}
|
||||||
<label
|
<label
|
||||||
for={inputId}
|
for={inputId}
|
||||||
class="mb-1.5 block text-sm font-medium text-stein-700"
|
class="mb-1.5 block {size === 'sm' ? 'text-xs' : 'text-sm'} font-medium text-stein-700"
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
{#if required}
|
{#if required}
|
||||||
@@ -41,16 +48,16 @@
|
|||||||
aria-required={required}
|
aria-required={required}
|
||||||
aria-invalid={invalid || !!error}
|
aria-invalid={invalid || !!error}
|
||||||
aria-describedby={[error ? errorId : '', helpText ? helpId : ''].filter(Boolean).join(' ')}
|
aria-describedby={[error ? errorId : '', helpText ? helpId : ''].filter(Boolean).join(' ')}
|
||||||
class="min-h-[120px] w-full resize-y rounded-lg border bg-white px-4 py-3 text-base text-stein-800 outline-none placeholder:text-stein-400 {error || invalid
|
class="{sizeClasses} w-full resize-y border bg-white text-stein-800 outline-none placeholder:text-stein-400 {error || invalid
|
||||||
? 'border-error ring-2 ring-error-subtle'
|
? 'border-error ring-2 ring-error-subtle'
|
||||||
: 'border-stein-300 hover:border-stein-400 focus:border-wald-500 focus:ring-2 focus:ring-wald-100'} disabled:bg-stein-100 disabled:opacity-60"
|
: 'border-stein-300 hover:border-stein-400 focus:border-wald-500 focus:ring-2 focus:ring-wald-100'} disabled:bg-stein-100 disabled:opacity-60"
|
||||||
></textarea>
|
></textarea>
|
||||||
{#if error}
|
{#if error}
|
||||||
<p id={errorId} class="mt-1.5 text-[0.8125rem] text-error" role="alert">
|
<p id={errorId} class="mt-1 text-[0.75rem] text-error" role="alert">
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
{:else if helpText}
|
{:else if helpText}
|
||||||
<p id={helpId} class="mt-1.5 text-[0.8125rem] text-stein-500">
|
<p id={helpId} class="mt-1 text-[0.75rem] text-stein-500">
|
||||||
{helpText}
|
{helpText}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { json } from "@sveltejs/kit";
|
||||||
|
import { env as publicEnv } from "$env/dynamic/public";
|
||||||
|
import type { RequestHandler } from "./$types";
|
||||||
|
import {
|
||||||
|
HONEYPOT_FIELDS,
|
||||||
|
LIMITS,
|
||||||
|
validateContact,
|
||||||
|
} from "$lib/components/internal/contact-form-validation";
|
||||||
|
|
||||||
|
const ALLOWED_ORIGINS = new Set([
|
||||||
|
"https://windwiderstand.de",
|
||||||
|
"https://www.windwiderstand.de",
|
||||||
|
"http://localhost:5173",
|
||||||
|
"http://localhost:4173",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request, getClientAddress, url }) => {
|
||||||
|
// Origin/Referer must match — blocks naive cross-site POSTs
|
||||||
|
const origin = request.headers.get("origin") ?? "";
|
||||||
|
const referer = request.headers.get("referer") ?? "";
|
||||||
|
const refererOrigin = referer ? safeOrigin(referer) : "";
|
||||||
|
if (!ALLOWED_ORIGINS.has(origin) && !ALLOWED_ORIGINS.has(refererOrigin)) {
|
||||||
|
return json({ error: "Ungültige Anfrage." }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = await request.json().catch(() => null);
|
||||||
|
if (!raw || typeof raw !== "object") {
|
||||||
|
return json({ error: "Ungültige Anfrage." }, { status: 400 });
|
||||||
|
}
|
||||||
|
const body = raw as Record<string, unknown>;
|
||||||
|
|
||||||
|
// Honeypots — must all be empty
|
||||||
|
for (const hp of HONEYPOT_FIELDS) {
|
||||||
|
const val = body[hp];
|
||||||
|
if (typeof val === "string" && val.trim() !== "") {
|
||||||
|
// Pretend success so bots don't learn the trap exists
|
||||||
|
return json({ ok: true }, { status: 201 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client age — bots usually post immediately
|
||||||
|
const clientAge = Number(body._client_age_ms);
|
||||||
|
if (!Number.isFinite(clientAge) || clientAge < LIMITS.MIN_CLIENT_AGE_MS) {
|
||||||
|
return json({ error: "Bitte etwas mehr Zeit lassen." }, { status: 429 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full validation
|
||||||
|
const fields = {
|
||||||
|
name: typeof body.name === "string" ? body.name : "",
|
||||||
|
email: typeof body.email === "string" ? body.email : "",
|
||||||
|
subject: typeof body.subject === "string" ? body.subject : "",
|
||||||
|
message: typeof body.message === "string" ? body.message : "",
|
||||||
|
consent: body.consent === true,
|
||||||
|
};
|
||||||
|
const errors = validateContact(fields);
|
||||||
|
if (Object.keys(errors).length > 0) {
|
||||||
|
return json({ errors }, { status: 422 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward to CMS forms plugin
|
||||||
|
const cmsBase = (publicEnv.PUBLIC_CMS_URL || "http://localhost:3099").replace(/\/$/, "");
|
||||||
|
const cmsEnv = publicEnv.PUBLIC_CMS_ENV || "";
|
||||||
|
const cmsUrl = cmsEnv
|
||||||
|
? `${cmsBase}/api/forms/kontakt/submit?_environment=${encodeURIComponent(cmsEnv)}`
|
||||||
|
: `${cmsBase}/api/forms/kontakt/submit`;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name: fields.name.trim(),
|
||||||
|
email: fields.email.trim(),
|
||||||
|
subject: fields.subject.trim(),
|
||||||
|
message: fields.message.trim(),
|
||||||
|
consent_at: new Date().toISOString(),
|
||||||
|
submitted_via: "windwiderstand.de/api/contact",
|
||||||
|
user_agent: (request.headers.get("user-agent") ?? "").slice(0, 200),
|
||||||
|
};
|
||||||
|
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(cmsUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Forwarded-For": getClientAddress(),
|
||||||
|
Origin: url.origin,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[contact] CMS fetch failed:", e);
|
||||||
|
return json({ error: "Server nicht erreichbar." }, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
return json({ ok: true }, { status: 201 });
|
||||||
|
}
|
||||||
|
if (res.status === 429) {
|
||||||
|
return json({ error: "Zu viele Anfragen. Bitte später erneut versuchen." }, { status: 429 });
|
||||||
|
}
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
console.error("[contact] CMS error", res.status, text);
|
||||||
|
return json({ error: "Fehler beim Senden." }, { status: 502 });
|
||||||
|
};
|
||||||
|
|
||||||
|
function safeOrigin(u: string): string {
|
||||||
|
try {
|
||||||
|
return new URL(u).origin;
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user