diff --git a/package.json b/package.json
index 39b7c99..5c095fd 100644
--- a/package.json
+++ b/package.json
@@ -16,6 +16,7 @@
"@fontsource/poppins": "^5.1.0",
"@headlessui/react": "^2.1.10",
"@heroicons/react": "^2.1.5",
+ "@hookform/resolvers": "^3.9.0",
"@iconify-json/mdi": "^1.2.1",
"@tailwindcss/typography": "^0.5.15",
"@types/react": "^18.3.11",
@@ -28,7 +29,9 @@
"prettier-plugin-tailwindcss": "^0.6.8",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+ "react-hook-form": "^7.53.1",
"tailwindcss": "^3.4.13",
- "typescript": "^5.6.2"
+ "typescript": "^5.6.2",
+ "zod": "^3.23.8"
}
}
diff --git a/src/components/forms/component/alert.tsx b/src/components/forms/component/alert.tsx
new file mode 100644
index 0000000..7bea057
--- /dev/null
+++ b/src/components/forms/component/alert.tsx
@@ -0,0 +1,23 @@
+import { clsx } from "clsx";
+
+interface Props {
+ type?: "error" | "success";
+ children?: React.ReactNode;
+}
+
+export function Alert({ type, children }: Props) {
+ const alertClasses = clsx("rounded-md p-4", {
+ "bg-red-50": type === "error",
+ "bg-green-50": type === "success",
+ });
+ const textClasses = clsx("text-sm font-medium", {
+ "text-red-800": type === "error",
+ "text-green-800": type === "success",
+ });
+
+ return (
+
+ );
+}
diff --git a/src/components/forms/component/button.tsx b/src/components/forms/component/button.tsx
new file mode 100644
index 0000000..6d5f535
--- /dev/null
+++ b/src/components/forms/component/button.tsx
@@ -0,0 +1,44 @@
+import { clsx } from "clsx";
+import type { ComponentPropsWithRef } from "react";
+
+interface Props extends ComponentPropsWithRef<"button"> {
+ isLoading?: boolean;
+ children?: React.ReactNode;
+}
+
+export const Button = ({ isLoading, children, ...rest }: Props) => {
+ const buttonClasses = clsx(
+ "inline-flex justify-center rounded-md border border-transparent bg-wsg-orange-500 py-2 px-4 text-sm font-medium text-white shadow-sm",
+ {
+ "cursor-not-allowed opacity-60": isLoading,
+ },
+ );
+
+ return (
+
+ );
+};
diff --git a/src/components/forms/component/input.tsx b/src/components/forms/component/input.tsx
new file mode 100644
index 0000000..548f936
--- /dev/null
+++ b/src/components/forms/component/input.tsx
@@ -0,0 +1,33 @@
+import { clsx } from "clsx";
+import { type ComponentPropsWithoutRef, forwardRef } from "react";
+
+interface Props extends ComponentPropsWithoutRef<"input"> {
+ id: string;
+ label: string;
+ error?: string;
+}
+
+export const Input = forwardRef(
+ ({ id, label, error, ...rest }, ref) => {
+ const inputClasses = clsx(
+ "block w-full rounded-lg focus:outline-none shadow-sm sm:text-sm border py-2 px-3 bg-white",
+ {
+ "border-red-300 focus:border-red-500 focus:ring-red-500": error,
+ "border-gray-300 focus:border-wsg-orange-500 focus:ring-wsg-orange-500":
+ !error,
+ },
+ );
+
+ return (
+
+
+
+
+
+ {error &&
{error}
}
+
+ );
+ },
+);
diff --git a/src/components/forms/component/select.tsx b/src/components/forms/component/select.tsx
new file mode 100644
index 0000000..3efdf6b
--- /dev/null
+++ b/src/components/forms/component/select.tsx
@@ -0,0 +1,36 @@
+import { clsx } from "clsx";
+import { type ComponentPropsWithoutRef, forwardRef } from "react";
+
+interface Props extends ComponentPropsWithoutRef<"select"> {
+ id: string;
+ label: string;
+ error?: string;
+ children?: React.ReactNode;
+}
+
+export const Select = forwardRef(
+ ({ id, label, error, children, ...rest }, ref) => {
+ const inputClasses = clsx(
+ "block w-full rounded-lg focus:outline-none shadow-sm sm:text-sm border py-2 px-3 bg-white",
+ {
+ "border-red-300 focus:border-red-500 focus:ring-red-500": error,
+ "border-gray-300 focus:border-wsg-orange-500 focus:ring-wsg-orange-500":
+ !error,
+ },
+ );
+
+ return (
+
+
+
+
+
+ {error &&
{error}
}
+
+ );
+ },
+);
diff --git a/src/components/forms/registration-form.tsx b/src/components/forms/registration-form.tsx
new file mode 100644
index 0000000..ca4aa17
--- /dev/null
+++ b/src/components/forms/registration-form.tsx
@@ -0,0 +1,366 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useState } from "react";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+import { Alert } from "./component/alert";
+import { Button } from "./component/button";
+import { Input } from "./component/input";
+import { Select } from "./component/select";
+import { fixOptional } from "./utils";
+
+const institutionSchema = z.object({
+ name: z
+ .string()
+ .min(1, "Der Name darf nicht leer sein.")
+ .max(255, "Der Name darf nicht länger als 255 Zeichen sein."),
+ city: z
+ .string()
+ .min(1, "Die Stadt darf nicht leer sein.")
+ .max(255, "Die Stadt darf nicht länger als 255 Zeichen sein."),
+});
+
+export const participantSchema = z.object({
+ firstName: z
+ .string()
+ .min(1, "Der Vorname darf nicht leer sein.")
+ .max(255, "Der Vorname darf nicht länger als 255 Zeichen sein."),
+ lastName: z
+ .string()
+ .min(1, "Der Nachname darf nicht leer sein.")
+ .max(255, "Der Nachname darf nicht länger als 255 Zeichen sein."),
+ birthday: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Ungültiges Datum"),
+ email: z.string().email("Ungültige E-Mail-Adresse."),
+ state: z.enum(
+ [
+ "BADEN_WUERTTEMBERG",
+ "BAVARIA",
+ "BERLIN",
+ "BRANDENBURG",
+ "BREMEN",
+ "HAMBURG",
+ "HESSE",
+ "MECKLENBURG_WESTERN_POMERANIA",
+ "LOWER_SAXONY",
+ "NORTH_RHINE_WESTPHALIA",
+ "RHINELAND_PALATINATE",
+ "SAARLAND",
+ "SAXONY",
+ "SAXONY_ANHALT",
+ "SCHLESWIG_HOLSTEIN",
+ "THURINGIA",
+ ],
+ { message: "Das Bundesland ist erforderlich." },
+ ),
+ city: z
+ .string()
+ .min(1, "Die Stadt darf nicht leer sein.")
+ .max(255, "Die Stadt darf nicht länger als 255 Zeichen sein."),
+ phone: z.preprocess(
+ fixOptional,
+ z
+ .string()
+ .min(1, "Die Telefonnummer darf nicht leer sein.")
+ .max(255, "Die Telefonnummer darf nicht länger als 255 Zeichen sein.")
+ .optional(),
+ ),
+ occupation: z.enum(["APPRENTICE", "PUPIL", "STUDENT", "EMPLOYEE", "OTHER"], {
+ message: "Die Beschäftigungsart ist erforderlich.",
+ }),
+ company: z.preprocess(fixOptional, institutionSchema.optional()),
+ educationalInsitution: z.preprocess(
+ fixOptional,
+ institutionSchema.optional(),
+ ),
+});
+
+type Participant = z.infer;
+
+interface Message {
+ type: "error" | "success";
+ text: string;
+}
+
+export default function RegistrationForm() {
+ const [message, setMessage] = useState(null);
+
+ const {
+ register,
+ handleSubmit,
+ reset,
+ formState: { isSubmitting, errors },
+ watch,
+ } = useForm({ resolver: zodResolver(participantSchema) });
+
+ const occupation = watch("occupation");
+
+ const onSubmit = handleSubmit(async (data) => {
+ // check that birthday is after 2004-01-01
+ const birthday = new Date(data.birthday);
+ if (birthday < new Date("2004-01-01")) {
+ setMessage({
+ type: "error",
+ text: "Es können nur Personen teilnehmen, die 2004 oder später geboren sind.",
+ });
+ return;
+ }
+
+ const response = await fetch(
+ "https://registration-api.blz-it.de/participants",
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(data),
+ },
+ );
+
+ if (response.ok) {
+ setMessage({ type: "success", text: "Erfolgreich angemeldet!" });
+ reset();
+ } else {
+ const json = await response.json();
+ setMessage({
+ type: "error",
+ text:
+ json.message ??
+ "Es ist ein unbekannter Fehler aufgetreten! Bitte versuche es erneut.",
+ });
+ }
+ });
+
+ return (
+
+ );
+}
diff --git a/src/components/forms/utils.ts b/src/components/forms/utils.ts
new file mode 100644
index 0000000..905aea8
--- /dev/null
+++ b/src/components/forms/utils.ts
@@ -0,0 +1,11 @@
+export const fixOptional = (value: unknown) => {
+ if (value === "") return undefined;
+ if (typeof value === "object" && value !== null) {
+ // Check if every child value (even nested) is empty
+ const isEmpty = Object.values(value).every(
+ (v) => fixOptional(v) === undefined,
+ );
+ if (isEmpty) return undefined;
+ }
+ return value;
+};
diff --git a/src/components/skill/SkillInformation.astro b/src/components/skill/SkillInformation.astro
index 3bccb7b..e1cde17 100644
--- a/src/components/skill/SkillInformation.astro
+++ b/src/components/skill/SkillInformation.astro
@@ -1,5 +1,7 @@
---
+import { getRelativeLocaleUrl } from "astro:i18n";
import { getLangFromUrl, useTranslations } from "~/i18n";
+import Link from "../Link.astro";
import Headline from "../typography/Headline.astro";
interface Props {
@@ -31,10 +33,17 @@ const t = useTranslations(lang);
}
-
+
+ {
+ t({
+ de: "Du bist interessiert?",
+ en: "You are interested?",
+ })
+ }
+
+ {t({ de: "Jetzt anmelden!", en: "Register now!" })}
+
+
@@ -54,7 +54,7 @@ const t = useTranslations(lang);
de: "28. Dezember 2024 – 28. Februar 2025",
en: "28 December 2024 – 28 February 2025",
})}
- link={getRelativeLocaleUrl(lang, "dm-registration")}
+ link={getRelativeLocaleUrl(lang, "anmeldung")}
className="md:float-right md:mt-[-4rem]"
hoverTextColor="white"
/>
@@ -86,7 +86,7 @@ const t = useTranslations(lang);
-
-
+
{t({ de: "Du registrierst dich", en: "You register yourself" })}
{
diff --git a/src/layouts/SkillPage.astro b/src/layouts/SkillPage.astro
index 281523a..438dbfe 100644
--- a/src/layouts/SkillPage.astro
+++ b/src/layouts/SkillPage.astro
@@ -68,7 +68,7 @@ const t = useTranslations(lang);
There is currently no national team.{" "}
- Apply now!
+ Apply now!
)
diff --git a/src/pages/[lang]/anmeldung.astro b/src/pages/[lang]/anmeldung.astro
new file mode 100644
index 0000000..4e0f61e
--- /dev/null
+++ b/src/pages/[lang]/anmeldung.astro
@@ -0,0 +1,44 @@
+---
+import { InformationCircleIcon } from "@heroicons/react/20/solid";
+import RegistrationForm from "~/components/forms/registration-form";
+import NoTranslate from "~/components/NoTranslate.astro";
+import { localeParams } from "~/i18n";
+import Layout from "~/layouts/Layout.astro";
+
+export const getStaticPaths = localeParams;
+---
+
+
+
+
+
+
+ Anmeldung zur Deutschen Meisterschaft
+
+
+ Der Online-Vorausscheid wird für alle drei Disziplinen gemeinsam
+ durchgeführt. Je nachdem, für welche Disziplin oder welche Disziplinen
+ du dich interessierst, bekommst du unterschiedliche Aufgaben.
Bei den Deutschen Meisterschaften im Juni tritt jede Person dann nur
+ noch in einer Disziplin an.
+
+
+
+
+
+
+
+
+ Es können nur Personen teilnehmen, die 2004 oder später geboren
+ sind.
+
+
+
+
+
+
+
+
diff --git a/src/pages/en/index.astro b/src/pages/en/index.astro
index 4801062..71068ad 100644
--- a/src/pages/en/index.astro
+++ b/src/pages/en/index.astro
@@ -86,10 +86,10 @@ const lang = (Astro.currentLocale || defaultLang) as Language;
diff --git a/src/pages/index.astro b/src/pages/index.astro
index 384c16e..38889a8 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -87,10 +87,10 @@ const lang = (Astro.currentLocale || defaultLang) as Language;
diff --git a/yarn.lock b/yarn.lock
index ed5983a..6fa5f0c 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -574,6 +574,11 @@
resolved "https://registry.yarnpkg.com/@heroicons/react/-/react-2.1.5.tgz#1e13f34976cc542deae92353c01c8b3d7942e9ba"
integrity sha512-FuzFN+BsHa+7OxbvAERtgBTNeZpUjgM/MIizfVkSCL2/edriN0Hx/DWRCR//aPYwO5QX/YlgLGXk+E3PcfZwjA==
+"@hookform/resolvers@^3.9.0":
+ version "3.9.0"
+ resolved "https://registry.yarnpkg.com/@hookform/resolvers/-/resolvers-3.9.0.tgz#cf540ac21c6c0cd24a40cf53d8e6d64391fb753d"
+ integrity sha512-bU0Gr4EepJ/EQsH/IwEzYLsT/PEj5C0ynLQ4m+GSHS+xKH4TfSelhluTgOaoc4kA5s7eCsQbM4wvZLzELmWzUg==
+
"@iconify-json/mdi@^1.2.1":
version "1.2.1"
resolved "https://registry.yarnpkg.com/@iconify-json/mdi/-/mdi-1.2.1.tgz#029deff92cedf38430a9ed2ee811a8818f1ded43"
@@ -3582,6 +3587,11 @@ react-dom@^18.3.1:
loose-envify "^1.1.0"
scheduler "^0.23.2"
+react-hook-form@^7.53.1:
+ version "7.53.1"
+ resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.53.1.tgz#3f2cd1ed2b3af99416a4ac674da2d526625add67"
+ integrity sha512-6aiQeBda4zjcuaugWvim9WsGqisoUk+etmFEsSUMm451/Ic8L/UAb7sRtMj3V+Hdzm6mMjU1VhiSzYUZeBm0Vg==
+
react-refresh@^0.14.2:
version "0.14.2"
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.14.2.tgz#3833da01ce32da470f1f936b9d477da5c7028bf9"