add: webseite mit jwt

This commit is contained in:
Philippe Torrel
2026-09-01 12:05:06 +02:00
parent 2aef8b91bd
commit c1774746c9
311 changed files with 34382 additions and 1 deletions

BIN
webseite-react-php-jwt.zip Normal file

Binary file not shown.

View File

@@ -0,0 +1,8 @@
{
"workbench.colorCustomizations": {
"titleBar.activeForeground": "#333",
"titleBar.activeBackground": "#86cd8b",
"titleBar.inactiveForeground": "#ddd",
"titleBar.inactiveBackground": "#6fa973"
}
}

View File

@@ -0,0 +1,377 @@
# Installation der Pakete
## Dependencies
```bash
npm install react-router-dom bootstrap uuid axios react-icons
```
## DevDependencies
```bash
npm install sass -D
```
## Configuration von Vite
```js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
css: {
preprocessorOptions: {
scss: {
quietDeps: true, // Unix
silenceDeprecations: [
'color-functions',
'global-builtin',
'import',
'legacy-js-api',
],
},
},
},
});
```
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## 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 ESLint 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 [`typescript-eslint`](https://typescript-eslint.io) in your project.
# Products Store mit useContext und useReducer
`TableProducts` hält den Tabellen-State nicht mehr lokal in der Komponente. Stattdessen gibt es einen ausgelagerten Store aus **Context + Reducer**. So können Kind-Komponenten wie `RowProduct` den State lesen und Aktionen auslösen, ohne Callbacks per Props durchzureichen (kein Prop Drilling).
## Dateistruktur
```
src/stores/products/
initialState.js // Startzustand (Factory)
reducer.js // reine State-Übergänge
ProductsStore.jsx // Context, Provider, Custom Hooks
index.js // öffentliche Exports
```
## Step 1: `initialState` auslagern
Datei: `src/stores/products/initialState.js`
Der Startzustand wird als **Factory-Funktion** geschrieben, nicht als festes Objekt. So kann der Provider die geladenen Produkte (`items`) übergeben, und Validierung bleibt an einer Stelle.
```js
export const createInitialState = (items) => {
return { items: items || [] };
};
```
Später wird sie so verwendet:
```js
useReducer(productsReducer, items, createInitialState);
```
React ruft `createInitialState(items)` einmal beim ersten Render auf (lazy init). Das dritte Argument von `useReducer` ist genau dafür da.
## Step 2: Reducer auslagern
Datei: `src/stores/products/reducer.js`
Der Reducer bleibt eine **reine Funktion**: `(state, action) => newState`. Keine Side Effects außer einem `console.warn` für unbekannte Actions.
Action-Typen liegen in einem Objekt `PRODUCTS_ACTIONS`. Tippfehler fallen so früher auf als bei losen Strings wie `'UPDATE_AMMOUNT'`.
```js
export const PRODUCTS_ACTIONS = {
UPDATE_AMOUNT: 'UPDATE_AMOUNT',
UPDATE_PRICE: 'UPDATE_PRICE',
};
```
Jede Action folgt dem Muster `{ type, payload }`. Der Reducer kopiert den State (`...state`) und ersetzt nur das betroffene Item über `map`. Unveränderte Items behalten dieselbe Referenz das ist wichtig für `memo(RowProduct)`.
## Step 3: Context aufteilen (State und Dispatch)
Datei: `src/stores/products/ProductsStore.jsx`
Best Practice: **zwei Contexts**, nicht einen.
| Context | Inhalt | Wann re-rendern Kind-Komponenten? |
| ------------------------- | ----------- | ------------------------------------- |
| `ProductsStateContext` | `{ items }` | bei jeder State-Änderung |
| `ProductsDispatchContext` | `dispatch` | praktisch nie (`dispatch` ist stabil) |
`RowProduct` braucht nur Aktionen (also `dispatch`). Würde State und Dispatch in einem Value `{ state, dispatch }` stecken, bekäme jede Zeile bei jeder Eingabe ein neues Objekt und würde neu rendern `memo` wäre wirkungslos.
```js
const ProductsStateContext = createContext(null);
const ProductsDispatchContext = createContext(null);
```
`null` als Default ist Absicht: so erkennt der Custom Hook, wenn jemand den Store **außerhalb** des Providers nutzt.
## Step 4: Provider mit `useReducer` bauen
`ProductsProvider` ist die einzige Stelle, an der `useReducer` läuft. Er bekommt `items` (z. B. aus dem Fetch auf der Page) und reicht State sowie `dispatch` nach unten.
```jsx
export const ProductsProvider = (props) => {
const { children, items = [] } = props;
const [state, dispatch] = useReducer(
productsReducer,
items,
createInitialState,
);
return (
<ProductsStateContext.Provider value={state}>
<ProductsDispatchContext.Provider value={dispatch}>
{children}
</ProductsDispatchContext.Provider>
</ProductsStateContext.Provider>
);
};
```
Wichtig: Die Komponente, die `useContext` aufruft, muss ein **Kind** des Providers sein nicht dieselbe Komponente, die den Provider rendert. Deshalb gibt es `TableProducts` (Provider) und `TableProductsView` (Consumer).
## Step 5: Custom Hooks statt nacktem `useContext`
Drei Hooks kapseln den Zugriff und werfen einen Fehler, wenn der Provider fehlt:
1. `useProductsState()` liest `{ items }`
2. `useProductsDispatch()` liefert `dispatch`
3. `useProductsActions()` fertige Callbacks `updateAmount` / `updatePrice`
Komponenten sollen nicht `dispatch({ type: '…' })` selbst zusammenbauen. Die Action-Objekte gehören in den Store, analog zu Redux Action Creators.
```js
const updateAmount = useCallback(
(amount, id) => {
dispatch({ type: PRODUCTS_ACTIONS.UPDATE_AMOUNT, payload: { id, amount } });
},
[dispatch],
);
```
`useCallback` + stabiles `dispatch` hält die Funktionsreferenz gleich. Zusammen mit `memo(RowProduct)` rendern nur Zeilen neu, deren Item-Props sich geändert haben.
## Step 6: Barrel-Export
Datei: `src/stores/products/index.js`
Andere Dateien importieren nur den Ordner, nicht interne Pfade:
```js
import {
ProductsProvider,
useProductsState,
useProductsActions,
} from '../../stores/products';
```
Reducer und `initialState` bleiben intern austauschbar, ohne dass `TableProducts` oder `RowProduct` ihre Imports ändern müssen.
## Step 7: `TableProducts` an den Store anbinden
`TableProducts` wrappt die Ansicht mit dem Provider und übergibt die Props als Initial-Items:
```jsx
const TableProducts = (props) => {
return (
<ProductsProvider items={props.items}>
<TableProductsView />
</ProductsProvider>
);
};
```
`TableProductsView` liest den State und berechnet den Gesamtpreis lokal mit `useMemo`. Der Gesamtpreis ist **abgeleiteter State** er gehört nicht in den Reducer, sondern wird aus `items` berechnet.
Die Handler `onHandleAmount` / `onHandlePrice` entfallen. `RowProduct` holt sie selbst aus dem Store.
## Step 8: Prop Drilling in `RowProduct` entfernen
Vorher: `TableProducts``RowProduct` → Inputs (Callbacks als Props).
Nachher: `RowProduct` ruft `useProductsActions()` auf. Die Zeile bekommt nur noch die Produktdaten als Props (`_id`, `title`, `stock`, `price`, `sku`). Die `_id` bindet den Input-Wert an das richtige Item:
```js
const { updateAmount, updatePrice } = useProductsActions();
const handleAmount = useCallback(
(value) => {
updateAmount(value, _id);
},
[updateAmount, _id],
);
```
Die Produktdaten bleiben Props (nicht aus dem Context gelesen). Würde jede Zeile `items` aus dem State-Context lesen, würden **alle** Zeilen bei jeder Änderung neu rendern.
## Datenfluss
```
TableProductsPage fetch JSON → items
TableProducts ProductsProvider(items)
├── TableProductsView useProductsState() → Tabelle + Total
└── RowProduct useProductsActions() → UPDATE_AMOUNT / UPDATE_PRICE
productsReducer neuer items-Array → Context → UI
```
## Kurz: warum diese Aufteilung?
- **`initialState.js`** Startzustand und Validierung, unabhängig von React
- **`reducer.js`** alle State-Übergänge testbar ohne Komponenten
- **`ProductsStore.jsx`** React-Anbindung (Context, Provider, Hooks)
- **zwei Contexts** Zeilen, die nur dispatchen, re-rendern nicht unnötig
- **Custom Hooks** klare API, Fehler wenn der Provider fehlt
- **kein Prop Drilling** neue Kind-Komponenten können den Store direkt nutzen
---
# Architektur & Funktionsweise des ProductsProvider
Dieser Store implementiert ein **Optimistic UI mit entkoppeltem, gepuffertem Backend-Sync (Debounce & Merge)**.
```
UI Input (z. B. Tippen im Input-Feld)
├──▶ 1. dispatch(...) ──▶ State aktualisiert sich SOFORT (UI reagiert flüssig)
└──▶ 2. schedulePatch(...) ──▶ Änderungen sammeln & Timer starten (400 ms)
(nach 400 ms Ruhe)
flushPatch(...) ──▶ HTTP PATCH an API
```
---
## 1. Kernkonzept: `schedulePatch` vs. `flushPatch`
Beim Bearbeiten von Feldern (wie Preis oder Menge) feuert die Eingabe bei jedem Tastendruck (`onChange`). Würde man jedes Mal sofort einen API-Request senden, würde das Backend mit HTTP-Aufrufen überflutet werden.
### `schedulePatch(id, patch)` Das Sammelbecken & der Timer
**Zweck:** Nimmt Änderungen entgegen, führt sie pro Produkt zusammen (_mergen_) und setzt den Countdown zurück (_debouncen_).
1. **Merge (`pendingPatches`):**
- Tippt der Nutzer schnell hintereinander Menge `5` und Preis `19.99`, merkt sich die Map: `{ stock: 5, price: 19.99 }`.
2. **Timer-Reset (`persistTimers`):**
- Läuft bereits ein Timer für dieses Produkt, wird er mit `clearTimeout` gestoppt.
- Ein neuer 400ms-Timer startet. Erst wenn der Nutzer **400 ms lang nichts mehr tippt**, läuft der Timer ab.
---
### `flushPatch(id)` Der Ausführer
**Zweck:** Holt die gesammelten Änderungen ab und sendet den eigentlichen API-Request.
1. **Puffer leeren:** Holt das zusammengefasste Patch-Objekt aus `pendingPatches.current` und löscht es sofort aus der Map (verhindert doppelte Requests).
2. **Timer aufräumen:** Entfernt eventuell noch vorhandene Timer-Referenzen.
3. **API-Call (`patchProduct`):** Sendet den minimalen Patch an den Server.
4. **Erfolg:** Aktualisiert das Produkt im State mit den offiziellen Serverdaten (`SET_ITEM`).
5. **Fehlerfall (Rollback):** Schlägt der Request fehl, wird der Fehler gesetzt und via `loadProducts()` der alte/gültige Serverzustand nachgeladen (Rollback des optimistischen UI-Updates).
---
## 2. Der Unmount-Cleanup (`useEffect`)
Verlässt der Nutzer die Seite oder wird der Provider abgebaut, greift die Cleanup-Funktion:
```javascript
useEffect(() => {
return () => {
// 1. Laufende Timer sofort stoppen
timers.forEach((timer) => clearTimeout(timer));
timers.clear();
// 2. Ungespeicherte Änderungen sofort absenden ("Flush on Unmount")
patches.forEach((patch, id) => {
if (patch && Object.keys(patch).length > 0) {
patchProduct(id, patch).catch(() => {});
}
});
patches.clear();
};
}, []);
```
- Verhindert Datenverlust, wenn der Nutzer kurz nach einer Eingabe navigiert.
---
## 3. Die 3-Context-Architektur
Der Store teilt die Verantwortlichkeiten in **drei separate Contexte** auf, um unnötige Re-Renders im Komponentenbaum vollständig zu unterbinden:
| Context | Hook | Inhalt | Re-Render Verhalten |
| ----------------------------- | ----------------------- | -------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| **`ProductsStateContext`** | `useProductsState()` | `state` (Items, Loading, Error) | Rendert **nur** neu, wenn sich der State ändert (z. B. Listen-Komponenten). |
| **`ProductsDispatchContext`** | `useProductsDispatch()` | `dispatch` | **Rendert nie neu** (Referenz bleibt über gesamten App-Lebenszyklus stabil). |
| **`ProductsActionsContext`** | `useProductsActions()` | Methoden (`updatePrice`, `addProduct`, etc.) | **Rendert nie neu**, da alle Actions mit `useCallback` und stabilen Abhängigkeiten memoisiert sind. |
---
## 4. Übersicht der Action-Methoden
- **`updateAmount(amount, id)` & `updatePrice(price, id)`:**
- **Optimistisch & Debounced:** Ändern sofort den lokalen State via `dispatch` und reichen die Daten an `schedulePatch` weiter.
- **`addProduct(product)`, `saveProduct(id, product)`, `removeProduct(id)`:**
- **Pessimistisch & Direkt:** Warten direkt auf die API-Antwort (`async/await`) und updaten erst nach erfolgreichem Server-Response den Reducer-State.
- **`loadProducts()`:**
- Lädt die komplette Produktliste manuell neu (z. B. nach einem Rollback oder Pull-to-Refresh).
---
## useLayoutEffect vs. useEffect
Der Hauptunterschied liegt im **Timing der Ausführung** relativ zum Browser-Rendering:
| Eigenschaft | `useLayoutEffect` | `useEffect` |
| --- | --- | --- |
| **Ausführungsart** | **Synchron** (blockiert das Painting) | **Asynchron / Deferiert** (nach dem Painting) |
| **Zeitpunkt** | Direkt nach den DOM-Mutationen, *bevor* der Browser den Bildschirm aktualisiert | Nachdem der Browser die Pixel auf den Bildschirm gezeichnet hat |
| **Haupt-Use-Case** | DOM-Messungen (`getBoundingClientRect`), visuelle DOM-Mutationen zur Vermeidung von Layout Shifts / Flackern | Data Fetching, Subscriptions, Logging, Storage-Zugriffe |
| **Performance-Impact** | Kann UI-Ruckler verursachen, wenn Berechnungen zu lange dauern | Keine Blockierung der UI-Darstellung |
---
**Analyse deines Codes:**
* **`useLayoutEffect` für `setAuthToken(state.token)**`:
* *Zweck hier:* Der Token (z. B. in Axios-Headern oder API-Clients) wird **synchron** gesetzt, noch bevor Kindkomponenten in ihren eigenen `useEffect`-Hooks initiale API-Aufrufe ausführen. Dadurch wird ein Race Condition vermieden, bei dem ein Kind-Effekt einen Request ohne gültigen Header losschickt.
* **`useEffect` für `persistAuth(...)**`:
* *Zweck hier:* Das Schreiben in den `localStorage`/`IndexedDB` ist ein Side Effect, der die visuelle Darstellung nicht direkt beeinflusst und daher die UI nicht blockieren muss.

