From 6915e60cec09bb91ff40e15b28624b4952ce8c90 Mon Sep 17 00:00:00 2001 From: Theis Date: Sun, 24 May 2026 13:07:06 +0200 Subject: [PATCH] implemented tanstack form --- frontend/package-lock.json | 125 ++++- frontend/package.json | 6 +- frontend/src/config/interfaces.config.ts | 35 ++ frontend/src/pages/MainForm.tsx | 623 +++++++++++++++------ frontend/src/utils/i18n/locales/de/de.json | 5 +- frontend/src/utils/i18n/locales/en/en.json | 5 +- frontend/src/utils/uxFncs.ts | 16 + 7 files changed, 622 insertions(+), 193 deletions(-) create mode 100644 frontend/src/utils/uxFncs.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e676de0..5985dea 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,6 +16,7 @@ "@mui/joy": "^5.0.0-beta.52", "@mui/material": "^9.0.1", "@tailwindcss/vite": "^4.3.0", + "@tanstack/react-form": "^1.32.0", "@tanstack/react-query": "^5.100.10", "i18next": "^26.0.10", "js-cookie": "^3.0.5", @@ -25,7 +26,9 @@ "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-i18next": "^17.0.7", "react-router-dom": "^7.15.0", - "tailwindcss": "^4.3.0" + "tailwindcss": "^4.3.0", + "validator": "^13.15.35", + "zod": "^4.4.3" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -33,6 +36,7 @@ "@types/node": "^24.12.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@types/validator": "^13.15.10", "@vitejs/plugin-react": "^6.0.1", "eslint": "^10.2.1", "eslint-plugin-react-hooks": "^7.1.1", @@ -1768,6 +1772,50 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@tanstack/devtools-event-client": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@tanstack/devtools-event-client/-/devtools-event-client-0.4.3.tgz", + "integrity": "sha512-OZI6QyULw0FI0wjgmeYzCIfbgPsOEzwJtCpa69XrfLMtNXLGnz3d/dIabk7frg0TmHo+Ah49w5I4KC7Tufwsvw==", + "license": "MIT", + "bin": { + "intent": "bin/intent.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/form-core": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/@tanstack/form-core/-/form-core-1.32.0.tgz", + "integrity": "sha512-Tn5VRDSjyqjmaet2tJMuEWDRFyrCaon03vxXPlSSaiSs6C/N7lCIwGCXJbZXEUq1kTj8jYN9qyXHbsz4LQHcow==", + "license": "MIT", + "dependencies": { + "@tanstack/devtools-event-client": "^0.4.1", + "@tanstack/pacer-lite": "^0.1.1", + "@tanstack/store": "^0.9.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/pacer-lite": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@tanstack/pacer-lite/-/pacer-lite-0.1.1.tgz", + "integrity": "sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/query-core": { "version": "5.100.10", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.10.tgz", @@ -1778,6 +1826,28 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tanstack/react-form": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-form/-/react-form-1.32.0.tgz", + "integrity": "sha512-6WP5SQTA6/H9crCpvpq3ZppYWqtrdE5NjOy6ebABi6uAQPqhfTzrdjS9t40mCZCFtGI5585OhJV6zBP/KN2zcw==", + "license": "MIT", + "dependencies": { + "@tanstack/form-core": "1.32.0", + "@tanstack/react-store": "^0.9.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@tanstack/react-start": { + "optional": true + } + } + }, "node_modules/@tanstack/react-query": { "version": "5.100.10", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.10.tgz", @@ -1794,6 +1864,34 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-store": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.9.3.tgz", + "integrity": "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.9.3", + "use-sync-external-store": "^1.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/store": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.9.3.tgz", + "integrity": "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -1882,6 +1980,13 @@ "@types/react": "*" } }, + "node_modules/@types/validator": { + "version": "13.15.10", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", + "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.59.2", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz", @@ -3004,12 +3109,12 @@ } }, "node_modules/js-cookie": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", - "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.7.tgz", + "integrity": "sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==", "license": "MIT", "engines": { - "node": ">=14" + "node": ">=20" } }, "node_modules/js-tokens": { @@ -4093,6 +4198,15 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/validator": { + "version": "13.15.35", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", + "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vite": { "version": "8.0.11", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.11.tgz", @@ -4229,7 +4343,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/frontend/package.json b/frontend/package.json index 8e872d4..bb4b617 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,6 +18,7 @@ "@mui/joy": "^5.0.0-beta.52", "@mui/material": "^9.0.1", "@tailwindcss/vite": "^4.3.0", + "@tanstack/react-form": "^1.32.0", "@tanstack/react-query": "^5.100.10", "i18next": "^26.0.10", "js-cookie": "^3.0.5", @@ -27,7 +28,9 @@ "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-i18next": "^17.0.7", "react-router-dom": "^7.15.0", - "tailwindcss": "^4.3.0" + "tailwindcss": "^4.3.0", + "validator": "^13.15.35", + "zod": "^4.4.3" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0", @@ -39,6 +42,7 @@ "@types/node": "^24.12.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@types/validator": "^13.15.10", "@vitejs/plugin-react": "^6.0.1", "eslint": "^10.2.1", "eslint-plugin-react-hooks": "^7.1.1", diff --git a/frontend/src/config/interfaces.config.ts b/frontend/src/config/interfaces.config.ts index 2a62639..dde4d15 100644 --- a/frontend/src/config/interfaces.config.ts +++ b/frontend/src/config/interfaces.config.ts @@ -1,3 +1,6 @@ +import z from "zod"; +import validator from "validator"; + export interface FormData { firstName: string; lastName: string; @@ -19,3 +22,35 @@ export interface Message { headline: string; text: string; } + +export const createFormSchema = ( + t: (key: string) => string, + invoice: boolean, +) => + z.object({ + firstName: z.string().min(1, t("name-error")), + lastName: z.string().min(1, t("name-error")), + email: z.email(t("email-error")), + phoneNumber: z.string(t("phone-error")).refine(validator.isMobilePhone), + tickets: z.number(t("ticket-error")).min(1), + companyName: invoice + ? z.string().min(1, t("name-error")) + : z.string().optional(), + cmpFirstName: invoice + ? z.string().min(1, t("name-error")) + : z.string().optional(), + cpmLastName: invoice + ? z.string().min(1, t("name-error")) + : z.string().optional(), + cpmEmail: invoice ? z.email(t("email-error")) : z.string().optional(), + cpmPhoneNumber: invoice + ? z.string(t("phone-error")).refine(validator.isMobilePhone) + : z.string().optional(), + street: invoice + ? z.string().min(1, t("name-error")) + : z.string().optional(), + postalCode: invoice + ? z.string().min(1, t("name-error")) + : z.string().optional(), + paymentMethod: z.string().min(1, t("name-error")), + }); diff --git a/frontend/src/pages/MainForm.tsx b/frontend/src/pages/MainForm.tsx index f6dacba..40445b9 100644 --- a/frontend/src/pages/MainForm.tsx +++ b/frontend/src/pages/MainForm.tsx @@ -1,6 +1,5 @@ import { useTranslation } from "react-i18next"; import { useState, useEffect } from "react"; -import * as React from "react"; import Cookies from "js-cookie"; import { Sheet, @@ -25,6 +24,10 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { confirmUser, fetchUsers } from "../utils/api/users"; import { QRcodeModal } from "../components/modals/QR-CodeModal"; import { SelectUserModal } from "../components/modals/SelectUserModal"; +import { useForm } from "@tanstack/react-form"; +import { changeTranslation } from "../utils/uxFncs"; +import { createFormSchema } from "../config/interfaces.config"; +import type { ZodObject, ZodRawShape } from "zod"; const PAYMENT_METHODS = ["bar", "paypal", "andere"] as const; const PAYMENT_LABELS: Record = { @@ -33,65 +36,93 @@ const PAYMENT_LABELS: Record = { andere: "Transfer", }; -const DEFAULT_FORM: FormData = { - firstName: "", - lastName: "", - email: "", - phoneNumber: "", - tickets: 1, - companyName: "", - cmpFirstName: "", - cpmLastName: "", - cpmEmail: "", - cpmPhoneNumber: "", - street: "", - postalCode: "", - paymentMethod: "", -}; - -// ─── Field component lives OUTSIDE MainForm so React doesn't treat it as a -// new component type on every render, which would cause inputs to lose focus. -const Field = ({ - label, - name, - type = "text", - required = true, - formData, - onChange, -}: { - label: string; - name: keyof FormData; - type?: string; - required?: boolean; - formData: FormData; - onChange: (e: React.ChangeEvent) => void; -}) => ( - - {label} - - -); +/** + * Validates a single field against the full Zod schema. + * Returns the first error message for that field, or undefined if valid. + */ +function validateFieldWithZod( + schema: ZodObject, + fieldName: string, + allValues: Record, +): string | undefined { + const result = schema.safeParse(allValues); + if (result.success) return undefined; + const issue = result.error.issues.find( + (i) => i.path.length === 1 && i.path[0] === fieldName, + ); + return issue?.message; +} export const MainForm = () => { - const { t, i18n } = useTranslation(); + const { t } = useTranslation(); const queryClient = useQueryClient(); const [invoice, setInvoice] = useState(false); const [msg, setMsg] = useState(null); const [selectedUser, setSelectedUser] = useState(null); - const [formData, setFormData] = useState(DEFAULT_FORM); const [showSelectUser, setShowSelectUser] = useState(false); const [QRmodal, setQRmodal] = useState(false); - const handleChange = (e: React.ChangeEvent) => { - setFormData({ ...formData, [e.target.name]: e.target.value }); + const formSchema = createFormSchema(t, invoice); + + const makeFieldValidator = (fieldName: string) => ({ + onSubmit: ({ + value, + fieldApi, + }: { + value: unknown; + fieldApi: { form: { state: { values: Record } } }; + }) => { + const allValues = fieldApi.form.state.values; + return validateFieldWithZod(formSchema, fieldName, allValues); + }, + onBlur: ({ + value, + fieldApi, + }: { + value: unknown; + fieldApi: { form: { state: { values: Record } } }; + }) => { + const allValues = fieldApi.form.state.values; + return validateFieldWithZod(formSchema, fieldName, allValues); + }, + }); + + const { Field, Subscribe, handleSubmit, setFieldValue } = useForm({ + defaultValues: { + firstName: "", + lastName: "", + email: "", + phoneNumber: "", + tickets: 1, + companyName: "", + cmpFirstName: "", + cpmLastName: "", + cpmEmail: "", + cpmPhoneNumber: "", + street: "", + postalCode: "", + paymentMethod: "", + }, + onSubmit: async ({ value }) => { + const result = formSchema.safeParse(value); + if (!result.success) return; + mutateForm(value as FormData); + }, + }); + + const getErrors = (field: { + state: { meta: { errorMap: Record } }; + }) => { + const normalizeErrors = (value: unknown) => { + if (Array.isArray(value)) return value as string[]; + if (typeof value === "string" && value.length > 0) return [value]; + return [] as string[]; + }; + + const blurErrors = normalizeErrors(field.state.meta.errorMap["onBlur"]); + const submitErrors = normalizeErrors(field.state.meta.errorMap["onSubmit"]); + return [...blurErrors, ...submitErrors].filter(Boolean); }; useEffect(() => { @@ -119,10 +150,10 @@ export const MainForm = () => { }); const { mutate: mutateForm, isPending: mutateFormIsPending } = useMutation({ - mutationFn: () => submitFormData(formData, selectedUser), - onSuccess: () => { + mutationFn: (values: FormData) => submitFormData(values, selectedUser), + onSuccess: (_, values) => { queryClient.invalidateQueries({ queryKey: ["user", selectedUser] }); - document.location.href = `/success?id=${nextID}&tickets=${formData.tickets}`; + document.location.href = `/success?id=${nextID}&tickets=${values.tickets}`; }, onError: () => { queryClient.invalidateQueries({ queryKey: ["user", selectedUser] }); @@ -134,38 +165,15 @@ export const MainForm = () => { }, }); - // Setting the nextID after a user is selected const nextID = userData?.nextID ?? "N/A"; const handleUserSelection = (username: string | null) => { if (username == null || username == "") { return; } - setSelectedUser(username); }; - const changeTranslation = () => { - const clientLng = i18n.language; - - if (clientLng === "en") { - i18n.changeLanguage("de"); - Cookies.set("language", "de"); - } else if (clientLng === "de") { - i18n.changeLanguage("en"); - Cookies.set("language", "en"); - } else { - setMsg({ - type: "danger", - headline: "Error", - text: "Cannot change langugage.", - }); - } - }; - - // Shorthand so we don't repeat formData + onChange on every Field usage - const fieldProps = { formData, onChange: handleChange }; - return ( <> { setQRmodal(true)}> - {/* Language toggle */} @@ -225,11 +232,10 @@ export const MainForm = () => {
{ e.preventDefault(); - mutateForm(); + handleSubmit(); }} className="flex flex-col gap-4" > - {/* Next ID badge */} { {/* Name row */}
- - + {t("first-name")} + + {(field) => { + const errors = getErrors(field); + return ( + <> + field.handleChange(e.target.value)} + variant="soft" + sx={{ borderRadius: "10px" }} + /> + {errors.length > 0 && ( + + {errors[0]} + + )} + + ); + }} + + + {t("last-name")} + + {(field) => { + const errors = getErrors(field); + return ( + <> + field.handleChange(e.target.value)} + variant="soft" + sx={{ borderRadius: "10px" }} + /> + {errors.length > 0 && ( + + {errors[0]} + + )} + + ); + }} +
+ {t("email")} + + {(field) => { + const errors = getErrors(field); + return ( + <> + field.handleChange(e.target.value)} + variant="soft" + type="email" + sx={{ borderRadius: "10px" }} + /> + {errors.length > 0 && ( + {errors[0]} + )} + + ); + }} + + + {t("phone-number")} - + validators={makeFieldValidator("phoneNumber")} + > + {(field) => { + const errors = getErrors(field); + return ( + <> + field.handleChange(e.target.value)} + variant="soft" + type="tel" + sx={{ borderRadius: "10px" }} + /> + {errors.length > 0 && ( + {errors[0]} + )} + + ); + }} + {/* Tickets + Invoice toggle */}
{t("tickets")} - + validators={makeFieldValidator("tickets")} + > + {(field) => { + const errors = getErrors(field); + return ( + <> + + field.handleChange(Number(e.target.value)) + } + slotProps={{ input: { min: 1 } }} + variant="soft" + sx={{ borderRadius: "10px" }} + /> + {errors.length > 0 && ( + + {errors[0]} + + )} + + ); + }} +
setInvoice(e.target.checked)} + onChange={(e) => { + const checked = e.target.checked; + setInvoice(checked); + if (!checked) { + setFieldValue("companyName", ""); + setFieldValue("cmpFirstName", ""); + setFieldValue("cpmLastName", ""); + setFieldValue("cpmEmail", ""); + setFieldValue("cpmPhoneNumber", ""); + setFieldValue("street", ""); + setFieldValue("postalCode", ""); + } + }} label={t("invoice")} variant="outlined" /> @@ -293,92 +413,224 @@ export const MainForm = () => { {t("invoice-details")} + + {t("company-name")} + validators={makeFieldValidator("companyName")} + > + {(field) => { + const errors = getErrors(field); + return ( + <> + field.handleChange(e.target.value)} + variant="soft" + sx={{ borderRadius: "10px" }} + /> + {errors.length > 0 && ( + + {errors[0]} + + )} + + ); + }} + +
+ {t("first-name")} + validators={makeFieldValidator("cmpFirstName")} + > + {(field) => { + const errors = getErrors(field); + return ( + <> + field.handleChange(e.target.value)} + variant="soft" + sx={{ borderRadius: "10px" }} + /> + {errors.length > 0 && ( + + {errors[0]} + + )} + + ); + }} + + + {t("last-name")} + validators={makeFieldValidator("cpmLastName")} + > + {(field) => { + const errors = getErrors(field); + return ( + <> + field.handleChange(e.target.value)} + variant="soft" + sx={{ borderRadius: "10px" }} + /> + {errors.length > 0 && ( + + {errors[0]} + + )} + + ); + }} +
- + + {t("street")} + + {(field) => { + const errors = getErrors(field); + return ( + <> + field.handleChange(e.target.value)} + variant="soft" + sx={{ borderRadius: "10px" }} + /> + {errors.length > 0 && ( + + {errors[0]} + + )} + + ); + }} + + + {t("postal-code")} + validators={makeFieldValidator("postalCode")} + > + {(field) => { + const errors = getErrors(field); + return ( + <> + field.handleChange(e.target.value)} + variant="soft" + sx={{ borderRadius: "10px" }} + /> + {errors.length > 0 && ( + + {errors[0]} + + )} + + ); + }} + + + {t("phone-number")} + validators={makeFieldValidator("cpmPhoneNumber")} + > + {(field) => { + const errors = getErrors(field); + return ( + <> + field.handleChange(e.target.value)} + variant="soft" + type="tel" + sx={{ borderRadius: "10px" }} + /> + {errors.length > 0 && ( + + {errors[0]} + + )} + + ); + }} + + + {t("email")} + validators={makeFieldValidator("cpmEmail")} + > + {(field) => { + const errors = getErrors(field); + return ( + <> + field.handleChange(e.target.value)} + variant="soft" + type="email" + sx={{ borderRadius: "10px" }} + /> + {errors.length > 0 && ( + + {errors[0]} + + )} + + ); + }} +
)} {/* Payment method selection */} {t("select-payment-method")} -
- {PAYMENT_METHODS.map((method) => ( - - ))} -
- {/* Hidden required input to enforce payment selection on submit */} - {!formData.paymentMethod && ( - {}} - style={{ - opacity: 0, - width: 0, - height: 0, - position: "absolute", - }} - /> - )} + state.values.paymentMethod}> + {(paymentMethod) => ( +
+ {PAYMENT_METHODS.map((method) => ( + + ))} +
+ )} +
{mutateFormIsPending ? ( @@ -386,26 +638,29 @@ export const MainForm = () => {
) : ( - + state.values.paymentMethod}> + {(paymentMethod) => ( + + )} + )} - {/* Message */} {msg && ( { + const clientLng = i18n.language; + + if (clientLng === "en") { + i18n.changeLanguage("de"); + Cookies.set("language", "de"); + } else if (clientLng === "de") { + i18n.changeLanguage("en"); + Cookies.set("language", "en"); + } else { + alert("Cannot change language."); + } +};