Ajout fidelite_abo_pret_commande_groupe
This commit is contained in:
@@ -22,6 +22,16 @@ Même logique que la branche d’au-dessus : **pas de backend** du projet, on si
|
||||
|
||||
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.
|
||||
|
||||
## Ce qu’on a rajouté (branche Marvin_fidelite_abo_pret_commande_groupe, tout en français courant)
|
||||
|
||||
Encore une fois : **aucun backend du sujet**, pas d’URL d’API cours, rien. On refait les 4 bouts manquants du PDF en **Context + `localStorage`**, comme un faux `GET` / `POST` qui ne sort jamais du navigateur.
|
||||
|
||||
- **Points de fidélité** (`GET /api/users/:id/loyalty-points`, en local) : page **Fidélité** (`/fidelite`). Un pseudo-utilisateur fixe `local-user`, solde + petit historique dans **`librairie-loyalty`**. À chaque commande validée, on crédite des points (démo automatique depuis la page Commande).
|
||||
- **Abonnement** (`POST /api/subscriptions`) : page **Abo** (`/abonnement`), formules mensuelle / annuelle factices, tout est dans **`librairie-subscriptions`** (pas de vrai paiement).
|
||||
- **Prêt entre lecteurs** (`POST /api/books/:id/loans`) : sur une fiche livre, bloc **Prêter** + page **Prêts** (`/prets`) pour clôturer avec **Livre rendu**. **`librairie-loans`**. Si le livre est déjà **réservé** côté biblio fictive, on bloque pour pas mélanger les délires.
|
||||
- **Commande groupée** (`POST /api/groups/:id/orders`) : **Groupes** (`/groupes`) pour créer une coloc de commande, puis détail **`/groupes/:id-du-groupe`** où chacune et chacun poste pseudo + montant € + petite note → tout est dans **`librairie-groups-v1`** sur une seule clé locale (liste groupes + lignes participants).
|
||||
- **Navigation** : raccourcis **Fidélité**, **Abo**, **Prêts**, **Groupes** dans la barre du haut avec le reste.
|
||||
|
||||
## Les membres du projet
|
||||
|
||||
Marvin Aubert, Maxime Lebreton et Patrick Felix-Vimalaratnam
|
||||
@@ -74,3 +84,7 @@ npm run build
|
||||
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.
|
||||
7. **Fidélité** (`/fidelite`) : noter le solde avant / après avoir passé une **commande** ; vérifier que des points ont été ajoutés (local).
|
||||
8. **Abo** (`/abonnement`) : souscrire à une formule puis résilier (local).
|
||||
9. **Prêt** (`/<id>` + `/prets`) : prêter un livre puis le marquer **rendu** sur la liste des prêts.
|
||||
10. **Groupes** (`/groupes` puis lien vers un groupe) : créer un groupe et ajouter plusieurs participations fictives avec montants différents, vérifier le total agrégé.
|
||||
|
||||
+68
@@ -935,3 +935,71 @@
|
||||
font-size: 0.88rem;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.fidelite-points {
|
||||
font-family: var(--font-display);
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.fidelite-box {
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.loan-borrower {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--ink-muted);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.loan-borrower input {
|
||||
font: inherit;
|
||||
padding: 0.55rem 0.65rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
text-transform: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.book-loan-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.abo-plans {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.abo-plan-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
background: var(--paper);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.abo-plan-name {
|
||||
margin: 0;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.abo-plan-price {
|
||||
margin: 0.35rem 0 0.75rem;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
+10
@@ -1,8 +1,13 @@
|
||||
import { Route, Routes } from 'react-router-dom'
|
||||
import { RootLayout } from './layout/RootLayout.jsx'
|
||||
import AbonnementPage from './pages/AbonnementPage.jsx'
|
||||
import BookDetailPage from './pages/BookDetailPage.jsx'
|
||||
import CommandePage from './pages/CommandePage.jsx'
|
||||
import FidelitePage from './pages/FidelitePage.jsx'
|
||||
import GroupDetailPage from './pages/GroupDetailPage.jsx'
|
||||
import GroupesPage from './pages/GroupesPage.jsx'
|
||||
import MesLivresPage from './pages/MesLivresPage.jsx'
|
||||
import PretsPage from './pages/PretsPage.jsx'
|
||||
import PromotionsPage from './pages/PromotionsPage.jsx'
|
||||
import RetoursPage from './pages/RetoursPage.jsx'
|
||||
import SearchPage from './pages/SearchPage.jsx'
|
||||
@@ -17,6 +22,11 @@ export default function App() {
|
||||
<Route path="commande" element={<CommandePage />} />
|
||||
<Route path="promotions" element={<PromotionsPage />} />
|
||||
<Route path="retours" element={<RetoursPage />} />
|
||||
<Route path="fidelite" element={<FidelitePage />} />
|
||||
<Route path="abonnement" element={<AbonnementPage />} />
|
||||
<Route path="prets" element={<PretsPage />} />
|
||||
<Route path="groupes" element={<GroupesPage />} />
|
||||
<Route path="groupes/:groupId" element={<GroupDetailPage />} />
|
||||
<Route path=":id" element={<BookDetailPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
|
||||
const STORAGE_KEY = 'librairie-groups-v1'
|
||||
|
||||
function loadPersisted() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (raw === null) return { groups: [], orders: [] }
|
||||
const parsed = JSON.parse(raw)
|
||||
return {
|
||||
groups: Array.isArray(parsed?.groups) ? parsed.groups : [],
|
||||
orders: Array.isArray(parsed?.orders) ? parsed.orders : [],
|
||||
}
|
||||
} catch {
|
||||
return { groups: [], orders: [] }
|
||||
}
|
||||
}
|
||||
|
||||
function savePersisted(data) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
|
||||
}
|
||||
|
||||
const GroupsContext = createContext(null)
|
||||
|
||||
export function GroupsProvider({ children }) {
|
||||
const [{ groups, orders }, setPersisted] = useState(loadPersisted)
|
||||
|
||||
useEffect(() => {
|
||||
savePersisted({ groups, orders })
|
||||
}, [groups, orders])
|
||||
|
||||
const createGroup = useCallback((name) => {
|
||||
const trimmed = String(name || '').trim()
|
||||
if (!trimmed) throw new Error('Nom du groupe obligatoire')
|
||||
const g = {
|
||||
id: crypto.randomUUID(),
|
||||
name: trimmed,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
setPersisted((prev) => ({ ...prev, groups: [...prev.groups, g] }))
|
||||
return g
|
||||
}, [])
|
||||
|
||||
/** Équivalent local de POST /api/groups/:id/orders */
|
||||
const addGroupOrder = useCallback((groupId, payload) => {
|
||||
const gid = String(groupId || '')
|
||||
const contributor = String(payload?.contributor || '').trim()
|
||||
const note = String(payload?.note || '').trim()
|
||||
if (!gid) throw new Error('Groupe invalide')
|
||||
if (!contributor) throw new Error('Indique ton pseudo / prénom')
|
||||
|
||||
const exists = groups.some((g) => g.id === gid)
|
||||
if (!exists) throw new Error('Groupe inconnu.')
|
||||
|
||||
const amount = Number(payload?.amount)
|
||||
const euros = Number.isFinite(amount) && amount >= 0 ? amount : 0
|
||||
|
||||
const row = {
|
||||
id: crypto.randomUUID(),
|
||||
groupId: gid,
|
||||
contributor,
|
||||
amountEuros: euros,
|
||||
note,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
setPersisted((prev) => ({
|
||||
...prev,
|
||||
orders: [...prev.orders, row],
|
||||
}))
|
||||
}, [groups])
|
||||
|
||||
const getOrdersForGroup = useCallback(
|
||||
(groupId) =>
|
||||
orders
|
||||
.filter((o) => o.groupId === String(groupId))
|
||||
.sort((a, b) =>
|
||||
String(b.createdAt).localeCompare(String(a.createdAt)),
|
||||
),
|
||||
[orders],
|
||||
)
|
||||
|
||||
const groupTotalEuros = useCallback(
|
||||
(groupId) =>
|
||||
orders
|
||||
.filter((o) => o.groupId === String(groupId))
|
||||
.reduce((s, o) => s + Number(o.amountEuros || 0), 0),
|
||||
[orders],
|
||||
)
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
groups,
|
||||
orders,
|
||||
createGroup,
|
||||
addGroupOrder,
|
||||
getOrdersForGroup,
|
||||
groupTotalEuros,
|
||||
}),
|
||||
[
|
||||
groups,
|
||||
orders,
|
||||
createGroup,
|
||||
addGroupOrder,
|
||||
getOrdersForGroup,
|
||||
groupTotalEuros,
|
||||
],
|
||||
)
|
||||
|
||||
return (
|
||||
<GroupsContext.Provider value={value}>{children}</GroupsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useGroups() {
|
||||
const ctx = useContext(GroupsContext)
|
||||
if (!ctx) {
|
||||
throw new Error('useGroups doit être utilisé dans un GroupsProvider')
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
|
||||
const STORAGE_KEY = 'librairie-loans'
|
||||
|
||||
function loadLoans() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (raw === null) return []
|
||||
const parsed = JSON.parse(raw)
|
||||
return Array.isArray(parsed) ? parsed : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function saveLoans(items) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(items))
|
||||
}
|
||||
|
||||
const LoansContext = createContext(null)
|
||||
|
||||
export function LoansProvider({ children }) {
|
||||
const [loans, setLoans] = useState(loadLoans)
|
||||
|
||||
useEffect(() => {
|
||||
saveLoans(loans)
|
||||
}, [loans])
|
||||
|
||||
const activeLoans = useMemo(
|
||||
() => loans.filter((l) => !l.returnedAt),
|
||||
[loans],
|
||||
)
|
||||
|
||||
const hasActiveLoanForBook = useCallback(
|
||||
(bookId) =>
|
||||
activeLoans.some((l) => l.bookId === String(bookId)),
|
||||
[activeLoans],
|
||||
)
|
||||
|
||||
/** Équivalent local de POST /api/books/:id/loans */
|
||||
const createLoan = useCallback((book, borrowerName) => {
|
||||
const bookId = String(book?.id || '')
|
||||
const name = String(borrowerName || '').trim()
|
||||
if (!bookId) throw new Error('Livre invalide')
|
||||
if (!name) throw new Error('Indique le nom du lecteur.')
|
||||
|
||||
setLoans((prev) => {
|
||||
const taken = prev.some(
|
||||
(l) => l.bookId === bookId && !l.returnedAt,
|
||||
)
|
||||
if (taken) return prev
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
bookId,
|
||||
bookTitle: String(book.title || ''),
|
||||
borrowerName: name,
|
||||
lentAt: new Date().toISOString(),
|
||||
returnedAt: null,
|
||||
},
|
||||
]
|
||||
})
|
||||
}, [])
|
||||
|
||||
/** Clôturer un prêt (local uniquement). */
|
||||
const registerLoanReturn = useCallback((loanId) => {
|
||||
const lid = String(loanId || '')
|
||||
setLoans((prev) => {
|
||||
const t = prev.find((l) => l.id === lid && !l.returnedAt)
|
||||
if (!t) return prev
|
||||
return prev.map((l) =>
|
||||
l.id === lid
|
||||
? { ...l, returnedAt: new Date().toISOString() }
|
||||
: l,
|
||||
)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
loans,
|
||||
activeLoans,
|
||||
hasActiveLoanForBook,
|
||||
createLoan,
|
||||
registerLoanReturn,
|
||||
}),
|
||||
[
|
||||
loans,
|
||||
activeLoans,
|
||||
hasActiveLoanForBook,
|
||||
createLoan,
|
||||
registerLoanReturn,
|
||||
],
|
||||
)
|
||||
|
||||
return <LoansContext.Provider value={value}>{children}</LoansContext.Provider>
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useLoans() {
|
||||
const ctx = useContext(LoansContext)
|
||||
if (!ctx) {
|
||||
throw new Error('useLoans doit être utilisé dans un LoansProvider')
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
|
||||
/** Pseudo-utilisateur unique : équivalent métier sans auth réelle */
|
||||
export const LOCAL_USER_ID = 'local-user'
|
||||
|
||||
const STORAGE_KEY = 'librairie-loyalty'
|
||||
|
||||
function loadState() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (raw === null) {
|
||||
return { userId: LOCAL_USER_ID, points: 0, ledger: [] }
|
||||
}
|
||||
const parsed = JSON.parse(raw)
|
||||
return {
|
||||
userId: LOCAL_USER_ID,
|
||||
points: Math.max(0, Math.floor(Number(parsed?.points) || 0)),
|
||||
ledger: Array.isArray(parsed?.ledger) ? parsed.ledger : [],
|
||||
}
|
||||
} catch {
|
||||
return { userId: LOCAL_USER_ID, points: 0, ledger: [] }
|
||||
}
|
||||
}
|
||||
|
||||
function saveState(state) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
|
||||
}
|
||||
|
||||
const LoyaltyContext = createContext(null)
|
||||
|
||||
export function LoyaltyProvider({ children }) {
|
||||
const [state, setState] = useState(loadState)
|
||||
|
||||
useEffect(() => {
|
||||
saveState(state)
|
||||
}, [state])
|
||||
|
||||
/** Équivalent local de GET /api/users/:id/loyalty-points (lecture depuis le contexte uniquement). */
|
||||
const getLoyaltyForUser = useCallback((userId) => {
|
||||
if (String(userId) !== LOCAL_USER_ID) {
|
||||
return { points: null, unknownUser: true }
|
||||
}
|
||||
return { points: state.points, ledger: [...state.ledger].reverse(), unknownUser: false }
|
||||
}, [state.points, state.ledger])
|
||||
|
||||
/** +points après une commande (exemple : 1 pt par euro TTC arrondi, minimum 1). */
|
||||
const earnFromOrderTotal = useCallback((orderTotalEUR) => {
|
||||
const euros = Number(orderTotalEUR)
|
||||
const delta =
|
||||
Number.isFinite(euros) && euros > 0
|
||||
? Math.max(1, Math.floor(euros))
|
||||
: 0
|
||||
if (!delta) return
|
||||
const entry = {
|
||||
id: crypto.randomUUID(),
|
||||
delta,
|
||||
reason: `Commande (${delta} pt)`,
|
||||
at: new Date().toISOString(),
|
||||
}
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
points: prev.points + delta,
|
||||
ledger: [...prev.ledger.slice(-49), entry],
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
userId: LOCAL_USER_ID,
|
||||
points: state.points,
|
||||
ledger: state.ledger,
|
||||
getLoyaltyForUser,
|
||||
earnFromOrderTotal,
|
||||
}),
|
||||
[
|
||||
state.points,
|
||||
state.ledger,
|
||||
getLoyaltyForUser,
|
||||
earnFromOrderTotal,
|
||||
],
|
||||
)
|
||||
|
||||
return (
|
||||
<LoyaltyContext.Provider value={value}>{children}</LoyaltyContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useLoyalty() {
|
||||
const ctx = useContext(LoyaltyContext)
|
||||
if (!ctx) {
|
||||
throw new Error('useLoyalty doit être utilisé dans un LoyaltyProvider')
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
|
||||
const STORAGE_KEY = 'librairie-subscriptions'
|
||||
|
||||
const PLANS = [
|
||||
{ id: 'mensuel', label: 'Mensuel', price: 9.99 },
|
||||
{ id: 'annuel', label: 'Annuel', price: 79.99 },
|
||||
]
|
||||
|
||||
function load() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (raw === null) return null
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!parsed || typeof parsed !== 'object') return null
|
||||
return {
|
||||
planId: parsed.planId,
|
||||
subscribedAt: parsed.subscribedAt,
|
||||
cancelledAt: parsed.cancelledAt ?? null,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function save(active) {
|
||||
if (active === null) {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
return
|
||||
}
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(active))
|
||||
}
|
||||
|
||||
const SubscriptionsContext = createContext(null)
|
||||
|
||||
export function SubscriptionsProvider({ children }) {
|
||||
const [active, setActive] = useState(load)
|
||||
|
||||
useEffect(() => {
|
||||
save(active)
|
||||
}, [active])
|
||||
|
||||
const getPlanLabel = useCallback((planId) => {
|
||||
const p = PLANS.find((x) => x.id === planId)
|
||||
return p ? p.label : planId
|
||||
}, [])
|
||||
|
||||
/** Équivalent local de POST /api/subscriptions */
|
||||
const subscribe = useCallback((planId) => {
|
||||
const p = PLANS.find((x) => x.id === planId)
|
||||
if (!p) throw new Error('Formule inconnue')
|
||||
setActive({
|
||||
planId: p.id,
|
||||
subscribedAt: new Date().toISOString(),
|
||||
cancelledAt: null,
|
||||
})
|
||||
}, [])
|
||||
|
||||
const cancelSubscription = useCallback(() => {
|
||||
setActive(null)
|
||||
}, [])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
plans: PLANS,
|
||||
subscription: active,
|
||||
subscribe,
|
||||
cancelSubscription,
|
||||
getPlanLabel,
|
||||
}),
|
||||
[active, subscribe, cancelSubscription, getPlanLabel],
|
||||
)
|
||||
|
||||
return (
|
||||
<SubscriptionsContext.Provider value={value}>
|
||||
{children}
|
||||
</SubscriptionsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useSubscriptions() {
|
||||
const ctx = useContext(SubscriptionsContext)
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
'useSubscriptions doit être utilisé dans un SubscriptionsProvider',
|
||||
)
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
@@ -55,6 +55,38 @@ export function RootLayout() {
|
||||
>
|
||||
Retours
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/fidelite"
|
||||
className={({ isActive }) =>
|
||||
isActive ? 'nav-link active' : 'nav-link'
|
||||
}
|
||||
>
|
||||
Fidélité
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/abonnement"
|
||||
className={({ isActive }) =>
|
||||
isActive ? 'nav-link active' : 'nav-link'
|
||||
}
|
||||
>
|
||||
Abo
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/prets"
|
||||
className={({ isActive }) =>
|
||||
isActive ? 'nav-link active' : 'nav-link'
|
||||
}
|
||||
>
|
||||
Prêts
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/groupes"
|
||||
className={({ isActive }) =>
|
||||
isActive ? 'nav-link active' : 'nav-link'
|
||||
}
|
||||
>
|
||||
Groupes
|
||||
</NavLink>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
+19
-7
@@ -2,10 +2,14 @@ import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { BooksProvider } from './context/BooksContext.jsx'
|
||||
import { GroupsProvider } from './context/GroupsContext.jsx'
|
||||
import { LoansProvider } from './context/LoansContext.jsx'
|
||||
import { LoyaltyProvider } from './context/LoyaltyContext.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 { SubscriptionsProvider } from './context/SubscriptionsContext.jsx'
|
||||
import App from './App.jsx'
|
||||
import './index.css'
|
||||
|
||||
@@ -14,13 +18,21 @@ createRoot(document.getElementById('root')).render(
|
||||
<BrowserRouter>
|
||||
<PromotionsProvider>
|
||||
<OrdersProvider>
|
||||
<BooksProvider>
|
||||
<ReservationsProvider>
|
||||
<ReviewsProvider>
|
||||
<App />
|
||||
</ReviewsProvider>
|
||||
</ReservationsProvider>
|
||||
</BooksProvider>
|
||||
<LoyaltyProvider>
|
||||
<BooksProvider>
|
||||
<ReservationsProvider>
|
||||
<ReviewsProvider>
|
||||
<SubscriptionsProvider>
|
||||
<LoansProvider>
|
||||
<GroupsProvider>
|
||||
<App />
|
||||
</GroupsProvider>
|
||||
</LoansProvider>
|
||||
</SubscriptionsProvider>
|
||||
</ReviewsProvider>
|
||||
</ReservationsProvider>
|
||||
</BooksProvider>
|
||||
</LoyaltyProvider>
|
||||
</OrdersProvider>
|
||||
</PromotionsProvider>
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useState } from 'react'
|
||||
import { useSubscriptions } from '../context/SubscriptionsContext.jsx'
|
||||
|
||||
function formatEUR(n) {
|
||||
return new Intl.NumberFormat('fr-FR', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
}).format(Number(n) || 0)
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('fr-FR', {
|
||||
dateStyle: 'long',
|
||||
}).format(new Date(iso))
|
||||
} catch {
|
||||
return iso || '—'
|
||||
}
|
||||
}
|
||||
|
||||
export default function AbonnementPage() {
|
||||
const { plans, subscription, subscribe, cancelSubscription, getPlanLabel } =
|
||||
useSubscriptions()
|
||||
const [err, setErr] = useState(null)
|
||||
const [ok, setOk] = useState(null)
|
||||
|
||||
function handleSubscribe(planId) {
|
||||
setErr(null)
|
||||
setOk(null)
|
||||
try {
|
||||
subscribe(planId)
|
||||
setOk('Abonnement pris en compte (POST /api/subscriptions en local).')
|
||||
} catch (e) {
|
||||
setErr(e?.message || 'Impossible de souscrire.')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="abonnement-page">
|
||||
<h2 className="page-title">Abonnement</h2>
|
||||
<p className="page-lead">
|
||||
Équivalent local de <code>POST /api/subscriptions</code> · stockage{' '}
|
||||
<code>librairie-subscriptions</code> · pas de paiement réel, c’est une
|
||||
démo.
|
||||
</p>
|
||||
|
||||
{err ? (
|
||||
<p className="form-error" role="alert">
|
||||
{err}
|
||||
</p>
|
||||
) : null}
|
||||
{ok ? <p className="form-notice">{ok}</p> : null}
|
||||
|
||||
{subscription ? (
|
||||
<section className="order-panel abo-active">
|
||||
<h3 className="panel-title">Abonnement actif</h3>
|
||||
<p>
|
||||
Formule : <strong>{getPlanLabel(subscription.planId)}</strong>
|
||||
</p>
|
||||
<p className="book-section-lead">
|
||||
Depuis le {formatDate(subscription.subscribedAt)}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn danger"
|
||||
onClick={() => {
|
||||
cancelSubscription()
|
||||
setOk('Abonnement annulé (local).')
|
||||
}}
|
||||
>
|
||||
Résilier (local)
|
||||
</button>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="order-panel">
|
||||
<h3 className="panel-title">Choisir une formule</h3>
|
||||
<ul className="abo-plans">
|
||||
{plans.map((p) => (
|
||||
<li key={p.id} className="abo-plan-card">
|
||||
<p className="abo-plan-name">{p.label}</p>
|
||||
<p className="abo-plan-price">{formatEUR(p.price)}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary small"
|
||||
onClick={() => handleSubscribe(p.id)}
|
||||
disabled={Boolean(subscription)}
|
||||
>
|
||||
Souscrire (POST)
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{subscription ? (
|
||||
<p className="book-section-lead">
|
||||
Pour changer de formule, résilie d’abord puis reprends une offre.
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ 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 { useLoans } from '../context/LoansContext.jsx'
|
||||
import { useReviews } from '../context/ReviewsContext.jsx'
|
||||
|
||||
function formatDate(iso) {
|
||||
@@ -23,6 +24,10 @@ export default function BookDetailPage() {
|
||||
createReservation,
|
||||
hasActiveReservationForBook,
|
||||
} = useReservations()
|
||||
const {
|
||||
createLoan,
|
||||
hasActiveLoanForBook,
|
||||
} = useLoans()
|
||||
const { addReview, getReviewsForBook } = useReviews()
|
||||
const book = books.find((b) => b.id === id)
|
||||
|
||||
@@ -32,9 +37,13 @@ export default function BookDetailPage() {
|
||||
const [reviewComment, setReviewComment] = useState('')
|
||||
const [reviewErr, setReviewErr] = useState(null)
|
||||
const [reviewOk, setReviewOk] = useState(null)
|
||||
const [borrowerName, setBorrowerName] = useState('')
|
||||
const [loanMsg, setLoanMsg] = useState(null)
|
||||
const [loanErr, setLoanErr] = useState(null)
|
||||
|
||||
const bookReviews = book ? getReviewsForBook(book.id) : []
|
||||
const reserved = book ? hasActiveReservationForBook(book.id) : false
|
||||
const lentOut = book ? hasActiveLoanForBook(book.id) : false
|
||||
|
||||
if (!book) {
|
||||
return (
|
||||
@@ -73,6 +82,42 @@ export default function BookDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleLoan() {
|
||||
setLoanErr(null)
|
||||
setLoanMsg(null)
|
||||
if (lentOut) {
|
||||
setLoanErr('Ce livre est déjà marqué comme prêté (non rendu).')
|
||||
return
|
||||
}
|
||||
if (reserved) {
|
||||
setLoanErr(
|
||||
'Une réservation est déjà active sur ce titre ; règle le retour biblio d’abord.',
|
||||
)
|
||||
return
|
||||
}
|
||||
const name = borrowerName.trim()
|
||||
if (!name) {
|
||||
setLoanErr('Indique le nom ou le pseudo de l’emprunteur.')
|
||||
return
|
||||
}
|
||||
try {
|
||||
createLoan(
|
||||
{
|
||||
id: book.id,
|
||||
title: book.title,
|
||||
author: book.author,
|
||||
},
|
||||
name,
|
||||
)
|
||||
setLoanMsg(
|
||||
'Prêt enregistré (équivalent local POST /api/books/:id/loans). Voir aussi la page Prêts.',
|
||||
)
|
||||
setBorrowerName('')
|
||||
} catch (e) {
|
||||
setLoanErr(e?.message || 'Impossible de créer le prêt.')
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmitReview(e) {
|
||||
e.preventDefault()
|
||||
setReviewErr(null)
|
||||
@@ -103,6 +148,11 @@ export default function BookDetailPage() {
|
||||
Réservé
|
||||
</span>
|
||||
) : null}
|
||||
{lentOut ? (
|
||||
<span className="badge unread" title="Ce livre est prêté à quelqu’un">
|
||||
Prêté
|
||||
</span>
|
||||
) : null}
|
||||
<h2 className="book-detail-title">{book.title}</h2>
|
||||
<p className="book-detail-author">{book.author}</p>
|
||||
<dl className="book-detail-meta">
|
||||
@@ -153,6 +203,46 @@ export default function BookDetailPage() {
|
||||
</Link>
|
||||
</section>
|
||||
|
||||
<section className="book-section">
|
||||
<h3 className="book-section-title">Prêter ce livre</h3>
|
||||
<p className="book-section-lead">
|
||||
Équivalent local de <code>POST /api/books/:id/loans</code> (
|
||||
<code>librairie-loans</code>). Un exemplaire fictif à la fois.
|
||||
</p>
|
||||
{loanErr ? (
|
||||
<p className="form-error" role="alert">
|
||||
{loanErr}
|
||||
</p>
|
||||
) : null}
|
||||
{loanMsg ? <p className="form-notice">{loanMsg}</p> : null}
|
||||
<label className="loan-borrower">
|
||||
Emprunteur (nom ou pseudo)
|
||||
<input
|
||||
value={borrowerName}
|
||||
onChange={(e) => setBorrowerName(e.target.value)}
|
||||
placeholder="Ex. Marvin"
|
||||
/>
|
||||
</label>
|
||||
<div className="book-loan-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
disabled={lentOut || reserved}
|
||||
onClick={handleLoan}
|
||||
>
|
||||
{lentOut ? 'Prêt actif' : 'Enregistrer le prêt (POST)'}
|
||||
</button>
|
||||
<Link to="/prets" className="nav-link book-section-link">
|
||||
Liste des prêts →
|
||||
</Link>
|
||||
</div>
|
||||
{reserved || lentOut ? (
|
||||
<p className="book-section-lead">
|
||||
Astuce : on évite réservation biblio + prêt lecteur au même moment.
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="book-section">
|
||||
<h3 className="book-section-title">Avis des lecteurs</h3>
|
||||
<p className="book-section-lead">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useBooks } from '../context/BooksContext.jsx'
|
||||
import { useLoyalty } from '../context/LoyaltyContext.jsx'
|
||||
import { useOrders } from '../context/OrdersContext.jsx'
|
||||
import { usePromotions } from '../context/PromotionsContext.jsx'
|
||||
|
||||
@@ -21,6 +22,7 @@ function toQtyMap(items) {
|
||||
export default function CommandePage() {
|
||||
const { books } = useBooks()
|
||||
const { createOrder } = useOrders()
|
||||
const { earnFromOrderTotal } = useLoyalty()
|
||||
const { getActivePromotionByCode, normalizeCode } = usePromotions()
|
||||
|
||||
const [qtyByBookId, setQtyByBookId] = useState(() => ({}))
|
||||
@@ -77,6 +79,7 @@ export default function CommandePage() {
|
||||
? { code: promo.code, type: promo.type, value: promo.value }
|
||||
: null,
|
||||
})
|
||||
earnFromOrderTotal(order.total)
|
||||
setQtyByBookId({})
|
||||
setPromoInput('')
|
||||
const map = toQtyMap(order.items)
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { LOCAL_USER_ID, useLoyalty } from '../context/LoyaltyContext.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 FidelitePage() {
|
||||
const { getLoyaltyForUser, points, ledger } = useLoyalty()
|
||||
const info = getLoyaltyForUser(LOCAL_USER_ID)
|
||||
const displayPoints =
|
||||
typeof info.points === 'number' ? info.points : points
|
||||
|
||||
const reverseLedger = [...ledger].reverse().slice(0, 25)
|
||||
|
||||
return (
|
||||
<div className="fidelite-page">
|
||||
<h2 className="page-title">Points de fidélité</h2>
|
||||
<p className="page-lead">
|
||||
Lecture locale équivalente à{' '}
|
||||
<code>GET /api/users/:id/loyalty-points</code> · utilisateur fictif{' '}
|
||||
<code>{LOCAL_USER_ID}</code> · stocké dans{' '}
|
||||
<code>librairie-loyalty</code>. Aucune API distante.
|
||||
</p>
|
||||
|
||||
<section className="order-panel fidelite-box">
|
||||
<h3 className="panel-title">Ton solde</h3>
|
||||
<p className="fidelite-points">{displayPoints} pts</p>
|
||||
<p className="book-section-lead">
|
||||
Les points augmentent automatiquement quand tu valides une commande (
|
||||
exemple : environ 1 point par euro TTC du total, avec un minimum par
|
||||
commande ).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="order-panel">
|
||||
<h3 className="panel-title">Historique (extraits)</h3>
|
||||
{reverseLedger.length === 0 ? (
|
||||
<p className="empty-state">Pas encore de mouvements.</p>
|
||||
) : (
|
||||
<ul className="reviews-list">
|
||||
{reverseLedger.map((e) => (
|
||||
<li key={e.id} className="reviews-item">
|
||||
<p className="reviews-meta">
|
||||
<strong>+{e.delta}</strong> · {formatDate(e.at)}
|
||||
</p>
|
||||
<p className="reviews-text">{e.reason}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { useGroups } from '../context/GroupsContext.jsx'
|
||||
|
||||
function formatEUR(n) {
|
||||
return new Intl.NumberFormat('fr-FR', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
maximumFractionDigits: 2,
|
||||
}).format(Number(n) || 0)
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('fr-FR', {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(iso))
|
||||
} catch {
|
||||
return iso || '—'
|
||||
}
|
||||
}
|
||||
|
||||
export default function GroupDetailPage() {
|
||||
const { groupId } = useParams()
|
||||
const {
|
||||
groups,
|
||||
addGroupOrder,
|
||||
getOrdersForGroup,
|
||||
groupTotalEuros,
|
||||
} = useGroups()
|
||||
|
||||
const group = useMemo(
|
||||
() => groups.find((g) => g.id === String(groupId)),
|
||||
[groups, groupId],
|
||||
)
|
||||
|
||||
const lines = group ? getOrdersForGroup(group.id) : []
|
||||
const total = group ? groupTotalEuros(group.id) : 0
|
||||
|
||||
const [contributor, setContributor] = useState('')
|
||||
const [amount, setAmount] = useState('')
|
||||
const [note, setNote] = useState('')
|
||||
const [err, setErr] = useState(null)
|
||||
const [ok, setOk] = useState(null)
|
||||
|
||||
function handleSubmit(e) {
|
||||
e.preventDefault()
|
||||
setErr(null)
|
||||
setOk(null)
|
||||
if (!group) return
|
||||
try {
|
||||
addGroupOrder(group.id, {
|
||||
contributor,
|
||||
amount,
|
||||
note,
|
||||
})
|
||||
setContributor('')
|
||||
setAmount('')
|
||||
setNote('')
|
||||
setOk('Contribution enregistrée (POST /api/groups/:id/orders en local).')
|
||||
} catch (ex) {
|
||||
setErr(ex?.message || 'Impossible d’enregistrer.')
|
||||
}
|
||||
}
|
||||
|
||||
if (!group) {
|
||||
return (
|
||||
<div className="groupes-page">
|
||||
<p className="empty-state">Groupe introuvable.</p>
|
||||
<Link to="/groupes" className="nav-link">
|
||||
← Liste des groupes
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="groupes-page">
|
||||
<Link to="/groupes" className="nav-link book-detail-back">
|
||||
← Tous les groupes
|
||||
</Link>
|
||||
<h2 className="page-title">{group.name}</h2>
|
||||
<p className="page-lead">
|
||||
Pool actuel : <strong>{formatEUR(total)}</strong> · id{' '}
|
||||
<code>{group.id.slice(0, 8)}…</code>
|
||||
</p>
|
||||
|
||||
{err ? (
|
||||
<p className="form-error" role="alert">
|
||||
{err}
|
||||
</p>
|
||||
) : null}
|
||||
{ok ? <p className="form-notice">{ok}</p> : null}
|
||||
|
||||
<section className="order-panel">
|
||||
<h3 className="panel-title">Participer à la commande groupée</h3>
|
||||
<form className="review-form" onSubmit={handleSubmit}>
|
||||
<label>
|
||||
Pseudo / prénom
|
||||
<input
|
||||
value={contributor}
|
||||
onChange={(e) => setContributor(e.target.value)}
|
||||
placeholder="Marvin"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Montant (€) — optionnel pour la démo
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.5"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
placeholder="12.5"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Note (ex. titres souhaités)
|
||||
<textarea
|
||||
rows={2}
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="2 romans SF…"
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" className="btn primary">
|
||||
Envoyer ma participation (POST)
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="order-panel">
|
||||
<h3 className="panel-title">Contributions</h3>
|
||||
{lines.length === 0 ? (
|
||||
<p className="empty-state">Personne n’a encore participé.</p>
|
||||
) : (
|
||||
<ul className="reviews-list">
|
||||
{lines.map((o) => (
|
||||
<li key={o.id} className="reviews-item">
|
||||
<p className="reviews-meta">
|
||||
<strong>{o.contributor}</strong> · {formatDate(o.createdAt)} ·{' '}
|
||||
{formatEUR(o.amountEuros)}
|
||||
</p>
|
||||
{o.note ? (
|
||||
<p className="reviews-text">{o.note}</p>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useGroups } from '../context/GroupsContext.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 GroupesPage() {
|
||||
const { groups, groupTotalEuros, createGroup } = useGroups()
|
||||
const [name, setName] = useState('')
|
||||
const [err, setErr] = useState(null)
|
||||
const [ok, setOk] = useState(null)
|
||||
|
||||
const sorted = [...groups].sort((a, b) =>
|
||||
String(b.createdAt).localeCompare(String(a.createdAt)),
|
||||
)
|
||||
|
||||
function handleCreate(e) {
|
||||
e.preventDefault()
|
||||
setErr(null)
|
||||
setOk(null)
|
||||
try {
|
||||
const g = createGroup(name)
|
||||
setName('')
|
||||
setOk(`Groupe « ${g.name} » créé.`)
|
||||
} catch (ex) {
|
||||
setErr(ex?.message || 'Impossible de créer le groupe.')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="groupes-page">
|
||||
<h2 className="page-title">Commandes groupées</h2>
|
||||
<p className="page-lead">
|
||||
Équivalent local de <code>POST /api/groups/:id/orders</code> : tu crées
|
||||
un groupe, puis chacun ajoute sa « ligne » (montant + message). Stocké
|
||||
dans <code>librairie-groups-v1</code>.
|
||||
</p>
|
||||
|
||||
{err ? (
|
||||
<p className="form-error" role="alert">
|
||||
{err}
|
||||
</p>
|
||||
) : null}
|
||||
{ok ? <p className="form-notice">{ok}</p> : null}
|
||||
|
||||
<section className="order-panel">
|
||||
<h3 className="panel-title">Nouveau groupe</h3>
|
||||
<form className="promo-form" onSubmit={handleCreate}>
|
||||
<label>
|
||||
Nom du groupe (ex. promo rentrée)
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Lecteurs du 8e étage"
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" className="btn primary">
|
||||
Créer le groupe
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="order-panel">
|
||||
<h3 className="panel-title">Tes groupes</h3>
|
||||
{sorted.length === 0 ? (
|
||||
<p className="empty-state">Aucun groupe encore.</p>
|
||||
) : (
|
||||
<ul className="reviews-list">
|
||||
{sorted.map((g) => (
|
||||
<li key={g.id} className="reviews-item">
|
||||
<p className="reviews-meta">{formatDate(g.createdAt)}</p>
|
||||
<p className="reviews-text">
|
||||
<Link to={`/groupes/${g.id}`}>{g.name}</Link>
|
||||
{' · '}
|
||||
Pool actuel ~{' '}
|
||||
<strong>{groupTotalEuros(g.id).toFixed(2)} €</strong>
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useLoans } from '../context/LoansContext.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 PretsPage() {
|
||||
const { activeLoans, loans, registerLoanReturn } = useLoans()
|
||||
|
||||
const returned = loans
|
||||
.filter((l) => l.returnedAt)
|
||||
.sort((a, b) =>
|
||||
String(b.returnedAt).localeCompare(String(a.returnedAt)),
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="prets-page">
|
||||
<h2 className="page-title">Prêts entre lecteurs</h2>
|
||||
<p className="page-lead">
|
||||
Équivalent local de <code>POST /api/books/:id/loans</code> + suivi des
|
||||
retours · <code>librairie-loans</code> · aucun backend.
|
||||
</p>
|
||||
|
||||
<section className="order-panel">
|
||||
<h3 className="panel-title">Prêts en cours</h3>
|
||||
{activeLoans.length === 0 ? (
|
||||
<p className="empty-state">
|
||||
Rien en cours. Depuis une fiche livre, section « Prêter ce livre ».
|
||||
</p>
|
||||
) : (
|
||||
<ul className="retours-list">
|
||||
{activeLoans.map((l) => (
|
||||
<li key={l.id} className="retours-item">
|
||||
<div>
|
||||
<p className="retours-book">
|
||||
<Link to={`/${l.bookId}`}>{l.bookTitle}</Link>
|
||||
</p>
|
||||
<p className="retours-meta">
|
||||
Emprunteur : <strong>{l.borrowerName}</strong> · depuis le{' '}
|
||||
{formatDate(l.lentAt)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary small"
|
||||
onClick={() => registerLoanReturn(l.id)}
|
||||
>
|
||||
Livre rendu
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="order-panel">
|
||||
<h3 className="panel-title">Historique</h3>
|
||||
{returned.length === 0 ? (
|
||||
<p className="empty-state">Aucun prêt clôturé.</p>
|
||||
) : (
|
||||
<ul className="retours-list history">
|
||||
{returned.slice(0, 20).map((l) => (
|
||||
<li key={l.id} className="retours-item muted">
|
||||
<p className="retours-book">{l.bookTitle}</p>
|
||||
<p className="retours-meta">
|
||||
{l.borrowerName} · rendu le {formatDate(l.returnedAt)}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user