View File

@@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>react-app</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,34 @@
{
"name": "react-app",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@splidejs/react-splide": "^0.7.12",
"axios": "^1.20.0",
"bootstrap": "^5.3.8",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-icons": "^5.7.0",
"react-router-dom": "^7.18.3",
"uuid": "^14.0.2"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.5",
"@vitejs/plugin-react": "^6.1.1",
"eslint": "^10.9.1",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.5",
"globals": "^17.11.0",
"sass": "^1.103.1",
"vite": "^8.2.2"
}
}

View File

@@ -0,0 +1,119 @@
[
{
"_id": "675bee96bb39023b77e6cd92",
"title": "Smart Coffee Mug with LCD Level Indicator",
"sku": "MUG0007",
"stock": 25,
"price": 49.99,
"description": "Know exactly how much coffee you have left with this LCD-equipped mug.",
"tagline": "Never face an empty mug again."
},
{
"_id": "675bee96bb39023b77e6cd93",
"title": "Bluetooth Coffee Mug with Fortune Telling Scanner",
"sku": "MUG0013",
"stock": 15,
"price": 99.99,
"description": "Get your daily fortune with every sip using this Bluetooth-connected mug.",
"tagline": "Coffee reading, made smart."
},
{
"_id": "675bee96bb39023b77e6cd94",
"title": "South Pole Tested Pre-Warmed Ink Pen",
"sku": "OFF3145",
"stock": 34,
"price": 29.95,
"description": "Write smoothly even in the coldest conditions with this pre-warmed ink pen.",
"tagline": "The pen that conquers the cold."
},
{
"_id": "675bee96bb39023b77e6cd95",
"title": "Ambidextrous Computer Mouse",
"sku": "COM1001",
"stock": 50,
"price": 19.9,
"description": "Finally, a mouse designed for both left and right-handed users.",
"tagline": "End the left-right struggle."
},
{
"_id": "675bee96bb39023b77e6cd96",
"title": "Easter Egg Themed Webcam",
"sku": "COM0404",
"stock": 8,
"price": 14.99,
"description": "Add some festive flair to your video calls with this Easter egg webcam.",
"tagline": "Happy Easter, every day."
},
{
"_id": "675bee96bb39023b77e6cd97",
"title": "Vulcan Language vi Cheatsheet",
"sku": "COM0001",
"stock": 6,
"price": 9.9,
"description": "Master the Vulcan language and vi editor simultaneously with this handy cheatsheet.",
"tagline": "Learn vi and Vulcan, live long and prosper."
},
{
"_id": "675bee96bb39023b77e6cd98",
"title": "Klingon Language emacs Cheatsheet",
"sku": "COM1536",
"stock": 33,
"price": 9.9,
"description": "Conquer the Klingon language and emacs with this comprehensive cheatsheet.",
"tagline": "Learn emacs and Klingon, qapla'!"
},
{
"_id": "675bee96bb39023b77e6cda0",
"title": "Self-Folding Laundry Basket",
"sku": "HME0023",
"stock": 15,
"price": 79.99,
"description": "Say goodbye to laundry clutter with this self-folding basket.",
"tagline": "Laundry day just got easier."
},
{
"_id": "675bee96bb39023b77e6cda1",
"title": "Noise-Cancelling Headphones for Cats",
"sku": "PET0112",
"stock": 39,
"price": 39.95,
"description": "Give your cat the gift of silence with these noise-canceling headphones.",
"tagline": "Purrfect tranquility for your feline friend."
},
{
"_id": "675bee96bb39023b77e6cda2",
"title": "Glow-in-the-Dark Toilet Paper",
"sku": "HME0221",
"stock": 65,
"price": 12.5,
"description": "Navigate your midnight bathroom trips with ease using this glow-in-the-dark toilet paper.",
"tagline": "A guiding light in the darkness."
},
{
"_id": "675bee96bb39023b77e6cda3",
"title": "Automatic Plant Waterer with Compliment Dispenser",
"sku": "GRD0334",
"stock": 17,
"price": 69.99,
"description": "Keep your plants happy and hydrated with automatic watering and daily compliments.",
"tagline": "Nurture your plants, boost their self-esteem."
},
{
"_id": "675bee96bb39023b77e6cda4",
"title": "Self-Stirring Cereal Bowl",
"sku": "KIT0445",
"stock": 55,
"price": 24.95,
"description": "Enjoy perfectly crunchy cereal every time with this self-stirring bowl.",
"tagline": "No more soggy surprises."
},
{
"_id": "675bee96bb39023b77e6cda5",
"title": "Polygon Planet Lamp",
"sku": "LMP0556",
"stock": 4,
"price": 39.9,
"description": "Transform your space with the enchanting glow of a planetarium-inspired lamp.",
"tagline": "Bring the cosmos into your home."
}
]

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 675 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.8 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,20 @@
import FooterMain from './components/footers/FooterMain';
import HeaderMain from './components/headers/HeaderMain';
import NavMain from './components/navs/NavMain';
import Router from './routes/Router';
import { AuthProvider } from './stores/auth';
function App() {
return (
<AuthProvider>
<HeaderMain />
<NavMain />
<main>
<Router />
</main>
<FooterMain />
</AuthProvider>
);
}
export default App;

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 148.14 90.61"><defs><linearGradient id="a" x1="76.09" x2="76.09" y1="9.78" y2="56.04" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#65c1c3"/><stop offset="1" stop-color="#569ed6"/></linearGradient><linearGradient id="b" x1="239.45" x2="220.4" y1="312.2" y2="331.38" gradientTransform="matrix(1 0 0 -1 -187.36 360.99)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#5784c3"/><stop offset=".2" stop-color="#5480c0"/><stop offset=".39" stop-color="#4c74b8"/><stop offset=".58" stop-color="#3f60aa"/><stop offset=".67" stop-color="#3856a3"/></linearGradient><linearGradient xlink:href="#a" id="c" x1="228.06" x2="228.06" y1="319.94" y2="351.23" gradientTransform="matrix(1 0 0 -1 -187.36 360.99)"/><linearGradient xlink:href="#b" id="d" x1="-129.62" x2="-148.68" y1="17.01" y2="36.19" gradientTransform="matrix(-1 0 0 1 -33.63 0)"/><linearGradient xlink:href="#a" id="e" x1="-141.01" x2="-141.01" y1="24.75" y2="56.04" gradientTransform="matrix(-1 0 0 1 -33.63 0)"/></defs><path fill="url(#a)" d="M73.98 56.04H60.7L78.21 9.78h13.28z"/><path d="M15.96 66.6h2.13l.98 10.8h.04l1.04-10.8h2.41l1.04 10.8h.04l.98-10.8h1.91L25.1 80.65h-2.75l-1-9.48h-.04l-1 9.48h-2.93L15.95 66.6ZM27.66 66.6h6.02v2.01h-3.81v3.71h3.03v2.01h-3.03v4.32h3.81v2.01h-6.02V66.61ZM34.95 66.6h3.33c2.29 0 3.27 1.06 3.27 3.23v.56c0 1.45-.44 2.35-1.43 2.75v.04c1.18.4 1.65 1.44 1.65 2.93v1.21c0 2.17-1.14 3.33-3.35 3.33h-3.47zm3.07 5.72c.82 0 1.33-.36 1.33-1.49v-.78c0-1-.34-1.45-1.12-1.45h-1.06v3.71h.86Zm.41 6.32c.74 0 1.14-.34 1.14-1.39v-1.22c0-1.31-.42-1.71-1.43-1.71h-.98v4.32h1.26ZM46.28 66.6h3.37c2.21 0 3.29 1.22 3.29 3.47v7.11c0 2.25-1.08 3.47-3.29 3.47h-3.37zm3.33 12.04c.7 0 1.12-.36 1.12-1.37v-7.31c0-1-.42-1.37-1.12-1.37h-1.12v10.04h1.12ZM54.43 66.6h6.02v2.01h-3.81v3.71h3.03v2.01h-3.03v4.32h3.81v2.01h-6.02V66.61ZM61.13 66.6h2.23l1.45 10.9h.04l1.45-10.9h2.03L66.2 80.65h-2.93L61.14 66.6ZM69.38 66.6h6.02v2.01h-3.81v3.71h3.03v2.01h-3.03v4.32h3.81v2.01h-6.02V66.61ZM76.67 66.6h2.21v12.05h3.63v2.01h-5.84V66.61ZM83.34 77.32v-7.39c0-2.25 1.18-3.53 3.35-3.53s3.35 1.28 3.35 3.53v7.39c0 2.25-1.18 3.53-3.35 3.53s-3.35-1.28-3.35-3.53m4.49.14v-7.67c0-1-.44-1.39-1.14-1.39s-1.14.38-1.14 1.39v7.67c0 1 .44 1.39 1.14 1.39s1.14-.38 1.14-1.39M91.53 66.6h3.25c2.21 0 3.29 1.22 3.29 3.47v1.39c0 2.25-1.08 3.47-3.29 3.47h-1.04v5.72h-2.21zm3.25 6.32c.7 0 1.08-.32 1.08-1.32v-1.67c0-1-.38-1.33-1.08-1.33h-1.04v4.32zM99.28 66.6h3.15l1.41 10.06h.04l1.41-10.06h3.15v14.05h-2.09V70.01h-.04l-1.61 10.64h-1.85l-1.61-10.64h-.04v10.64h-1.93V66.6ZM110.08 66.6h6.02v2.01h-3.81v3.71h3.03v2.01h-3.03v4.32h3.81v2.01h-6.02V66.61ZM117.36 66.6h2.77l2.15 8.41h.04V66.6h1.97v14.05h-2.27l-2.65-10.26h-.04v10.26h-1.97zM127.66 68.61h-2.31V66.6h6.83v2.01h-2.31v12.05h-2.21z"/><path fill="url(#b)" d="M51.27 56.03V39.76L36.26 24.75l-4.65 4.65a4.943 4.943 0 0 0 0 6.99l19.64 19.64.02-.02Z"/><path fill="url(#c)" d="M51.26 26.05V9.78l-.02-.02-19.67 19.67a4.913 4.913 0 0 0 0 6.95l4.68 4.68 8.13-8.13z"/><path fill="url(#d)" d="M96.81 9.78v16.27l15.01 15.01 4.65-4.65a4.943 4.943 0 0 0 0-6.99L96.83 9.78l-.02.02Z"/><path fill="url(#e)" d="M96.82 39.76v16.27l.02.02 19.67-19.67a4.913 4.913 0 0 0 0-6.95l-4.68-4.68-8.13 8.13z"/></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,48 @@
import NavSocial from '../navs/NavSocial';
const FooterMain = (props) => {
// const { } = props;
return (
<footer className="m-footer-main">
<div className="container">
<div className="row">
<div className="col-12 col-md-4">
<div className="box">
<h2>Contact</h2>
<address>
John Smith
<br />
1 Example Street
<br />
Anytown, AB1 2CD
</address>
</div>
</div>
<div className="col-12 col-md-4">
<div className="box">
<h2>More Information</h2>
<nav className="nav-footer">
<ul className="list">
<li>
<a href="#">Imprint</a>
</li>
<li>
<a href="#">Our Privacy Policy</a>
</li>
</ul>
</nav>
</div>
</div>
<div className="col-12 col-md-4">
<div className="box">
<h2>Social Media</h2>
<NavSocial />
</div>
</div>
</div>
</div>
</footer>
);
};
export default FooterMain;

