feat: update project
This commit is contained in:
@@ -15,16 +15,16 @@
|
|||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@tailwindcss/forms": "^0.5.11",
|
"@tailwindcss/forms": "^0.5.11",
|
||||||
"@testing-library/jest-dom": "^7.0.1",
|
"@testing-library/jest-dom": "^7.0.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.3",
|
||||||
"@testing-library/user-event": "^14.6.6",
|
"@testing-library/user-event": "^14.6.6",
|
||||||
"@types/react": "^19.2.18",
|
"@types/react": "^19.2.18",
|
||||||
"@types/react-dom": "^19.2.5",
|
"@types/react-dom": "^19.2.5",
|
||||||
"@vitejs/plugin-react": "^6.1.0",
|
"@vitejs/plugin-react": "^6.1.1",
|
||||||
"@vitest/ui": "^4.1.11",
|
"@vitest/ui": "^4.1.11",
|
||||||
"autoprefixer": "^10.5.4",
|
"autoprefixer": "^10.5.4",
|
||||||
"eslint": "^10.9.0",
|
"eslint": "^10.9.1",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.4",
|
"eslint-plugin-react-refresh": "^0.5.5",
|
||||||
"globals": "^17.11.0",
|
"globals": "^17.11.0",
|
||||||
"jsdom": "^30.0.1",
|
"jsdom": "^30.0.1",
|
||||||
"postcss": "^8.5.26",
|
"postcss": "^8.5.26",
|
||||||
@@ -1223,9 +1223,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@testing-library/react": {
|
"node_modules/@testing-library/react": {
|
||||||
"version": "16.3.2",
|
"version": "16.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz",
|
||||||
"integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
|
"integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1332,9 +1332,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "6.1.0",
|
"version": "6.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz",
|
||||||
"integrity": "sha512-qd2BzUBehkov86WFhg0JkEFEYyCLG9uPCe6qWTY/kRlss9OvJrOF2UbIWT7p+8IzZHkEu0DNGHc4HSv+JdDLsw==",
|
"integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2058,9 +2058,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint": {
|
"node_modules/eslint": {
|
||||||
"version": "10.9.0",
|
"version": "10.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.9.1.tgz",
|
||||||
"integrity": "sha512-5KeEOJZBfEVA47boFiBsf+6MmmJpffM7qEBg4pLla2e4nlKgdKlqCW0oSLOGsT8Wl5uCGJptLV1bkaiShj90Gw==",
|
"integrity": "sha512-9VaAkDURekixUQJy0oJYl2DcN6oKMfxay7XzaGYAWQwsb6qfKf+x76R2k1L8kb1boc+FyCAaTA9GmiKaaiaF+A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -2137,9 +2137,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint-plugin-react-refresh": {
|
"node_modules/eslint-plugin-react-refresh": {
|
||||||
"version": "0.5.4",
|
"version": "0.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.5.tgz",
|
||||||
"integrity": "sha512-7bqTKz7T0r+HKWFarNXByDE9/5+73wI2ru+M3zuqGbR7s/b/5/pQJXZoufWlrngqGqoZto73ZkGumCdLxk+4rw==",
|
"integrity": "sha512-vG7yLURXNvCHy0FBdbZRwIu0BLPJMlUUJS2Ep7ud9w1YCLftFZtuEjyjhym0Qq9yuZ6LJUitNlu/hMk0gakXAw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
|||||||
@@ -19,16 +19,16 @@
|
|||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@tailwindcss/forms": "^0.5.11",
|
"@tailwindcss/forms": "^0.5.11",
|
||||||
"@testing-library/jest-dom": "^7.0.1",
|
"@testing-library/jest-dom": "^7.0.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.3",
|
||||||
"@testing-library/user-event": "^14.6.6",
|
"@testing-library/user-event": "^14.6.6",
|
||||||
"@types/react": "^19.2.18",
|
"@types/react": "^19.2.18",
|
||||||
"@types/react-dom": "^19.2.5",
|
"@types/react-dom": "^19.2.5",
|
||||||
"@vitejs/plugin-react": "^6.1.0",
|
"@vitejs/plugin-react": "^6.1.1",
|
||||||
"@vitest/ui": "^4.1.11",
|
"@vitest/ui": "^4.1.11",
|
||||||
"autoprefixer": "^10.5.4",
|
"autoprefixer": "^10.5.4",
|
||||||
"eslint": "^10.9.0",
|
"eslint": "^10.9.1",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.4",
|
"eslint-plugin-react-refresh": "^0.5.5",
|
||||||
"globals": "^17.11.0",
|
"globals": "^17.11.0",
|
||||||
"jsdom": "^30.0.1",
|
"jsdom": "^30.0.1",
|
||||||
"postcss": "^8.5.26",
|
"postcss": "^8.5.26",
|
||||||
|
|||||||
@@ -18,17 +18,13 @@ describe('GoalSetting Component', () => {
|
|||||||
handleChange={() => {}}
|
handleChange={() => {}}
|
||||||
handleCheckboxChange={() => {}}
|
handleCheckboxChange={() => {}}
|
||||||
errors={{}}
|
errors={{}}
|
||||||
/>
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Use getByRole to target the fieldset for "What are your goals?"
|
// Use getByRole to target the fieldset for "What are your goals?"
|
||||||
expect(
|
expect(screen.getByRole('group', { name: /What are your goals\?/i })).toBeInTheDocument();
|
||||||
screen.getByRole('group', { name: /What are your goals\?/i })
|
|
||||||
).toBeInTheDocument();
|
|
||||||
|
|
||||||
expect(
|
expect(screen.getByLabelText(/How much time can you commit each day\?/i)).toBeInTheDocument();
|
||||||
screen.getByLabelText(/How much time can you commit each day\?/i)
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('displays validation errors for empty fields', () => {
|
it('displays validation errors for empty fields', () => {
|
||||||
@@ -38,21 +34,10 @@ describe('GoalSetting Component', () => {
|
|||||||
};
|
};
|
||||||
const errors = validate(formData, step);
|
const errors = validate(formData, step);
|
||||||
|
|
||||||
render(
|
render(<GoalSetting formData={formData} handleChange={() => {}} handleCheckboxChange={() => {}} errors={errors} />);
|
||||||
<GoalSetting
|
|
||||||
formData={formData}
|
|
||||||
handleChange={() => {}}
|
|
||||||
handleCheckboxChange={() => {}}
|
|
||||||
errors={errors}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(
|
expect(screen.getByText(/At least one goal is required/i)).toBeInTheDocument();
|
||||||
screen.getByText(/At least one goal is required/i)
|
expect(screen.getByText(/Time Commitment is required/i)).toBeInTheDocument();
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(
|
|
||||||
screen.getByText(/Time Commitment is required/i)
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('displays no validation errors for valid data', () => {
|
it('displays no validation errors for valid data', () => {
|
||||||
@@ -62,14 +47,7 @@ describe('GoalSetting Component', () => {
|
|||||||
};
|
};
|
||||||
const errors = validate(formData, step);
|
const errors = validate(formData, step);
|
||||||
|
|
||||||
render(
|
render(<GoalSetting formData={formData} handleChange={() => {}} handleCheckboxChange={() => {}} errors={errors} />);
|
||||||
<GoalSetting
|
|
||||||
formData={formData}
|
|
||||||
handleChange={() => {}}
|
|
||||||
handleCheckboxChange={() => {}}
|
|
||||||
errors={errors}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.queryByText(/is required/i)).not.toBeInTheDocument();
|
expect(screen.queryByText(/is required/i)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -79,9 +57,7 @@ describe('GoalSetting Component', () => {
|
|||||||
let errors = validate(formData, step);
|
let errors = validate(formData, step);
|
||||||
|
|
||||||
const handleCheckboxChangeTest = (field, id, isChecked) => {
|
const handleCheckboxChangeTest = (field, id, isChecked) => {
|
||||||
const updatedGoals = isChecked
|
const updatedGoals = isChecked ? [...formData[field], id] : formData[field].filter((goal) => goal !== id);
|
||||||
? [...formData[field], id]
|
|
||||||
: formData[field].filter((goal) => goal !== id);
|
|
||||||
|
|
||||||
formData = { ...formData, [field]: updatedGoals };
|
formData = { ...formData, [field]: updatedGoals };
|
||||||
errors = validate(formData, step);
|
errors = validate(formData, step);
|
||||||
@@ -93,7 +69,7 @@ describe('GoalSetting Component', () => {
|
|||||||
handleChange={() => {}}
|
handleChange={() => {}}
|
||||||
handleCheckboxChange={handleCheckboxChangeTest}
|
handleCheckboxChange={handleCheckboxChangeTest}
|
||||||
errors={errors}
|
errors={errors}
|
||||||
/>
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const weightLossCheckbox = screen.getByLabelText(/Weight Loss/i);
|
const weightLossCheckbox = screen.getByLabelText(/Weight Loss/i);
|
||||||
@@ -106,13 +82,11 @@ describe('GoalSetting Component', () => {
|
|||||||
handleChange={() => {}}
|
handleChange={() => {}}
|
||||||
handleCheckboxChange={handleCheckboxChangeTest}
|
handleCheckboxChange={handleCheckboxChangeTest}
|
||||||
errors={errors}
|
errors={errors}
|
||||||
/>
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(weightLossCheckbox).toBeChecked();
|
expect(weightLossCheckbox).toBeChecked();
|
||||||
expect(
|
expect(screen.queryByText(/At least one goal is required/i)).not.toBeInTheDocument();
|
||||||
screen.queryByText(/At least one goal is required/i)
|
|
||||||
).not.toBeInTheDocument();
|
|
||||||
|
|
||||||
await userEvent.click(muscleGainCheckbox);
|
await userEvent.click(muscleGainCheckbox);
|
||||||
rerender(
|
rerender(
|
||||||
@@ -121,7 +95,7 @@ describe('GoalSetting Component', () => {
|
|||||||
handleChange={() => {}}
|
handleChange={() => {}}
|
||||||
handleCheckboxChange={handleCheckboxChangeTest}
|
handleCheckboxChange={handleCheckboxChangeTest}
|
||||||
errors={errors}
|
errors={errors}
|
||||||
/>
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(muscleGainCheckbox).toBeChecked();
|
expect(muscleGainCheckbox).toBeChecked();
|
||||||
@@ -141,33 +115,25 @@ describe('GoalSetting Component', () => {
|
|||||||
const { rerender } = render(
|
const { rerender } = render(
|
||||||
<GoalSetting
|
<GoalSetting
|
||||||
formData={formData}
|
formData={formData}
|
||||||
handleChange={({ target }) =>
|
handleChange={({ target }) => handleChangeTest(target.name, target.value)}
|
||||||
handleChangeTest(target.name, target.value)
|
|
||||||
}
|
|
||||||
handleCheckboxChange={() => {}}
|
handleCheckboxChange={() => {}}
|
||||||
errors={errors}
|
errors={errors}
|
||||||
/>
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const timeCommitmentSelect = screen.getByLabelText(
|
const timeCommitmentSelect = screen.getByLabelText(/How much time can you commit each day\?/i);
|
||||||
/How much time can you commit each day\?/i
|
await userEvent.selectOptions(timeCommitmentSelect, '30');
|
||||||
);
|
|
||||||
await userEvent.selectOptions(timeCommitmentSelect, '45');
|
|
||||||
|
|
||||||
rerender(
|
rerender(
|
||||||
<GoalSetting
|
<GoalSetting
|
||||||
formData={formData}
|
formData={formData}
|
||||||
handleChange={({ target }) =>
|
handleChange={({ target }) => handleChangeTest(target.name, target.value)}
|
||||||
handleChangeTest(target.name, target.value)
|
|
||||||
}
|
|
||||||
handleCheckboxChange={() => {}}
|
handleCheckboxChange={() => {}}
|
||||||
errors={errors}
|
errors={errors}
|
||||||
/>
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(timeCommitmentSelect).toHaveValue('30');
|
expect(timeCommitmentSelect).toHaveValue('30');
|
||||||
expect(
|
expect(screen.queryByText(/Time Commitment is required/i)).not.toBeInTheDocument();
|
||||||
screen.queryByText(/Time Commitment is required/i)
|
|
||||||
).not.toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,46 @@
|
|||||||
|
|
||||||
|
## Map
|
||||||
|
|
||||||
|
Ein **`Array`** ist eine geordnete Liste indizierter Werte (Zugriff über numerische Indizes `0, 1, 2...`), während eine **`Map`** eine geordnete Sammlung von **Schlüssel-Wert-Paaren (Key-Value Pairs)** ist, bei der beliebige Datentypen als Schlüssel dienen können.
|
||||||
|
|
||||||
|
| Merkmal | `Array` (`[]` / `new Array()`) | `Map` (`new Map()`) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Datenstruktur** | Geordnete Liste von Elementen | Geordnete Schlüssel-Wert-Paare |
|
||||||
|
| **Schlüssel / Indizes** | Ausschließlich sequentielle Integer (`0, 1, 2...`) | Beliebige Typen (Objekte, Funktionen, Strings, Booleans) |
|
||||||
|
| **Elementanzahl** | `array.length` | `map.size` |
|
||||||
|
| **Zugriff / Abfrage** | Index `arr[0]` oder Suche `arr.find(...)` ($O(n)$) | Direkter Lookup `map.get(key)` ($O(1)$) |
|
||||||
|
| **Vorhandensein prüfen** | `arr.includes(val)` ($O(n)$) | `map.has(key)` ($O(1)$) |
|
||||||
|
| **Einfügen / Löschen** | `push()`, `splice()` (Verschiebung im Speicher) | `set(key, val)`, `delete(key)` (Optimiert für Keys) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Wann macht `Map` Sinn?**
|
||||||
|
|
||||||
|
* **Häufiges Nachschlagen (Lookups):** Wenn du Werte anhand eines eindeutigen Identifikators (ID, Slug, Token) extrem schnell abrufen musst ($O(1)$ statt Array-Iteration mit `find()`).
|
||||||
|
* **Objekte oder Funktionen als Schlüssel:** Im Gegensatz zu regulären JS-Objekten (die Keys immer in Strings/Symbols umwandeln) akzeptiert `Map` Referenzen als Key:
|
||||||
|
```javascript
|
||||||
|
const userMap = new Map();
|
||||||
|
const userObj = { id: 42 };
|
||||||
|
|
||||||
|
userMap.set(userObj, { role: "admin", active: true });
|
||||||
|
console.log(userMap.get(userObj)); // { role: "admin", active: true }
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
* **Häufiges Hinzufügen und Entfernen:** `Map` ist intern für dynamisches Hinzufügen (`set`) und Löschen (`delete`) optimiert. Beim Entfernen aus einem Array via `splice()` müssen alle nachfolgenden Indizes neu berechnet werden.
|
||||||
|
* **Saubere Schlüssel ohne Prototype-Pollution:** Ein normales Objekt `{}` erbt Standard-Methoden wie `toString`. Eine `Map` enthält strikt nur die Schlüssel, die du selbst definierst.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Wann bleibt `Array` die bessere Wahl?**
|
||||||
|
|
||||||
|
* Wenn die **Reihenfolge und Indexposition** im Vordergrund stehen (z. B. Sortieren, Paginieren).
|
||||||
|
* Wenn du Transformations-Pipelines wie `.map()`, `.filter()`, `.reduce()` oder `.flat()` brauchst.
|
||||||
|
* Für homogene Datenlisten, die direkt als JSON serialisiert werden sollen (`JSON.stringify()` unterstützt Arrays nativ, Maps müssen vorher konvertiert werden).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# React + Vite
|
# React + Vite
|
||||||
|
|
||||||
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
import RowProduct from './content/RowProduct';
|
import RowProduct from './content/RowProduct';
|
||||||
import { ProductsProvider, useProductsState } from '../../stores/products';
|
import { ProductsProvider, useProductsActions, useProductsState } from '../../stores/products';
|
||||||
|
import { FaSpinner } from 'react-icons/fa6';
|
||||||
|
|
||||||
|
// import { useContext } from 'react';
|
||||||
|
// import { ProductsStateContext } from '../../stores/products/ProductsStore';
|
||||||
|
|
||||||
const TableProductsView = (props) => {
|
const TableProductsView = (props) => {
|
||||||
const { items } = useProductsState(); // const {items} = useContext(ProductsStateContext);
|
// const state = useContext(ProductsStateContext);
|
||||||
|
// const { items, loading, error } = state;
|
||||||
|
const { items, loading, error } = useProductsState(); // const {items} = useContext(ProductsStateContext);
|
||||||
|
|
||||||
|
const { loadProducts } = useProductsActions();
|
||||||
|
|
||||||
// const dispatch = useProductsDispatch() // const dispatch = useContext(ProductsDispatchContext);
|
// const dispatch = useProductsDispatch() // const dispatch = useContext(ProductsDispatchContext);
|
||||||
|
|
||||||
//const [{ items }, dispatch] = useReducer(reducer, initialState(props));
|
//const [{ items }, dispatch] = useReducer(reducer, initialState(props));
|
||||||
|
|
||||||
// EventHandler ============
|
// EventHandler ============
|
||||||
@@ -26,16 +33,28 @@ const TableProductsView = (props) => {
|
|||||||
}, 0);
|
}, 0);
|
||||||
}, [items]);
|
}, [items]);
|
||||||
|
|
||||||
// useCallback weiter Einsatzbereich in Kombination mit ref- Attribut
|
if (loading && items.length === 0) {
|
||||||
const theadRef = useCallback((el) => {
|
return (
|
||||||
if (el) console.log(el);
|
<p className="text-muted alert alert-info">
|
||||||
}, []);
|
Produkte werden geladen....
|
||||||
|
<FaSpinner className="fa-spin" />
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="m-table-products table-products">
|
<div className="m-table-products table-products">
|
||||||
<div className="container py-2">
|
<div className="container py-2">
|
||||||
|
{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">
|
<table className="table table-striped">
|
||||||
<thead className="table-dark" ref={theadRef}>
|
<thead className="table-dark">
|
||||||
<tr>
|
<tr>
|
||||||
<th>SKU</th>
|
<th>SKU</th>
|
||||||
<th>Stock</th>
|
<th>Stock</th>
|
||||||
|
|||||||
@@ -1,34 +1,281 @@
|
|||||||
import { createContext, useCallback, useContext, useReducer } from 'react';
|
import { createContext, useCallback, useContext, useEffect, useMemo, useReducer, useRef } from 'react';
|
||||||
import { PRODUCTS_ACTIONS, productsReducer } from './reducer';
|
|
||||||
import { createInitialState } from './initialState';
|
import { createInitialState } from './initialState';
|
||||||
|
import { PRODUCTS_ACTIONS, productsReducer } from './reducer';
|
||||||
|
|
||||||
const ProductsStateContext = createContext(null);
|
import { createProduct, deleteProduct, fetchProducts, getApiErrorMessage, patchProduct, updateProduct } from './api';
|
||||||
|
|
||||||
|
// Zeitverzögerung für das Debouncing (in ms), bevor Änderungen an den Server gesendet werden.
|
||||||
|
const PERSIST_DELAY_MS = 400;
|
||||||
|
|
||||||
|
export const ProductsStateContext = createContext(null);
|
||||||
const ProductsDispatchContext = createContext(null);
|
const ProductsDispatchContext = createContext(null);
|
||||||
|
const ProductsActionsContext = createContext(null);
|
||||||
// const ProductsContext = createContext(null);
|
|
||||||
|
|
||||||
export const ProductsProvider = (props) => {
|
export const ProductsProvider = (props) => {
|
||||||
const { children, items = [] } = props;
|
const { children, items = [], autoLoad = true } = props;
|
||||||
|
|
||||||
const [state, dispatch] = useReducer(productsReducer, items, createInitialState);
|
const [state, dispatch] = useReducer(
|
||||||
|
productsReducer,
|
||||||
|
items,
|
||||||
|
createInitialState, // Lazy Initialization: Wird nur beim ersten Mounten ausgeführt.
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WARUM `useRef(new Map())` FÜR PATCHES & TIMER?
|
||||||
|
*
|
||||||
|
* 1. Überleben von Rerendern: `useRef` behält Werte über Renders hinweg stabil,
|
||||||
|
* ohne bei einer Änderung ein neues Rerendern der Komponente auszulösen.
|
||||||
|
* 2. Granularität pro Produkt: Da wir `Map` nutzen (Key = Product-ID), verwalten wir
|
||||||
|
* ausstehende Patches und Timer isoliert pro Item. Ein Ändern von Produkt A bricht
|
||||||
|
* den Timer für Produkt B nicht ab.
|
||||||
|
* 3. Kein API-Spam (Debouncing + Merging): Schnelle Änderungen (z. B. Tippen im Input-Feld)
|
||||||
|
* werden lokal gepuffert und erst nach Ablauf der Inaktivitätszeit als gebündelter Request gesendet.
|
||||||
|
*/
|
||||||
|
const pendingPatches = useRef(new Map()); // Speichert: Map<productId, { stock?: number, price?: number }>
|
||||||
|
const persistTimers = useRef(new Map()); // Speichert: Map<productId, setTimeoutId>
|
||||||
|
|
||||||
|
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(); // Bricht laufende Requests ab, falls die Komponente unmountet.
|
||||||
|
};
|
||||||
|
}, [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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `flushPatch(id)`
|
||||||
|
*
|
||||||
|
* Zweck: Führt das tatsächliche Senden der gepufferten Änderungen an das Backend aus.
|
||||||
|
*
|
||||||
|
* Ablauf:
|
||||||
|
* 1. Liest und entfernt den gepufferten Patch für die Produkt-ID aus `pendingPatches`.
|
||||||
|
* 2. Löscht ggf. den aktiven Timer für dieses Produkt aus `persistTimers`.
|
||||||
|
* 3. Sendet den `patchProduct`-Request an die API.
|
||||||
|
* 4. Bei Erfolg: Aktualisiert das Produkt im lokalen Reducer-State mit den Server-Daten.
|
||||||
|
* 5. Bei Fehler: Setzt eine Fehlermeldung und ruft `loadProducts()` auf, um inkonsistente
|
||||||
|
* optimistische UI-Zustände mit dem Serverzustand zu synchronisieren (Rollback).
|
||||||
|
*/
|
||||||
|
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],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `schedulePatch(id, patch)`
|
||||||
|
*
|
||||||
|
* Zweck: Plant das verzögerte Speichern ein (Debounce) und aggregiert Teildaten.
|
||||||
|
*
|
||||||
|
* Ablauf:
|
||||||
|
* 1. Merged neue Teiländerungen mit bereits ausstehenden Änderungen für diese ID
|
||||||
|
* (z. B. `{ stock: 5 }` + `{ price: 10 }` => `{ stock: 5, price: 10 }`).
|
||||||
|
* 2. Setzt einen eventuell laufenden Timeout für dieses Produkt zurück (Reset Debounce-Timer).
|
||||||
|
* 3. Startet einen neuen Timer über `PERSIST_DELAY_MS` (400 ms), nach dessen Ablauf `flushPatch(id)` aufgerufen wird.
|
||||||
|
*/
|
||||||
|
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],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unmount-Cleanup:
|
||||||
|
* Wenn der Provider unmountet wird (z. B. Seitenwechsel), werden alle offenen Timer gecancelt
|
||||||
|
* und noch ausstehende Patches sofort "best-effort" an die API geschickt, damit keine Daten verloren gehen.
|
||||||
|
*/
|
||||||
|
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) {
|
||||||
|
patchProduct(id, patch).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
patches.clear();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `updateAmount` & `updatePrice`:
|
||||||
|
* Setzen auf Optimistic UI.
|
||||||
|
* 1. Der UI-State wird sofort synchron via `dispatch` aktualisiert (keine Eingabelatenz für den User).
|
||||||
|
* 2. Das Backend-Update wird verzögert via `schedulePatch` eingereiht.
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Memoisierung der Action-Funktionen, damit Konsumenten des Actions-Contexts
|
||||||
|
// nicht bei jeder State-Änderung unnötig neu rendern.
|
||||||
|
const actions = useMemo(
|
||||||
|
() => ({
|
||||||
|
loadProducts,
|
||||||
|
updateAmount,
|
||||||
|
updatePrice,
|
||||||
|
addProduct,
|
||||||
|
saveProduct,
|
||||||
|
removeProduct,
|
||||||
|
}),
|
||||||
|
[loadProducts, updateAmount, updatePrice, addProduct, saveProduct, removeProduct],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// <ProductsContext.Provider value={{state, dispatch}}>
|
|
||||||
// {children}
|
|
||||||
// </ProductsContext.Provider>
|
|
||||||
|
|
||||||
<ProductsStateContext.Provider value={state}>
|
<ProductsStateContext.Provider value={state}>
|
||||||
<ProductsDispatchContext.Provider value={dispatch}>{children}</ProductsDispatchContext.Provider>
|
<ProductsDispatchContext.Provider value={dispatch}>
|
||||||
|
<ProductsActionsContext.Provider value={actions}>{children}</ProductsActionsContext.Provider>
|
||||||
|
</ProductsDispatchContext.Provider>
|
||||||
</ProductsStateContext.Provider>
|
</ProductsStateContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// eigene Hooks mit ErrorHandler
|
|
||||||
export const useProductsState = () => {
|
export const useProductsState = () => {
|
||||||
const state = useContext(ProductsStateContext);
|
const state = useContext(ProductsStateContext);
|
||||||
|
|
||||||
if (state == null) {
|
if (state == null) {
|
||||||
throw Error('useProductsState muss innerhalb von ProductsProvider verwendet werden.');
|
throw new Error('useProductsState muss innerhalb von ProductsProvider verwendet werden');
|
||||||
}
|
}
|
||||||
|
|
||||||
return state;
|
return state;
|
||||||
@@ -38,32 +285,18 @@ export const useProductsDispatch = () => {
|
|||||||
const dispatch = useContext(ProductsDispatchContext);
|
const dispatch = useContext(ProductsDispatchContext);
|
||||||
|
|
||||||
if (dispatch == null) {
|
if (dispatch == null) {
|
||||||
throw Error('useProductsDispatch muss innerhalb von ProductsProvider verwendet werden.');
|
throw new Error('useProductsDispatch muss innerhalb von ProductsProvider verwendet werden');
|
||||||
}
|
}
|
||||||
|
|
||||||
return dispatch;
|
return dispatch;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Factory Function
|
|
||||||
export const useProductsActions = () => {
|
export const useProductsActions = () => {
|
||||||
const dispatch = useProductsDispatch();
|
const actions = useContext(ProductsActionsContext);
|
||||||
|
|
||||||
const updateAmount = useCallback(
|
if (actions == null) {
|
||||||
(amount, id) => {
|
throw new Error('useProductsActions muss innerhalb von ProductsProvider verwendet werden');
|
||||||
dispatch({ type: PRODUCTS_ACTIONS.UPDATE_AMOUNT, payload: { id, amount } });
|
}
|
||||||
},
|
|
||||||
[dispatch],
|
|
||||||
);
|
|
||||||
|
|
||||||
const updatePrice = useCallback(
|
return actions;
|
||||||
(price, id) => {
|
|
||||||
dispatch({ type: PRODUCTS_ACTIONS.UPDATE_PRICE, payload: { id, price } });
|
|
||||||
},
|
|
||||||
[dispatch],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
updateAmount,
|
|
||||||
updatePrice,
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|||||||
54
webseite-react-php/react-app/src/stores/products/api.js
vendored
Normal file
54
webseite-react-php/react-app/src/stores/products/api.js
vendored
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const productsClient = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_API_URL ?? '/api',
|
||||||
|
timeout: 8000,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getApiErrorMessage = (error) => {
|
||||||
|
if (axios.isCancel(error) || error.code === 'ERR_CANCELED') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return error.response?.data?.error || error.message || 'Unbekannter API Fehler';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchProducts = async (config = {}) => {
|
||||||
|
const { data } = await productsClient.get('/products', config);
|
||||||
|
|
||||||
|
if (!Array.isArray(data)) {
|
||||||
|
throw new Error('Ungültige API-Antwort: Produktliste wird erwartet.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchProduct = async (id, config = {}) => {
|
||||||
|
const { data } = await productsClient.get(`/products/${id}`, config);
|
||||||
|
|
||||||
|
if (!Object.hasOwn(data, _id)) {
|
||||||
|
throw new Error('Ungültige API-Antwort: Produkt wird erwartet. (keine id)');
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createProduct = async (product) => {
|
||||||
|
const { data } = await productsClient.post('/products', product);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateProduct = async (id, product) => {
|
||||||
|
const { data } = await productsClient.put(`/products/${id}`, product);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const patchProduct = async (id, patch) => {
|
||||||
|
const { data } = await productsClient.patch(`/products/${id}`, patch);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteProduct = async (id) => {
|
||||||
|
await productsClient.delete(`/products/${id}`);
|
||||||
|
};
|
||||||
@@ -1,3 +1,12 @@
|
|||||||
export { createInitialState } from './initialState';
|
export { createInitialState } from './initialState';
|
||||||
export { PRODUCTS_ACTIONS, productsReducer } from './reducer';
|
export { PRODUCTS_ACTIONS, productsReducer } from './reducer';
|
||||||
export { ProductsProvider, useProductsActions, useProductsDispatch, useProductsState } from './ProductsStore';
|
export { ProductsProvider, useProductsActions, useProductsDispatch, useProductsState } from './ProductsStore';
|
||||||
|
export {
|
||||||
|
createProduct,
|
||||||
|
deleteProduct,
|
||||||
|
fetchProduct,
|
||||||
|
fetchProducts,
|
||||||
|
getApiErrorMessage,
|
||||||
|
patchProduct,
|
||||||
|
updateProduct,
|
||||||
|
} from './api';
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
// import mit {}
|
// import mit {}
|
||||||
export const createInitialState = (items) => {
|
export const createInitialState = (items) => {
|
||||||
// validation and sanitizing
|
// validation and sanitizing
|
||||||
return { items: items || [] };
|
return {
|
||||||
|
items: items || [], //
|
||||||
|
loading: true,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default createInitialState; // import mit eigener Benennung möglich
|
export default createInitialState; // import mit eigener Benennung möglich
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
// in ts enum
|
// in ts enum
|
||||||
export const PRODUCTS_ACTIONS = {
|
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_AMOUNT: 'UPDATE_AMOUNT',
|
||||||
UPDATE_PRICE: 'UPDATE_PRICE',
|
UPDATE_PRICE: 'UPDATE_PRICE',
|
||||||
};
|
};
|
||||||
@@ -7,25 +13,59 @@ export const PRODUCTS_ACTIONS = {
|
|||||||
export const productsReducer = (state, action) => {
|
export const productsReducer = (state, action) => {
|
||||||
const { type, payload } = action;
|
const { type, payload } = action;
|
||||||
|
|
||||||
console.log(state);
|
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
|
case PRODUCTS_ACTIONS.SET_LOADING:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
loading: payload,
|
||||||
|
error: payload ? null : state.error,
|
||||||
|
};
|
||||||
|
case PRODUCTS_ACTIONS.SET_ITEMS:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
items: payload,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
case PRODUCTS_ACTIONS.SET_ITEM:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
items: state.items.map((item) => (item._id === payload._id ? payload : item)),
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
case PRODUCTS_ACTIONS.SET_ERROR:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
loading: false,
|
||||||
|
error: payload,
|
||||||
|
};
|
||||||
|
case PRODUCTS_ACTIONS.ADD_ITEM:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
items: [...state.items, payload],
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
case PRODUCTS_ACTIONS.REMOVE_ITEM:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
items: state.items.filter((item) => item._id !== payload),
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
case PRODUCTS_ACTIONS.UPDATE_AMOUNT:
|
case PRODUCTS_ACTIONS.UPDATE_AMOUNT:
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
items: state.items.map((item) =>
|
items: state.items.map((item) =>
|
||||||
item._id === payload.id //
|
item._id === payload.id
|
||||||
? { ...item, stock: payload.amount }
|
? { ...item, stock: payload.amount } //
|
||||||
: item,
|
: item,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
case PRODUCTS_ACTIONS.UPDATE_PRICE:
|
case PRODUCTS_ACTIONS.UPDATE_PRICE:
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
items: state.items.map((item) =>
|
items: state.items.map((item) =>
|
||||||
item._id === payload.id //
|
item._id === payload.id
|
||||||
? { ...item, price: payload.price }
|
? { ...item, price: payload.price } //
|
||||||
: item,
|
: item,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user