diff --git a/06_react/linksammlung.md b/06_react/linksammlung.md
index 0647c9c..24182d4 100644
--- a/06_react/linksammlung.md
+++ b/06_react/linksammlung.md
@@ -89,4 +89,11 @@
- Tipp10
-https://stackshare.io/stacks
\ No newline at end of file
+https://stackshare.io/stacks
+
+
+## Loader
+
+- https://css-loaders.com/3d/
+- https://cssloaders.github.io/#text
+- https://loading.io/
\ No newline at end of file
diff --git a/06_react/unterricht/tag39/01_react-api-with-hooks/.gitignore b/06_react/unterricht/tag39/01_react-api-with-hooks/.gitignore
new file mode 100644
index 0000000..a547bf3
--- /dev/null
+++ b/06_react/unterricht/tag39/01_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/tag39/01_react-api-with-hooks/.oxlintrc.json b/06_react/unterricht/tag39/01_react-api-with-hooks/.oxlintrc.json
new file mode 100644
index 0000000..1255078
--- /dev/null
+++ b/06_react/unterricht/tag39/01_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/tag39/01_react-api-with-hooks/README.md b/06_react/unterricht/tag39/01_react-api-with-hooks/README.md
new file mode 100644
index 0000000..d937833
--- /dev/null
+++ b/06_react/unterricht/tag39/01_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/tag39/01_react-api-with-hooks/index.html b/06_react/unterricht/tag39/01_react-api-with-hooks/index.html
new file mode 100644
index 0000000..2ea4e09
--- /dev/null
+++ b/06_react/unterricht/tag39/01_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-lock.json b/06_react/unterricht/tag39/01_react-api-with-hooks/package-lock.json
similarity index 100%
rename from 06_react/unterricht/tag38/02_react-api-with-hooks/package-lock.json
rename to 06_react/unterricht/tag39/01_react-api-with-hooks/package-lock.json
diff --git a/06_react/unterricht/tag39/01_react-api-with-hooks/package.json b/06_react/unterricht/tag39/01_react-api-with-hooks/package.json
new file mode 100644
index 0000000..afa00c4
--- /dev/null
+++ b/06_react/unterricht/tag39/01_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.1",
+ "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.107.0",
+ "vite": "^8.3.5"
+ }
+}
diff --git a/06_react/unterricht/tag39/01_react-api-with-hooks/public/favicon.svg b/06_react/unterricht/tag39/01_react-api-with-hooks/public/favicon.svg
new file mode 100644
index 0000000..6893eb1
--- /dev/null
+++ b/06_react/unterricht/tag39/01_react-api-with-hooks/public/favicon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/06_react/unterricht/tag39/01_react-api-with-hooks/public/icons.svg b/06_react/unterricht/tag39/01_react-api-with-hooks/public/icons.svg
new file mode 100644
index 0000000..e952219
--- /dev/null
+++ b/06_react/unterricht/tag39/01_react-api-with-hooks/public/icons.svg
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/06_react/unterricht/tag39/01_react-api-with-hooks/src/App.jsx b/06_react/unterricht/tag39/01_react-api-with-hooks/src/App.jsx
new file mode 100644
index 0000000..fe1cec5
--- /dev/null
+++ b/06_react/unterricht/tag39/01_react-api-with-hooks/src/App.jsx
@@ -0,0 +1,19 @@
+import Navbar from './components/navs/Navbar';
+import Router from './routes/Router';
+import HeaderMain from './components/headers/HeaderMain';
+import FooterMain from './components/footers/FooterMain';
+
+function App() {
+ return (
+ <>
+
+
+
+
+
+
+ >
+ );
+}
+
+export default App;
diff --git a/06_react/unterricht/tag39/01_react-api-with-hooks/src/assets/react.svg b/06_react/unterricht/tag39/01_react-api-with-hooks/src/assets/react.svg
new file mode 100644
index 0000000..6c87de9
--- /dev/null
+++ b/06_react/unterricht/tag39/01_react-api-with-hooks/src/assets/react.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/06_react/unterricht/tag39/01_react-api-with-hooks/src/assets/vite.svg b/06_react/unterricht/tag39/01_react-api-with-hooks/src/assets/vite.svg
new file mode 100644
index 0000000..5101b67
--- /dev/null
+++ b/06_react/unterricht/tag39/01_react-api-with-hooks/src/assets/vite.svg
@@ -0,0 +1 @@
+Vite
diff --git a/06_react/unterricht/tag39/01_react-api-with-hooks/src/components/common/Badge.jsx b/06_react/unterricht/tag39/01_react-api-with-hooks/src/components/common/Badge.jsx
new file mode 100644
index 0000000..df1b229
--- /dev/null
+++ b/06_react/unterricht/tag39/01_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/tag39/01_react-api-with-hooks/src/components/examples/ExampleUseContext.jsx b/06_react/unterricht/tag39/01_react-api-with-hooks/src/components/examples/ExampleUseContext.jsx
new file mode 100644
index 0000000..66c0a02
--- /dev/null
+++ b/06_react/unterricht/tag39/01_react-api-with-hooks/src/components/examples/ExampleUseContext.jsx
@@ -0,0 +1,18 @@
+import { useContext } from 'react';
+import ThemeContext from './theme-switcher/ThemeContext';
+
+const ExampleUseContext = (props) => {
+ // const { } = props;
+
+ const theme = useContext(ThemeContext);
+
+ return (
+
+
useContext
+
+ The theme of this component is: {theme}
+
+
+ );
+};
+export default ExampleUseContext;
diff --git a/06_react/unterricht/tag39/01_react-api-with-hooks/src/components/examples/ExampleUseEffectDidUpdate.jsx b/06_react/unterricht/tag39/01_react-api-with-hooks/src/components/examples/ExampleUseEffectDidUpdate.jsx
new file mode 100644
index 0000000..1250041
--- /dev/null
+++ b/06_react/unterricht/tag39/01_react-api-with-hooks/src/components/examples/ExampleUseEffectDidUpdate.jsx
@@ -0,0 +1,30 @@
+import { useState, useEffect } from 'react';
+import { FaPlus } from 'react-icons/fa6';
+const ExampleUseEffectDidUpdate = (props) => {
+ // const { } = props;
+
+ const [value, setValue] = useState(0);
+
+ useEffect(() => {
+ // mount
+ console.log('first render and rerender: ', value);
+
+ return () => {
+ // unmount
+ };
+ }, [value]); // <-- wenn state sich ändert, wird useEffect erneut ausgeführt (did update)
+
+ return (
+
+
The value is: {value}
+
setValue(value + 1)}>
+ Count Up
+
+
+ );
+};
+export default ExampleUseEffectDidUpdate;
diff --git a/06_react/unterricht/tag39/01_react-api-with-hooks/src/components/examples/ExampleUseEffectMount.jsx b/06_react/unterricht/tag39/01_react-api-with-hooks/src/components/examples/ExampleUseEffectMount.jsx
new file mode 100644
index 0000000..80bd65c
--- /dev/null
+++ b/06_react/unterricht/tag39/01_react-api-with-hooks/src/components/examples/ExampleUseEffectMount.jsx
@@ -0,0 +1,27 @@
+import { useEffect, useId } from 'react';
+
+const ExampleUseEffectMount = (props) => {
+ // const { } = props;
+
+ const id = useId();
+
+ console.log(document.getElementById(`box${id}`)); // null
+ useEffect(() => {
+ //mount
+ console.log(document.getElementById(`box${id}`)); //
// JSX Element liegt als gerendertes HTML vor.
+ const box = document.getElementById(`box${id}`);
+
+ box.style.color = 'white';
+ box.style.backgroundColor = '#2a2a2a';
+ box.style.padding = '1rem';
+ }, []);
+
+ return (
+
+ );
+};
+export default ExampleUseEffectMount;
diff --git a/06_react/unterricht/tag39/01_react-api-with-hooks/src/components/examples/ExampleUseEffectUnMount.jsx b/06_react/unterricht/tag39/01_react-api-with-hooks/src/components/examples/ExampleUseEffectUnMount.jsx
new file mode 100644
index 0000000..112a639
--- /dev/null
+++ b/06_react/unterricht/tag39/01_react-api-with-hooks/src/components/examples/ExampleUseEffectUnMount.jsx
@@ -0,0 +1,36 @@
+import { useState, useEffect } from 'react';
+
+const ExampleUseEffectUnMount = (props) => {
+ // const { } = props;
+
+ const [timer, setTimer] = useState(0);
+
+ useEffect(() => {
+ // mount
+ const interval = setInterval(() => {
+ setTimer((time) => time + 1);
+ }, 1000);
+
+ window.addEventListener('resize', handleResize);
+
+ return () => {
+ // cleanup - unmount
+ clearInterval(interval);
+ window.removeEventListener('resize', handleResize);
+ };
+ }, []); // didupdate
+
+ // EventHandler
+ const handleResize = (e) => {
+ // console.log('Event: ', e);
+ const { x, y, width: w, height: h } = document.body.getBoundingClientRect(); // window.outerWidth,
+ console.log({ x, y, w: parseInt(w), h: parseInt(h) });
+ };
+
+ return (
+
+ );
+};
+export default ExampleUseEffectUnMount;
diff --git a/06_react/unterricht/tag39/01_react-api-with-hooks/src/components/examples/ExampleUseId.jsx b/06_react/unterricht/tag39/01_react-api-with-hooks/src/components/examples/ExampleUseId.jsx
new file mode 100644
index 0000000..90ec7f8
--- /dev/null
+++ b/06_react/unterricht/tag39/01_react-api-with-hooks/src/components/examples/ExampleUseId.jsx
@@ -0,0 +1,21 @@
+import { useId } from 'react';
+// import { v4 as uid} from 'uuid';
+const ExampleUseId = (props) => {
+ // const { } = props;
+
+ const id = useId(); // _r_1
+ // const id = uid(); // von uuid ⇨ 'b18794e8-5d0d-417c-b361-ba38e78411b4'
+
+ return (
+
+
+ ID: {id}
+
+
+ Datum:
+
+
+
+ );
+};
+export default ExampleUseId;
diff --git a/06_react/unterricht/tag39/01_react-api-with-hooks/src/components/examples/ExampleUseReducer.jsx b/06_react/unterricht/tag39/01_react-api-with-hooks/src/components/examples/ExampleUseReducer.jsx
new file mode 100644
index 0000000..d44cb1a
--- /dev/null
+++ b/06_react/unterricht/tag39/01_react-api-with-hooks/src/components/examples/ExampleUseReducer.jsx
@@ -0,0 +1,77 @@
+import { useReducer } from 'react';
+import { FaPlus, FaMinus } from 'react-icons/fa6';
+
+const initialState = {
+ counter: 0,
+ // more props....
+};
+
+// Reducer Funktion verwaltet das state Objekt. Über diese Funktion lässt sich das "komplexere" state-Objekt gezielt verändern.
+function reducer(state, action) {
+ // action Objekt hat immer folgende Eigenschaften: {type: 'ACTION_NAME', payload: changeValue }
+ const { type, payload = 1 } = action;
+
+ switch (type) {
+ case 'INCREASE':
+ return { ...state, counter: state.counter + 1 };
+
+ case 'DECREASE':
+ return { ...state, counter: state.counter - 1 };
+ case 'DOUBLE':
+ return { ...state, counter: state.counter * 2 };
+ case 'INC_VALUE':
+ return { ...state, counter: state.counter + payload };
+ default:
+ console.warn('Action Type not found');
+ return state;
+ }
+}
+
+const ExampleUseReducer = (props) => {
+ // const { } = props;
+
+ // Hooks - Top Level
+ // const [counter, setCounter] = useState(0);
+ const [state, dispatch] = useReducer(reducer, initialState);
+
+ const handleClick = (e) => {
+ const btn = e.currentTarget;
+ const label = btn.ariaLabel; // btn.getAttribute('aria-label')
+ switch (label.toLowerCase()) {
+ case 'increase':
+ dispatch({ type: 'INCREASE' });
+ break;
+ case 'decrease':
+ dispatch({ type: 'DECREASE' });
+ break;
+ case 'double':
+ dispatch({ type: 'DOUBLE' });
+ break;
+ default: //nothing
+ }
+ };
+
+ const handleClickIncValue = (e) => {
+ dispatch({ type: 'INC_VALUE', payload: 5 });
+ };
+
+ return (
+
+ Counter: {state.counter}
+
+
+
+
+
+
+
+
+
+
+
+ 5
+
+
+ );
+};
+export default ExampleUseReducer;
diff --git a/06_react/unterricht/tag39/01_react-api-with-hooks/src/components/examples/ExampleUseState.jsx b/06_react/unterricht/tag39/01_react-api-with-hooks/src/components/examples/ExampleUseState.jsx
new file mode 100644
index 0000000..1a93578
--- /dev/null
+++ b/06_react/unterricht/tag39/01_react-api-with-hooks/src/components/examples/ExampleUseState.jsx
@@ -0,0 +1,36 @@
+import { useState } from 'react';
+import { FaPlus, FaMinus } from 'react-icons/fa6';
+
+const ExampleUseState = (props) => {
+ // const { } = props;
+ const [counter, setCounter] = useState(0);
+
+ const handleClick = (e) => {
+ const btn = e.currentTarget;
+ const label = btn.ariaLabel; // btn.getAttribute('aria-label')
+ switch (label.toLowerCase()) {
+ case 'increase':
+ setCounter(counter + 1);
+ break;
+ case 'decrease':
+ setCounter((prevState) => {
+ return prevState - 1;
+ });
+ break;
+ default: //nothing
+ }
+ };
+
+ return (
+
+ Counter: {counter}
+
+
+
+
+
+
+
+ );
+};
+export default ExampleUseState;
diff --git a/06_react/unterricht/tag39/01_react-api-with-hooks/src/components/examples/theme-switcher/ThemeContext.jsx b/06_react/unterricht/tag39/01_react-api-with-hooks/src/components/examples/theme-switcher/ThemeContext.jsx
new file mode 100644
index 0000000..dda5e98
--- /dev/null
+++ b/06_react/unterricht/tag39/01_react-api-with-hooks/src/components/examples/theme-switcher/ThemeContext.jsx
@@ -0,0 +1,8 @@
+import { createContext } from 'react';
+
+export const THEME_DEFAULT_VALUE = 'light';
+
+const ThemeContext = createContext(THEME_DEFAULT_VALUE);
+// createContext -> JSX-Element mit value Attribut
+// ...
+export default ThemeContext;
diff --git a/06_react/unterricht/tag39/01_react-api-with-hooks/src/components/examples/theme-switcher/ThemeProvider.jsx b/06_react/unterricht/tag39/01_react-api-with-hooks/src/components/examples/theme-switcher/ThemeProvider.jsx
new file mode 100644
index 0000000..fa141c5
--- /dev/null
+++ b/06_react/unterricht/tag39/01_react-api-with-hooks/src/components/examples/theme-switcher/ThemeProvider.jsx
@@ -0,0 +1,25 @@
+import { useState } from 'react';
+import ThemeContext, { THEME_DEFAULT_VALUE } from './ThemeContext';
+
+const ThemeProvider = (props) => {
+ const { children } = props;
+
+ console.log(ThemeContext._currentValue); // 'light' <- _currentValue beinhaltet initialWert. Soll aber in production Umgebung nicht verwendet werden, da kein offizieller Standard.
+
+ const [theme, setTheme] = useState(THEME_DEFAULT_VALUE);
+
+ const handleToggleTheme = (e) => {
+ console.log('toggle theme');
+ setTheme((prevState) => (prevState === 'light' ? 'dark' : 'light'));
+ };
+
+ return (
+
+
+ Toggle Theme
+
+ {children}
+
+ );
+};
+export default ThemeProvider;
diff --git a/06_react/unterricht/tag39/01_react-api-with-hooks/src/components/footers/FooterMain.jsx b/06_react/unterricht/tag39/01_react-api-with-hooks/src/components/footers/FooterMain.jsx
new file mode 100644
index 0000000..5006bda
--- /dev/null
+++ b/06_react/unterricht/tag39/01_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/tag39/01_react-api-with-hooks/src/components/forms/FormContact.jsx b/06_react/unterricht/tag39/01_react-api-with-hooks/src/components/forms/FormContact.jsx
new file mode 100644
index 0000000..9f2d181
--- /dev/null
+++ b/06_react/unterricht/tag39/01_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 (
+
+ );
+};
+export default FormContact;
diff --git a/06_react/unterricht/tag39/01_react-api-with-hooks/src/components/forms/FormNewsletter.jsx b/06_react/unterricht/tag39/01_react-api-with-hooks/src/components/forms/FormNewsletter.jsx
new file mode 100644
index 0000000..d8c7ec7
--- /dev/null
+++ b/06_react/unterricht/tag39/01_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/tag39/01_react-api-with-hooks/src/components/forms/joi/FormContact.jsx b/06_react/unterricht/tag39/01_react-api-with-hooks/src/components/forms/joi/FormContact.jsx
new file mode 100644
index 0000000..2e17e5c
--- /dev/null
+++ b/06_react/unterricht/tag39/01_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/tag39/01_react-api-with-hooks/src/components/forms/joi/validate.js b/06_react/unterricht/tag39/01_react-api-with-hooks/src/components/forms/joi/validate.js
new file mode 100644
index 0000000..e1d9df0
--- /dev/null
+++ b/06_react/unterricht/tag39/01_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/tag39/01_react-api-with-hooks/src/components/forms/react-hook-form/FormContact.jsx b/06_react/unterricht/tag39/01_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/tag39/01_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/tag39/01_react-api-with-hooks/src/components/headers/HeaderMain.jsx b/06_react/unterricht/tag39/01_react-api-with-hooks/src/components/headers/HeaderMain.jsx
new file mode 100644
index 0000000..acbddb2
--- /dev/null
+++ b/06_react/unterricht/tag39/01_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/tag39/01_react-api-with-hooks/src/components/icons/Logo.jsx b/06_react/unterricht/tag39/01_react-api-with-hooks/src/components/icons/Logo.jsx
new file mode 100644
index 0000000..dd3b6f0
--- /dev/null
+++ b/06_react/unterricht/tag39/01_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/tag39/01_react-api-with-hooks/src/components/inputs/InputAmount.jsx b/06_react/unterricht/tag39/01_react-api-with-hooks/src/components/inputs/InputAmount.jsx
new file mode 100644
index 0000000..d6ac3f5
--- /dev/null
+++ b/06_react/unterricht/tag39/01_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/tag39/01_react-api-with-hooks/src/components/lists/ListTodo.jsx b/06_react/unterricht/tag39/01_react-api-with-hooks/src/components/lists/ListTodo.jsx
new file mode 100644
index 0000000..9663ed6
--- /dev/null
+++ b/06_react/unterricht/tag39/01_react-api-with-hooks/src/components/lists/ListTodo.jsx
@@ -0,0 +1,84 @@
+import { useEffect, useState } from 'react';
+
+const ListTodo = (props) => {
+ // const { } = props;
+
+ const [todos, setTodos] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ // AbortController instanziieren (seit mid 2018)
+ const controller = new AbortController();
+
+ setIsLoading(true);
+ setError(null);
+
+ fetch('https://dummyjson.com/todos/random/6', {
+ signal: controller.signal, // signal mitsenden
+ })
+ .then((res) => {
+ if (!res.ok) {
+ throw new Error('HTTP-Request went wrong');
+ }
+ return res.json(); // -> Promise HTTP-Response->Body
+ })
+ .then((data) => {
+ setTodos(data);
+ })
+ .catch((err) => {
+ console.log(err);
+ if (err?.name === 'AbortError') {
+ console.error('Fetch wurde abgebrochen.');
+ } else if (err instanceof Error) {
+ console.error(err.message);
+ setError(err);
+ }
+ })
+ .finally(() => {
+ if (!controller.signal.aborted) {
+ setIsLoading(false);
+ }
+ });
+
+ return () => {
+ // cleanup
+ controller.abort(); // The abort() method of the AbortController interface aborts an asynchronous operation before it has completed.
+ };
+ }, []);
+
+ return (
+ <>
+ {isLoading ? (
+
+ ) : (
+
+ )}
+ {error && (
+
+ )}
+ >
+ );
+};
+export default ListTodo;
diff --git a/06_react/unterricht/tag39/01_react-api-with-hooks/src/components/lists/ListUser.jsx b/06_react/unterricht/tag39/01_react-api-with-hooks/src/components/lists/ListUser.jsx
new file mode 100644
index 0000000..ceb3878
--- /dev/null
+++ b/06_react/unterricht/tag39/01_react-api-with-hooks/src/components/lists/ListUser.jsx
@@ -0,0 +1,53 @@
+import { useEffect, useState } from 'react';
+import axios from 'axios';
+
+const ListUser = (props) => {
+ // const { } = props;
+
+ const [users, setUsers] = useState([]);
+
+ useEffect(() => {
+ const fetchUsers = async () => {
+ try {
+ const { data } = await axios.get('https://dummyjson.com/users?limit=5&select=firstName,lastName,age,email');
+
+ if (!data) throw Error('Something went wrong');
+ setUsers(data.users);
+ } catch (error) {
+ console.error(error);
+ throw Error(`Error: ${error}`);
+ }
+ };
+
+ fetchUsers();
+
+ // axios
+ // .get('https://dummyjson.com/users?limit=10&select=firstName,lastName,age,email')
+ // .then(({ data }) => {
+ // setUsers(data.users);
+ // })
+ // .catch((err) => {
+ // console.error(err);
+ // });
+
+ return () => {};
+ }, []);
+
+ return (
+
+ );
+};
+export default ListUser;
diff --git a/06_react/unterricht/tag39/01_react-api-with-hooks/src/components/logos/LogoMain.jsx b/06_react/unterricht/tag39/01_react-api-with-hooks/src/components/logos/LogoMain.jsx
new file mode 100644
index 0000000..2649b9e
--- /dev/null
+++ b/06_react/unterricht/tag39/01_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/tag39/01_react-api-with-hooks/src/components/navs/Navbar.jsx b/06_react/unterricht/tag39/01_react-api-with-hooks/src/components/navs/Navbar.jsx
new file mode 100644
index 0000000..1dc0796
--- /dev/null
+++ b/06_react/unterricht/tag39/01_react-api-with-hooks/src/components/navs/Navbar.jsx
@@ -0,0 +1,110 @@
+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 active' //
+ : 'nav-link'
+ }
+ to="/api">
+ APIs
+
+
+
+
+
+
+ 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/tag39/01_react-api-with-hooks/src/layouts/Layout.jsx b/06_react/unterricht/tag39/01_react-api-with-hooks/src/layouts/Layout.jsx
new file mode 100644
index 0000000..8b55dbf
--- /dev/null
+++ b/06_react/unterricht/tag39/01_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/tag39/01_react-api-with-hooks/src/main.jsx b/06_react/unterricht/tag39/01_react-api-with-hooks/src/main.jsx
new file mode 100644
index 0000000..672352b
--- /dev/null
+++ b/06_react/unterricht/tag39/01_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/tag39/01_react-api-with-hooks/src/pages/AboutPage.jsx b/06_react/unterricht/tag39/01_react-api-with-hooks/src/pages/AboutPage.jsx
new file mode 100644
index 0000000..8112904
--- /dev/null
+++ b/06_react/unterricht/tag39/01_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/tag39/01_react-api-with-hooks/src/pages/ApiPage.jsx b/06_react/unterricht/tag39/01_react-api-with-hooks/src/pages/ApiPage.jsx
new file mode 100644
index 0000000..2715797
--- /dev/null
+++ b/06_react/unterricht/tag39/01_react-api-with-hooks/src/pages/ApiPage.jsx
@@ -0,0 +1,20 @@
+import ListTodo from '../components/lists/ListTodo';
+import ListUser from '../components/lists/ListUser';
+
+const ApiPage = (props) => {
+ // const { } = props;
+
+ return (
+
+
+
APIs auslesen mit React
+ Users
+
+
+ Todos
+
+
+
+ );
+};
+export default ApiPage;
diff --git a/06_react/unterricht/tag39/01_react-api-with-hooks/src/pages/ContactPage.jsx b/06_react/unterricht/tag39/01_react-api-with-hooks/src/pages/ContactPage.jsx
new file mode 100644
index 0000000..ca4e431
--- /dev/null
+++ b/06_react/unterricht/tag39/01_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/tag39/01_react-api-with-hooks/src/pages/HomePage.jsx b/06_react/unterricht/tag39/01_react-api-with-hooks/src/pages/HomePage.jsx
new file mode 100644
index 0000000..b9d1853
--- /dev/null
+++ b/06_react/unterricht/tag39/01_react-api-with-hooks/src/pages/HomePage.jsx
@@ -0,0 +1,34 @@
+import ExampleUseEffectDidUpdate from '../components/examples/ExampleUseEffectDidUpdate';
+import ExampleUseEffectMount from '../components/examples/ExampleUseEffectMount';
+import ExampleUseEffectUnMount from '../components/examples/ExampleUseEffectUnMount';
+
+function HomePage() {
+ return (
+
+
Example Hooks
+
+
Hooks-Regeln
+
Es gibt zwei wichtige Regeln für die Verwendung von Hooks:
+
+ Hooks sollten nur in Funktionskomponenten oder anderen Hooks verwendet werden.
+
+ Hooks sollten immer oben in der Komponente verwendet werden, nicht in Schleifen, Bedingungen oder
+ verschachtelten Funktionen.
+
+
+
+
+
Example useEffect (mount)
+
+
+
+
Example useEffect (did update)
+
+
+
Example useEffect (unmount)
+
+
+ );
+}
+
+export default HomePage;
diff --git a/06_react/unterricht/tag39/01_react-api-with-hooks/src/pages/UserPage.jsx b/06_react/unterricht/tag39/01_react-api-with-hooks/src/pages/UserPage.jsx
new file mode 100644
index 0000000..3d35b61
--- /dev/null
+++ b/06_react/unterricht/tag39/01_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/tag39/01_react-api-with-hooks/src/pages/UserProfilePage.jsx b/06_react/unterricht/tag39/01_react-api-with-hooks/src/pages/UserProfilePage.jsx
new file mode 100644
index 0000000..053f8ce
--- /dev/null
+++ b/06_react/unterricht/tag39/01_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/tag39/01_react-api-with-hooks/src/routes/Router.jsx b/06_react/unterricht/tag39/01_react-api-with-hooks/src/routes/Router.jsx
new file mode 100644
index 0000000..2e86b9b
--- /dev/null
+++ b/06_react/unterricht/tag39/01_react-api-with-hooks/src/routes/Router.jsx
@@ -0,0 +1,79 @@
+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 ApiPage from '../pages/ApiPage';
+
+// 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/tag39/01_react-api-with-hooks/src/styles/01_frameworks/_index.scss b/06_react/unterricht/tag39/01_react-api-with-hooks/src/styles/01_frameworks/_index.scss
new file mode 100644
index 0000000..42edceb
--- /dev/null
+++ b/06_react/unterricht/tag39/01_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/tag39/01_react-api-with-hooks/src/styles/02_fonts/_gloria-hallelujah-v24-latin.scss b/06_react/unterricht/tag39/01_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/tag39/01_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/tag39/01_react-api-with-hooks/src/styles/02_fonts/_index.scss b/06_react/unterricht/tag39/01_react-api-with-hooks/src/styles/02_fonts/_index.scss
new file mode 100644
index 0000000..d377888
--- /dev/null
+++ b/06_react/unterricht/tag39/01_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/tag39/01_react-api-with-hooks/src/styles/02_fonts/_lato-v25-latin.scss b/06_react/unterricht/tag39/01_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/tag39/01_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/tag39/01_react-api-with-hooks/src/styles/02_fonts/_source-sans-3-v19-latin.scss b/06_react/unterricht/tag39/01_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/tag39/01_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/tag39/01_react-api-with-hooks/src/styles/03_base/_colors.scss b/06_react/unterricht/tag39/01_react-api-with-hooks/src/styles/03_base/_colors.scss
new file mode 100644
index 0000000..a550f3d
--- /dev/null
+++ b/06_react/unterricht/tag39/01_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/tag39/01_react-api-with-hooks/src/styles/03_base/_index.scss b/06_react/unterricht/tag39/01_react-api-with-hooks/src/styles/03_base/_index.scss
new file mode 100644
index 0000000..aaf4e12
--- /dev/null
+++ b/06_react/unterricht/tag39/01_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/tag39/01_react-api-with-hooks/src/styles/03_base/_theme.scss b/06_react/unterricht/tag39/01_react-api-with-hooks/src/styles/03_base/_theme.scss
new file mode 100644
index 0000000..26b9863
--- /dev/null
+++ b/06_react/unterricht/tag39/01_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/tag39/01_react-api-with-hooks/src/styles/03_base/_typography.scss b/06_react/unterricht/tag39/01_react-api-with-hooks/src/styles/03_base/_typography.scss
new file mode 100644
index 0000000..7c9b095
--- /dev/null
+++ b/06_react/unterricht/tag39/01_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/tag39/01_react-api-with-hooks/src/styles/04_plugins/_index.scss b/06_react/unterricht/tag39/01_react-api-with-hooks/src/styles/04_plugins/_index.scss
new file mode 100644
index 0000000..bd5ba6a
--- /dev/null
+++ b/06_react/unterricht/tag39/01_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/tag39/01_react-api-with-hooks/src/styles/04_plugins/toastify-1.12/_toastify.scss b/06_react/unterricht/tag39/01_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/tag39/01_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/tag39/01_react-api-with-hooks/src/styles/05_modules/_button.scss b/06_react/unterricht/tag39/01_react-api-with-hooks/src/styles/05_modules/_button.scss
new file mode 100644
index 0000000..1660f6b
--- /dev/null
+++ b/06_react/unterricht/tag39/01_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/tag39/01_react-api-with-hooks/src/styles/05_modules/_chuck-norris-joke.scss b/06_react/unterricht/tag39/01_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/tag39/01_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/tag39/01_react-api-with-hooks/src/styles/05_modules/_footerMain.scss b/06_react/unterricht/tag39/01_react-api-with-hooks/src/styles/05_modules/_footerMain.scss
new file mode 100644
index 0000000..fd73f7c
--- /dev/null
+++ b/06_react/unterricht/tag39/01_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/tag39/01_react-api-with-hooks/src/styles/05_modules/_header-main.scss b/06_react/unterricht/tag39/01_react-api-with-hooks/src/styles/05_modules/_header-main.scss
new file mode 100644
index 0000000..2884477
--- /dev/null
+++ b/06_react/unterricht/tag39/01_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/tag39/01_react-api-with-hooks/src/styles/05_modules/_index.scss b/06_react/unterricht/tag39/01_react-api-with-hooks/src/styles/05_modules/_index.scss
new file mode 100644
index 0000000..2c015ec
--- /dev/null
+++ b/06_react/unterricht/tag39/01_react-api-with-hooks/src/styles/05_modules/_index.scss
@@ -0,0 +1,9 @@
+@forward 'button';
+// @forward 'chuck-norris-joke';
+@forward 'footerMain';
+@forward 'header-main';
+@forward 'input-amount';
+@forward 'logo-main';
+@forward 'loader';
+// @forward 'menu-main';
+// @forward 'product-manager';
diff --git a/06_react/unterricht/tag39/01_react-api-with-hooks/src/styles/05_modules/_input-amount.scss b/06_react/unterricht/tag39/01_react-api-with-hooks/src/styles/05_modules/_input-amount.scss
new file mode 100644
index 0000000..8701a7c
--- /dev/null
+++ b/06_react/unterricht/tag39/01_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/tag39/01_react-api-with-hooks/src/styles/05_modules/_loader.scss b/06_react/unterricht/tag39/01_react-api-with-hooks/src/styles/05_modules/_loader.scss
new file mode 100644
index 0000000..ad01c25
--- /dev/null
+++ b/06_react/unterricht/tag39/01_react-api-with-hooks/src/styles/05_modules/_loader.scss
@@ -0,0 +1,22 @@
+.loader {
+ --color-1: #478e9e;
+ --size: 1px;
+ width: calc(32 * var(--size));
+ height: calc(32 * var(--size));
+ border: calc(5 * var(--size)) solid var(--color-1);
+ border-bottom-color: transparent;
+ border-radius: 50%;
+ display: inline-block;
+ box-sizing: border-box;
+ animation: rotation 1s linear infinite;
+ margin: 0.5rem;
+}
+
+@keyframes rotation {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+}
diff --git a/06_react/unterricht/tag39/01_react-api-with-hooks/src/styles/05_modules/_logo-main.scss b/06_react/unterricht/tag39/01_react-api-with-hooks/src/styles/05_modules/_logo-main.scss
new file mode 100644
index 0000000..399bd12
--- /dev/null
+++ b/06_react/unterricht/tag39/01_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/tag39/01_react-api-with-hooks/src/styles/05_modules/_menu-main.scss b/06_react/unterricht/tag39/01_react-api-with-hooks/src/styles/05_modules/_menu-main.scss
new file mode 100644
index 0000000..df4627e
--- /dev/null
+++ b/06_react/unterricht/tag39/01_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/tag39/01_react-api-with-hooks/src/styles/05_modules/_product-manager.scss b/06_react/unterricht/tag39/01_react-api-with-hooks/src/styles/05_modules/_product-manager.scss
new file mode 100644
index 0000000..4f3ed4d
--- /dev/null
+++ b/06_react/unterricht/tag39/01_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/tag39/01_react-api-with-hooks/src/styles/main.scss b/06_react/unterricht/tag39/01_react-api-with-hooks/src/styles/main.scss
new file mode 100644
index 0000000..297e74f
--- /dev/null
+++ b/06_react/unterricht/tag39/01_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/tag39/01_react-api-with-hooks/src/utils/validators/validate.js b/06_react/unterricht/tag39/01_react-api-with-hooks/src/utils/validators/validate.js
new file mode 100644
index 0000000..9d778df
--- /dev/null
+++ b/06_react/unterricht/tag39/01_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/tag39/01_react-api-with-hooks/vite.config.js b/06_react/unterricht/tag39/01_react-api-with-hooks/vite.config.js
new file mode 100644
index 0000000..24ad103
--- /dev/null
+++ b/06_react/unterricht/tag39/01_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'],
+ },
+ },
+ },
+});