View File

@@ -0,0 +1,21 @@
import LogoMain from '../logos/LogoMain';
import SliderSplide from '../sliders/SliderSplide';
const HeaderMain = () => {
const images = [
'/img/slides/slide-01.jpg', //
'/img/slides/slide-02.jpg',
'/img/slides/slide-03.jpg',
];
return (
<header className="m-header-main header-main">
<SliderSplide images={images} />
<div className="content-box">
<LogoMain />
</div>
</header>
);
};
export default HeaderMain;

View File

@@ -0,0 +1,62 @@
const Logo = (props) => {
// const { } = props;
return (
<svg xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink" viewBox="0 0 148.14 90.61">
<defs>
<linearGradient id="a" x1="76.09" x2="76.09" y1="9.78" y2="56.04" gradientUnits="userSpaceOnUse">
<stop offset="0" stopColor="#65c1c3" />
<stop offset="1" stopColor="#569ed6" />
</linearGradient>
<linearGradient
id="b"
x1="239.45"
x2="220.4"
y1="312.2"
y2="331.38"
gradientTransform="matrix(1 0 0 -1 -187.36 360.99)"
gradientUnits="userSpaceOnUse">
<stop offset="0" stopColor="#5784c3" />
<stop offset=".2" stopColor="#5480c0" />
<stop offset=".39" stopColor="#4c74b8" />
<stop offset=".58" stopColor="#3f60aa" />
<stop offset=".67" stopColor="#3856a3" />
</linearGradient>
<linearGradient
xlinkHref="#a"
id="c"
x1="228.06"
x2="228.06"
y1="319.94"
y2="351.23"
gradientTransform="matrix(1 0 0 -1 -187.36 360.99)"
/>
<linearGradient
xlinkHref="#b"
id="d"
x1="-129.62"
x2="-148.68"
y1="17.01"
y2="36.19"
gradientTransform="matrix(-1 0 0 1 -33.63 0)"
/>
<linearGradient
xlinkHref="#a"
id="e"
x1="-141.01"
x2="-141.01"
y1="24.75"
y2="56.04"
gradientTransform="matrix(-1 0 0 1 -33.63 0)"
/>
</defs>
<path fill="url(#a)" d="M73.98 56.04H60.7L78.21 9.78h13.28z" />
<path d="M15.96 66.6h2.13l.98 10.8h.04l1.04-10.8h2.41l1.04 10.8h.04l.98-10.8h1.91L25.1 80.65h-2.75l-1-9.48h-.04l-1 9.48h-2.93L15.95 66.6ZM27.66 66.6h6.02v2.01h-3.81v3.71h3.03v2.01h-3.03v4.32h3.81v2.01h-6.02V66.61ZM34.95 66.6h3.33c2.29 0 3.27 1.06 3.27 3.23v.56c0 1.45-.44 2.35-1.43 2.75v.04c1.18.4 1.65 1.44 1.65 2.93v1.21c0 2.17-1.14 3.33-3.35 3.33h-3.47zm3.07 5.72c.82 0 1.33-.36 1.33-1.49v-.78c0-1-.34-1.45-1.12-1.45h-1.06v3.71h.86Zm.41 6.32c.74 0 1.14-.34 1.14-1.39v-1.22c0-1.31-.42-1.71-1.43-1.71h-.98v4.32h1.26ZM46.28 66.6h3.37c2.21 0 3.29 1.22 3.29 3.47v7.11c0 2.25-1.08 3.47-3.29 3.47h-3.37zm3.33 12.04c.7 0 1.12-.36 1.12-1.37v-7.31c0-1-.42-1.37-1.12-1.37h-1.12v10.04h1.12ZM54.43 66.6h6.02v2.01h-3.81v3.71h3.03v2.01h-3.03v4.32h3.81v2.01h-6.02V66.61ZM61.13 66.6h2.23l1.45 10.9h.04l1.45-10.9h2.03L66.2 80.65h-2.93L61.14 66.6ZM69.38 66.6h6.02v2.01h-3.81v3.71h3.03v2.01h-3.03v4.32h3.81v2.01h-6.02V66.61ZM76.67 66.6h2.21v12.05h3.63v2.01h-5.84V66.61ZM83.34 77.32v-7.39c0-2.25 1.18-3.53 3.35-3.53s3.35 1.28 3.35 3.53v7.39c0 2.25-1.18 3.53-3.35 3.53s-3.35-1.28-3.35-3.53m4.49.14v-7.67c0-1-.44-1.39-1.14-1.39s-1.14.38-1.14 1.39v7.67c0 1 .44 1.39 1.14 1.39s1.14-.38 1.14-1.39M91.53 66.6h3.25c2.21 0 3.29 1.22 3.29 3.47v1.39c0 2.25-1.08 3.47-3.29 3.47h-1.04v5.72h-2.21zm3.25 6.32c.7 0 1.08-.32 1.08-1.32v-1.67c0-1-.38-1.33-1.08-1.33h-1.04v4.32zM99.28 66.6h3.15l1.41 10.06h.04l1.41-10.06h3.15v14.05h-2.09V70.01h-.04l-1.61 10.64h-1.85l-1.61-10.64h-.04v10.64h-1.93V66.6ZM110.08 66.6h6.02v2.01h-3.81v3.71h3.03v2.01h-3.03v4.32h3.81v2.01h-6.02V66.61ZM117.36 66.6h2.77l2.15 8.41h.04V66.6h1.97v14.05h-2.27l-2.65-10.26h-.04v10.26h-1.97zM127.66 68.61h-2.31V66.6h6.83v2.01h-2.31v12.05h-2.21z" />
<path fill="url(#b)" d="M51.27 56.03V39.76L36.26 24.75l-4.65 4.65a4.943 4.943 0 0 0 0 6.99l19.64 19.64.02-.02Z" />
<path fill="url(#c)" d="M51.26 26.05V9.78l-.02-.02-19.67 19.67a4.913 4.913 0 0 0 0 6.95l4.68 4.68 8.13-8.13z" />
<path fill="url(#d)" d="M96.81 9.78v16.27l15.01 15.01 4.65-4.65a4.943 4.943 0 0 0 0-6.99L96.83 9.78l-.02.02Z" />
<path fill="url(#e)" d="M96.82 39.76v16.27l.02.02 19.67-19.67a4.913 4.913 0 0 0 0-6.95l-4.68-4.68-8.13 8.13z" />
</svg>
);
};
export default Logo;

