Skip to content
This repository was archived by the owner on Mar 23, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions app/(web)/verein/ehrenamtler-des-jahres/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Metadata } from "next";
import { getTitle } from "#/lib/page";
import { ehrenamtlerDesJahres } from "#/content/sitemap";
import { PageContent } from "#/components/web/page/PageContent";
import { SectionTitle } from "#/components/web/section/SectionTitle";
import { EhrenamtlerForm } from "#/components/form/EhrenamtlerForm";

export const metadata: Metadata = {
title: getTitle(ehrenamtlerDesJahres.name),
};

export default function EhrenamtlerDesJahres() {
return (
<PageContent>
<SectionTitle title={ehrenamtlerDesJahres.name} />
<div className="flex flex-col gap-4">
<p>Schlage einen Ehrenamtler des Jahres vor und beschreibe kurz warum er eine Auszeichnung verdient hat.</p>
<EhrenamtlerForm />
</div>
</PageContent>
);
}
48 changes: 48 additions & 0 deletions app/api/suggest-volunteer-of-the-year/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { NextRequest } from "next/server";
import { createTransport } from "nodemailer";

export async function POST(request: NextRequest) {
const formData = await request.formData();

const suggesterName = formData.get("suggester-name");
if (!suggesterName) {
return Response.json({ error: "Name is required", sent: false }, { status: 400 });
}

const suggesterEmail = formData.get("suggester-email");
if (!suggesterEmail) {
return Response.json({ error: "Email is required", sent: false }, { status: 400 });
}

const volunteerName = formData.get("volunteer-name");
if (!volunteerName) {
return Response.json({ error: "Name is required", sent: false }, { status: 400 });
}

const volunteerWhy = formData.get("volunteer-why");
if (!volunteerWhy) {
return Response.json({ error: "Why is required", sent: false }, { status: 400 });
}

const transporter = createTransport({
port: Number.parseInt(process.env.MAIL_CONTACT_PORT ?? "587"),
host: process.env.MAIL_CONTACT_SMTP,
auth: {
user: process.env.MAIL_CONTACT_USER,
pass: process.env.MAIL_CONTACT_PASS,
},
requireTLS: true,
});

const sentMessageInfo = await transporter.sendMail({
from: process.env.MAIL_CONTACT_FROM,
to: process.env.MAIL_VOLUNTEER_CONTACT_TO,
subject: `[svwalddorf.de] Neuer Vorschlag für Ehrenamtler des Jahres von ${suggesterName}`,
text: `${suggesterName} (${suggesterEmail}): ${volunteerName} - ${volunteerWhy}`,
html: `<p>${suggesterName} (${suggesterEmail}):</p><p>${volunteerName}</p><p>${volunteerWhy}</p>`,
});

console.log("info:", sentMessageInfo);

return Response.json({ sent: true });
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,22 @@ import { PropsWithChildren, type JSX } from "react";
type Props = {
text?: string;
iconPosition?: "left" | "right";
disabled?: boolean;
type?: "button" | "submit" | "reset" | undefined;
};
export default function CallToActionButton({
export default function Button({
text,
iconPosition = "left",
disabled = false,
type = "button",
children,
}: PropsWithChildren<Props>): JSX.Element {
return (
<button className="hover:bg-svw-blue-darker gray-400 text-white py-1 px-2 inline-flex items-center">
<button
className="hover:bg-svw-blue-darker bg-svw-blue-dark gray-400 text-white py-2 px-2 inline-flex items-center justify-center"
disabled={disabled}
type={type}
>
{iconPosition === "left" ? <div className="mr-2">{children}</div> : null}
{text && <span>{text}</span>}
{iconPosition === "right" ? <div className="ml-2">{children}</div> : null}
Expand Down
11 changes: 10 additions & 1 deletion components/cms/input/TextField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ export type TextFieldMutationVariables = { value: string | null };
export type MutateResult = { type: "success" } | { type: "error"; message: string };
export type MutateFn = (variables: TextFieldMutationVariables) => Promise<MutateResult | undefined>;

export interface TextFieldProps extends Pick<MuiTextFieldProps, "defaultValue" | "fullWidth" | "ref" | "inputRef"> {
export interface TextFieldProps
extends Pick<MuiTextFieldProps, "defaultValue" | "fullWidth" | "ref" | "inputRef" | "type" | "id" | "placeholder"> {
StartIcon?: IconType;
label?: string;
mutate?: MutateFn;
Expand All @@ -24,6 +25,9 @@ export function TextField({
fullWidth = true,
ref,
inputRef,
type,
id,
placeholder,
}: TextFieldProps) {
const [value, setValue] = useState(defaultValue ?? "");
const [mutationResult, setMutationResult] = useState<MutateResult | undefined>();
Expand All @@ -41,8 +45,11 @@ export function TextField({

return (
<MuiTextField
type={type}
id={id}
ref={ref}
value={value}
placeholder={placeholder}
inputRef={inputRef}
onChange={onChangeHandler}
onKeyDown={(event) => {
Expand All @@ -57,6 +64,8 @@ export function TextField({
aria-label={label}
slotProps={{
input: {
id,
type,
startAdornment: StartIcon ? (
<InputAdornment position="start">
<StartIcon />
Expand Down
43 changes: 43 additions & 0 deletions components/form/EhrenamtlerForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"use client";

import { FormEventHandler, useState } from "react";
import { GrCheckmark, GrSend } from "react-icons/gr";
import Button from "#/components/button/Button";
import { TextField } from "@mui/material";

export function EhrenamtlerForm() {
const [sent, setSent] = useState(false);
const [error, setError] = useState<string | undefined>();

const sendEmail: FormEventHandler<HTMLFormElement> = (event) => {
event.preventDefault();

if (sent) return;

const form = event.currentTarget;
fetch("/api/suggest-volunteer-of-the-year", { method: "POST", body: new FormData(form) })
.then((value) => {
if (value.ok) {
setError(undefined);
setSent(true);
form.reset();
} else {
value.json().then((value) => setError(value.error));
}
})
.catch((error) => console.log("error", error));
};

return (
<form className="sm:self-center flex flex-col gap-4 sm:w-80" onSubmit={sendEmail}>
<TextField type="text" name="suggester-name" placeholder="Dein Name" size="small" />
<TextField type="email" name="suggester-email" placeholder="Deine E-Mail" size="small" />
<TextField type="text" name="volunteer-name" placeholder="Dein Vorschlag (voller Name)" size="small" />
<TextField type="text" name="volunteer-why" placeholder="Warum?" size="small" />
<Button type="submit" disabled={sent} text="Abschicken">
{sent ? <GrCheckmark /> : <GrSend />}
</Button>
{error && <span>{error}</span>}
</form>
);
}
2 changes: 2 additions & 0 deletions content/sitemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export const formales = {
url: "/verein/formales",
subMenu: [satzung, ehrenordnung, geschaeftsordnung, beitragsordnung, jugendschutzordnung, datenschutz, impressum],
};
export const ehrenamtlerDesJahres = { name: "Ehrenamtler des Jahres", url: "/verein/ehrenamtler-des-jahres" };
export const verein: MenuItem = {
name: "Verein",
url: "/verein",
Expand All @@ -73,6 +74,7 @@ export const verein: MenuItem = {
historie,
foerderkreis,
formales,
ehrenamtlerDesJahres,
],
};

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@
"@types/mdx": "^2.0.10",
"@types/node": "^22.15.21",
"@types/node-fetch": "^2.6.12",
"@types/nodemailer": "^6.4.14",
"@types/nodemailer": "^6.4.17",
"@types/react": "19.0.10",
"@types/react-dom": "19.0.4",
"@types/ws": "^8.18.1",
Expand Down
2 changes: 1 addition & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.