diff --git a/README.md b/README.md index 503a971..a7e4742 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,16 @@ Même logique que la branche d’au-dessus : **pas de backend** du projet, on si Les données qui tournaient déjà (`librairie-books` pour le catalogue, puis `librairie-orders` et `librairie-promotions` si tu viens de l’autre branche) : **toujours zéro serveur**, tout est chez toi dans le navigateur, pas sur l’infra du cours. +## Ce qu’on a rajouté (branche Marvin_fidelite_abo_pret_commande_groupe, tout en français courant) + +Encore une fois : **aucun backend du sujet**, pas d’URL d’API cours, rien. On refait les 4 bouts manquants du PDF en **Context + `localStorage`**, comme un faux `GET` / `POST` qui ne sort jamais du navigateur. + +- **Points de fidélité** (`GET /api/users/:id/loyalty-points`, en local) : page **Fidélité** (`/fidelite`). Un pseudo-utilisateur fixe `local-user`, solde + petit historique dans **`librairie-loyalty`**. À chaque commande validée, on crédite des points (démo automatique depuis la page Commande). +- **Abonnement** (`POST /api/subscriptions`) : page **Abo** (`/abonnement`), formules mensuelle / annuelle factices, tout est dans **`librairie-subscriptions`** (pas de vrai paiement). +- **Prêt entre lecteurs** (`POST /api/books/:id/loans`) : sur une fiche livre, bloc **Prêter** + page **Prêts** (`/prets`) pour clôturer avec **Livre rendu**. **`librairie-loans`**. Si le livre est déjà **réservé** côté biblio fictive, on bloque pour pas mélanger les délires. +- **Commande groupée** (`POST /api/groups/:id/orders`) : **Groupes** (`/groupes`) pour créer une coloc de commande, puis détail **`/groupes/:id-du-groupe`** où chacune et chacun poste pseudo + montant € + petite note → tout est dans **`librairie-groups-v1`** sur une seule clé locale (liste groupes + lignes participants). +- **Navigation** : raccourcis **Fidélité**, **Abo**, **Prêts**, **Groupes** dans la barre du haut avec le reste. + ## Les membres du projet Marvin Aubert, Maxime Lebreton et Patrick Felix-Vimalaratnam @@ -74,3 +84,7 @@ npm run build 4. **Promotions** (`/promotions`) : créer un code promo (ex. `BUT10` avec −10 %), puis vérifier que tu peux le désactiver / le réactiver / le supprimer. 5. **Commande** (`/commande`) : mettre au moins un livre avec une quantité strictement positive, tester sans promo puis avec le code créé avant ; passer la commande et vérifier le message + que le sous-total, la remise et le total sont cohérents (toujours en local). 6. **Retours** (`/retours`) : après une réservation, vérifier que le livre apparaît puis **Enregistrer le retour** ; vérifier l’historique et que tu peux de nouveau réserver le même titre. +7. **Fidélité** (`/fidelite`) : noter le solde avant / après avoir passé une **commande** ; vérifier que des points ont été ajoutés (local). +8. **Abo** (`/abonnement`) : souscrire à une formule puis résilier (local). +9. **Prêt** (`/` + `/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é. diff --git a/src/App.css b/src/App.css index 66a82fa..a601d10 100644 --- a/src/App.css +++ b/src/App.css @@ -935,3 +935,71 @@ font-size: 0.88rem; color: var(--ink-muted); } + +.fidelite-points { + font-family: var(--font-display); + font-size: 2rem; + font-weight: 800; + margin: 0 0 0.5rem; + color: var(--accent); +} + +.fidelite-box { + max-width: 520px; +} + +.loan-borrower { + display: grid; + gap: 0.35rem; + font-size: 0.8rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--ink-muted); + margin-bottom: 0.75rem; +} + +.loan-borrower input { + font: inherit; + padding: 0.55rem 0.65rem; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--paper); + color: var(--ink); + text-transform: none; + font-weight: 500; +} + +.book-loan-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; +} + +.abo-plans { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 0.75rem; +} + +.abo-plan-card { + border: 1px solid var(--border); + border-radius: 12px; + padding: 1rem; + background: var(--paper); + text-align: center; +} + +.abo-plan-name { + margin: 0; + font-weight: 800; +} + +.abo-plan-price { + margin: 0.35rem 0 0.75rem; + color: var(--ink-muted); +} diff --git a/src/App.jsx b/src/App.jsx index 5d362fe..c41a66b 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,8 +1,13 @@ import { Route, Routes } from 'react-router-dom' import { RootLayout } from './layout/RootLayout.jsx' +import AbonnementPage from './pages/AbonnementPage.jsx' import BookDetailPage from './pages/BookDetailPage.jsx' import CommandePage from './pages/CommandePage.jsx' +import FidelitePage from './pages/FidelitePage.jsx' +import GroupDetailPage from './pages/GroupDetailPage.jsx' +import GroupesPage from './pages/GroupesPage.jsx' import MesLivresPage from './pages/MesLivresPage.jsx' +import PretsPage from './pages/PretsPage.jsx' import PromotionsPage from './pages/PromotionsPage.jsx' import RetoursPage from './pages/RetoursPage.jsx' import SearchPage from './pages/SearchPage.jsx' @@ -17,6 +22,11 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> + } /> + } /> } /> diff --git a/src/context/GroupsContext.jsx b/src/context/GroupsContext.jsx new file mode 100644 index 0000000..e3846d5 --- /dev/null +++ b/src/context/GroupsContext.jsx @@ -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 ( + {children} + ) +} + +// 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 +} diff --git a/src/context/LoansContext.jsx b/src/context/LoansContext.jsx new file mode 100644 index 0000000..309bd3d --- /dev/null +++ b/src/context/LoansContext.jsx @@ -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 {children} +} + +// 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 +} diff --git a/src/context/LoyaltyContext.jsx b/src/context/LoyaltyContext.jsx new file mode 100644 index 0000000..61550a2 --- /dev/null +++ b/src/context/LoyaltyContext.jsx @@ -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 ( + {children} + ) +} + +// 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 +} diff --git a/src/context/SubscriptionsContext.jsx b/src/context/SubscriptionsContext.jsx new file mode 100644 index 0000000..68b7014 --- /dev/null +++ b/src/context/SubscriptionsContext.jsx @@ -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 ( + + {children} + + ) +} + +// 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 +} diff --git a/src/layout/RootLayout.jsx b/src/layout/RootLayout.jsx index 6ffc161..19fe42e 100644 --- a/src/layout/RootLayout.jsx +++ b/src/layout/RootLayout.jsx @@ -55,6 +55,38 @@ export function RootLayout() { > Retours + + isActive ? 'nav-link active' : 'nav-link' + } + > + Fidélité + + + isActive ? 'nav-link active' : 'nav-link' + } + > + Abo + + + isActive ? 'nav-link active' : 'nav-link' + } + > + Prêts + + + isActive ? 'nav-link active' : 'nav-link' + } + > + Groupes + diff --git a/src/main.jsx b/src/main.jsx index 94c3163..a353b54 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -2,10 +2,14 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { BrowserRouter } from 'react-router-dom' import { BooksProvider } from './context/BooksContext.jsx' +import { GroupsProvider } from './context/GroupsContext.jsx' +import { LoansProvider } from './context/LoansContext.jsx' +import { LoyaltyProvider } from './context/LoyaltyContext.jsx' import { OrdersProvider } from './context/OrdersContext.jsx' import { PromotionsProvider } from './context/PromotionsContext.jsx' import { ReservationsProvider } from './context/ReservationsContext.jsx' import { ReviewsProvider } from './context/ReviewsContext.jsx' +import { SubscriptionsProvider } from './context/SubscriptionsContext.jsx' import App from './App.jsx' import './index.css' @@ -14,13 +18,21 @@ createRoot(document.getElementById('root')).render( - - - - - - - + + + + + + + + + + + + + + + diff --git a/src/pages/AbonnementPage.jsx b/src/pages/AbonnementPage.jsx new file mode 100644 index 0000000..0170720 --- /dev/null +++ b/src/pages/AbonnementPage.jsx @@ -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 ( +
+