View File

@@ -0,0 +1,57 @@
// import { useState } from 'react';
import { FaMinus, FaPlus } from 'react-icons/fa6';
const InputAmount = (props) => {
const { min = 0, max = 100, amount = 0 } = props;
// const [amount, setAmount] = useState(props.amount || 0);
const handleChange = (e) => {
const inputValue = e.target.value;
const numericRegExp = /^-?\d*$/; // regular expression for numbers (including optional minus sign)
if (numericRegExp.test(inputValue)) {
// setAmount(inputValue);
props.onHandleAmount(inputValue);
}
};
const handleClickIncrement = () => {
if (amount < max) {
// setAmount((amount) => amount + 1);
props.onHandleAmount(amount + 1);
}
};
const handleClickDecrement = () => {
if (amount > min) {
// setAmount((amount) => amount - 1);
props.onHandleAmount(amount - 1);
}
};
return (
<div className="m-input-amount">
<div className="input-group mb-3">
<span className="input-group-text">
<button className="btn btn-dark button-dec" onClick={handleClickDecrement} disabled={amount === min}>
<FaMinus />
</button>
</span>
<input
type="number"
className="form-control input-amount"
aria-label="Amount"
onChange={handleChange}
value={amount}
/>
<span className="input-group-text">
<button className="btn btn-dark button-inc" onClick={handleClickIncrement} disabled={amount === max}>
<FaPlus />
</button>
</span>
</div>
</div>
);
};
export default InputAmount;

View File

@@ -0,0 +1,57 @@
// import { useState } from 'react';
const isValidPrice = (value) => {
// regular expression for price (up to 2 decimal places)
const regex = new RegExp(/^\d+(\.\d{0,2})?$/);
return regex.test(value);
};
const InputPrice = (props) => {
const { currency = '$', price = 0 } = props;
// const [price, setPrice] = useState(parseFloat(props.price) || 0);
const handleChange = (e) => {
const inputValue = parseFloat(e.target.value);
// setPrice(inputValue);
props.onHandlePrice(inputValue);
};
const handleBlur = (e) => {
const inputValue = e.target.value;
if (!isValidPrice(inputValue)) {
// setPrice(0);
props.onHandlePrice(0);
}
};
const renderCurrency = () => {
if (currency === '$') {
return <div className="input-group-text">{currency}</div>;
} else if (currency === '€' || currency === '¥') {
return <div className="input-group-text">{currency}</div>;
} else {
return null; // or throw an error
}
};
return (
<div className="m-input-price">
<div className="input-group mb-3">
{currency === '$' && renderCurrency()}
<input
type="number"
className={`form-control input-price ${!isValidPrice(price) ? 'is-invalid' : ''} `}
aria-label="Price"
onChange={handleChange}
onBlur={handleBlur}
value={price}
/>
{(currency === '€' || currency === '¥') && renderCurrency()}
</div>
</div>
);
};
export default InputPrice;

View File

@@ -0,0 +1,14 @@
// import Logo from '../../assets/img/logo-js.min.svg';
import Logo from '../icons/Logo';
const LogoMain = (props) => {
// const { } = props;
return (
<figure className="m-logo-main logo-main">
{/* <img src={Logo} alt="React" /> */}
<Logo />
</figure>
);
};
export default LogoMain;

View File

