diff --git a/06_react/unterricht/tag38/01_react-with-forms.zip b/06_react/unterricht/tag38/01_react-with-forms.zip
deleted file mode 100644
index 624e96f..0000000
Binary files a/06_react/unterricht/tag38/01_react-with-forms.zip and /dev/null differ
diff --git a/06_react/unterricht/tag38/01_react-with-forms/package.json b/06_react/unterricht/tag38/01_react-with-forms/package.json
index 74e3126..4c79159 100644
--- a/06_react/unterricht/tag38/01_react-with-forms/package.json
+++ b/06_react/unterricht/tag38/01_react-with-forms/package.json
@@ -11,6 +11,7 @@
},
"dependencies": {
"bootstrap": "^5.3.8",
+ "joi": "^18.3.0",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"react-hook-form": "^7.91.0",
diff --git a/06_react/unterricht/tag38/01_react-with-forms/src/components/forms/joi/FormContact.jsx b/06_react/unterricht/tag38/01_react-with-forms/src/components/forms/joi/FormContact.jsx
new file mode 100644
index 0000000..2e17e5c
--- /dev/null
+++ b/06_react/unterricht/tag38/01_react-with-forms/src/components/forms/joi/FormContact.jsx
@@ -0,0 +1,159 @@
+import { useState } from 'react';
+import validate from './validate'; // joi validate schema object
+
+const INITIAL_FORM_DATA = {
+ firstname: '', //
+ lastname: '',
+ email: '',
+ message: '',
+};
+
+const FormContact = (props) => {
+ // const { } = props;
+
+ const [formData, setFormData] = useState(INITIAL_FORM_DATA);
+
+ const [errors, setErrors] = useState({});
+
+ // EventHandlers =======
+ const handleSubmit = (e) => {
+ e.preventDefault(); // Standardverhalten (Versenden vom Formular) unterbinden.
+
+ const form = e.currentTarget;
+
+ const errors = validate(formData);
+
+ if (Object.keys(errors).length === 0) {
+ console.log('Form submitted ....');
+ // TODO: send over AJAX/Fetch
+ form.reset();
+ setFormData(INITIAL_FORM_DATA);
+ setErrors({});
+ } else {
+ console.warn('Form submit error');
+ setErrors(errors);
+ }
+ };
+
+ const handleChange = (e) => {
+ const { name, value } = e.target;
+
+ setFormData((state) => ({ ...state, [name]: value }));
+
+ // console.log({ [e.target.name]: e.target.value });
+ // console.log('Formdata', formData);
+ };
+
+ const handleBlur = (e) => {
+ const { name, value } = e.target;
+ const validationErrors = validate({ ...formData, [name]: value });
+
+ setErrors((state) => {
+ const nextErrors = { ...state };
+
+ if (validationErrors[name]) {
+ nextErrors[name] = validationErrors[name];
+ } else {
+ delete nextErrors[name];
+ }
+
+ return nextErrors;
+ });
+ };
+
+ return (
+
+ );
+};
+export default FormContact;
diff --git a/06_react/unterricht/tag38/01_react-with-forms/src/components/forms/joi/validate.js b/06_react/unterricht/tag38/01_react-with-forms/src/components/forms/joi/validate.js
new file mode 100644
index 0000000..e1d9df0
--- /dev/null
+++ b/06_react/unterricht/tag38/01_react-with-forms/src/components/forms/joi/validate.js
@@ -0,0 +1,67 @@
+import Joi from 'joi';
+
+const validate = (formData) => {
+ // const { } = props;
+ const schema = Joi.object({
+ firstname: Joi.string() //
+ .min(2)
+ .max(50)
+ .required()
+ .messages({
+ 'string.min': 'Firstname must have a min length of 2 chars',
+ 'string.empty': 'Firstname is required',
+ })
+ .label('Firstname'),
+ lastname: Joi.string()
+ .required()
+ .min(2)
+ .max(60)
+ .messages({
+ 'string.min': 'Lastname must have a min length of 2 chars',
+ 'string.max': 'Lastname must have a max length of 60 chars',
+ 'string.empty': 'Lastname is required',
+ })
+ .label('Lastname'),
+ email: Joi.string()
+ .email({ tlds: { allow: false } })
+ .message('Email is invalid')
+ .required()
+ .label('Email'), // tld = top level domain is deactivated,
+ message: Joi.string()
+ .required()
+ .min(10)
+ .max(1000)
+ .messages({
+ 'string.min': 'Message must have a min length of 10 chars',
+ 'string.max': 'Message must have a max length of 1000 chars',
+ 'string.empty': 'Message is required',
+ })
+ .label('Message'),
+ });
+
+ const { error } = schema.validate(formData, { abortEarly: false });
+
+ // Guard
+ if (!error) {
+ return {};
+ }
+
+ console.log(error);
+ console.log(error.details);
+
+ // [{
+ // context : {label: 'Firstname', value: '', key: 'firstname'}
+ // message : "\"Firstname\" is not allowed to be empty"
+ // path: ['firstname']
+ // type : "string.empty"}
+ // },...];
+
+ const errors = error.details.reduce((obj, current) => {
+ obj[current.path[0]] = current.message;
+ return obj;
+ }, {}); // InitialValue {}
+
+ // {'firstname': "\"Firstname\" is not allowed to be empty", 'lastname': "\"Lastname\" is not allowed to be empty" }
+ return errors;
+};
+export default validate;
diff --git a/06_react/unterricht/tag38/01_react-with-forms/src/pages/ContactPage.jsx b/06_react/unterricht/tag38/01_react-with-forms/src/pages/ContactPage.jsx
index d00ae27..ca4e431 100644
--- a/06_react/unterricht/tag38/01_react-with-forms/src/pages/ContactPage.jsx
+++ b/06_react/unterricht/tag38/01_react-with-forms/src/pages/ContactPage.jsx
@@ -1,4 +1,4 @@
-import FormContact from '../components/forms/react-hook-form/FormContact';
+import FormContact from '../components/forms/joi/FormContact';
function ContactPage() {
return (
diff --git a/06_react/unterricht/tag38/01_react-with-forms/src/utils/validators/validate.js b/06_react/unterricht/tag38/01_react-with-forms/src/utils/validators/validate.js
index d8395a4..9d778df 100644
--- a/06_react/unterricht/tag38/01_react-with-forms/src/utils/validators/validate.js
+++ b/06_react/unterricht/tag38/01_react-with-forms/src/utils/validators/validate.js
@@ -1,4 +1,6 @@
-import validator from 'validator';
+import isEmail from 'validator/lib/isEmail';
+import isLength from 'validator/lib/isLength';
+import isEmpty from 'validator/lib/isEmpty';
const validate = (formData) => {
const errors = {};
@@ -7,27 +9,27 @@ const validate = (formData) => {
const email = formData.email.trim();
const message = formData.message.trim();
- if (validator.isEmpty(firstname)) {
+ if (isEmpty(firstname)) {
errors.firstname = 'Firstname is required';
- } else if (!validator.isLength(firstname, { min: 2, max: 50 })) {
+ } else if (!isLength(firstname, { min: 2, max: 50 })) {
errors.firstname = 'Firstname must be between 2 and 50 characters';
}
- if (validator.isEmpty(lastname)) {
+ if (isEmpty(lastname)) {
errors.lastname = 'Lastname is required';
- } else if (!validator.isLength(lastname, { min: 2, max: 60 })) {
+ } else if (!isLength(lastname, { min: 2, max: 60 })) {
errors.lastname = 'Lastname must be between 2 and 60 characters';
}
- if (validator.isEmpty(email)) {
+ if (isEmpty(email)) {
errors.email = 'Email is required';
- } else if (!validator.isEmail(email)) {
+ } else if (!isEmail(email)) {
errors.email = 'Email is invalid';
}
- if (validator.isEmpty(message)) {
+ if (isEmpty(message)) {
errors.message = 'Message is required';
- } else if (!validator.isLength(message, { min: 10, max: 1000 })) {
+ } else if (!isLength(message, { min: 10, max: 1000 })) {
errors.message = 'Message must be between 10 and 1000 characters';
}
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/.gitignore b/06_react/unterricht/tag38/02_react-api-with-hooks/.gitignore
new file mode 100644
index 0000000..a547bf3
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/.oxlintrc.json b/06_react/unterricht/tag38/02_react-api-with-hooks/.oxlintrc.json
new file mode 100644
index 0000000..1255078
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/.oxlintrc.json
@@ -0,0 +1,8 @@
+{
+ "$schema": "./node_modules/oxlint/configuration_schema.json",
+ "plugins": ["react", "oxc"],
+ "rules": {
+ "react/rules-of-hooks": "error",
+ "react/only-export-components": ["warn", { "allowConstantExport": true }]
+ }
+}
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/README.md b/06_react/unterricht/tag38/02_react-api-with-hooks/README.md
new file mode 100644
index 0000000..d937833
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/README.md
@@ -0,0 +1,16 @@
+# React + Vite
+
+This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
+
+Currently, two official plugins are available:
+
+- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
+- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
+
+## React Compiler
+
+The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
+
+## Expanding the Oxlint configuration
+
+If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and Oxlint's TypeScript related rules in your project.
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/index.html b/06_react/unterricht/tag38/02_react-api-with-hooks/index.html
new file mode 100644
index 0000000..2ea4e09
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/index.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+ React API with hooks
+
+
+
+ Die Seite benötigt JS.
+
+
+
+
+
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/package.json b/06_react/unterricht/tag38/02_react-api-with-hooks/package.json
new file mode 100644
index 0000000..ab968d8
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "react-api-with-hooks",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "lint": "oxlint",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "axios": "^1.21.0",
+ "bootstrap": "^5.3.8",
+ "joi": "^18.3.0",
+ "react": "^19.3.1",
+ "react-dom": "^19.3.1",
+ "react-hook-form": "^7.91.0",
+ "react-icons": "^5.7.0",
+ "react-router-dom": "^7.18.5",
+ "validator": "^13.15.35"
+ },
+ "devDependencies": {
+ "@types/react": "^19.3.0",
+ "@types/react-dom": "^19.3.0",
+ "@vitejs/plugin-react": "^6.1.2",
+ "oxlint": "^1.90.0",
+ "sass-embedded": "^1.106.1",
+ "vite": "^8.3.5"
+ }
+}
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/public/favicon.svg b/06_react/unterricht/tag38/02_react-api-with-hooks/public/favicon.svg
new file mode 100644
index 0000000..6893eb1
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/public/favicon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/public/icons.svg b/06_react/unterricht/tag38/02_react-api-with-hooks/public/icons.svg
new file mode 100644
index 0000000..e952219
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/public/icons.svg
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/src/App.jsx b/06_react/unterricht/tag38/02_react-api-with-hooks/src/App.jsx
new file mode 100644
index 0000000..38600c8
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/src/App.jsx
@@ -0,0 +1,28 @@
+import Navbar from './components/navs/Navbar';
+import Router from './routes/Router';
+import HeaderMain from './components/headers/HeaderMain';
+import FooterMain from './components/footers/FooterMain';
+import { Navigate, useNavigate } from 'react-router-dom';
+import { useEffect } from 'react';
+
+function App() {
+ // const navigate = useNavigate();
+
+ // useEffect(() => {
+ // navigate('/contact');
+ // }, []);
+
+ return (
+ <>
+
+
+
+
+
+
+
+ >
+ );
+}
+
+export default App;
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/src/assets/react.svg b/06_react/unterricht/tag38/02_react-api-with-hooks/src/assets/react.svg
new file mode 100644
index 0000000..6c87de9
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/src/assets/react.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/src/assets/vite.svg b/06_react/unterricht/tag38/02_react-api-with-hooks/src/assets/vite.svg
new file mode 100644
index 0000000..5101b67
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/src/assets/vite.svg
@@ -0,0 +1 @@
+Vite
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/src/components/common/Badge.jsx b/06_react/unterricht/tag38/02_react-api-with-hooks/src/components/common/Badge.jsx
new file mode 100644
index 0000000..df1b229
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/src/components/common/Badge.jsx
@@ -0,0 +1,22 @@
+const Badge = (props) => {
+ const { text = '', variant = 'secondary', color } = props;
+ // const inline = typeof color !== 'undefined' ? { backgroundColor: color } : '';
+
+ // if (color) {
+ // return (
+ //
+ // {text}
+ //
+ // );
+ // } else {
+ // return {text} ;
+ // }
+
+ return (
+
+ {text}
+
+ );
+};
+
+export default Badge;
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/src/components/footers/FooterMain.jsx b/06_react/unterricht/tag38/02_react-api-with-hooks/src/components/footers/FooterMain.jsx
new file mode 100644
index 0000000..5006bda
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/src/components/footers/FooterMain.jsx
@@ -0,0 +1,78 @@
+const FooterMain = (props) => {
+ // const { } = props;
+
+ return (
+
+
+
+
+
+
Address
+
+ John Wick
+
+ Musterstr. 33
+
+ 5555 Frankfurt
+
+ Germany
+
+
+
+
+
+
Links
+ {/* nav.menu-footer>ul.list>li*2>a[href="#"]{Link $} */}
+
+
+
+
+
+
+
+
Socials
+ {/* nav.menu-social>ul.list>li*5>a[href="#"]>span{Link $} */}
+
+
+
+
+
+
+
+
+ );
+};
+export default FooterMain;
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/src/components/forms/FormContact.jsx b/06_react/unterricht/tag38/02_react-api-with-hooks/src/components/forms/FormContact.jsx
new file mode 100644
index 0000000..9f2d181
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/src/components/forms/FormContact.jsx
@@ -0,0 +1,159 @@
+import { useState } from 'react';
+import validate from '../../utils/validators/validate';
+
+const INITIAL_FORM_DATA = {
+ firstname: '', //
+ lastname: '',
+ email: '',
+ message: '',
+};
+
+const FormContact = (props) => {
+ // const { } = props;
+
+ const [formData, setFormData] = useState(INITIAL_FORM_DATA);
+
+ const [errors, setErrors] = useState({});
+
+ // EventHandlers =======
+ const handleSubmit = (e) => {
+ e.preventDefault(); // Standardverhalten (Versenden vom Formular) unterbinden.
+
+ const form = e.currentTarget;
+
+ const errors = validate(formData);
+
+ if (Object.keys(errors).length === 0) {
+ console.log('Form submitted ....');
+ // TODO: send over AJAX/Fetch
+ form.reset();
+ setFormData(INITIAL_FORM_DATA);
+ setErrors({});
+ } else {
+ console.warn('Form submit error');
+ setErrors(errors);
+ }
+ };
+
+ const handleChange = (e) => {
+ const { name, value } = e.target;
+
+ setFormData((state) => ({ ...state, [name]: value }));
+
+ // console.log({ [e.target.name]: e.target.value });
+ // console.log('Formdata', formData);
+ };
+
+ const handleBlur = (e) => {
+ const { name, value } = e.target;
+ const validationErrors = validate({ ...formData, [name]: value });
+
+ setErrors((state) => {
+ const nextErrors = { ...state };
+
+ if (validationErrors[name]) {
+ nextErrors[name] = validationErrors[name];
+ } else {
+ delete nextErrors[name];
+ }
+
+ return nextErrors;
+ });
+ };
+
+ return (
+
+
+
+
+
+ Firstname
+
+
+
OK
+
{errors?.firstname}
+
+
+
+
+
+ Lastname
+
+
+
OK
+
{errors?.lastname}
+
+
+
+
+
+
+
+
+ E-Mail
+
+
+
OK
+
{errors?.email}
+
+
+
+
+
+
+
+
+ Message
+
+
+
OK
+
{errors?.message}
+
+
+
+
+
+
+ );
+};
+export default FormContact;
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/src/components/forms/FormNewsletter.jsx b/06_react/unterricht/tag38/02_react-api-with-hooks/src/components/forms/FormNewsletter.jsx
new file mode 100644
index 0000000..d8c7ec7
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/src/components/forms/FormNewsletter.jsx
@@ -0,0 +1,35 @@
+const FormNewsletter = (props) => {
+ // const { } = props;
+
+ // EventHandler
+ const handleSubmit = (e) => {
+ e.preventDefault(); // Standardverhalten unterbinden
+ console.log('Event: ', e);
+ };
+
+ return (
+
+
+
+
+
+
+ Subscribe Now
+
+
+
+
+
+ );
+};
+export default FormNewsletter;
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/src/components/forms/joi/FormContact.jsx b/06_react/unterricht/tag38/02_react-api-with-hooks/src/components/forms/joi/FormContact.jsx
new file mode 100644
index 0000000..2e17e5c
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/src/components/forms/joi/FormContact.jsx
@@ -0,0 +1,159 @@
+import { useState } from 'react';
+import validate from './validate'; // joi validate schema object
+
+const INITIAL_FORM_DATA = {
+ firstname: '', //
+ lastname: '',
+ email: '',
+ message: '',
+};
+
+const FormContact = (props) => {
+ // const { } = props;
+
+ const [formData, setFormData] = useState(INITIAL_FORM_DATA);
+
+ const [errors, setErrors] = useState({});
+
+ // EventHandlers =======
+ const handleSubmit = (e) => {
+ e.preventDefault(); // Standardverhalten (Versenden vom Formular) unterbinden.
+
+ const form = e.currentTarget;
+
+ const errors = validate(formData);
+
+ if (Object.keys(errors).length === 0) {
+ console.log('Form submitted ....');
+ // TODO: send over AJAX/Fetch
+ form.reset();
+ setFormData(INITIAL_FORM_DATA);
+ setErrors({});
+ } else {
+ console.warn('Form submit error');
+ setErrors(errors);
+ }
+ };
+
+ const handleChange = (e) => {
+ const { name, value } = e.target;
+
+ setFormData((state) => ({ ...state, [name]: value }));
+
+ // console.log({ [e.target.name]: e.target.value });
+ // console.log('Formdata', formData);
+ };
+
+ const handleBlur = (e) => {
+ const { name, value } = e.target;
+ const validationErrors = validate({ ...formData, [name]: value });
+
+ setErrors((state) => {
+ const nextErrors = { ...state };
+
+ if (validationErrors[name]) {
+ nextErrors[name] = validationErrors[name];
+ } else {
+ delete nextErrors[name];
+ }
+
+ return nextErrors;
+ });
+ };
+
+ return (
+
+
+
+
+
+ Firstname
+
+
+
OK
+
{errors?.firstname}
+
+
+
+
+
+ Lastname
+
+
+
OK
+
{errors?.lastname}
+
+
+
+
+
+
+
+
+ E-Mail
+
+
+
OK
+
{errors?.email}
+
+
+
+
+
+
+
+
+ Message
+
+
+
OK
+
{errors?.message}
+
+
+
+
+
+
+ );
+};
+export default FormContact;
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/src/components/forms/joi/validate.js b/06_react/unterricht/tag38/02_react-api-with-hooks/src/components/forms/joi/validate.js
new file mode 100644
index 0000000..e1d9df0
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/src/components/forms/joi/validate.js
@@ -0,0 +1,67 @@
+import Joi from 'joi';
+
+const validate = (formData) => {
+ // const { } = props;
+ const schema = Joi.object({
+ firstname: Joi.string() //
+ .min(2)
+ .max(50)
+ .required()
+ .messages({
+ 'string.min': 'Firstname must have a min length of 2 chars',
+ 'string.empty': 'Firstname is required',
+ })
+ .label('Firstname'),
+ lastname: Joi.string()
+ .required()
+ .min(2)
+ .max(60)
+ .messages({
+ 'string.min': 'Lastname must have a min length of 2 chars',
+ 'string.max': 'Lastname must have a max length of 60 chars',
+ 'string.empty': 'Lastname is required',
+ })
+ .label('Lastname'),
+ email: Joi.string()
+ .email({ tlds: { allow: false } })
+ .message('Email is invalid')
+ .required()
+ .label('Email'), // tld = top level domain is deactivated,
+ message: Joi.string()
+ .required()
+ .min(10)
+ .max(1000)
+ .messages({
+ 'string.min': 'Message must have a min length of 10 chars',
+ 'string.max': 'Message must have a max length of 1000 chars',
+ 'string.empty': 'Message is required',
+ })
+ .label('Message'),
+ });
+
+ const { error } = schema.validate(formData, { abortEarly: false });
+
+ // Guard
+ if (!error) {
+ return {};
+ }
+
+ console.log(error);
+ console.log(error.details);
+
+ // [{
+ // context : {label: 'Firstname', value: '', key: 'firstname'}
+ // message : "\"Firstname\" is not allowed to be empty"
+ // path: ['firstname']
+ // type : "string.empty"}
+ // },...];
+
+ const errors = error.details.reduce((obj, current) => {
+ obj[current.path[0]] = current.message;
+ return obj;
+ }, {}); // InitialValue {}
+
+ // {'firstname': "\"Firstname\" is not allowed to be empty", 'lastname': "\"Lastname\" is not allowed to be empty" }
+ return errors;
+};
+export default validate;
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/src/components/forms/react-hook-form/FormContact.jsx b/06_react/unterricht/tag38/02_react-api-with-hooks/src/components/forms/react-hook-form/FormContact.jsx
new file mode 100644
index 0000000..5151368
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/src/components/forms/react-hook-form/FormContact.jsx
@@ -0,0 +1,132 @@
+import { useState } from 'react';
+import { useForm } from 'react-hook-form';
+// import validate from '../../../utils/validators/validate';
+
+const FormContact = (props) => {
+ // const { } = props;
+
+ const {
+ register,
+ handleSubmit,
+ formState: { errors },
+ } = useForm();
+
+ // EventHandlers =======
+ const onSubmit = (data) => {
+ console.log('submitted');
+ console.log('Form data:', data);
+
+ // fetch('/send', {
+ // method: 'POST',
+ // headers: {
+ // 'Content-Type': 'application/json',
+ // },
+ // body: JSON.stringify(data),
+ // })
+ // .then((res) => res.json())
+ // .then((data) => console.log(data))
+ // .catch((err) => console.error(err));
+ };
+
+ if (errors) {
+ console.log(errors);
+ }
+
+ return (
+
+
+
+
+
+ Firstname
+
+
+
OK
+
{errors?.firstname?.message || 'Error'}
+
+
+
+
+
+ Lastname
+
+
+
OK
+
{errors?.lastname?.message}
+
+
+
+
+
+
+
+
+ E-Mail
+
+
+
OK
+
Email is required
+
+
+
+
+
+
+
+
+ Message
+
+
+
OK
+
{errors?.message?.message}
+
+
+
+
+
+
+ );
+};
+export default FormContact;
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/src/components/headers/HeaderMain.jsx b/06_react/unterricht/tag38/02_react-api-with-hooks/src/components/headers/HeaderMain.jsx
new file mode 100644
index 0000000..acbddb2
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/src/components/headers/HeaderMain.jsx
@@ -0,0 +1,12 @@
+import LogoMain from '../logos/LogoMain';
+
+const HeaderMain = () => {
+ return (
+
+ );
+};
+export default HeaderMain;
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/src/components/icons/Logo.jsx b/06_react/unterricht/tag38/02_react-api-with-hooks/src/components/icons/Logo.jsx
new file mode 100644
index 0000000..dd3b6f0
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/src/components/icons/Logo.jsx
@@ -0,0 +1,48 @@
+const Logo = () => {
+ return (
+
+
+
+
+
+
+
+
+ );
+};
+export default Logo;
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/src/components/inputs/InputAmount.jsx b/06_react/unterricht/tag38/02_react-api-with-hooks/src/components/inputs/InputAmount.jsx
new file mode 100644
index 0000000..d6ac3f5
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/src/components/inputs/InputAmount.jsx
@@ -0,0 +1,61 @@
+import { useState } from 'react';
+import { FaMinus, FaPlus } from 'react-icons/fa6';
+
+const InputAmount = (props) => {
+ const [amount, setAmount] = useState(0);
+
+ // const { } = props;
+
+ // Eventhandler
+ const handleChange = (e) => {
+ const value = Number(e.target.value); // e.target.valueAsNumber
+
+ // Guard
+ if (isNaN(value)) return;
+
+ setAmount(value);
+ };
+ const handleClick = (e) => {
+ console.log('Event: ', e);
+ console.log(e.currentTarget.name);
+ switch (e.currentTarget.name) {
+ case 'increase':
+ setAmount((prevState) => prevState + 1);
+ console.log('inc');
+ break;
+ case 'decrease':
+ setAmount(amount - 1);
+ console.log('dec');
+ break;
+ default: //nothing
+ }
+ };
+
+ return (
+
+
+
+
+
+ Decrease
+
+
+
+
+
+
+
+ Increase
+
+
+
+
+ );
+};
+export default InputAmount;
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/src/components/logos/LogoMain.jsx b/06_react/unterricht/tag38/02_react-api-with-hooks/src/components/logos/LogoMain.jsx
new file mode 100644
index 0000000..2649b9e
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/src/components/logos/LogoMain.jsx
@@ -0,0 +1,15 @@
+import Logo from '../icons/Logo';
+
+// import Logo from '/logo-js.min.svg';
+const LogoMain = (props) => {
+ // const { } = props;
+
+ return (
+
+ {/* */}
+
+ Firmen Slogan
+
+ );
+};
+export default LogoMain;
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/src/components/navs/Navbar.jsx b/06_react/unterricht/tag38/02_react-api-with-hooks/src/components/navs/Navbar.jsx
new file mode 100644
index 0000000..983afdb
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/src/components/navs/Navbar.jsx
@@ -0,0 +1,99 @@
+import { NavLink } from 'react-router-dom';
+
+function Navbar() {
+ return (
+
+
+
+ JS Course
+
+
+
+
+
+
+
+
+ isActive
+ ? 'nav-link active' //
+ : 'nav-link'
+ }
+ to="/">
+ Home
+
+
+
+
+ isActive
+ ? 'nav-link active' //
+ : 'nav-link'
+ }
+ to="/about">
+ About
+
+
+
+
+ isActive
+ ? 'nav-link active' //
+ : 'nav-link'
+ }
+ to="/contact"
+ style={({ isActive }) => (isActive ? { color: 'red' } : {})}>
+ Contact
+
+
+
+
+
+
+ isActive
+ ? 'nav-link dropdown-toggle active' //
+ : 'nav-link'
+ }
+ to="/user"
+ role="button"
+ data-bs-toggle="dropdown"
+ aria-expanded="false">
+ User
+
+
+
+
+ Profile
+
+
+
+
+ Dashboard
+
+
+
+
+
+
+
+ Settings
+
+
+
+
+
+
+
+
+ );
+}
+
+export default Navbar;
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/src/layouts/Layout.jsx b/06_react/unterricht/tag38/02_react-api-with-hooks/src/layouts/Layout.jsx
new file mode 100644
index 0000000..8b55dbf
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/src/layouts/Layout.jsx
@@ -0,0 +1,20 @@
+import { Outlet } from 'react-router-dom';
+import Navbar from '../components/navs/Navbar';
+
+const Layout = (props) => {
+ // const { } = props;
+
+ return (
+ <>
+
+
+
+
+
+
+ >
+ );
+};
+export default Layout;
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/src/main.jsx b/06_react/unterricht/tag38/02_react-api-with-hooks/src/main.jsx
new file mode 100644
index 0000000..672352b
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/src/main.jsx
@@ -0,0 +1,18 @@
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+import { BrowserRouter } from 'react-router-dom';
+
+import App from './App.jsx';
+
+import 'bootstrap'; // js
+import './styles/main.scss';
+
+const root = createRoot(document.getElementById('root'));
+
+root.render(
+
+
+
+
+ ,
+);
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/src/pages/AboutPage.jsx b/06_react/unterricht/tag38/02_react-api-with-hooks/src/pages/AboutPage.jsx
new file mode 100644
index 0000000..8112904
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/src/pages/AboutPage.jsx
@@ -0,0 +1,14 @@
+function AboutPage() {
+ return (
+
+
About
+
+ sit amet consectetur adipisicing elit. Minima, voluptatibus nobis molestiae, dignissimos id dolores minus iste
+ quisquam harum vitae rem dolorem debitis natus asperiores amet rerum ratione laboriosam quam. Sit corporis
+ voluptate laboriosam doloribus incidunt voluptatibus repellat. Excepturi vitae perferendis odit
+
+
+ );
+}
+
+export default AboutPage;
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/src/pages/ContactPage.jsx b/06_react/unterricht/tag38/02_react-api-with-hooks/src/pages/ContactPage.jsx
new file mode 100644
index 0000000..ca4e431
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/src/pages/ContactPage.jsx
@@ -0,0 +1,20 @@
+import FormContact from '../components/forms/joi/FormContact';
+
+function ContactPage() {
+ return (
+
+ );
+}
+
+export default ContactPage;
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/src/pages/HomePage.jsx b/06_react/unterricht/tag38/02_react-api-with-hooks/src/pages/HomePage.jsx
new file mode 100644
index 0000000..d4a4d32
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/src/pages/HomePage.jsx
@@ -0,0 +1,10 @@
+function HomePage() {
+ return (
+
+
Home
+
Lorem, ipsum dolor sit amet consectetur adipisicing elit. Ad, voluptatibus.
+
+ );
+}
+
+export default HomePage;
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/src/pages/UserPage.jsx b/06_react/unterricht/tag38/02_react-api-with-hooks/src/pages/UserPage.jsx
new file mode 100644
index 0000000..3d35b61
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/src/pages/UserPage.jsx
@@ -0,0 +1,24 @@
+import { Outlet } from 'react-router-dom';
+
+const UserPage = (props) => {
+ // const { } = props;
+
+ return (
+
+
+
+ User
+ The nested user site:
+
+
+
+
+
+
+ );
+};
+export default UserPage;
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/src/pages/UserProfilePage.jsx b/06_react/unterricht/tag38/02_react-api-with-hooks/src/pages/UserProfilePage.jsx
new file mode 100644
index 0000000..053f8ce
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/src/pages/UserProfilePage.jsx
@@ -0,0 +1,19 @@
+import { useParams } from 'react-router-dom';
+import Badge from '../components/common/Badge';
+
+function UserProfilePage() {
+ const { userId } = useParams();
+
+ return (
+
+
+ User Profile
+
+
+ sit amet consectetur adipisicing elit. Minima, voluptatibus nobis molestiae, dignissimos id dolores minus iste
+
+
+ );
+}
+
+export default UserProfilePage;
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/src/routes/Router.jsx b/06_react/unterricht/tag38/02_react-api-with-hooks/src/routes/Router.jsx
new file mode 100644
index 0000000..38f5bd2
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/src/routes/Router.jsx
@@ -0,0 +1,77 @@
+import { Navigate, Route, Routes } from 'react-router-dom';
+//import { createBrowserRouter, RouterProvider } from 'react-router-dom';
+
+// Pages Komponenten
+import HomePage from '../pages/HomePage';
+import AboutPage from '../pages/AboutPage';
+import ContactPage from '../pages/ContactPage';
+import UserProfilePage from '../pages/UserProfilePage';
+import UserPage from '../pages/UserPage';
+
+// import Layout from '../layouts/Layout';
+
+// Alternative Definition von Routes über "createBrowserRouter" mit Objekten
+// const router = createBrowserRouter([
+// { path: '/', element: },
+// { path: '/about', element: },
+// { path: '/contact', element: },
+// ]);
+// // App.jsx
+// {/* */} ;
+
+const checkAuth = () => {
+ const token = localStorage.getItem('token');
+ return token && token === 'secret-token' ? true : false;
+};
+
+const ProtectedRoute = (props) => {
+ const { redirectTo = '/', children } = props;
+
+ const isAuthenticated = checkAuth(); // => true | false // Authentication - Prüfung
+
+ return isAuthenticated ? children : ;
+};
+
+const Router = () => {
+ // const { } = props;
+
+ return (
+
+ {/* Static Routes */}
+ } />
+ } />
+ } />
+
+ {/* Dynamic Routes */}
+ Flights} />
+ } />
+
+ {/* Nested Routes */}
+ {/* }>
+ } />
+ } />
+ } />
+ */}
+
+ }>
+ Index} />
+ Profile} />
+ Dashboard} />
+ Settings} />
+
+
+ {/* Protected Routes | Geschützte Routen */}
+
+
+
Admin Dashboard Page
+
Lorem ipsum dolor sit amet consectetur adipisicing elit. Vero atque eveniet hic temporibus
+
+
+ }>
+
+ );
+};
+export default Router;
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/01_frameworks/_index.scss b/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/01_frameworks/_index.scss
new file mode 100644
index 0000000..42edceb
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/01_frameworks/_index.scss
@@ -0,0 +1,3 @@
+// Bootstrap 5.3.8 or 6 (6.0.0-alpha.1) straight from node_modules.
+// @forward 'bootstrap/scss/bootstrap-grid'
+@forward 'bootstrap/scss/bootstrap';
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/02_fonts/_gloria-hallelujah-v24-latin.scss b/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/02_fonts/_gloria-hallelujah-v24-latin.scss
new file mode 100644
index 0000000..f46eba2
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/02_fonts/_gloria-hallelujah-v24-latin.scss
@@ -0,0 +1,8 @@
+// gloria-hallelujah-regular - latin
+@font-face {
+ font-display: swap;
+ font-family: 'Gloria Hallelujah';
+ font-style: normal;
+ font-weight: 400;
+ src: url('../../assets/fonts/gloria-hallelujah-v24-latin/gloria-hallelujah-v24-latin-regular.woff2') format('woff2');
+}
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/02_fonts/_index.scss b/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/02_fonts/_index.scss
new file mode 100644
index 0000000..d377888
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/02_fonts/_index.scss
@@ -0,0 +1,3 @@
+// @forward 'gloria-hallelujah-v24-latin';
+// @forward 'lato-v25-latin';
+// @forward 'source-sans-3-v19-latin';
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/02_fonts/_lato-v25-latin.scss b/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/02_fonts/_lato-v25-latin.scss
new file mode 100644
index 0000000..522cfda
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/02_fonts/_lato-v25-latin.scss
@@ -0,0 +1,89 @@
+// lato-100 - latin
+@font-face {
+ font-display: swap;
+ font-family: 'Lato';
+ font-style: normal;
+ font-weight: 100;
+ src: url('../../assets/fonts/lato-v25-latin/lato-v25-latin-100.woff2') format('woff2');
+}
+
+// lato-100italic - latin
+@font-face {
+ font-display: swap;
+ font-family: 'Lato';
+ font-style: italic;
+ font-weight: 100;
+ src: url('../../assets/fonts/lato-v25-latin/lato-v25-latin-100italic.woff2') format('woff2');
+}
+
+// lato-300 - latin
+@font-face {
+ font-display: swap;
+ font-family: 'Lato';
+ font-style: normal;
+ font-weight: 300;
+ src: url('../../assets/fonts/lato-v25-latin/lato-v25-latin-300.woff2') format('woff2');
+}
+
+// lato-300italic - latin
+@font-face {
+ font-display: swap;
+ font-family: 'Lato';
+ font-style: italic;
+ font-weight: 300;
+ src: url('../../assets/fonts/lato-v25-latin/lato-v25-latin-300italic.woff2') format('woff2');
+}
+
+// lato-regular - latin
+@font-face {
+ font-display: swap;
+ font-family: 'Lato';
+ font-style: normal;
+ font-weight: 400;
+ src: url('../../assets/fonts/lato-v25-latin/lato-v25-latin-regular.woff2') format('woff2');
+}
+
+// lato-italic - latin
+@font-face {
+ font-display: swap;
+ font-family: 'Lato';
+ font-style: italic;
+ font-weight: 400;
+ src: url('../../assets/fonts/lato-v25-latin/lato-v25-latin-italic.woff2') format('woff2');
+}
+
+// lato-700 - latin
+@font-face {
+ font-display: swap;
+ font-family: 'Lato';
+ font-style: normal;
+ font-weight: 700;
+ src: url('../../assets/fonts/lato-v25-latin/lato-v25-latin-700.woff2') format('woff2');
+}
+
+// lato-700italic - latin
+@font-face {
+ font-display: swap;
+ font-family: 'Lato';
+ font-style: italic;
+ font-weight: 700;
+ src: url('../../assets/fonts/lato-v25-latin/lato-v25-latin-700italic.woff2') format('woff2');
+}
+
+// lato-900 - latin
+@font-face {
+ font-display: swap;
+ font-family: 'Lato';
+ font-style: normal;
+ font-weight: 900;
+ src: url('../../assets/fonts/lato-v25-latin/lato-v25-latin-900.woff2') format('woff2');
+}
+
+// lato-900italic - latin
+@font-face {
+ font-display: swap;
+ font-family: 'Lato';
+ font-style: italic;
+ font-weight: 900;
+ src: url('../../assets/fonts/lato-v25-latin/lato-v25-latin-900italic.woff2') format('woff2');
+}
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/02_fonts/_source-sans-3-v19-latin.scss b/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/02_fonts/_source-sans-3-v19-latin.scss
new file mode 100644
index 0000000..b732c16
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/02_fonts/_source-sans-3-v19-latin.scss
@@ -0,0 +1,143 @@
+// source-sans-3-200 - latin
+@font-face {
+ font-display: swap;
+ font-family: 'Source Sans 3';
+ font-style: normal;
+ font-weight: 200;
+ src: url('../../assets/fonts/source-sans-3-v19-latin/source-sans-3-v19-latin-200.woff2') format('woff2');
+}
+
+// source-sans-3-200italic - latin
+@font-face {
+ font-display: swap;
+ font-family: 'Source Sans 3';
+ font-style: italic;
+ font-weight: 200;
+ src: url('../../assets/fonts/source-sans-3-v19-latin/source-sans-3-v19-latin-200italic.woff2') format('woff2');
+}
+
+// source-sans-3-300 - latin
+@font-face {
+ font-display: swap;
+ font-family: 'Source Sans 3';
+ font-style: normal;
+ font-weight: 300;
+ src: url('../../assets/fonts/source-sans-3-v19-latin/source-sans-3-v19-latin-300.woff2') format('woff2');
+}
+
+// source-sans-3-300italic - latin
+@font-face {
+ font-display: swap;
+ font-family: 'Source Sans 3';
+ font-style: italic;
+ font-weight: 300;
+ src: url('../../assets/fonts/source-sans-3-v19-latin/source-sans-3-v19-latin-300italic.woff2') format('woff2');
+}
+
+// source-sans-3-regular - latin
+@font-face {
+ font-display: swap;
+ font-family: 'Source Sans 3';
+ font-style: normal;
+ font-weight: 400;
+ src: url('../../assets/fonts/source-sans-3-v19-latin/source-sans-3-v19-latin-regular.woff2') format('woff2');
+}
+
+// source-sans-3-italic - latin
+@font-face {
+ font-display: swap;
+ font-family: 'Source Sans 3';
+ font-style: italic;
+ font-weight: 400;
+ src: url('../../assets/fonts/source-sans-3-v19-latin/source-sans-3-v19-latin-italic.woff2') format('woff2');
+}
+
+// source-sans-3-500 - latin
+@font-face {
+ font-display: swap;
+ font-family: 'Source Sans 3';
+ font-style: normal;
+ font-weight: 500;
+ src: url('../../assets/fonts/source-sans-3-v19-latin/source-sans-3-v19-latin-500.woff2') format('woff2');
+}
+
+// source-sans-3-500italic - latin
+@font-face {
+ font-display: swap;
+ font-family: 'Source Sans 3';
+ font-style: italic;
+ font-weight: 500;
+ src: url('../../assets/fonts/source-sans-3-v19-latin/source-sans-3-v19-latin-500italic.woff2') format('woff2');
+}
+
+// source-sans-3-600 - latin
+@font-face {
+ font-display: swap;
+ font-family: 'Source Sans 3';
+ font-style: normal;
+ font-weight: 600;
+ src: url('../../assets/fonts/source-sans-3-v19-latin/source-sans-3-v19-latin-600.woff2') format('woff2');
+}
+
+// source-sans-3-600italic - latin
+@font-face {
+ font-display: swap;
+ font-family: 'Source Sans 3';
+ font-style: italic;
+ font-weight: 600;
+ src: url('../../assets/fonts/source-sans-3-v19-latin/source-sans-3-v19-latin-600italic.woff2') format('woff2');
+}
+
+// source-sans-3-700 - latin
+@font-face {
+ font-display: swap;
+ font-family: 'Source Sans 3';
+ font-style: normal;
+ font-weight: 700;
+ src: url('../../assets/fonts/source-sans-3-v19-latin/source-sans-3-v19-latin-700.woff2') format('woff2');
+}
+
+// source-sans-3-700italic - latin
+@font-face {
+ font-display: swap;
+ font-family: 'Source Sans 3';
+ font-style: italic;
+ font-weight: 700;
+ src: url('../../assets/fonts/source-sans-3-v19-latin/source-sans-3-v19-latin-700italic.woff2') format('woff2');
+}
+
+// source-sans-3-800 - latin
+@font-face {
+ font-display: swap;
+ font-family: 'Source Sans 3';
+ font-style: normal;
+ font-weight: 800;
+ src: url('../../assets/fonts/source-sans-3-v19-latin/source-sans-3-v19-latin-800.woff2') format('woff2');
+}
+
+// source-sans-3-800italic - latin
+@font-face {
+ font-display: swap;
+ font-family: 'Source Sans 3';
+ font-style: italic;
+ font-weight: 800;
+ src: url('../../assets/fonts/source-sans-3-v19-latin/source-sans-3-v19-latin-800italic.woff2') format('woff2');
+}
+
+// source-sans-3-900 - latin
+@font-face {
+ font-display: swap;
+ font-family: 'Source Sans 3';
+ font-style: normal;
+ font-weight: 900;
+ src: url('../../assets/fonts/source-sans-3-v19-latin/source-sans-3-v19-latin-900.woff2') format('woff2');
+}
+
+// source-sans-3-900italic - latin
+@font-face {
+ font-display: swap;
+ font-family: 'Source Sans 3';
+ font-style: italic;
+ font-weight: 900;
+ src: url('../../assets/fonts/source-sans-3-v19-latin/source-sans-3-v19-latin-900italic.woff2') format('woff2');
+}
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/03_base/_colors.scss b/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/03_base/_colors.scss
new file mode 100644
index 0000000..a550f3d
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/03_base/_colors.scss
@@ -0,0 +1,13 @@
+/* Color Theme Swatches in Hex */
+$color-1: #5c4b51;
+$color-2: #8cbeb2;
+$color-3: #f2ebbf;
+$color-4: #f3b562;
+$color-5: #f06060;
+
+$color-red: $color-5;
+$color-white: white; // falls weiß geändert wird z.B.: #f8f8f8
+$color-black: #101010;
+
+$color-dark-blue: #283e4a;
+$linear-gradient-dark: linear-gradient(180deg, rgba(40, 62, 74, 1) 0%, rgba(24, 32, 38, 1) 100%);
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/03_base/_index.scss b/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/03_base/_index.scss
new file mode 100644
index 0000000..aaf4e12
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/03_base/_index.scss
@@ -0,0 +1,2 @@
+// Variables (colors, typography) are pulled in by the partials that need them via @use.
+@forward 'theme';
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/03_base/_theme.scss b/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/03_base/_theme.scss
new file mode 100644
index 0000000..26b9863
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/03_base/_theme.scss
@@ -0,0 +1,52 @@
+@use 'typography' as *;
+
+// Allgemeine Formatierung
+*,
+html {
+ box-sizing: border-box;
+}
+
+html,
+body,
+#root,
+#app {
+ height: 100%;
+}
+// Sticky Footer
+body,
+#root,
+#app {
+ display: flex;
+ flex-direction: column;
+}
+
+main,
+#main {
+ flex: 1 0 auto;
+}
+
+body > footer,
+.footer-main {
+ flex-shrink: 0;
+}
+// sticky end
+
+body {
+ margin: 0;
+ padding: 0;
+ font-family: $font-copy;
+}
+
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+ font-family: $font-headline;
+ font-weight: 600;
+}
+
+blockquote {
+ font-family: $font-quote;
+}
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/03_base/_typography.scss b/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/03_base/_typography.scss
new file mode 100644
index 0000000..7c9b095
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/03_base/_typography.scss
@@ -0,0 +1,9 @@
+$fallback-serif: Cambria, Cochin, Georgia, Times, 'Times New Roman', serif;
+$fallback-sans-serif: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
+
+$font-headline: 'Lato', $fallback-serif;
+$font-subclaim: $fallback-sans-serif;
+
+$font-menu: 'Source Sans 3', $fallback-sans-serif;
+$font-copy: 'Source Sans 3', $fallback-sans-serif;
+$font-quote: 'Gloria Hallelujah', $fallback-serif;
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/04_plugins/_index.scss b/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/04_plugins/_index.scss
new file mode 100644
index 0000000..bd5ba6a
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/04_plugins/_index.scss
@@ -0,0 +1,7 @@
+// @use '@fortawesome/fontawesome-free/scss/variables' with (
+// $font-path: '@fa-webfonts'
+// );
+// @use '@fortawesome/fontawesome-free/scss/fontawesome';
+// @use '@fortawesome/fontawesome-free/scss/solid';
+
+// @use 'toastify-1.12/toastify';
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/04_plugins/toastify-1.12/_toastify.scss b/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/04_plugins/toastify-1.12/_toastify.scss
new file mode 100644
index 0000000..e359a14
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/04_plugins/toastify-1.12/_toastify.scss
@@ -0,0 +1,88 @@
+/*!
+ * Toastify js 1.12.0
+ * https://github.com/apvarun/toastify-js
+ * @license MIT licensed
+ *
+ * Copyright (C) 2018 Varun A P
+ */
+
+.toastify {
+ padding: 12px 20px;
+ color: #ffffff;
+ display: inline-block;
+ box-shadow:
+ 0 3px 6px -1px rgba(0, 0, 0, 0.12),
+ 0 10px 36px -4px rgba(77, 96, 232, 0.3);
+ background: -webkit-linear-gradient(315deg, #73a5ff, #5477f5);
+ background: linear-gradient(135deg, #73a5ff, #5477f5);
+ position: fixed;
+ opacity: 0;
+ transition: all 0.4s cubic-bezier(0.215, 0.61, 0.355, 1);
+ border-radius: 2px;
+ cursor: pointer;
+ text-decoration: none;
+ max-width: calc(50% - 20px);
+ z-index: 2147483647;
+}
+
+.toastify.on {
+ opacity: 1;
+}
+
+.toast-close {
+ background: transparent;
+ border: 0;
+ color: white;
+ cursor: pointer;
+ font-family: inherit;
+ font-size: 1em;
+ opacity: 0.4;
+ padding: 0 5px;
+}
+
+.toastify-right {
+ right: 15px;
+}
+
+.toastify-left {
+ left: 15px;
+}
+
+.toastify-top {
+ top: -150px;
+}
+
+.toastify-bottom {
+ bottom: -150px;
+}
+
+.toastify-rounded {
+ border-radius: 25px;
+}
+
+.toastify-avatar {
+ width: 1.5em;
+ height: 1.5em;
+ margin: -7px 5px;
+ border-radius: 2px;
+}
+
+.toastify-center {
+ margin-left: auto;
+ margin-right: auto;
+ left: 0;
+ right: 0;
+ max-width: fit-content;
+ max-width: -moz-fit-content;
+}
+
+@media only screen and (max-width: 360px) {
+ .toastify-right,
+ .toastify-left {
+ margin-left: auto;
+ margin-right: auto;
+ left: 0;
+ right: 0;
+ max-width: fit-content;
+ }
+}
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/05_modules/_button.scss b/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/05_modules/_button.scss
new file mode 100644
index 0000000..1660f6b
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/05_modules/_button.scss
@@ -0,0 +1,17 @@
+button,
+input {
+ &.btn-custom {
+ background-color: #f0f0f0;
+ color: #333;
+ border: 1px solid #ccc;
+ padding: 8px 16px;
+ border-radius: 0px;
+ margin: 3px;
+ &:hover,
+ &:focus {
+ background-color: #e0e0e0;
+ color: #333;
+ border: 1px solid #ccc;
+ }
+ }
+}
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/05_modules/_chuck-norris-joke.scss b/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/05_modules/_chuck-norris-joke.scss
new file mode 100644
index 0000000..232b479
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/05_modules/_chuck-norris-joke.scss
@@ -0,0 +1,38 @@
+@use '../03_base/colors' as *;
+
+.chuck-norris-joke {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ min-height: calc(100vh - 3.5rem); // full screen below the navbar
+ background: #555 url('../../assets/img/concrete-bg.jpg') center no-repeat fixed;
+ background-size: cover;
+
+ &::after {
+ content: '';
+ position: absolute;
+ z-index: 1;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: radial-gradient(circle, rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0.8) 100%);
+ }
+
+ > * {
+ position: relative;
+ z-index: 2;
+ }
+
+ .joke-text {
+ color: $color-white;
+ text-align: center;
+ font-size: 3rem;
+ max-width: 80%;
+ padding: 2rem;
+ text-shadow: 2px 2px 5px rgba(black, 0.5);
+ }
+}
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/05_modules/_footerMain.scss b/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/05_modules/_footerMain.scss
new file mode 100644
index 0000000..fd73f7c
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/05_modules/_footerMain.scss
@@ -0,0 +1,45 @@
+@use '../03_base/colors' as *;
+
+.footer-main {
+ background-color: $color-black;
+ background: $linear-gradient-dark;
+ padding: 3rem 0;
+ [class^='col'] {
+ color: $color-white;
+ a {
+ color: $color-white;
+ }
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ font-size: 1.5rem;
+ color: $color-2;
+ padding: 0.5rem 0;
+ margin-bottom: 1rem;
+ letter-spacing: 2px;
+ }
+ }
+ // Mobile First
+ .row {
+ flex-direction: column;
+ > div {
+ &:nth-child(1) {
+ order: 3;
+ }
+ &:nth-child(2) {
+ order: 2;
+ }
+ &:nth-child(3) {
+ order: 1;
+ }
+ }
+ }
+ @media (min-width: 992px) {
+ // @include media-breakpoint-up(md) {
+ .row {
+ flex-direction: row;
+ }
+ }
+}
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/05_modules/_header-main.scss b/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/05_modules/_header-main.scss
new file mode 100644
index 0000000..2884477
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/05_modules/_header-main.scss
@@ -0,0 +1,15 @@
+.m-header-main {
+ width: 100%;
+ height: 400px;
+
+ // https://cssreference.io/property/background/
+ background: #efefef url('../../assets/img/header-bg.webp') center no-repeat border-box;
+ background-size: cover;
+
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: center;
+
+ border: 20px solid rgba(white, 0.25);
+}
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/05_modules/_index.scss b/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/05_modules/_index.scss
new file mode 100644
index 0000000..484b0de
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/05_modules/_index.scss
@@ -0,0 +1,8 @@
+@forward 'button';
+// @forward 'chuck-norris-joke';
+@forward 'footerMain';
+@forward 'header-main';
+@forward 'input-amount';
+@forward 'logo-main';
+// @forward 'menu-main';
+// @forward 'product-manager';
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/05_modules/_input-amount.scss b/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/05_modules/_input-amount.scss
new file mode 100644
index 0000000..8701a7c
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/05_modules/_input-amount.scss
@@ -0,0 +1,27 @@
+.m-input-amount {
+ .input-group {
+ .input-group-text {
+ padding: 0;
+ display: inline-block;
+ overflow: hidden;
+ button {
+ border-radius: 0;
+ }
+ }
+ input {
+ max-width: 85px;
+ text-align: center;
+ }
+ /* Für Chrome, Safari, Edge und Opera */
+ input::-webkit-outer-spin-button,
+ input::-webkit-inner-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+ }
+
+ /* Für Firefox */
+ input[type='number'] {
+ appearance: textfield;
+ }
+ }
+}
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/05_modules/_logo-main.scss b/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/05_modules/_logo-main.scss
new file mode 100644
index 0000000..399bd12
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/05_modules/_logo-main.scss
@@ -0,0 +1,21 @@
+.logo-main {
+ margin: 0;
+
+ img,
+ svg {
+ width: 320px;
+ height: auto;
+ max-width: 320px;
+ }
+
+ figcaption {
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+ }
+}
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/05_modules/_menu-main.scss b/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/05_modules/_menu-main.scss
new file mode 100644
index 0000000..df4627e
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/05_modules/_menu-main.scss
@@ -0,0 +1,20 @@
+@use '../03_base/colors' as *;
+@use '../03_base/typography' as *;
+
+// Main navigation = Bootstrap 6 navbar (see includes/menu-main.html).
+// Only the look from frontend-gulp is added here: black bar, uppercase links.
+.menu-main {
+ background-color: $color-black;
+ font-family: $font-menu;
+
+ .nav-link {
+ text-transform: uppercase;
+ }
+}
+
+// Mobile drawer: Bootstrap 6 alpha keeps it white although the navbar is dark
+// (data-bs-theme="dark"), which leaves the light link text invisible. Give it the bar's colour.
+.menu-main .drawer {
+ background-color: $color-black;
+ color: $color-white;
+}
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/05_modules/_product-manager.scss b/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/05_modules/_product-manager.scss
new file mode 100644
index 0000000..4f3ed4d
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/05_modules/_product-manager.scss
@@ -0,0 +1,47 @@
+.product-manager {
+ .nav-products-actions {
+ ul {
+ padding: 0;
+ margin: 0;
+ list-style: none;
+ display: flex;
+ justify-content: flex-end;
+
+ li {
+ button {
+ margin: 0.5rem 0 0.5rem 0.5rem;
+ }
+ }
+ }
+ }
+
+ table {
+ .th-actions {
+ width: 200px;
+ }
+ .th-dragger {
+ width: 50px;
+ }
+
+ td {
+ vertical-align: middle;
+ button {
+ margin: 0 2px;
+ }
+ }
+
+ tbody:empty::after {
+ content: 'loading products...';
+ width: max-content;
+ display: inline-block;
+ color: #999;
+ padding: 1rem;
+ }
+ tfoot .row > * {
+ margin-bottom: 0.5rem;
+ @media (min-width: 992px) {
+ margin-bottom: 0;
+ }
+ }
+ }
+}
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/main.scss b/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/main.scss
new file mode 100644
index 0000000..297e74f
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/src/styles/main.scss
@@ -0,0 +1,13 @@
+// Single stylesheet entry, linked in src/html/includes/head.html.
+// Same folders as in frontend-gulp; @use instead of the deprecated @import.
+
+// 01 Frameworks
+@use '01_frameworks' as *;
+// 02 Fonts
+@use '02_fonts' as *;
+// 03 Base
+@use '03_base' as *;
+// 04 Plugins
+@use '04_plugins' as *;
+// 05 Modules
+@use '05_modules' as *;
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/src/utils/validators/validate.js b/06_react/unterricht/tag38/02_react-api-with-hooks/src/utils/validators/validate.js
new file mode 100644
index 0000000..9d778df
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/src/utils/validators/validate.js
@@ -0,0 +1,39 @@
+import isEmail from 'validator/lib/isEmail';
+import isLength from 'validator/lib/isLength';
+import isEmpty from 'validator/lib/isEmpty';
+
+const validate = (formData) => {
+ const errors = {};
+ const firstname = formData.firstname.trim();
+ const lastname = formData.lastname.trim();
+ const email = formData.email.trim();
+ const message = formData.message.trim();
+
+ if (isEmpty(firstname)) {
+ errors.firstname = 'Firstname is required';
+ } else if (!isLength(firstname, { min: 2, max: 50 })) {
+ errors.firstname = 'Firstname must be between 2 and 50 characters';
+ }
+
+ if (isEmpty(lastname)) {
+ errors.lastname = 'Lastname is required';
+ } else if (!isLength(lastname, { min: 2, max: 60 })) {
+ errors.lastname = 'Lastname must be between 2 and 60 characters';
+ }
+
+ if (isEmpty(email)) {
+ errors.email = 'Email is required';
+ } else if (!isEmail(email)) {
+ errors.email = 'Email is invalid';
+ }
+
+ if (isEmpty(message)) {
+ errors.message = 'Message is required';
+ } else if (!isLength(message, { min: 10, max: 1000 })) {
+ errors.message = 'Message must be between 10 and 1000 characters';
+ }
+
+ return errors;
+};
+
+export default validate;
diff --git a/06_react/unterricht/tag38/02_react-api-with-hooks/vite.config.js b/06_react/unterricht/tag38/02_react-api-with-hooks/vite.config.js
new file mode 100644
index 0000000..24ad103
--- /dev/null
+++ b/06_react/unterricht/tag38/02_react-api-with-hooks/vite.config.js
@@ -0,0 +1,15 @@
+import react from '@vitejs/plugin-react';
+import { defineConfig } from 'vite';
+
+// https://vite.dev/config/
+export default defineConfig({
+ plugins: [react()],
+ css: {
+ preprocessorOptions: {
+ scss: {
+ quietDeps: true, // Unix
+ silenceDeprecations: ['color-functions', 'global-builtin', 'import', 'legacy-js-api', 'if-function'],
+ },
+ },
+ },
+});