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 ( +
+

{children}

+
+ ); +} 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 ( +
+
+
+
+

+ Persönliche Informationen +

+

+ Die folgenden Angaben sind für die Anmeldung verpflichtend. +

+
+
+
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
+
+ +
+
+

+ Zusätzliche Persönliche Informationen +

+

+ Die folgenden Angaben sind vollkommen optional. +

+
+
+
+ +
+
+
+ + {(occupation === "APPRENTICE" || + occupation === "STUDENT" || + occupation === "EMPLOYEE" || + occupation == "OTHER") && ( +
+
+

+ Deine Firma +

+

+ {occupation === "APPRENTICE" + ? "Bitte gib hier die Firma an, von der du ausgebildet wirst." + : occupation === "EMPLOYEE" + ? "Bitte gib hier die Firma an, bei der du arbeitest." + : "Falls du in einer Firma arbeitest, kannst du diese hier angeben."} +

+
+
+
+ +
+
+ +
+
+
+ )} + + {(occupation === "APPRENTICE" || + occupation === "PUPIL" || + occupation === "STUDENT" || + occupation === "OTHER") && ( +
+
+

+ {occupation === "PUPIL" + ? "Deine Schule" + : "Deine Universität/Hochschule"} +

+

+ {occupation === "PUPIL" + ? "Bitte gib hier deine Schule an." + : occupation == "STUDENT" + ? "Bitte gib hier deine Universität oder Hochschule an." + : "Falls du an einer Universität oder Hochschule bist, kannst du diese hier angeben."} +

+
+
+
+ +
+
+ +
+
+
+ )} +
+ +
+ {message && ( +
+ {message.text} +
+ )} + +
+ +
+
+
+ ); +} 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!" })} + +