@@ -0,0 +1,135 @@
import { useState } from 'react';
import { NavLink } from 'react-router-dom';
import { useAuthActions, useAuthState } from '../../stores/auth';
const NavMain = () => {
const { user, loading, error } = useAuthState();
const { login, logout } = useAuthActions();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const handleLogin = async (event) => {
event.preventDefault();
try {
await login(username, password);
setUsername('');
setPassword('');
} catch {
// Fehler kommt aus dem Auth-Store
}
};
return (
<nav className="m-nav-main nav-main navbar navbar-expand-lg bg-body-dark">
<div className="container-fluid">
<NavLink className="navbar-brand" to="/">
React SPA
</NavLink>
<button
className="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbarNavDropdown"
aria-controls="navbarNavDropdown"
aria-expanded="false"
aria-label="Toggle navigation">
<span className="navbar-toggler-icon"></span>
</button>
<div className="collapse navbar-collapse" id="navbarNavDropdown">
<ul className="navbar-nav">
<li className="nav-item">
<NavLink className="nav-link" to="/">
Home
</NavLink>
</li>
<li className="nav-item">
<NavLink className="nav-link" to="/about">
About
</NavLink>
</li>
<li className="nav-item dropdown">
<NavLink
className="nav-link dropdown-toggle"
to="/projects"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false">
Projects
</NavLink>
<ul className="dropdown-menu">
<li>
<NavLink className="dropdown-item" to="/projects/table-products">
Table Products
</NavLink>
</li>
<li>
<NavLink className="dropdown-item" to="/projects/table-users">
Table Users
</NavLink>
</li>
</ul>
</li>
<li className="nav-item">
<NavLink className="nav-link" to="/contact">
Contact
</NavLink>
</li>
</ul>
<div className="nav-auth ms-lg-auto">
{user ? (
<div className="nav-auth-session">
<span className="nav-auth-user">{user.username}</span>
<button type="button" className="btn btn-sm btn-outline-light" onClick={logout}>
Logout
</button>
</div>
) : (
<form className="nav-auth-form" onSubmit={handleLogin}>
<label className="visually-hidden" htmlFor="nav-login-username">
Benutzername
</label>
<input
id="nav-login-username"
className="form-control form-control-sm"
type="text"
name="username"
autoComplete="username"
placeholder="Benutzername"
value={username}
onChange={(event) => setUsername(event.target.value)}
required
/>
<label className="visually-hidden" htmlFor="nav-login-password">
Passwort
</label>
<input
id="nav-login-password"
className="form-control form-control-sm"
type="password"
name="password"
autoComplete="current-password"
placeholder="Passwort"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
/>
<button className="btn btn-sm btn-warning" type="submit" disabled={loading}>
Login
</button>
{error && (
<p className="nav-auth-error" role="alert">
{error}
</p>
)}
</form>
)}
</div>
</div>
</div>
</nav>
);
};
export default NavMain;

View File

@@ -0,0 +1,35 @@
import { FaYoutube, FaXTwitter, FaFacebookF, FaInstagram } from 'react-icons/fa6';
const NavSocial = () => {
return (
<nav className="m-nav-social nav-social">
<ul className="list">
<li>
<a href="#" target="_blank" rel="nofollow" aria-label="x-twitter">
<FaXTwitter />
</a>
</li>
<li>
<a href="#" aria-label="youtube">
<FaYoutube />
</a>
</li>
<li>
<a
href="https://www.facebook.com/gfn.gmbh/?locale=de_DE"
target="_blank"
rel="nofollow"
aria-label="facebook">
<FaFacebookF />
</a>
</li>
<li>
<a href="#" aria-label="instagram">
<FaInstagram />
</a>
</li>
</ul>
</nav>
);
};
export default NavSocial;

View File

@@ -0,0 +1,32 @@
import { Splide, SplideSlide } from '@splidejs/react-splide';
const SliderSplide = (props) => {
const {
images = [],
label = 'JS Header Slides',
options = {
rewind: true,
height: '100%',
autoplay: true,
speed: 800,
type: 'fade',
},
} = props;
return (
<>
{images && images.length > 0 && (
<div className="m-slider-splide slider-splide">
<Splide aria-label={label} options={options}>
{images.map((image, idx) => (
<SplideSlide key={`splide-slide-${idx}`}>
<img src={image} alt="" />
</SplideSlide>
))}
</Splide>
</div>
)}
</>
);
};
export default SliderSplide;

View File

@@ -0,0 +1,70 @@
import { useMemo } from 'react';
import { ProductsProvider, useProductsActions, useProductsState } from '../../stores/products';
import { useAuthState } from '../../stores/auth';
import RowProduct from './content/RowProduct';
const TableProductsView = () => {
const { user } = useAuthState();
const { items, loading, error } = useProductsState();
const { loadProducts } = useProductsActions();
const totalPrice = useMemo(() => {
return items.reduce((sum, obj) => {
return sum + Number(obj.price) * Number(obj.stock);
}, 0);
}, [items]);
if (loading && items.length === 0) {
return <p className="text-muted">Produkte werden geladen </p>;
}
return (
<div className="m-table-products table-products py-5">
<div className="container">
{error && (
<div className="alert alert-danger" role="alert">
<p className="mb-2">{error}</p>
<button type="button" className="btn btn-sm btn-dark" onClick={loadProducts}>
Erneut laden
</button>
</div>
)}
<table className="table table-striped">
<thead className="table-dark">
<tr>
<th>SKU</th>
<th>Stock</th>
<th>Title</th>
<th>Price</th>
<th>Total</th>
{user && <th className="col-actions">Admin</th>}
</tr>
</thead>
<tbody>
{items.length > 0 &&
items.map((item) => (
<RowProduct key={`row-product-${item._id}`} {...item} canManage={Boolean(user)} />
))}
</tbody>
<tfoot>
<tr>
<td colSpan={4}>Total:</td>
<td className="col-total">${totalPrice.toFixed(2)}</td>
{user && <td className="col-actions" />}
</tr>
</tfoot>
</table>
</div>
</div>
);
};
const TableProducts = () => {
return (
<ProductsProvider>
<TableProductsView />
</ProductsProvider>
);
};
export default TableProducts;

View File

@@ -0,0 +1,59 @@
import { UsersProvider, useUsersActions, useUsersState } from '../../stores/users';
import { useAuthState } from '../../stores/auth';
import RowUser from './content/RowUser';
const TableUsersView = () => {
const { user } = useAuthState();
const { items, loading, error } = useUsersState();
const { loadUsers } = useUsersActions();
if (!user) {
return (
<div className="alert alert-warning" role="status">
Bitte oben rechts einloggen, um die User-Tabelle zu sehen.
</div>
);
}
if (loading && items.length === 0) {
return <p className="text-muted">Benutzer werden geladen </p>;
}
return (
<div className="m-table-users table-users">
{error && (
<div className="alert alert-danger" role="alert">
<p className="mb-2">{error}</p>
<button type="button" className="btn btn-sm btn-dark" onClick={loadUsers}>
Erneut laden
</button>
</div>
)}
<table className="table table-striped">
<thead className="table-dark">
<tr>
<th>ID</th>
<th>Username</th>
<th>E-Mail</th>
<th>Angelegt</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<RowUser key={`row-user-${item.id}`} {...item} />
))}
</tbody>
</table>
</div>
);
};
const TableUsers = () => {
return (
<UsersProvider>
<TableUsersView />
</UsersProvider>
);
};
export default TableUsers;

View File

@@ -0,0 +1,89 @@
import { memo, useCallback, useState } from 'react';
import { FaPen, FaTrash } from 'react-icons/fa6';
import InputAmount from '../../inputs/InputAmount';
import InputPrice from '../../inputs/InputPrice';
import { useProductsActions } from '../../../stores/products';
const RowProduct = (props) => {
const {
_id,
title,
stock: amount = 0,
price = 0,
sku = '',
canManage = false,
} = props;
const [isEditing, setIsEditing] = useState(false);
const { updateAmount, updatePrice, removeProduct } = useProductsActions();
const handleAmount = useCallback(
(value) => {
updateAmount(value, _id);
},
[updateAmount, _id]
);
const handlePrice = useCallback(
(value) => {
updatePrice(value, _id);
},
[updatePrice, _id]
);
const handleToggleEdit = useCallback(() => {
setIsEditing((current) => !current);
}, []);
const handleRemove = useCallback(async () => {
const confirmed = window.confirm(`Produkt „${title}“ wirklich entfernen?`);
if (!confirmed) {
return;
}
await removeProduct(_id);
}, [removeProduct, _id, title]);
return (
<tr className={`row-product ${isEditing ? 'is-editing' : ''}`}>
<td>{sku}</td>
<td>
{canManage && isEditing ? (
<InputAmount amount={amount} onHandleAmount={handleAmount} />
) : (
amount
)}
</td>
<td>{title}</td>
<td>
{canManage && isEditing ? (
<InputPrice price={price} onHandlePrice={handlePrice} />
) : (
Number(price).toFixed(2)
)}
</td>
<td className="col-total">{(Number(amount) * Number(price)).toFixed(2)}</td>
{canManage && (
<td className="col-actions">
<button
type="button"
className={`btn btn-sm btn-action btn-action-edit ${isEditing ? 'is-active' : ''}`}
aria-label={isEditing ? 'Bearbeiten beenden' : 'Bearbeiten'}
aria-pressed={isEditing}
onClick={handleToggleEdit}>
<FaPen />
</button>
<button
type="button"
className="btn btn-sm btn-action btn-action-remove"
aria-label="Entfernen"
onClick={handleRemove}>
<FaTrash />
</button>
</td>
)}
</tr>
);
};
export default memo(RowProduct);

View File

@@ -0,0 +1,16 @@
import { memo } from 'react';
const RowUser = (props) => {
const { id, username, email, created_at: createdAt = '' } = props;
return (
<tr className="row-user">
<td>{id}</td>
<td>{username}</td>
<td>{email}</td>
<td>{createdAt}</td>
</tr>
);
};
export default memo(RowUser);

View File

