Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d1f880cfbb | |||
| efb21f73ce | |||
| 95f5683ac5 |
@@ -1,7 +1,6 @@
|
|||||||
# Ma librairie
|
# Ma librairie
|
||||||
|
|
||||||
Application React (Vite) : livres enregistrés localement (équivalent métier `POST /api/books`), React Router, `useContext`, Axios (Open Library).
|
Application React (Vite) : livres enregistrés localement (équivalent métier `POST /api/books`), React Router, `useContext`, Axios (Open Library).
|
||||||
Il s'agit d'une application de gestion d'une librairie en ligne (pour un max de $$$).
|
|
||||||
|
|
||||||
## Ce qu’on a rajouté (branche Patrick_commande_promo, tout en français courant)
|
## Ce qu’on a rajouté (branche Patrick_commande_promo, tout en français courant)
|
||||||
|
|
||||||
@@ -10,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`.
|
- **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`.
|
- **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.
|
- **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 (`/<id>` 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
|
## Les membres du projet
|
||||||
|
|
||||||
@@ -61,7 +69,8 @@ npm run build
|
|||||||
**Tests manuels dans le navigateur** (après `npm run dev`) :
|
**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.
|
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 `/<id>` ; vérifier lu / non lu et suppression avec retour à **Mes livres**.
|
2. **Fiche livre** (`/<id>`) : 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**.
|
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.
|
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).
|
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.
|
||||||
|
|||||||
+139
@@ -796,3 +796,142 @@
|
|||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
justify-content: flex-end;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import BookDetailPage from './pages/BookDetailPage.jsx'
|
|||||||
import CommandePage from './pages/CommandePage.jsx'
|
import CommandePage from './pages/CommandePage.jsx'
|
||||||
import MesLivresPage from './pages/MesLivresPage.jsx'
|
import MesLivresPage from './pages/MesLivresPage.jsx'
|
||||||
import PromotionsPage from './pages/PromotionsPage.jsx'
|
import PromotionsPage from './pages/PromotionsPage.jsx'
|
||||||
|
import RetoursPage from './pages/RetoursPage.jsx'
|
||||||
import SearchPage from './pages/SearchPage.jsx'
|
import SearchPage from './pages/SearchPage.jsx'
|
||||||
import './App.css'
|
import './App.css'
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ export default function App() {
|
|||||||
<Route path="recherche" element={<SearchPage />} />
|
<Route path="recherche" element={<SearchPage />} />
|
||||||
<Route path="commande" element={<CommandePage />} />
|
<Route path="commande" element={<CommandePage />} />
|
||||||
<Route path="promotions" element={<PromotionsPage />} />
|
<Route path="promotions" element={<PromotionsPage />} />
|
||||||
|
<Route path="retours" element={<RetoursPage />} />
|
||||||
<Route path=":id" element={<BookDetailPage />} />
|
<Route path=":id" element={<BookDetailPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<ReservationsContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</ReservationsContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<ReviewsContext.Provider value={value}>{children}</ReviewsContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -47,6 +47,14 @@ export function RootLayout() {
|
|||||||
>
|
>
|
||||||
Promotions
|
Promotions
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
to="/retours"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
isActive ? 'nav-link active' : 'nav-link'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Retours
|
||||||
|
</NavLink>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { BrowserRouter } from 'react-router-dom'
|
|||||||
import { BooksProvider } from './context/BooksContext.jsx'
|
import { BooksProvider } from './context/BooksContext.jsx'
|
||||||
import { OrdersProvider } from './context/OrdersContext.jsx'
|
import { OrdersProvider } from './context/OrdersContext.jsx'
|
||||||
import { PromotionsProvider } from './context/PromotionsContext.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 App from './App.jsx'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
@@ -13,7 +15,11 @@ createRoot(document.getElementById('root')).render(
|
|||||||
<PromotionsProvider>
|
<PromotionsProvider>
|
||||||
<OrdersProvider>
|
<OrdersProvider>
|
||||||
<BooksProvider>
|
<BooksProvider>
|
||||||
|
<ReservationsProvider>
|
||||||
|
<ReviewsProvider>
|
||||||
<App />
|
<App />
|
||||||
|
</ReviewsProvider>
|
||||||
|
</ReservationsProvider>
|
||||||
</BooksProvider>
|
</BooksProvider>
|
||||||
</OrdersProvider>
|
</OrdersProvider>
|
||||||
</PromotionsProvider>
|
</PromotionsProvider>
|
||||||
|
|||||||
@@ -1,12 +1,41 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
import { useBooks } from '../context/BooksContext.jsx'
|
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() {
|
export default function BookDetailPage() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { books, toggleRead, removeBook } = useBooks()
|
const { books, toggleRead, removeBook } = useBooks()
|
||||||
|
const {
|
||||||
|
createReservation,
|
||||||
|
hasActiveReservationForBook,
|
||||||
|
} = useReservations()
|
||||||
|
const { addReview, getReviewsForBook } = useReviews()
|
||||||
const book = books.find((b) => b.id === id)
|
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) {
|
if (!book) {
|
||||||
return (
|
return (
|
||||||
<div className="book-detail">
|
<div className="book-detail">
|
||||||
@@ -23,6 +52,44 @@ export default function BookDetailPage() {
|
|||||||
navigate('/')
|
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 (
|
return (
|
||||||
<article className="book-detail">
|
<article className="book-detail">
|
||||||
<Link to="/" className="nav-link book-detail-back">
|
<Link to="/" className="nav-link book-detail-back">
|
||||||
@@ -31,6 +98,11 @@ export default function BookDetailPage() {
|
|||||||
<span className={`badge ${book.read ? 'read' : 'unread'}`}>
|
<span className={`badge ${book.read ? 'read' : 'unread'}`}>
|
||||||
{book.read ? 'Lu' : 'À lire'}
|
{book.read ? 'Lu' : 'À lire'}
|
||||||
</span>
|
</span>
|
||||||
|
{reserved ? (
|
||||||
|
<span className="badge unread" title="Une réservation est en cours pour ce titre">
|
||||||
|
Réservé
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
<h2 className="book-detail-title">{book.title}</h2>
|
<h2 className="book-detail-title">{book.title}</h2>
|
||||||
<p className="book-detail-author">{book.author}</p>
|
<p className="book-detail-author">{book.author}</p>
|
||||||
<dl className="book-detail-meta">
|
<dl className="book-detail-meta">
|
||||||
@@ -55,6 +127,87 @@ export default function BookDetailPage() {
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
|
<section className="book-section">
|
||||||
|
<h3 className="book-section-title">Réserver ce livre</h3>
|
||||||
|
<p className="book-section-lead">
|
||||||
|
<code>POST /api/books/:id/reservations</code> en local uniquement (
|
||||||
|
<code>librairie-reservations</code>).
|
||||||
|
</p>
|
||||||
|
{resErr ? (
|
||||||
|
<p className="form-error" role="alert">
|
||||||
|
{resErr}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{resMsg ? <p className="form-notice">{resMsg}</p> : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn primary"
|
||||||
|
onClick={handleReserve}
|
||||||
|
disabled={reserved}
|
||||||
|
>
|
||||||
|
{reserved ? 'Déjà réservé (en cours)' : 'Réserver (POST)'}
|
||||||
|
</button>
|
||||||
|
<Link to="/retours" className="nav-link book-section-link">
|
||||||
|
Gérer les retours →
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="book-section">
|
||||||
|
<h3 className="book-section-title">Avis des lecteurs</h3>
|
||||||
|
<p className="book-section-lead">
|
||||||
|
<code>POST /api/books/:id/reviews</code> en local (
|
||||||
|
<code>librairie-reviews</code>).
|
||||||
|
</p>
|
||||||
|
{reviewErr ? (
|
||||||
|
<p className="form-error" role="alert">
|
||||||
|
{reviewErr}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{reviewOk ? <p className="form-notice">{reviewOk}</p> : null}
|
||||||
|
<form className="review-form" onSubmit={handleSubmitReview}>
|
||||||
|
<label>
|
||||||
|
Note (1 à 5)
|
||||||
|
<select
|
||||||
|
value={reviewRating}
|
||||||
|
onChange={(e) => setReviewRating(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
{[1, 2, 3, 4, 5].map((n) => (
|
||||||
|
<option key={n} value={n}>
|
||||||
|
{n}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Ton avis
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={reviewComment}
|
||||||
|
onChange={(e) => setReviewComment(e.target.value)}
|
||||||
|
placeholder="Ce que tu as pensé du livre…"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" className="btn primary">
|
||||||
|
Publier l’avis (POST)
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{bookReviews.length === 0 ? (
|
||||||
|
<p className="empty-state book-reviews-empty">Pas encore d’avis.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="reviews-list">
|
||||||
|
{bookReviews.map((r) => (
|
||||||
|
<li key={r.id} className="reviews-item">
|
||||||
|
<p className="reviews-meta">
|
||||||
|
<strong>{r.rating}/5</strong> · {formatDate(r.createdAt)}
|
||||||
|
</p>
|
||||||
|
<p className="reviews-text">{r.comment}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="book-detail-actions">
|
<div className="book-detail-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { useReservations } from '../context/ReservationsContext.jsx'
|
||||||
|
|
||||||
|
function formatDate(iso) {
|
||||||
|
if (!iso) return '—'
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat('fr-FR', {
|
||||||
|
dateStyle: 'short',
|
||||||
|
timeStyle: 'short',
|
||||||
|
}).format(new Date(iso))
|
||||||
|
} catch {
|
||||||
|
return iso
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RetoursPage() {
|
||||||
|
const { activeReservations, reservations, registerReturn } = useReservations()
|
||||||
|
const [notice, setNotice] = useState(null)
|
||||||
|
|
||||||
|
const returned = reservations
|
||||||
|
.filter((r) => r.returnedAt)
|
||||||
|
.sort((a, b) =>
|
||||||
|
String(b.returnedAt).localeCompare(String(a.returnedAt)),
|
||||||
|
)
|
||||||
|
|
||||||
|
function handleReturn(id) {
|
||||||
|
setNotice(null)
|
||||||
|
registerReturn(id)
|
||||||
|
setNotice('Retour enregistré (équivalent local POST /api/returns).')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="retours-page">
|
||||||
|
<h2 className="page-title">Retours (admin fictif)</h2>
|
||||||
|
<p className="page-lead">
|
||||||
|
Ici tu gères les réservations encore “sorties”. Rien sur le backend :
|
||||||
|
tout vit dans{' '}
|
||||||
|
<code>localStorage</code> (<code>librairie-reservations</code>).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{notice ? <p className="form-notice">{notice}</p> : null}
|
||||||
|
|
||||||
|
<section className="order-panel retours-panel">
|
||||||
|
<h3 className="panel-title">Réservations en cours</h3>
|
||||||
|
{activeReservations.length === 0 ? (
|
||||||
|
<p className="empty-state">Aucune réservation à retourner.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="retours-list">
|
||||||
|
{activeReservations.map((r) => (
|
||||||
|
<li key={r.id} className="retours-item">
|
||||||
|
<div>
|
||||||
|
<p className="retours-book">
|
||||||
|
<Link to={`/${r.bookId}`}>{r.title || 'Sans titre'}</Link>
|
||||||
|
</p>
|
||||||
|
<p className="retours-meta">
|
||||||
|
{r.author} · réservé le {formatDate(r.createdAt)}
|
||||||
|
</p>
|
||||||
|
<p className="retours-id">
|
||||||
|
<code>{r.id.slice(0, 8)}…</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn primary small"
|
||||||
|
onClick={() => handleReturn(r.id)}
|
||||||
|
>
|
||||||
|
Enregistrer le retour
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="order-panel retours-panel">
|
||||||
|
<h3 className="panel-title">Historique des retours</h3>
|
||||||
|
{returned.length === 0 ? (
|
||||||
|
<p className="empty-state">Aucun retour enregistré pour l’instant.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="retours-list history">
|
||||||
|
{returned.slice(0, 20).map((r) => (
|
||||||
|
<li key={r.id} className="retours-item muted">
|
||||||
|
<div>
|
||||||
|
<p className="retours-book">{r.title}</p>
|
||||||
|
<p className="retours-meta">
|
||||||
|
Retour le {formatDate(r.returnedAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user