Compare commits
5 Commits
main
...
634b398dc7
| Author | SHA1 | Date | |
|---|---|---|---|
| 634b398dc7 | |||
| f80dfdcdb5 | |||
| d1f880cfbb | |||
| efb21f73ce | |||
| 95f5683ac5 |
@@ -2,6 +2,36 @@
|
|||||||
|
|
||||||
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).
|
||||||
|
|
||||||
|
## Ce qu’on a rajouté (branche Patrick_commande_promo)
|
||||||
|
|
||||||
|
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 l’instant c’est une remise en %), tu peux les activer / désactiver / supprimer. C’est le pendant local d’un `POST /api/promotions`, stocké sous `librairie-promotions`.
|
||||||
|
- **Prix des livres** : chaque livre a un champ **prix (€)** (formulaire, fiche détail, liste ; les anciens livres sans prix prennent 10 € par défaut). Les résultats ajoutés depuis Open Library partent aussi sur un prix par défaut.
|
||||||
|
- **Navigation** : dans le menu en haut, liens **Commande** et **Promotions** en plus de Mes livres / Recherche.
|
||||||
|
|
||||||
|
## Ce qu’on a rajouté (branche Patrick_reserve_retourne__avis_livre)
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Ce qu’on a rajouté (branche Marvin_fidelite_abo_pret_commande_groupe)
|
||||||
|
|
||||||
|
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
|
## Les membres du projet
|
||||||
|
|
||||||
Marvin Aubert, Maxime Lebreton et Patrick Felix-Vimalaratnam
|
Marvin Aubert, Maxime Lebreton et Patrick Felix-Vimalaratnam
|
||||||
@@ -49,5 +79,12 @@ 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.
|
||||||
|
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é.
|
||||||
|
|||||||
+410
@@ -593,3 +593,413 @@
|
|||||||
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|||||||
+16
@@ -1,7 +1,15 @@
|
|||||||
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 AbonnementPage from './pages/AbonnementPage.jsx'
|
||||||
import BookDetailPage from './pages/BookDetailPage.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 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'
|
import SearchPage from './pages/SearchPage.jsx'
|
||||||
import './App.css'
|
import './App.css'
|
||||||
|
|
||||||
@@ -11,6 +19,14 @@ 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="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 path=":id" element={<BookDetailPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 d’un POST /api/books (ici : persistance locale + id généré). */
|
/** Équivalent métier d’un 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) => {
|
||||||
|
|||||||
@@ -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,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 d’un 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
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -31,6 +31,62 @@ 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>
|
||||||
|
<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>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
+27
-3
@@ -2,15 +2,39 @@ 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 { 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 App from './App.jsx'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')).render(
|
createRoot(document.getElementById('root')).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<BooksProvider>
|
<PromotionsProvider>
|
||||||
<App />
|
<OrdersProvider>
|
||||||
</BooksProvider>
|
<LoyaltyProvider>
|
||||||
|
<BooksProvider>
|
||||||
|
<ReservationsProvider>
|
||||||
|
<ReviewsProvider>
|
||||||
|
<SubscriptionsProvider>
|
||||||
|
<LoansProvider>
|
||||||
|
<GroupsProvider>
|
||||||
|
<App />
|
||||||
|
</GroupsProvider>
|
||||||
|
</LoansProvider>
|
||||||
|
</SubscriptionsProvider>
|
||||||
|
</ReviewsProvider>
|
||||||
|
</ReservationsProvider>
|
||||||
|
</BooksProvider>
|
||||||
|
</LoyaltyProvider>
|
||||||
|
</OrdersProvider>
|
||||||
|
</PromotionsProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,12 +1,50 @@
|
|||||||
|
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 { useLoans } from '../context/LoansContext.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 {
|
||||||
|
createLoan,
|
||||||
|
hasActiveLoanForBook,
|
||||||
|
} = useLoans()
|
||||||
|
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 [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) {
|
if (!book) {
|
||||||
return (
|
return (
|
||||||
<div className="book-detail">
|
<div className="book-detail">
|
||||||
@@ -23,6 +61,80 @@ 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 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)
|
||||||
|
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 +143,16 @@ 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}
|
||||||
|
{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>
|
<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 +160,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 +177,127 @@ 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">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">
|
||||||
|
<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,214 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
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 { earnFromOrderTotal } = useLoyalty()
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
earnFromOrderTotal(order.total)
|
||||||
|
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”
|
||||||
|
d’abord.
|
||||||
|
</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é > 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user