From d1f880cfbb41d00f147b993c4df5a50fa0b52731 Mon Sep 17 00:00:00 2001 From: Aubert Marvin Date: Sun, 3 May 2026 11:43:25 +0200 Subject: [PATCH] Ajout Retour/Avis/Reservation Livre --- README.md | 16 ++- src/App.css | 139 +++++++++++++++++++++++++ src/App.jsx | 2 + src/context/ReservationsContext.jsx | 113 ++++++++++++++++++++ src/context/ReviewsContext.jsx | 85 ++++++++++++++++ src/layout/RootLayout.jsx | 8 ++ src/main.jsx | 8 +- src/pages/BookDetailPage.jsx | 153 ++++++++++++++++++++++++++++ src/pages/RetoursPage.jsx | 97 ++++++++++++++++++ 9 files changed, 617 insertions(+), 4 deletions(-) create mode 100644 src/context/ReservationsContext.jsx create mode 100644 src/context/ReviewsContext.jsx create mode 100644 src/pages/RetoursPage.jsx diff --git a/README.md b/README.md index aa7a0db..503a971 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,18 @@ En gros : on a continué sans brancher **aucun backend** du cours. Les “POST - **Commande** (`/commande`) : tu choisis des livres avec une quantité, tu peux entrer un code promo si tu en as créé une, et tu passes la commande. Ça revient métier à un `POST /api/orders`, sauf que tout est enregistré localement sous la clé `librairie-orders`. - **Promotions** (`/promotions`) : tu crées des codes promo (pour l’instant c’est une remise en %), tu peux les activer / désactiver / supprimer. C’est le pendant local d’un `POST /api/promotions`, stocké sous `librairie-promotions`. - **Prix des livres** : chaque livre a un champ **prix (€)** (formulaire, fiche détail, liste ; les anciens livres sans prix prennent 10 € par défaut). Les résultats ajoutés depuis Open Library partent aussi sur un prix par défaut. -- **Navigation** : dans le menu en haut, liens vers **Commande** et **Promotions** à côté de Mes livres / Recherche. +- **Navigation** : dans le menu en haut, liens **Commande** et **Promotions** en plus de Mes livres / Recherche. -Les données déjà là (`librairie-books` pour le catalogue, etc.) ne changent pas de principe : tout reste dans ton navigateur, pas sur le serveur du projet BUT. +## Ce qu’on a rajouté (branche Patrick_reserve_retourne__avis_livre, tout en français courant) + +Même logique que la branche d’au-dessus : **pas de backend** du projet, on simule les endpoints avec du **Context** + **`localStorage`**, comme si on faisait des `POST` / `GET` mais que tout reste dans le navigateur. + +- **Réserver un livre** (`POST /api/books/:id/reservations`) : tu vas sur la fiche d’un livre (`/` dans l’URL, ou en cliquant sur un titre), tu tapes sur **Réserver**. Une réservation **active** à la fois pour un même livre (sinon ça bloque, logique biblio pas folle sinon). Ça vit dans **`librairie-reservations`**. +- **Gérer le retour** (`POST /api/returns`) : page dédiée **Retours** (`/retours`) — vue “biblio qui récupère les exemplaires”. Tu vois les réservations pas encore rendues, tu cliques **Enregistrer le retour**, et ça part dans l’historique. Toujours le même stockage **`librairie-reservations`** que pour réserver : on ajoute juste une date de retour à l’entrée. +- **Avis sur un livre** (`POST /api/books/:id/reviews`) : toujours sur la fiche du livre, tu mets une **note sur 5** + un **petit pavé de texte**, tu publies, et tes avis s’affichent en dessous. Stocké sous **`librairie-reviews`** (un livre peut en avoir plusieurs, pas de problème). +- **Navigation** : on a rajouté le lien **Retours** dans la barre du haut avec le reste. + +Les données qui tournaient déjà (`librairie-books` pour le catalogue, puis `librairie-orders` et `librairie-promotions` si tu viens de l’autre branche) : **toujours zéro serveur**, tout est chez toi dans le navigateur, pas sur l’infra du cours. ## Les membres du projet @@ -60,7 +69,8 @@ npm run build **Tests manuels dans le navigateur** (après `npm run dev`) : 1. **Mes livres** (`/`) : enregistrer un nouveau livre (libellés + `POST /api/books` côté UI), supprimer, filtrer (Tous / Lus / À lire), rechercher dans la liste. -2. **Fiche livre** : cliquer sur le titre d’un livre ou aller sur `/` ; vérifier lu / non lu et suppression avec retour à **Mes livres**. +2. **Fiche livre** (`/`) : lu / non lu, suppression, **Réserver**, publier un **avis** puis vérifier l’historique sous le formulaire. 3. **Recherche** (`/recherche`) : recherche Open Library (Internet), enregistrer un résultat (même logique que `POST /api/books` en local), puis vérifier qu’il apparaît sous **Mes livres**. 4. **Promotions** (`/promotions`) : créer un code promo (ex. `BUT10` avec −10 %), puis vérifier que tu peux le désactiver / le réactiver / le supprimer. 5. **Commande** (`/commande`) : mettre au moins un livre avec une quantité strictement positive, tester sans promo puis avec le code créé avant ; passer la commande et vérifier le message + que le sous-total, la remise et le total sont cohérents (toujours en local). +6. **Retours** (`/retours`) : après une réservation, vérifier que le livre apparaît puis **Enregistrer le retour** ; vérifier l’historique et que tu peux de nouveau réserver le même titre. diff --git a/src/App.css b/src/App.css index 162a2b9..66a82fa 100644 --- a/src/App.css +++ b/src/App.css @@ -796,3 +796,142 @@ gap: 0.35rem; justify-content: flex-end; } + +.book-detail .badge + .badge { + margin-left: 0.35rem; +} + +.book-section { + margin: 1.5rem 0; + padding: 1rem 1.1rem; + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + max-width: 520px; + text-align: left; +} + +.book-section-title { + font-family: var(--font-display); + font-size: 1.1rem; + margin: 0 0 0.35rem; + color: var(--ink); +} + +.book-section-lead { + margin: 0 0 1rem; + font-size: 0.88rem; + color: var(--ink-muted); +} + +.book-section-lead code { + font-size: 0.85em; +} + +.book-section-link { + display: inline-block; + margin-top: 0.75rem; +} + +.review-form { + display: grid; + gap: 0.75rem; +} + +.review-form label { + display: grid; + gap: 0.35rem; + font-size: 0.8rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--ink-muted); +} + +.review-form select, +.review-form textarea { + font: inherit; + padding: 0.55rem 0.65rem; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--paper); + color: var(--ink); +} + +.book-reviews-empty { + margin: 1rem 0 0; +} + +.reviews-list { + list-style: none; + margin: 1rem 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.reviews-item { + padding: 0.75rem 0.85rem; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--paper); +} + +.reviews-meta { + margin: 0 0 0.35rem; + font-size: 0.85rem; + color: var(--ink-muted); +} + +.reviews-text { + margin: 0; + font-size: 0.95rem; + white-space: pre-wrap; +} + +.retours-page { + text-align: left; +} + +.retours-panel { + margin-bottom: 1.25rem; +} + +.retours-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.65rem; +} + +.retours-item { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.75rem 0.85rem; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--paper); +} + +.retours-item.muted { + opacity: 0.82; +} + +.retours-book { + margin: 0; + font-weight: 700; +} + +.retours-meta, +.retours-id { + margin: 0.2rem 0 0; + font-size: 0.88rem; + color: var(--ink-muted); +} diff --git a/src/App.jsx b/src/App.jsx index c3733d3..5d362fe 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -4,6 +4,7 @@ import BookDetailPage from './pages/BookDetailPage.jsx' import CommandePage from './pages/CommandePage.jsx' import MesLivresPage from './pages/MesLivresPage.jsx' import PromotionsPage from './pages/PromotionsPage.jsx' +import RetoursPage from './pages/RetoursPage.jsx' import SearchPage from './pages/SearchPage.jsx' import './App.css' @@ -15,6 +16,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/src/context/ReservationsContext.jsx b/src/context/ReservationsContext.jsx new file mode 100644 index 0000000..0407a67 --- /dev/null +++ b/src/context/ReservationsContext.jsx @@ -0,0 +1,113 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react' + +const STORAGE_KEY = 'librairie-reservations' + +function loadReservations() { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (raw === null) return [] + const parsed = JSON.parse(raw) + return Array.isArray(parsed) ? parsed : [] + } catch { + return [] + } +} + +function saveReservations(items) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(items)) +} + +const ReservationsContext = createContext(null) + +export function ReservationsProvider({ children }) { + const [reservations, setReservations] = useState(loadReservations) + + useEffect(() => { + saveReservations(reservations) + }, [reservations]) + + /** Équivalent local de POST /api/books/:id/reservations */ + const createReservation = useCallback((book) => { + const bookId = String(book?.id || '') + if (!bookId) throw new Error('Livre invalide') + setReservations((prev) => { + const already = prev.some((r) => r.bookId === bookId && !r.returnedAt) + if (already) return prev + return [ + ...prev, + { + id: crypto.randomUUID(), + bookId, + title: String(book.title || ''), + author: String(book.author || ''), + createdAt: new Date().toISOString(), + returnedAt: null, + }, + ] + }) + }, []) + + /** Équivalent local de POST /api/returns */ + const registerReturn = useCallback((reservationId) => { + const rid = String(reservationId || '') + setReservations((prev) => { + const target = prev.find((r) => r.id === rid && !r.returnedAt) + if (!target) return prev + return prev.map((r) => + r.id === rid + ? { ...r, returnedAt: new Date().toISOString() } + : r, + ) + }) + }, []) + + const activeReservations = useMemo( + () => reservations.filter((r) => !r.returnedAt), + [reservations], + ) + + const hasActiveReservationForBook = useCallback( + (bookId) => + reservations.some((r) => r.bookId === String(bookId) && !r.returnedAt), + [reservations], + ) + + const value = useMemo( + () => ({ + reservations, + activeReservations, + createReservation, + registerReturn, + hasActiveReservationForBook, + }), + [ + reservations, + activeReservations, + createReservation, + registerReturn, + hasActiveReservationForBook, + ], + ) + + return ( + + {children} + + ) +} + +// eslint-disable-next-line react-refresh/only-export-components +export function useReservations() { + const ctx = useContext(ReservationsContext) + if (!ctx) { + throw new Error('useReservations doit être utilisé dans un ReservationsProvider') + } + return ctx +} diff --git a/src/context/ReviewsContext.jsx b/src/context/ReviewsContext.jsx new file mode 100644 index 0000000..16bb7d6 --- /dev/null +++ b/src/context/ReviewsContext.jsx @@ -0,0 +1,85 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react' + +const STORAGE_KEY = 'librairie-reviews' + +function loadReviews() { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (raw === null) return [] + const parsed = JSON.parse(raw) + return Array.isArray(parsed) ? parsed : [] + } catch { + return [] + } +} + +function saveReviews(items) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(items)) +} + +const ReviewsContext = createContext(null) + +export function ReviewsProvider({ children }) { + const [reviews, setReviews] = useState(loadReviews) + + useEffect(() => { + saveReviews(reviews) + }, [reviews]) + + /** Équivalent local de POST /api/books/:id/reviews */ + const addReview = useCallback((bookId, payload) => { + const bid = String(bookId || '') + if (!bid) throw new Error('Livre invalide') + const comment = String(payload?.comment || '').trim() + const rating = Math.min(5, Math.max(1, Math.trunc(Number(payload?.rating) || 0))) + if (!comment) throw new Error('Écris un avis.') + if (!Number.isFinite(rating) || rating < 1) { + throw new Error('Choisis une note entre 1 et 5.') + } + setReviews((prev) => [ + ...prev, + { + id: crypto.randomUUID(), + bookId: bid, + comment, + rating, + createdAt: new Date().toISOString(), + }, + ]) + }, []) + + const getReviewsForBook = useCallback( + (bookId) => { + const bid = String(bookId || '') + return reviews + .filter((r) => r.bookId === bid) + .sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt))) + }, + [reviews], + ) + + const value = useMemo( + () => ({ reviews, addReview, getReviewsForBook }), + [reviews, addReview, getReviewsForBook], + ) + + return ( + {children} + ) +} + +// eslint-disable-next-line react-refresh/only-export-components +export function useReviews() { + const ctx = useContext(ReviewsContext) + if (!ctx) { + throw new Error('useReviews doit être utilisé dans un ReviewsProvider') + } + return ctx +} diff --git a/src/layout/RootLayout.jsx b/src/layout/RootLayout.jsx index d011eec..6ffc161 100644 --- a/src/layout/RootLayout.jsx +++ b/src/layout/RootLayout.jsx @@ -47,6 +47,14 @@ export function RootLayout() { > Promotions + + isActive ? 'nav-link active' : 'nav-link' + } + > + Retours + diff --git a/src/main.jsx b/src/main.jsx index e22f4eb..94c3163 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -4,6 +4,8 @@ import { BrowserRouter } from 'react-router-dom' import { BooksProvider } from './context/BooksContext.jsx' import { OrdersProvider } from './context/OrdersContext.jsx' import { PromotionsProvider } from './context/PromotionsContext.jsx' +import { ReservationsProvider } from './context/ReservationsContext.jsx' +import { ReviewsProvider } from './context/ReviewsContext.jsx' import App from './App.jsx' import './index.css' @@ -13,7 +15,11 @@ createRoot(document.getElementById('root')).render( - + + + + + diff --git a/src/pages/BookDetailPage.jsx b/src/pages/BookDetailPage.jsx index 53a75df..88c75fb 100644 --- a/src/pages/BookDetailPage.jsx +++ b/src/pages/BookDetailPage.jsx @@ -1,12 +1,41 @@ +import { useState } from 'react' import { Link, useNavigate, useParams } from 'react-router-dom' import { useBooks } from '../context/BooksContext.jsx' +import { useReservations } from '../context/ReservationsContext.jsx' +import { useReviews } from '../context/ReviewsContext.jsx' + +function formatDate(iso) { + try { + return new Intl.DateTimeFormat('fr-FR', { + dateStyle: 'short', + timeStyle: 'short', + }).format(new Date(iso)) + } catch { + return iso || '—' + } +} export default function BookDetailPage() { const { id } = useParams() const navigate = useNavigate() const { books, toggleRead, removeBook } = useBooks() + const { + createReservation, + hasActiveReservationForBook, + } = useReservations() + const { addReview, getReviewsForBook } = useReviews() const book = books.find((b) => b.id === id) + const [resMsg, setResMsg] = useState(null) + const [resErr, setResErr] = useState(null) + const [reviewRating, setReviewRating] = useState(5) + const [reviewComment, setReviewComment] = useState('') + const [reviewErr, setReviewErr] = useState(null) + const [reviewOk, setReviewOk] = useState(null) + + const bookReviews = book ? getReviewsForBook(book.id) : [] + const reserved = book ? hasActiveReservationForBook(book.id) : false + if (!book) { return (
@@ -23,6 +52,44 @@ export default function BookDetailPage() { navigate('/') } + function handleReserve() { + setResErr(null) + setResMsg(null) + if (hasActiveReservationForBook(book.id)) { + setResErr('Ce livre a déjà une réservation en cours.') + return + } + try { + createReservation({ + id: book.id, + title: book.title, + author: book.author, + }) + setResMsg( + 'Réservation enregistrée (équivalent local POST /api/books/:id/reservations). Va voir aussi la page Retours.', + ) + } catch (e) { + setResErr(e?.message || 'Erreur réservation.') + } + } + + function handleSubmitReview(e) { + e.preventDefault() + setReviewErr(null) + setReviewOk(null) + try { + addReview(book.id, { + rating: reviewRating, + comment: reviewComment, + }) + setReviewOk('Avis publié (équivalent local POST /api/books/:id/reviews).') + setReviewComment('') + setReviewRating(5) + } catch (err) { + setReviewErr(err?.message || 'Impossible de publier l’avis.') + } + } + return (
@@ -31,6 +98,11 @@ export default function BookDetailPage() { {book.read ? 'Lu' : 'À lire'} + {reserved ? ( + + Réservé + + ) : null}

{book.title}

{book.author}

@@ -55,6 +127,87 @@ export default function BookDetailPage() {
+ +
+

Réserver ce livre

+

+ POST /api/books/:id/reservations en local uniquement ( + librairie-reservations). +

+ {resErr ? ( +

+ {resErr} +

+ ) : null} + {resMsg ?

{resMsg}

: null} + + + Gérer les retours → + +
+ +
+

Avis des lecteurs

+

+ POST /api/books/:id/reviews en local ( + librairie-reviews). +

+ {reviewErr ? ( +

+ {reviewErr} +

+ ) : null} + {reviewOk ?

{reviewOk}

: null} +
+ +