@@ -0,0 +1,84 @@
# Projekt-Roadmap: Phasenplan
Übersicht über die schrittweise Entwicklung von der lokalen Client-App bis zum modernen Next.js + Convex Stack deployed via Coolify.
---
## Phasen-Übersicht
| Phase | Fokus | Verzeichnis / Module | Stack | Hosting / Ziel |
| :---------- | :-------------------------- | :-------------------------------------------------- | :-------------------------------------------- | :------------------------------- |
| **Phase 1** | Client-Prototyp & State | `/react-app` | React + Vite, `useContext`, `useReducer` | Lokal |
| **Phase 2** | Classic Fullstack & MariaDB | `/react-server/frontend`<br>`/react-server/backend` | React SPA + Slim PHP (REST API, PDO, MariaDB) | Apache Webspace / Shared Hosting |
| **Phase 3** | Modern Fullstack Rewrite | `/next-app` | Next.js (App Router, RSC/SSG) + Convex BaaS | Lokal / Staging |
| **Phase 4** | Production Deployment | `/next-app` + Infrastruktur | Coolify (Self-hosted PaaS) + Convex Cloud | VPS / Production Server |
---
## Phase 1: Client-Only SPA (`/react-app`)
**Ziel:** Schnelles Prototyping der Benutzeroberfläche und Etablierung des lokalen UI-State-Managements ohne Backend-Abhängigkeit.
- **Stack & Setup:**
- React + Vite
- Globaler State via `useContext` und `useReducer` (zur Vermeidung von Prop Drilling und State Uplifting)
- Mock-Daten / LocalStorage für initiale Datenhaltung
- **Meilensteine:**
- [ ] Grundlegendes Layout und Komponentenstruktur aufbauen
- [ ] State-Reducer für UI- und Dummy-Datenflüsse implementieren
- [ ] Service-Layer für Datenzugriffe vorbereiten (gekapselte Mock-APIs)
---
## Phase 2: Klassisches Client-Server-Modell (`/react-server`)
**Ziel:** Anbindung einer relationalen Datenbank über eine schlanke REST-API, lauffähig auf Standard-PHP/Apache-Webspaces ohne Node-Hosting-Zwang.
- **Struktur:**
- `/react-server/frontend`: Die React-App aus Phase 1 (Vite Build / statisches Bundle in `dist/`)
- `/react-server/backend`: Schlanke PHP-Schnittstelle
- **Stack & Setup:**
- Backend: Slim Framework (PHP) + PDO
- Datenbank: MariaDB / MySQL
- Routing & Server: Apache `.htaccess` (URL-Rewriting für Slim & SPA-Routing)
- CORS-Middleware für lokale Entwicklung
- **Meilensteine:**
- [ ] MariaDB-Datenbankschema erstellen
- [ ] REST-Endpunkte in Slim PHP anlegen (`GET`, `POST`, `PUT`, `DELETE`)
- [ ] Frontend-API-Services von Mock-Daten auf HTTP-Requests (`fetch`) umstellen
- [ ] Deployment auf klassischem Apache-Webspace testen
---
## Phase 3: Modern Stack Rewrite & Vibe Coding (`/next-app`)
**Ziel:** Kompletter Neuaufbau mit moderner Architektur, nativer SEO-Unterstützung und reaktiver Echtzeit-Datenbank.
- **Stack & Setup:**
- Next.js (App Router, React Server Components, SSG)
- Convex (BaaS, Schema-Validierung, WebSocket-Echtzeit, Server Functions)
- SEO: `sitemap.ts`, `robots.ts`, native `generateMetadata`-API
- **Meilensteine:**
- [ ] Neues Next.js-Projekt aufsetzen (`/next-app`)
- [ ] `convex/schema.ts` definieren (Übernahme der Datenstruktur aus MariaDB)
- [ ] Convex Backend Functions (Queries & Mutations) implementieren
- [ ] UI-Komponenten auf Next.js App Router und Convex-Hooks (`useQuery`, `useMutation`) portieren
- [ ] Statische Seiten / SSG für suchmaschinenrelevante Bereiche konfigurieren
---
## Phase 4: Deployment & Infrastruktur mit Coolify
**Ziel:** Automatisiertes, wartungsarmes Hosting der Next.js-Applikation über eine eigene Coolify-Instanz.
- **Stack & Setup:**
- Coolify (Self-hosted PaaS via Docker auf VPS)
- Next.js Standalone Build
- Convex Cloud / Self-Hosted Backend Connection
- Automatisches Git-Deployment (Webhooks, CI/CD)
- **Meilensteine:**
- [ ] `output: "standalone"` in `next.config.js` konfigurieren
- [ ] Dockerfile für Next.js optimieren (Multi-Stage Build für geringe Image-Größe)
- [ ] Applikation in Coolify als Service / Git-Repository verknüpfen
- [ ] Environment Variables einrichten (`CONVEX_DEPLOYMENT`, `NEXT_PUBLIC_CONVEX_URL`, etc.)
- [ ] SSL-Zertifikate, Domain-Routing und Health-Checks in Coolify verifizieren

View File

@@ -0,0 +1,16 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import './styles/main.scss';
import 'bootstrap';
import App from './App.jsx';
createRoot(document.getElementById('root')).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>
);

View File

@@ -0,0 +1,14 @@
const AboutPage = () => {
return (
<div className="page page-about">
<div className="container py-5">
<h2>About us</h2>
<p>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Qui rerum, fugit ipsa optio laboriosam sed est quae
explicabo! Reiciendis, iure soluta ad libero esse cupiditate pariatur nostrum accusantium! Obcaecati, in.
</p>
</div>
</div>
);
};
export default AboutPage;

View File

@@ -0,0 +1,11 @@
const ContactPage = () => {
return (
<div className="page page-contact">
<div className="container py-5">
<h2>Contact</h2>
<p>Lorem ipsum dolor sit amet consectetur</p>
</div>
</div>
);
};
export default ContactPage;

View File

@@ -0,0 +1,10 @@
const HomePage = () => {
return (
<div className="page page-home">
<div className="container py-5">
<h2>Hello from SPA!</h2>
</div>
</div>
);
};
export default HomePage;

View File

@@ -0,0 +1,10 @@
import { Outlet } from 'react-router-dom';
const ProjectsPage = () => {
return (
<div className="page page-projects">
<Outlet />
</div>
);
};
export default ProjectsPage;

View File

@@ -0,0 +1,15 @@
import TableProducts from '../../components/tables/TableProducts';
const TableProductsPage = () => {
return (
<div className="page page-projects-table-products">
<div className="container py-5">
<h2>Table Products</h2>
<p>Produkte öffentlich sichtbar Bearbeiten und Löschen nur nach Login</p>
<TableProducts />
</div>
</div>
);
};
export default TableProductsPage;

View File

@@ -0,0 +1,15 @@
import TableUsers from '../../components/tables/TableUsers';
const TableUsersPage = () => {
return (
<div className="page page-projects-table-users">
<div className="container py-5">
<h2>Table Users</h2>
<p>Benutzer aus der Slim REST API</p>
<TableUsers />
</div>
</div>
);
};
export default TableUsersPage;

View File

@@ -0,0 +1,23 @@
import { Route, Routes } from 'react-router-dom';
import HomePage from '../pages/HomePage';
import AboutPage from '../pages/AboutPage';
import ContactPage from '../pages/ContactPage';
import ProjectsPage from '../pages/ProjectsPage';
import TableProductsPage from '../pages/projects/TableProductsPage';
import TableUsersPage from '../pages/projects/TableUsersPage';
const Router = () => {
return (
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="/projects" element={<ProjectsPage />}>
<Route path="table-products" element={<TableProductsPage />} />
<Route path="table-users" element={<TableUsersPage />} />
</Route>
<Route path="/contact" element={<ContactPage />} />
</Routes>
);
};
export default Router;

View File

@@ -0,0 +1,127 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useReducer,
} from 'react';
import { setAuthToken, setOnUnauthorized } from '../http';
import { fetchMe, getApiErrorMessage, loginRequest, logoutRequest } from './api';
import { createInitialState, persistAuth } from './initialState';
import { AUTH_ACTIONS, authReducer } from './reducer';
const AuthStateContext = createContext(null);
const AuthActionsContext = createContext(null);
export const AuthProvider = (props) => {
const { children } = props;
const [state, dispatch] = useReducer(authReducer, undefined, createInitialState);
useLayoutEffect(() => {
setAuthToken(state.token);
}, [state.token]);
useEffect(() => {
persistAuth(state.user, state.token);
}, [state.user, state.token]);
useEffect(() => {
return setOnUnauthorized(() => {
persistAuth(null, null);
dispatch({ type: AUTH_ACTIONS.CLEAR });
});
}, []);
useEffect(() => {
if (!state.token) {
return undefined;
}
const controller = new AbortController();
const restore = async () => {
try {
const user = await fetchMe({ signal: controller.signal });
dispatch({
type: AUTH_ACTIONS.SET_SESSION,
payload: { user, token: state.token },
});
} catch (error) {
if (controller.signal.aborted) {
return;
}
setAuthToken(null);
persistAuth(null, null);
dispatch({ type: AUTH_ACTIONS.CLEAR });
}
};
restore();
return () => {
controller.abort();
};
}, []);
const login = useCallback(async (username, password) => {
dispatch({ type: AUTH_ACTIONS.SET_LOADING, payload: true });
try {
const session = await loginRequest(username, password);
setAuthToken(session.token);
dispatch({ type: AUTH_ACTIONS.SET_SESSION, payload: session });
return session.user;
} catch (error) {
const status = error.response?.status;
const message =
status === 401 ? 'Benutzername oder Passwort ist falsch' : getApiErrorMessage(error);
dispatch({ type: AUTH_ACTIONS.SET_ERROR, payload: message });
throw error;
}
}, []);
const logout = useCallback(async () => {
try {
await logoutRequest();
} catch {
// Session lokal trotzdem beenden
} finally {
setAuthToken(null);
persistAuth(null, null);
dispatch({ type: AUTH_ACTIONS.CLEAR });
}
}, []);
const actions = useMemo(() => ({ login, logout }), [login, logout]);
return (
<AuthStateContext.Provider value={state}>
<AuthActionsContext.Provider value={actions}>{children}</AuthActionsContext.Provider>
</AuthStateContext.Provider>
);
};
export const useAuthState = () => {
const state = useContext(AuthStateContext);
if (state == null) {
throw new Error('useAuthState muss innerhalb von AuthProvider verwendet werden');
}
return state;
};
export const useAuthActions = () => {
const actions = useContext(AuthActionsContext);
if (actions == null) {
throw new Error('useAuthActions muss innerhalb von AuthProvider verwendet werden');
}
return actions;
};