Abonnement

+

+ Équivalent local de POST /api/subscriptions · stockage{' '} + librairie-subscriptions · pas de paiement réel, c’est une + démo. +

+ + {err ? ( +

+ {err} +

+ ) : null} + {ok ?

{ok}

: null} + + {subscription ? ( +
+

Abonnement actif

+

+ Formule : {getPlanLabel(subscription.planId)} +

+

+ Depuis le {formatDate(subscription.subscribedAt)} +

+ +
+ ) : null} + +
+

Choisir une formule

+
    + {plans.map((p) => ( +
  • +

    {p.label}

    +

    {formatEUR(p.price)}

    + +
  • + ))} +
+ {subscription ? ( +

+ Pour changer de formule, résilie d’abord puis reprends une offre. +

+ ) : null} +
+
+ ) +} diff --git a/src/pages/BookDetailPage.jsx b/src/pages/BookDetailPage.jsx index 88c75fb..f7ed315 100644 --- a/src/pages/BookDetailPage.jsx +++ b/src/pages/BookDetailPage.jsx @@ -2,6 +2,7 @@ import { useState } from 'react' import { Link, useNavigate, useParams } from 'react-router-dom' import { useBooks } from '../context/BooksContext.jsx' import { useReservations } from '../context/ReservationsContext.jsx' +import { useLoans } from '../context/LoansContext.jsx' import { useReviews } from '../context/ReviewsContext.jsx' function formatDate(iso) { @@ -23,6 +24,10 @@ export default function BookDetailPage() { createReservation, hasActiveReservationForBook, } = useReservations() + const { + createLoan, + hasActiveLoanForBook, + } = useLoans() const { addReview, getReviewsForBook } = useReviews() const book = books.find((b) => b.id === id) @@ -32,9 +37,13 @@ export default function BookDetailPage() { const [reviewComment, setReviewComment] = useState('') const [reviewErr, setReviewErr] = useState(null) const [reviewOk, setReviewOk] = useState(null) + const [borrowerName, setBorrowerName] = useState('') + const [loanMsg, setLoanMsg] = useState(null) + const [loanErr, setLoanErr] = useState(null) const bookReviews = book ? getReviewsForBook(book.id) : [] const reserved = book ? hasActiveReservationForBook(book.id) : false + const lentOut = book ? hasActiveLoanForBook(book.id) : false if (!book) { return ( @@ -73,6 +82,42 @@ export default function BookDetailPage() { } } + function handleLoan() { + setLoanErr(null) + setLoanMsg(null) + if (lentOut) { + setLoanErr('Ce livre est déjà marqué comme prêté (non rendu).') + return + } + if (reserved) { + setLoanErr( + 'Une réservation est déjà active sur ce titre ; règle le retour biblio d’abord.', + ) + return + } + const name = borrowerName.trim() + if (!name) { + setLoanErr('Indique le nom ou le pseudo de l’emprunteur.') + return + } + try { + createLoan( + { + id: book.id, + title: book.title, + author: book.author, + }, + name, + ) + setLoanMsg( + 'Prêt enregistré (équivalent local POST /api/books/:id/loans). Voir aussi la page Prêts.', + ) + setBorrowerName('') + } catch (e) { + setLoanErr(e?.message || 'Impossible de créer le prêt.') + } + } + function handleSubmitReview(e) { e.preventDefault() setReviewErr(null) @@ -103,6 +148,11 @@ export default function BookDetailPage() { Réservé ) : null} + {lentOut ? ( + + Prêté + + ) : null}

{book.title}

{book.author}

@@ -153,6 +203,46 @@ export default function BookDetailPage() { +
+

Prêter ce livre

+

+ Équivalent local de POST /api/books/:id/loans ( + librairie-loans). Un exemplaire fictif à la fois. +

+ {loanErr ? ( +

+ {loanErr} +

+ ) : null} + {loanMsg ?

{loanMsg}

: null} + +
+ + + Liste des prêts → + +
+ {reserved || lentOut ? ( +

+ Astuce : on évite réservation biblio + prêt lecteur au même moment. +

+ ) : null} +
+

Avis des lecteurs

diff --git a/src/pages/CommandePage.jsx b/src/pages/CommandePage.jsx index f00c76e..0f841aa 100644 --- a/src/pages/CommandePage.jsx +++ b/src/pages/CommandePage.jsx @@ -1,5 +1,6 @@ import { useMemo, useState } from 'react' import { useBooks } from '../context/BooksContext.jsx' +import { useLoyalty } from '../context/LoyaltyContext.jsx' import { useOrders } from '../context/OrdersContext.jsx' import { usePromotions } from '../context/PromotionsContext.jsx' @@ -21,6 +22,7 @@ function toQtyMap(items) { export default function CommandePage() { const { books } = useBooks() const { createOrder } = useOrders() + const { earnFromOrderTotal } = useLoyalty() const { getActivePromotionByCode, normalizeCode } = usePromotions() const [qtyByBookId, setQtyByBookId] = useState(() => ({})) @@ -77,6 +79,7 @@ export default function CommandePage() { ? { code: promo.code, type: promo.type, value: promo.value } : null, }) + earnFromOrderTotal(order.total) setQtyByBookId({}) setPromoInput('') const map = toQtyMap(order.items) diff --git a/src/pages/FidelitePage.jsx b/src/pages/FidelitePage.jsx new file mode 100644 index 0000000..d25bd4a --- /dev/null +++ b/src/pages/FidelitePage.jsx @@ -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 ( +

+

Points de fidélité

+

+ Lecture locale équivalente à{' '} + GET /api/users/:id/loyalty-points · utilisateur fictif{' '} + {LOCAL_USER_ID} · stocké dans{' '} + librairie-loyalty. Aucune API distante. +

+ +
+

Ton solde

+

{displayPoints} pts

+

+ Les points augmentent automatiquement quand tu valides une commande ( + exemple : environ 1 point par euro TTC du total, avec un minimum par + commande ). +

+
+ +
+

Historique (extraits)

+ {reverseLedger.length === 0 ? ( +

Pas encore de mouvements.

+ ) : ( +
    + {reverseLedger.map((e) => ( +
  • +

    + +{e.delta} · {formatDate(e.at)} +

    +

    {e.reason}

    +
  • + ))} +
+ )} +
+
+ ) +} diff --git a/src/pages/GroupDetailPage.jsx b/src/pages/GroupDetailPage.jsx new file mode 100644 index 0000000..56d9b85 --- /dev/null +++ b/src/pages/GroupDetailPage.jsx @@ -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 ( +
+

Groupe introuvable.

+ + ← Liste des groupes + +
+ ) + } + + return ( +
+ + ← Tous les groupes + +

{group.name}

+

+ Pool actuel : {formatEUR(total)} · id{' '} + {group.id.slice(0, 8)}… +

+ + {err ? ( +

+ {err} +

+ ) : null} + {ok ?

{ok}

: null} + +
+

Participer à la commande groupée

+
+ + +