diff --git a/.cursor/rules/no-assumed-api.mdc b/.cursor/rules/no-assumed-api.mdc new file mode 100644 index 0000000..8f75cb4 --- /dev/null +++ b/.cursor/rules/no-assumed-api.mdc @@ -0,0 +1,53 @@ +--- +description: Nie API-Verträge raten — immer Backend-Doku lesen +alwaysApply: true +--- + +# Keine angenommenen APIs + +API-Shapes, Endpoints und Response-Felder **niemals erraten**, **niemals** aus ähnlichen Endpoints ableiten, **niemals** im Chat „vorschlagen“ und dann direkt implementieren. + +## Quelle der Wahrheit (in dieser Reihenfolge) + +1. **Laufendes Backend** — `dashback/docs/api/` (Markdown pro Route) und `dashback/docs/api/openapi.json` +2. **`src/api/index.js`** — nur Spiegel des Backend-Vertrags, keine Erfindung +3. **Explizite User-Vorgabe** — nur wenn der User den Vertrag wörtlich nennt + +Fehlt (1): **nicht implementieren**. Stattdessen Doku lesen oder User fragen. + +## Verboten + +```js +// ❌ „PATCH liefert { article }, also sicher auch POST …/image“ +requireObject(res.article, 'article'); + +// ❌ Endpoint oder Feldname erfinden +return httpMultipart(`/api/article/${sku}/photos`, …); + +// ❌ Response-Shape aus Konvention ableiten +onArticleUpdated?.(res.article ?? res.data); +``` + +```js +// ✅ Vertrag aus dashback übernommen, in index.js dokumentiert +// POST /api/article/:sku/image → 201 { image: { picId, sort, full, thumb, hash, thumbHash } } +requireObject(res.image, 'image'); +``` + +## Pflicht-Workflow bei neuem oder geändertem Endpoint + +1. Route in `dashback/docs/api` oder `openapi.json` finden und lesen +2. Method, Path, Request-Body, Status-Codes, Response-JSON exakt notieren +3. `src/api/index.js` kommentieren und Client-Methode schreiben +4. Response mit `requireObject` / `requireArray` / `requireDefined` aus `apiShape.js` validieren +5. Kein Frontend-Code, der Felder nutzt, die nicht im Vertrag stehen (z. B. `photo.url` wenn nur `thumbHash` dokumentiert ist) + +## Erlaubt + +- **Backend-Doku aktualisieren**, wenn der User den Vertrag bewusst ändert — danach Frontend anpassen +- **Blockieren mit Frage**, wenn Vertrag unklar oder widersprüchlich ist + +## Zusammenspiel mit `no-silent-fallbacks` + +- Keine Fallbacks auf alternative Feldnamen oder Endpoints +- Kein „vielleicht heißt es `article` oder `image`“ — der Vertrag legt es fest diff --git a/src/api/index.js b/src/api/index.js index 0cde0d8..095b751 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -180,7 +180,7 @@ export const article = { /** * POST /api/article/:sku/image * Body: raw AVIF bytes (Content-Type: image/avif, max 1000×1000, prepared client-side via WASM) - * → 201 { article } + * → 201 { image: { picId, sort, full, thumb, hash, thumbHash } } */ uploadImage(sku, file) { return httpBinary(`/api/article/${encodeURIComponent(sku)}/image`, { diff --git a/src/components/AuthBootstrap.jsx b/src/components/AuthBootstrap.jsx index e67985c..44bb017 100644 --- a/src/components/AuthBootstrap.jsx +++ b/src/components/AuthBootstrap.jsx @@ -2,6 +2,7 @@ import { Component } from 'react'; import { connect } from 'react-redux'; import { enqueueSnackbar } from 'notistack'; import { notifyError } from '../utils/notify'; +import { bootstrapSession as bootstrapSessionThunk } from '../store/authSlice'; let sessionBootstrapped = false; diff --git a/src/components/LoginPage.jsx b/src/components/LoginPage.jsx index d804153..260d705 100644 --- a/src/components/LoginPage.jsx +++ b/src/components/LoginPage.jsx @@ -3,7 +3,9 @@ import { connect } from 'react-redux'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import GoogleIcon from '@mui/icons-material/Google'; +import { enqueueSnackbar } from 'notistack'; import { notifyErrorMessage } from '../utils/notify'; +import { loginWithGoogle, selectAuthStatus, selectGoogleClientId } from '../store/authSlice'; import { abortGoogleSignIn, FEDCM_BLOCKED_MESSAGE, @@ -61,7 +63,7 @@ class LoginPage extends Component { const detail = [err?.message || String(err), err?.code, err?.status] .filter(Boolean) .join(' · '); - enqueueSnackbar(`Login failed: ${detail}`, { variant: 'error' }); + notifyErrorMessage(`Login failed: ${detail}`); } finally { this.setState({ busy: false }); } diff --git a/src/components/article/ArticlePhotoTile.jsx b/src/components/article/ArticlePhotoTile.jsx index aaf6dbb..9b0055a 100644 --- a/src/components/article/ArticlePhotoTile.jsx +++ b/src/components/article/ArticlePhotoTile.jsx @@ -6,7 +6,7 @@ import { requireObject } from '../../utils/apiShape'; import { notifyError } from '../../utils/notify'; import { prepareArticlePhoto } from '../../utils/prepareArticlePhoto'; import { pasteImageOrFallback } from '../../utils/pasteImage'; -import { primaryPhoto } from '../../utils/articleDetail'; +import { articleWithUploadedPhoto, primaryPhoto } from '../../utils/articleDetail'; import ArticlePhoto from './ArticlePhoto'; export default function ArticlePhotoTile({ @@ -28,8 +28,8 @@ export default function ArticlePhotoTile({ const file = await prepareArticlePhoto(rawBlob); const res = await article.uploadImage(articleData.sku, file); requireObject(res, 'POST /api/article/:sku/image'); - requireObject(res.article, 'article'); - onArticleUpdated?.(res.article); + requireObject(res.image, 'image'); + onArticleUpdated?.(articleWithUploadedPhoto(articleData, res.image)); } catch (err) { notifyError(err); } finally { diff --git a/src/utils/articleDetail.js b/src/utils/articleDetail.js index 70b00e6..4578138 100644 --- a/src/utils/articleDetail.js +++ b/src/utils/articleDetail.js @@ -10,6 +10,24 @@ export function primaryPhoto(article) { return photos[0] ?? null; } +/** Map POST /api/article/:sku/image → photos[] entry shape. */ +export function photoFromUploadedImage(image) { + return { + sort: image.sort, + thumbW: image.thumb.w, + thumbH: image.thumb.h, + thumbHash: image.thumbHash, + }; +} + +export function articleWithUploadedPhoto(article, image) { + const photo = photoFromUploadedImage(image); + const photos = sortedPhotos(article).filter((row) => row.sort !== photo.sort); + photos.push(photo); + photos.sort((a, b) => a.sort - b.sort); + return { ...article, photos }; +} + export function primaryPrice(article) { if (!Array.isArray(article?.prices) || article.prices.length === 0) return null; return article.prices.find((row) => row.minmultiplier === 1) ?? article.prices[0];