View File

@@ -0,0 +1,22 @@
import { apiClient, getApiErrorMessage } from '../http';
export { getApiErrorMessage };
export const loginRequest = async (username, password) => {
const { data } = await apiClient.post('/login', { username, password });
if (!data?.user || !data?.token) {
throw new Error('Ungültige API-Antwort: Session erwartet');
}
return data;
};
export const logoutRequest = async () => {
await apiClient.post('/logout');
};
export const fetchMe = async (config = {}) => {
const { data } = await apiClient.get('/me', config);
return data;
};

View File

@@ -0,0 +1,4 @@
export { createInitialState } from './initialState';
export { AUTH_ACTIONS, authReducer } from './reducer';
export { AuthProvider, useAuthActions, useAuthState } from './AuthStore';
export { fetchMe, getApiErrorMessage, loginRequest, logoutRequest } from './api';

View File

@@ -0,0 +1,38 @@
const STORAGE_KEY = 'nerdshop.auth';
export const loadStoredAuth = () => {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) {
return { user: null, token: null };
}
const parsed = JSON.parse(raw);
if (parsed?.token && parsed?.user) {
return { user: parsed.user, token: parsed.token };
}
} catch {
localStorage.removeItem(STORAGE_KEY);
}
return { user: null, token: null };
};
export const persistAuth = (user, token) => {
if (user && token) {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ user, token }));
} else {
localStorage.removeItem(STORAGE_KEY);
}
};
export const createInitialState = () => {
const stored = loadStoredAuth();
return {
user: stored.user,
token: stored.token,
loading: Boolean(stored.token),
error: null,
};
};

View File

@@ -0,0 +1,41 @@
export const AUTH_ACTIONS = {
SET_LOADING: 'SET_LOADING',
SET_SESSION: 'SET_SESSION',
SET_ERROR: 'SET_ERROR',
CLEAR: 'CLEAR',
};
export const authReducer = (state, action) => {
switch (action.type) {
case AUTH_ACTIONS.SET_LOADING:
return {
...state,
loading: action.payload,
error: action.payload ? null : state.error,
};
case AUTH_ACTIONS.SET_SESSION:
return {
...state,
user: action.payload.user,
token: action.payload.token,
loading: false,
error: null,
};
case AUTH_ACTIONS.SET_ERROR:
return {
...state,
loading: false,
error: action.payload,
};
case AUTH_ACTIONS.CLEAR:
return {
user: null,
token: null,
loading: false,
error: null,
};
default:
console.warn('no action found');
return state;
}
};

View File

@@ -0,0 +1,83 @@
import axios from 'axios';
import { loadStoredAuth } from './auth/initialState';
export const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_URL ?? '/api',
timeout: 8000,
headers: { 'Content-Type': 'application/json' },
});
let authToken = null;
let onUnauthorized = null;
const isLoginRequest = (config) => {
const url = config?.url ?? '';
return url === '/login' || url.endsWith('/login');
};
const resolveAuthToken = () => authToken || loadStoredAuth().token || null;
const applyAuthorizationHeader = (headers, token) => {
const value = `Bearer ${token}`;
if (headers && typeof headers.set === 'function') {
headers.set('Authorization', value);
return headers;
}
return { ...(headers || {}), Authorization: value };
};
export const setAuthToken = (token) => {
authToken = token || null;
if (authToken) {
apiClient.defaults.headers.common.Authorization = `Bearer ${authToken}`;
} else {
delete apiClient.defaults.headers.common.Authorization;
}
};
setAuthToken(loadStoredAuth().token);
export const setOnUnauthorized = (handler) => {
onUnauthorized = handler;
return () => {
if (onUnauthorized === handler) {
onUnauthorized = null;
}
};
};
apiClient.interceptors.request.use((config) => {
const token = resolveAuthToken();
if (token && !isLoginRequest(config)) {
config.headers = applyAuthorizationHeader(config.headers, token);
}
return config;
});
apiClient.interceptors.response.use(
(response) => response,
(error) => {
const status = error.response?.status;
if (status === 401 && !isLoginRequest(error.config)) {
setAuthToken(null);
onUnauthorized?.();
}
return Promise.reject(error);
}
);
export const getApiErrorMessage = (error) => {
if (axios.isCancel(error) || error.code === 'ERR_CANCELED') {
return null;
}
return error.response?.data?.error || error.message || 'Unbekannter API-Fehler';
};

View File

@@ -0,0 +1,294 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useReducer,
useRef,
} from 'react';
import { apiClient } from '../http';
import { createInitialState } from './initialState';
import { PRODUCTS_ACTIONS, productsReducer } from './reducer';
import {
createProduct,
deleteProduct,
fetchProducts,
getApiErrorMessage,
patchProduct,
updateProduct,
} from './api';
const PERSIST_DELAY_MS = 400;
const ProductsStateContext = createContext(null);
const ProductsDispatchContext = createContext(null);
const ProductsActionsContext = createContext(null);
export const ProductsProvider = (props) => {
const { children, items = [], autoLoad = true } = props;
const [state, dispatch] = useReducer(
productsReducer,
items,
createInitialState, // Der 3. Parameter dient der Lazy Initialization (verzögerten/faulen Initialisierung) des States.
);
// Warum macht man das?
// Performance (Expensive Calculations): Wenn die Erstellung des Ausgangszustands rechenintensiv ist (z. B. Parsen von localStorage, Filtern/Sortieren großer Arrays), wird createInitialState nur ein einziges Mal beim Mounten ausgeführt nicht bei jedem weiteren Render.
// Wiederverwendbarkeit: Du kannst dieselbe createInitialState-Funktion auch innerhalb deines Reducers aufrufen, um z. B. bei einer RESET-Action den State sauber auf die Ausgangswerte zurückzusetzen.
const pendingPatches = useRef(new Map());
const persistTimers = useRef(new Map());
useEffect(() => {
if (!autoLoad) {
return undefined;
}
const controller = new AbortController();
const load = async () => {
dispatch({ type: PRODUCTS_ACTIONS.SET_LOADING, payload: true });
try {
const loadedItems = await fetchProducts({ signal: controller.signal });
dispatch({ type: PRODUCTS_ACTIONS.SET_ITEMS, payload: loadedItems });
} catch (error) {
if (controller.signal.aborted) {
return;
}
dispatch({
type: PRODUCTS_ACTIONS.SET_ERROR,
payload: getApiErrorMessage(error),
});
}
};
load();
return () => {
controller.abort();
};
}, [autoLoad]);
const loadProducts = useCallback(async () => {
dispatch({ type: PRODUCTS_ACTIONS.SET_LOADING, payload: true });
try {
const loadedItems = await fetchProducts();
dispatch({ type: PRODUCTS_ACTIONS.SET_ITEMS, payload: loadedItems });
} catch (error) {
dispatch({
type: PRODUCTS_ACTIONS.SET_ERROR,
payload: getApiErrorMessage(error),
});
}
}, []);
const flushPatch = useCallback(
async (id) => {
const patch = pendingPatches.current.get(id);
pendingPatches.current.delete(id);
if (persistTimers.current.has(id)) {
clearTimeout(persistTimers.current.get(id));
persistTimers.current.delete(id);
}
if (!patch || Object.keys(patch).length === 0) {
return;
}
try {
const updated = await patchProduct(id, patch);
dispatch({ type: PRODUCTS_ACTIONS.SET_ITEM, payload: updated });
} catch (error) {
dispatch({
type: PRODUCTS_ACTIONS.SET_ERROR,
payload: getApiErrorMessage(error),
});
await loadProducts();
}
},
[loadProducts],
);
const schedulePatch = useCallback(
(id, patch) => {
const merged = { ...(pendingPatches.current.get(id) || {}), ...patch };
pendingPatches.current.set(id, merged);
if (persistTimers.current.has(id)) {
clearTimeout(persistTimers.current.get(id));
}
persistTimers.current.set(
id,
setTimeout(() => {
flushPatch(id);
}, PERSIST_DELAY_MS),
);
},
[flushPatch],
);
useEffect(() => {
const timers = persistTimers.current;
const patches = pendingPatches.current;
return () => {
timers.forEach((timer) => clearTimeout(timer));
timers.clear();
patches.forEach((patch, id) => {
if (
patch &&
Object.keys(patch).length > 0 &&
apiClient.defaults.headers.common.Authorization
) {
patchProduct(id, patch).catch(() => {});
}
});
patches.clear();
};
}, []);
const updateAmount = useCallback(
(amount, id) => {
dispatch({
type: PRODUCTS_ACTIONS.UPDATE_AMOUNT,
payload: { id, amount },
});
const stock = Number(amount);
if (Number.isFinite(stock)) {
schedulePatch(id, { stock });
}
},
[schedulePatch],
);
const updatePrice = useCallback(
(price, id) => {
dispatch({ type: PRODUCTS_ACTIONS.UPDATE_PRICE, payload: { id, price } });
const numericPrice = Number(price);
if (Number.isFinite(numericPrice)) {
schedulePatch(id, { price: numericPrice });
}
},
[schedulePatch],
);
const addProduct = useCallback(async (product) => {
try {
const created = await createProduct(product);
dispatch({ type: PRODUCTS_ACTIONS.ADD_ITEM, payload: created });
return created;
} catch (error) {
dispatch({
type: PRODUCTS_ACTIONS.SET_ERROR,
payload: getApiErrorMessage(error),
});
throw error;
}
}, []);
const saveProduct = useCallback(async (id, product) => {
try {
const updated = await updateProduct(id, product);
dispatch({ type: PRODUCTS_ACTIONS.SET_ITEM, payload: updated });
return updated;
} catch (error) {
dispatch({
type: PRODUCTS_ACTIONS.SET_ERROR,
payload: getApiErrorMessage(error),
});
throw error;
}
}, []);
const removeProduct = useCallback(async (id) => {
try {
await deleteProduct(id);
dispatch({ type: PRODUCTS_ACTIONS.REMOVE_ITEM, payload: id });
} catch (error) {
dispatch({
type: PRODUCTS_ACTIONS.SET_ERROR,
payload: getApiErrorMessage(error),
});
throw error;
}
}, []);
const actions = useMemo(
() => ({
loadProducts,
updateAmount,
updatePrice,
addProduct,
saveProduct,
removeProduct,
}),
[
loadProducts,
updateAmount,
updatePrice,
addProduct,
saveProduct,
removeProduct,
],
);
return (
<ProductsStateContext.Provider value={state}>
<ProductsDispatchContext.Provider value={dispatch}>
<ProductsActionsContext.Provider value={actions}>
{children}
</ProductsActionsContext.Provider>
</ProductsDispatchContext.Provider>
</ProductsStateContext.Provider>
);
};
export const useProductsState = () => {
const state = useContext(ProductsStateContext);
if (state == null) {
throw new Error(
'useProductsState muss innerhalb von ProductsProvider verwendet werden',
);
}
return state;
};
export const useProductsDispatch = () => {
const dispatch = useContext(ProductsDispatchContext);
if (dispatch == null) {
throw new Error(
'useProductsDispatch muss innerhalb von ProductsProvider verwendet werden',
);
}
return dispatch;
};
export const useProductsActions = () => {
const actions = useContext(ProductsActionsContext);
if (actions == null) {
throw new Error(
'useProductsActions muss innerhalb von ProductsProvider verwendet werden',
);
}
return actions;
};

