3 Commits

Author SHA1 Message Date
Aubert Marvin d1f880cfbb Ajout Retour/Avis/Reservation Livre 2026-05-03 11:43:25 +02:00
Aubert Marvin efb21f73ce Modif statue lié au new info 2026-05-03 11:27:56 +02:00
Aubert Marvin 95f5683ac5 Ajout de Promotion et de Commande fonctionnel 2026-04-25 16:04:54 +02:00
17 changed files with 1460 additions and 8 deletions
+24 -2
View File
@@ -1,7 +1,26 @@
# 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 quon a rajouté (branche Patrick_commande_promo, tout en français courant)
En gros : on a continué sans brancher **aucun backend** du cours. Les “POST” sont simulés dans le navigateur avec du **React (Context)** et du **`localStorage`**.
- **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 linstant cest une remise en %), tu peux les activer / désactiver / supprimer. Cest le pendant local dun `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 **Commande** et **Promotions** en plus de Mes livres / Recherche.
## Ce quon a rajouté (branche Patrick_reserve_retourne__avis_livre, tout en français courant)
Même logique que la branche dau-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 dun livre (`/<id>` dans lURL, 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 lhistorique. Toujours le même stockage **`librairie-reservations`** que pour réserver : on ajoute juste une date de retour à lentré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 saffichent 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 lautre branche) : **toujours zéro serveur**, tout est chez toi dans le navigateur, pas sur linfra du cours.
## Les membres du projet ## Les membres du projet
@@ -50,5 +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 dun 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 lhistorique 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 quil 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 quil 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 lhistorique et que tu peux de nouveau réserver le même titre.
+342
View File
@@ -593,3 +593,345 @@
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.5rem; gap: 0.5rem;
} }
.page-title {
font-family: var(--font-display);
margin: 0 0 0.5rem;
color: var(--ink);
}
.page-lead {
margin: 0 0 1.25rem;
color: var(--ink-muted);
max-width: 60ch;
}
.form-error {
color: var(--danger);
margin: 0 0 1rem;
}
.form-notice {
margin: 0 0 1rem;
color: var(--ink);
}
.order-grid,
.promo-grid {
display: grid;
grid-template-columns: 1.2fr 0.8fr;
gap: 1.25rem;
align-items: start;
}
@media (max-width: 900px) {
.order-grid,
.promo-grid {
grid-template-columns: 1fr;
}
}
.order-panel {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem 1.1rem;
box-shadow: var(--shadow);
}
.panel-title {
font-family: var(--font-display);
margin: 0 0 0.75rem;
font-size: 1.1rem;
}
.order-catalog,
.promo-list,
.order-summary {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.order-catalog-item,
.promo-item {
border: 1px solid var(--border);
border-radius: 12px;
padding: 0.75rem 0.85rem;
background: var(--paper);
}
.order-catalog-main,
.promo-item-main {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.order-book-title,
.promo-code {
margin: 0;
font-weight: 700;
}
.order-book-meta,
.promo-meta {
margin: 0.2rem 0 0;
color: var(--ink-muted);
font-size: 0.9rem;
}
.qty {
display: grid;
gap: 0.25rem;
align-items: center;
justify-items: end;
}
.qty-label {
font-size: 0.75rem;
color: var(--ink-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.qty input,
.promo-input,
.promo-form input {
font: inherit;
padding: 0.45rem 0.55rem;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--card);
color: var(--ink);
width: 7rem;
}
.promo-input {
width: 100%;
}
.order-summary-item {
display: flex;
justify-content: space-between;
gap: 1rem;
color: var(--ink);
}
.promo-box {
margin: 1rem 0;
padding: 0.9rem;
border-radius: 12px;
border: 1px solid var(--border);
background: var(--paper);
}
.promo-label {
display: grid;
gap: 0.35rem;
font-size: 0.8rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--ink-muted);
}
.promo-hint {
margin: 0.55rem 0 0;
font-size: 0.9rem;
color: var(--ink-muted);
}
.promo-warn {
margin: 0.55rem 0 0;
color: var(--danger);
}
.promo-ok {
margin: 0.55rem 0 0;
}
.totals {
border-top: 1px solid var(--border);
padding-top: 0.9rem;
margin-top: 0.9rem;
margin-bottom: 0.9rem;
}
.totals-row {
display: flex;
justify-content: space-between;
margin: 0.25rem 0;
color: var(--ink-muted);
}
.totals-row.total {
color: var(--ink);
font-weight: 800;
margin-top: 0.6rem;
}
.promo-form {
display: grid;
gap: 0.75rem;
}
.promo-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);
}
.promo-actions {
display: flex;
flex-wrap: wrap;
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);
}
+6
View File
@@ -1,7 +1,10 @@
import { Route, Routes } from 'react-router-dom' import { Route, Routes } from 'react-router-dom'
import { RootLayout } from './layout/RootLayout.jsx' import { RootLayout } from './layout/RootLayout.jsx'
import BookDetailPage from './pages/BookDetailPage.jsx' import BookDetailPage from './pages/BookDetailPage.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 RetoursPage from './pages/RetoursPage.jsx'
import SearchPage from './pages/SearchPage.jsx' import SearchPage from './pages/SearchPage.jsx'
import './App.css' import './App.css'
@@ -11,6 +14,9 @@ export default function App() {
<Route path="/" element={<RootLayout />}> <Route path="/" element={<RootLayout />}>
<Route index element={<MesLivresPage />} /> <Route index element={<MesLivresPage />} />
<Route path="recherche" element={<SearchPage />} /> <Route path="recherche" element={<SearchPage />} />
<Route path="commande" element={<CommandePage />} />
<Route path="promotions" element={<PromotionsPage />} />
<Route path="retours" element={<RetoursPage />} />
<Route path=":id" element={<BookDetailPage />} /> <Route path=":id" element={<BookDetailPage />} />
</Route> </Route>
</Routes> </Routes>
+17
View File
@@ -5,6 +5,7 @@ const emptyForm = () => ({
author: '', author: '',
year: new Date().getFullYear(), year: new Date().getFullYear(),
genre: '', genre: '',
price: 10,
read: false, read: false,
}) })
@@ -71,6 +72,22 @@ export function BookForm({ onSubmit }) {
placeholder="Roman, essai…" placeholder="Roman, essai…"
/> />
</label> </label>
<label>
Prix ()
<input
type="number"
min={0}
step="0.5"
value={form.price}
onChange={(e) =>
setForm((f) => ({
...f,
price: Number(e.target.value),
}))
}
placeholder="Ex. 12.5"
/>
</label>
</div> </div>
<label className="checkbox-row"> <label className="checkbox-row">
<input <input
+3 -1
View File
@@ -76,7 +76,9 @@ export function BookList({
</Link> </Link>
</h3> </h3>
<p className="author">{book.author}</p> <p className="author">{book.author}</p>
<p className="year">{book.year}</p> <p className="year">
{book.year} · {Number(book.price ?? 10).toFixed(2)}
</p>
<div className="card-actions"> <div className="card-actions">
<button <button
type="button" type="button"
+12 -2
View File
@@ -15,6 +15,7 @@ const demoBooks = [
author: 'Antoine de Saint-Exupéry', author: 'Antoine de Saint-Exupéry',
year: 1943, year: 1943,
genre: 'Conte', genre: 'Conte',
price: 8.5,
read: true, read: true,
}, },
{ {
@@ -23,16 +24,25 @@ const demoBooks = [
author: 'George Orwell', author: 'George Orwell',
year: 1949, year: 1949,
genre: 'Science-fiction', genre: 'Science-fiction',
price: 9.9,
read: false, read: false,
}, },
] ]
function normalizeBook(b) {
const price = Number(b?.price)
return {
...b,
price: Number.isFinite(price) ? price : 10,
}
}
function loadBooks() { function loadBooks() {
try { try {
const raw = localStorage.getItem(STORAGE_KEY) const raw = localStorage.getItem(STORAGE_KEY)
if (raw === null) return demoBooks if (raw === null) return demoBooks
const parsed = JSON.parse(raw) const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? parsed : demoBooks return Array.isArray(parsed) ? parsed.map(normalizeBook) : demoBooks
} catch { } catch {
return demoBooks return demoBooks
} }
@@ -54,7 +64,7 @@ export function BooksProvider({ children }) {
/** Équivalent métier dun POST /api/books (ici : persistance locale + id généré). */ /** Équivalent métier dun POST /api/books (ici : persistance locale + id généré). */
const postBook = useCallback((book) => { const postBook = useCallback((book) => {
const id = crypto.randomUUID() const id = crypto.randomUUID()
setBooks((prev) => [...prev, { ...book, id }]) setBooks((prev) => [...prev, normalizeBook({ ...book, id })])
}, []) }, [])
const removeBook = useCallback((id) => { const removeBook = useCallback((id) => {
+113
View File
@@ -0,0 +1,113 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react'
const STORAGE_KEY = 'librairie-orders'
function loadOrders() {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw === null) return []
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
function saveOrders(orders) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(orders))
}
function round2(n) {
return Math.round((Number(n) + Number.EPSILON) * 100) / 100
}
function calcSubtotal(items) {
return round2(
items.reduce((sum, it) => sum + Number(it.unitPrice) * Number(it.qty), 0),
)
}
function calcDiscount({ subtotal, promotion }) {
if (!promotion) return 0
if (promotion.type === 'percent') {
const pct = Number(promotion.value)
if (!Number.isFinite(pct) || pct <= 0) return 0
return round2((subtotal * pct) / 100)
}
return 0
}
const OrdersContext = createContext(null)
export function OrdersProvider({ children }) {
const [orders, setOrders] = useState(loadOrders)
useEffect(() => {
saveOrders(orders)
}, [orders])
/**
* Équivalent local dun POST /api/orders.
* orderDraft: { items: [{ bookId, title, unitPrice, qty }], promotion?: { code, type, value } }
*/
const createOrder = useCallback((orderDraft) => {
const items = Array.isArray(orderDraft?.items) ? orderDraft.items : []
const cleaned = items
.map((it) => ({
bookId: String(it.bookId),
title: String(it.title || ''),
unitPrice: Number(it.unitPrice),
qty: Math.max(0, Math.trunc(Number(it.qty) || 0)),
}))
.filter((it) => it.bookId && it.qty > 0 && Number.isFinite(it.unitPrice))
if (cleaned.length === 0) {
throw new Error('Aucun article dans la commande')
}
const subtotal = calcSubtotal(cleaned)
const promotion = orderDraft?.promotion
? {
code: String(orderDraft.promotion.code || ''),
type: String(orderDraft.promotion.type || 'percent'),
value: Number(orderDraft.promotion.value),
}
: null
const discount = calcDiscount({ subtotal, promotion })
const total = round2(Math.max(0, subtotal - discount))
const order = {
id: crypto.randomUUID(),
createdAt: new Date().toISOString(),
items: cleaned,
promotion: promotion && promotion.code ? promotion : null,
subtotal,
discount,
total,
}
setOrders((prev) => [order, ...prev])
return order
}, [])
const value = useMemo(() => ({ orders, createOrder }), [orders, createOrder])
return <OrdersContext.Provider value={value}>{children}</OrdersContext.Provider>
}
// eslint-disable-next-line react-refresh/only-export-components
export function useOrders() {
const ctx = useContext(OrdersContext)
if (!ctx) {
throw new Error('useOrders doit être utilisé dans un OrdersProvider')
}
return ctx
}
+122
View File
@@ -0,0 +1,122 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react'
const STORAGE_KEY = 'librairie-promotions'
function loadPromotions() {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw === null) return []
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
function savePromotions(promotions) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(promotions))
}
function normalizeCode(code) {
return String(code || '')
.trim()
.toUpperCase()
.replace(/\s+/g, '')
}
const PromotionsContext = createContext(null)
export function PromotionsProvider({ children }) {
const [promotions, setPromotions] = useState(loadPromotions)
useEffect(() => {
savePromotions(promotions)
}, [promotions])
const createPromotion = useCallback((promo) => {
const code = normalizeCode(promo.code)
const value = Number(promo.value)
if (!code) throw new Error('Code promo invalide')
if (!Number.isFinite(value) || value <= 0) {
throw new Error('Valeur de promo invalide')
}
setPromotions((prev) => {
if (prev.some((p) => p.code === code)) {
throw new Error('Ce code promo existe déjà')
}
return [
...prev,
{
id: crypto.randomUUID(),
code,
type: 'percent',
value,
active: true,
createdAt: new Date().toISOString(),
},
]
})
}, [])
const setPromotionActive = useCallback((code, active) => {
const normalized = normalizeCode(code)
setPromotions((prev) =>
prev.map((p) => (p.code === normalized ? { ...p, active } : p)),
)
}, [])
const removePromotion = useCallback((code) => {
const normalized = normalizeCode(code)
setPromotions((prev) => prev.filter((p) => p.code !== normalized))
}, [])
const getActivePromotionByCode = useCallback(
(code) => {
const normalized = normalizeCode(code)
return promotions.find((p) => p.code === normalized && p.active) || null
},
[promotions],
)
const value = useMemo(
() => ({
promotions,
createPromotion,
removePromotion,
setPromotionActive,
getActivePromotionByCode,
normalizeCode,
}),
[
promotions,
createPromotion,
removePromotion,
setPromotionActive,
getActivePromotionByCode,
],
)
return (
<PromotionsContext.Provider value={value}>
{children}
</PromotionsContext.Provider>
)
}
// eslint-disable-next-line react-refresh/only-export-components
export function usePromotions() {
const ctx = useContext(PromotionsContext)
if (!ctx) {
throw new Error('usePromotions doit être utilisé dans un PromotionsProvider')
}
return ctx
}
+113
View File
@@ -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
}
+85
View File
@@ -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
}
+24
View File
@@ -31,6 +31,30 @@ export function RootLayout() {
> >
Recherche (Axios) Recherche (Axios)
</NavLink> </NavLink>
<NavLink
to="/commande"
className={({ isActive }) =>
isActive ? 'nav-link active' : 'nav-link'
}
>
Commande
</NavLink>
<NavLink
to="/promotions"
className={({ isActive }) =>
isActive ? 'nav-link active' : 'nav-link'
}
>
Promotions
</NavLink>
<NavLink
to="/retours"
className={({ isActive }) =>
isActive ? 'nav-link active' : 'nav-link'
}
>
Retours
</NavLink>
</nav> </nav>
</div> </div>
</header> </header>
+12
View File
@@ -2,15 +2,27 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom' 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 { 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'
createRoot(document.getElementById('root')).render( createRoot(document.getElementById('root')).render(
<StrictMode> <StrictMode>
<BrowserRouter> <BrowserRouter>
<PromotionsProvider>
<OrdersProvider>
<BooksProvider> <BooksProvider>
<ReservationsProvider>
<ReviewsProvider>
<App /> <App />
</ReviewsProvider>
</ReservationsProvider>
</BooksProvider> </BooksProvider>
</OrdersProvider>
</PromotionsProvider>
</BrowserRouter> </BrowserRouter>
</StrictMode>, </StrictMode>,
) )
+157
View File
@@ -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 lavis.')
}
}
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">
@@ -38,6 +110,10 @@ export default function BookDetailPage() {
<dt>Année</dt> <dt>Année</dt>
<dd>{book.year}</dd> <dd>{book.year}</dd>
</div> </div>
<div>
<dt>Prix</dt>
<dd>{Number(book.price ?? 10).toFixed(2)} </dd>
</div>
{book.genre ? ( {book.genre ? (
<div> <div>
<dt>Genre</dt> <dt>Genre</dt>
@@ -51,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 lavis (POST)
</button>
</form>
{bookReviews.length === 0 ? (
<p className="empty-state book-reviews-empty">Pas encore davis.</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"
+211
View File
@@ -0,0 +1,211 @@
import { useMemo, useState } from 'react'
import { useBooks } from '../context/BooksContext.jsx'
import { useOrders } from '../context/OrdersContext.jsx'
import { usePromotions } from '../context/PromotionsContext.jsx'
function formatEUR(amount) {
const n = Number(amount) || 0
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
maximumFractionDigits: 2,
}).format(n)
}
function toQtyMap(items) {
const map = new Map()
for (const it of items) map.set(it.bookId, it.qty)
return map
}
export default function CommandePage() {
const { books } = useBooks()
const { createOrder } = useOrders()
const { getActivePromotionByCode, normalizeCode } = usePromotions()
const [qtyByBookId, setQtyByBookId] = useState(() => ({}))
const [promoInput, setPromoInput] = useState('')
const [notice, setNotice] = useState(null)
const [error, setError] = useState(null)
const items = useMemo(() => {
return books
.map((b) => {
const qty = Math.max(0, Math.trunc(Number(qtyByBookId[b.id]) || 0))
const unitPrice = Number(b.price)
return {
bookId: b.id,
title: b.title,
unitPrice: Number.isFinite(unitPrice) ? unitPrice : 10,
qty,
}
})
.filter((it) => it.qty > 0)
}, [books, qtyByBookId])
const subtotal = useMemo(() => {
return items.reduce((sum, it) => sum + it.unitPrice * it.qty, 0)
}, [items])
const promo = useMemo(() => {
const code = normalizeCode(promoInput)
if (!code) return null
return getActivePromotionByCode(code)
}, [promoInput, getActivePromotionByCode, normalizeCode])
const discount = useMemo(() => {
if (!promo) return 0
return (subtotal * promo.value) / 100
}, [promo, subtotal])
const total = useMemo(
() => Math.max(0, subtotal - discount),
[subtotal, discount],
)
function setQty(bookId, nextQty) {
setQtyByBookId((prev) => ({ ...prev, [bookId]: nextQty }))
}
function handleSubmit() {
setError(null)
setNotice(null)
try {
const order = createOrder({
items,
promotion: promo
? { code: promo.code, type: promo.type, value: promo.value }
: null,
})
setQtyByBookId({})
setPromoInput('')
const map = toQtyMap(order.items)
setNotice(
`Commande ${order.id.slice(0, 8)} créée (${map.size} livre${
map.size > 1 ? 's' : ''
}). Total: ${formatEUR(order.total)}.`,
)
} catch (e) {
setError(e?.message || 'Impossible de créer la commande.')
}
}
return (
<div className="commande-page">
<h2 className="page-title">Passer une commande</h2>
<p className="page-lead">
Équivalent local de <code>POST /api/orders</code> (aucun backend).
</p>
{error ? (
<p className="form-error" role="alert">
{error}
</p>
) : null}
{notice ? <p className="form-notice">{notice}</p> : null}
{books.length === 0 ? (
<p className="empty-state">
Aucun livre dans le catalogue. Ajoute un livre dans Mes livres
dabord.
</p>
) : (
<div className="order-grid">
<section className="order-panel">
<h3 className="panel-title">Catalogue</h3>
<ul className="order-catalog">
{books.map((b) => (
<li key={b.id} className="order-catalog-item">
<div className="order-catalog-main">
<div>
<p className="order-book-title">{b.title}</p>
<p className="order-book-meta">
{b.author} · {formatEUR(b.price ?? 10)}
</p>
</div>
<label className="qty">
<span className="qty-label">Qté</span>
<input
type="number"
min={0}
step={1}
value={qtyByBookId[b.id] ?? 0}
onChange={(e) => setQty(b.id, e.target.value)}
aria-label={`Quantité pour ${b.title}`}
/>
</label>
</div>
</li>
))}
</ul>
</section>
<aside className="order-panel">
<h3 className="panel-title">Récapitulatif</h3>
{items.length === 0 ? (
<p className="empty-state">
Sélectionne au moins un livre (quantité &gt; 0).
</p>
) : (
<ul className="order-summary">
{items.map((it) => (
<li key={it.bookId} className="order-summary-item">
<span>
{it.title} × {it.qty}
</span>
<span>{formatEUR(it.unitPrice * it.qty)}</span>
</li>
))}
</ul>
)}
<div className="promo-box">
<label className="promo-label">
Code promo
<input
className="promo-input"
placeholder="Ex. BUT10"
value={promoInput}
onChange={(e) => setPromoInput(e.target.value)}
/>
</label>
<p className="promo-hint">
Équivalent local de <code>POST /api/promotions</code> (créées dans
Promotions).
</p>
{promoInput.trim() && !promo ? (
<p className="promo-warn">Code invalide ou inactif.</p>
) : null}
{promo ? (
<p className="promo-ok">
Promo appliquée : <strong>{promo.code}</strong> ({promo.value}
%)
</p>
) : null}
</div>
<div className="totals">
<div className="totals-row">
<span>Sous-total</span>
<span>{formatEUR(subtotal)}</span>
</div>
<div className="totals-row">
<span>Remise</span>
<span>{formatEUR(discount)}</span>
</div>
<div className="totals-row total">
<span>Total</span>
<span>{formatEUR(total)}</span>
</div>
</div>
<button type="button" className="btn primary" onClick={handleSubmit}>
Passer commande (POST)
</button>
</aside>
</div>
)}
</div>
)
}
+118
View File
@@ -0,0 +1,118 @@
import { useMemo, useState } from 'react'
import { usePromotions } from '../context/PromotionsContext.jsx'
export default function PromotionsPage() {
const { promotions, createPromotion, removePromotion, setPromotionActive } =
usePromotions()
const [code, setCode] = useState('')
const [value, setValue] = useState(10)
const [notice, setNotice] = useState(null)
const [error, setError] = useState(null)
const sorted = useMemo(() => {
return [...promotions].sort((a, b) => {
if (a.active !== b.active) return a.active ? -1 : 1
return String(a.code).localeCompare(String(b.code))
})
}, [promotions])
function handleSubmit(e) {
e.preventDefault()
setError(null)
setNotice(null)
try {
createPromotion({ code, value })
setNotice('Promotion créée.')
setCode('')
setValue(10)
} catch (err) {
setError(err?.message || 'Impossible de créer la promotion.')
}
}
return (
<div className="promotions-page">
<h2 className="page-title">Promotions</h2>
<p className="page-lead">
Équivalent local de <code>POST /api/promotions</code> (aucun backend).
</p>
{error ? (
<p className="form-error" role="alert">
{error}
</p>
) : null}
{notice ? <p className="form-notice">{notice}</p> : null}
<div className="promo-grid">
<section className="order-panel">
<h3 className="panel-title">Créer une promotion</h3>
<form className="promo-form" onSubmit={handleSubmit}>
<label>
Code
<input
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="Ex. BUT10"
/>
</label>
<label>
Remise (%)
<input
type="number"
min={1}
max={90}
value={value}
onChange={(e) => setValue(Number(e.target.value) || 0)}
/>
</label>
<button type="submit" className="btn primary">
Créer (POST)
</button>
</form>
<p className="promo-hint">
Astuce : le code est normalisé (majuscules, sans espaces).
</p>
</section>
<section className="order-panel">
<h3 className="panel-title">Promotions existantes</h3>
{sorted.length === 0 ? (
<p className="empty-state">Aucune promotion créée.</p>
) : (
<ul className="promo-list">
{sorted.map((p) => (
<li key={p.id} className="promo-item">
<div className="promo-item-main">
<div>
<p className="promo-code">{p.code}</p>
<p className="promo-meta">{p.value}%</p>
</div>
<div className="promo-actions">
<button
type="button"
className="btn small"
onClick={() => setPromotionActive(p.code, !p.active)}
>
{p.active ? 'Désactiver' : 'Activer'}
</button>
<button
type="button"
className="btn small danger"
onClick={() => removePromotion(p.code)}
>
Supprimer
</button>
</div>
</div>
</li>
))}
</ul>
)}
</section>
</div>
</div>
)
}
+97
View File
@@ -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 linstant.</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>
)
}
+1
View File
@@ -15,6 +15,7 @@ function docToBookPayload(doc) {
? doc.first_publish_year ? doc.first_publish_year
: new Date().getFullYear(), : new Date().getFullYear(),
genre: Array.isArray(doc.subject) ? doc.subject[0] : '', genre: Array.isArray(doc.subject) ? doc.subject[0] : '',
price: 10,
read: false, read: false,
} }
} }