View File

@@ -0,0 +1,37 @@
import { apiClient, getApiErrorMessage } from '../http';
export { getApiErrorMessage };
export const fetchProducts = async (config = {}) => {
const { data } = await apiClient.get('/products', config);
if (!Array.isArray(data)) {
throw new Error('Ungültige API-Antwort: Produktliste erwartet');
}
return data;
};
export const fetchProduct = async (id, config = {}) => {
const { data } = await apiClient.get(`/products/${id}`, config);
return data;
};
export const createProduct = async (product) => {
const { data } = await apiClient.post('/products', product);
return data;
};
export const updateProduct = async (id, product) => {
const { data } = await apiClient.put(`/products/${id}`, product);
return data;
};
export const patchProduct = async (id, patch) => {
const { data } = await apiClient.patch(`/products/${id}`, patch);
return data;
};
export const deleteProduct = async (id) => {
await apiClient.delete(`/products/${id}`);
};

View File

@@ -0,0 +1,17 @@
export { createInitialState } from './initialState';
export { PRODUCTS_ACTIONS, productsReducer } from './reducer';
export {
ProductsProvider,
useProductsActions,
useProductsDispatch,
useProductsState,
} from './ProductsStore';
export {
createProduct,
deleteProduct,
fetchProduct,
fetchProducts,
getApiErrorMessage,
patchProduct,
updateProduct,
} from './api';

View File

@@ -0,0 +1,7 @@
export const createInitialState = (items) => {
return {
items: items || [],
loading: true,
error: null,
};
};

View File

@@ -0,0 +1,73 @@
export const PRODUCTS_ACTIONS = {
SET_LOADING: 'SET_LOADING',
SET_ITEMS: 'SET_ITEMS',
SET_ITEM: 'SET_ITEM',
SET_ERROR: 'SET_ERROR',
ADD_ITEM: 'ADD_ITEM',
REMOVE_ITEM: 'REMOVE_ITEM',
UPDATE_AMOUNT: 'UPDATE_AMOUNT',
UPDATE_PRICE: 'UPDATE_PRICE',
};
export const productsReducer = (state, action) => {
switch (action.type) {
case PRODUCTS_ACTIONS.SET_LOADING:
return {
...state,
loading: action.payload,
error: action.payload ? null : state.error,
};
case PRODUCTS_ACTIONS.SET_ITEMS:
return {
...state,
items: action.payload,
loading: false,
error: null,
};
case PRODUCTS_ACTIONS.SET_ITEM:
return {
...state,
items: state.items.map((item) => (item._id === action.payload._id ? action.payload : item)),
error: null,
};
case PRODUCTS_ACTIONS.SET_ERROR:
return {
...state,
loading: false,
error: action.payload,
};
case PRODUCTS_ACTIONS.ADD_ITEM:
return {
...state,
items: [...state.items, action.payload],
error: null,
};
case PRODUCTS_ACTIONS.REMOVE_ITEM:
return {
...state,
items: state.items.filter((item) => item._id !== action.payload),
error: null,
};
case PRODUCTS_ACTIONS.UPDATE_AMOUNT:
return {
...state,
items: state.items.map((item) =>
item._id === action.payload.id
? { ...item, stock: action.payload.amount } //
: item
),
};
case PRODUCTS_ACTIONS.UPDATE_PRICE:
return {
...state,
items: state.items.map((item) =>
item._id === action.payload.id
? { ...item, price: action.payload.price } //
: item
),
};
default:
console.warn('no action found');
return state;
}
};

View File

@@ -0,0 +1,72 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useReducer } from 'react';
import { useAuthState } from '../auth';
import { fetchUsers, getApiErrorMessage } from './api';
import { createInitialState } from './initialState';
import { USERS_ACTIONS, usersReducer } from './reducer';
const UsersStateContext = createContext(null);
const UsersActionsContext = createContext(null);
export const UsersProvider = (props) => {
const { children, items = [], autoLoad = true } = props;
const { token } = useAuthState();
const [state, dispatch] = useReducer(usersReducer, items, createInitialState);
const loadUsers = useCallback(async (signal) => {
dispatch({ type: USERS_ACTIONS.SET_LOADING, payload: true });
try {
const loadedItems = await fetchUsers(signal ? { signal } : {});
dispatch({ type: USERS_ACTIONS.SET_ITEMS, payload: loadedItems });
} catch (error) {
const message = getApiErrorMessage(error);
if (message == null) {
return;
}
dispatch({ type: USERS_ACTIONS.SET_ERROR, payload: message });
}
}, []);
useEffect(() => {
if (!autoLoad || !token) {
return undefined;
}
const controller = new AbortController();
loadUsers(controller.signal);
return () => {
controller.abort();
};
}, [autoLoad, token, loadUsers]);
const actions = useMemo(() => ({ loadUsers: () => loadUsers() }), [loadUsers]);
return (
<UsersStateContext.Provider value={state}>
<UsersActionsContext.Provider value={actions}>{children}</UsersActionsContext.Provider>
</UsersStateContext.Provider>
);
};
export const useUsersState = () => {
const state = useContext(UsersStateContext);
if (state == null) {
throw new Error('useUsersState muss innerhalb von UsersProvider verwendet werden');
}
return state;
};
export const useUsersActions = () => {
const actions = useContext(UsersActionsContext);
if (actions == null) {
throw new Error('useUsersActions muss innerhalb von UsersProvider verwendet werden');
}
return actions;
};

View File

@@ -0,0 +1,13 @@
import { apiClient, getApiErrorMessage } from '../http';
export { getApiErrorMessage };
export const fetchUsers = async (config = {}) => {
const { data } = await apiClient.get('/users', config);
if (!Array.isArray(data)) {
throw new Error('Ungültige API-Antwort: Benutzerliste erwartet');
}
return data;
};

View File

@@ -0,0 +1,4 @@
export { createInitialState } from './initialState';
export { USERS_ACTIONS, usersReducer } from './reducer';
export { UsersProvider, useUsersActions, useUsersState } from './UsersStore';
export { fetchUsers, getApiErrorMessage } from './api';

Some files were not shown because too many files have changed in this diff Show More