Ajout de Promotion et de Commande fonctionnel
This commit is contained in:
+203
@@ -593,3 +593,206 @@
|
||||
flex-wrap: wrap;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Route, Routes } from 'react-router-dom'
|
||||
import { RootLayout } from './layout/RootLayout.jsx'
|
||||
import BookDetailPage from './pages/BookDetailPage.jsx'
|
||||
import CommandePage from './pages/CommandePage.jsx'
|
||||
import MesLivresPage from './pages/MesLivresPage.jsx'
|
||||
import PromotionsPage from './pages/PromotionsPage.jsx'
|
||||
import SearchPage from './pages/SearchPage.jsx'
|
||||
import './App.css'
|
||||
|
||||
@@ -11,6 +13,8 @@ export default function App() {
|
||||
<Route path="/" element={<RootLayout />}>
|
||||
<Route index element={<MesLivresPage />} />
|
||||
<Route path="recherche" element={<SearchPage />} />
|
||||
<Route path="commande" element={<CommandePage />} />
|
||||
<Route path="promotions" element={<PromotionsPage />} />
|
||||
<Route path=":id" element={<BookDetailPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -5,6 +5,7 @@ const emptyForm = () => ({
|
||||
author: '',
|
||||
year: new Date().getFullYear(),
|
||||
genre: '',
|
||||
price: 10,
|
||||
read: false,
|
||||
})
|
||||
|
||||
@@ -71,6 +72,22 @@ export function BookForm({ onSubmit }) {
|
||||
placeholder="Roman, essai…"
|
||||
/>
|
||||
</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>
|
||||
<label className="checkbox-row">
|
||||
<input
|
||||
|
||||
@@ -76,7 +76,9 @@ export function BookList({
|
||||
</Link>
|
||||
</h3>
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -15,6 +15,7 @@ const demoBooks = [
|
||||
author: 'Antoine de Saint-Exupéry',
|
||||
year: 1943,
|
||||
genre: 'Conte',
|
||||
price: 8.5,
|
||||
read: true,
|
||||
},
|
||||
{
|
||||
@@ -23,16 +24,25 @@ const demoBooks = [
|
||||
author: 'George Orwell',
|
||||
year: 1949,
|
||||
genre: 'Science-fiction',
|
||||
price: 9.9,
|
||||
read: false,
|
||||
},
|
||||
]
|
||||
|
||||
function normalizeBook(b) {
|
||||
const price = Number(b?.price)
|
||||
return {
|
||||
...b,
|
||||
price: Number.isFinite(price) ? price : 10,
|
||||
}
|
||||
}
|
||||
|
||||
function loadBooks() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (raw === null) return demoBooks
|
||||
const parsed = JSON.parse(raw)
|
||||
return Array.isArray(parsed) ? parsed : demoBooks
|
||||
return Array.isArray(parsed) ? parsed.map(normalizeBook) : demoBooks
|
||||
} catch {
|
||||
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é). */
|
||||
const postBook = useCallback((book) => {
|
||||
const id = crypto.randomUUID()
|
||||
setBooks((prev) => [...prev, { ...book, id }])
|
||||
setBooks((prev) => [...prev, normalizeBook({ ...book, id })])
|
||||
}, [])
|
||||
|
||||
const removeBook = useCallback((id) => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -31,6 +31,22 @@ export function RootLayout() {
|
||||
>
|
||||
Recherche (Axios)
|
||||
</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>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -2,15 +2,21 @@ import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { BooksProvider } from './context/BooksContext.jsx'
|
||||
import { OrdersProvider } from './context/OrdersContext.jsx'
|
||||
import { PromotionsProvider } from './context/PromotionsContext.jsx'
|
||||
import App from './App.jsx'
|
||||
import './index.css'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<PromotionsProvider>
|
||||
<OrdersProvider>
|
||||
<BooksProvider>
|
||||
<App />
|
||||
</BooksProvider>
|
||||
</OrdersProvider>
|
||||
</PromotionsProvider>
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -38,6 +38,10 @@ export default function BookDetailPage() {
|
||||
<dt>Année</dt>
|
||||
<dd>{book.year}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Prix</dt>
|
||||
<dd>{Number(book.price ?? 10).toFixed(2)} €</dd>
|
||||
</div>
|
||||
{book.genre ? (
|
||||
<div>
|
||||
<dt>Genre</dt>
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useBooks } from '../context/BooksContext.jsx'
|
||||
import { useOrders } from '../context/OrdersContext.jsx'
|
||||
import { usePromotions } from '../context/PromotionsContext.jsx'
|
||||
|
||||
function formatEUR(amount) {
|
||||
const n = Number(amount) || 0
|
||||
return new Intl.NumberFormat('fr-FR', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
maximumFractionDigits: 2,
|
||||
}).format(n)
|
||||
}
|
||||
|
||||
function toQtyMap(items) {
|
||||
const map = new Map()
|
||||
for (const it of items) map.set(it.bookId, it.qty)
|
||||
return map
|
||||
}
|
||||
|
||||
export default function CommandePage() {
|
||||
const { books } = useBooks()
|
||||
const { createOrder } = useOrders()
|
||||
const { getActivePromotionByCode, normalizeCode } = usePromotions()
|
||||
|
||||
const [qtyByBookId, setQtyByBookId] = useState(() => ({}))
|
||||
const [promoInput, setPromoInput] = useState('')
|
||||
const [notice, setNotice] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const items = useMemo(() => {
|
||||
return books
|
||||
.map((b) => {
|
||||
const qty = Math.max(0, Math.trunc(Number(qtyByBookId[b.id]) || 0))
|
||||
const unitPrice = Number(b.price)
|
||||
return {
|
||||
bookId: b.id,
|
||||
title: b.title,
|
||||
unitPrice: Number.isFinite(unitPrice) ? unitPrice : 10,
|
||||
qty,
|
||||
}
|
||||
})
|
||||
.filter((it) => it.qty > 0)
|
||||
}, [books, qtyByBookId])
|
||||
|
||||
const subtotal = useMemo(() => {
|
||||
return items.reduce((sum, it) => sum + it.unitPrice * it.qty, 0)
|
||||
}, [items])
|
||||
|
||||
const promo = useMemo(() => {
|
||||
const code = normalizeCode(promoInput)
|
||||
if (!code) return null
|
||||
return getActivePromotionByCode(code)
|
||||
}, [promoInput, getActivePromotionByCode, normalizeCode])
|
||||
|
||||
const discount = useMemo(() => {
|
||||
if (!promo) return 0
|
||||
return (subtotal * promo.value) / 100
|
||||
}, [promo, subtotal])
|
||||
|
||||
const total = useMemo(
|
||||
() => Math.max(0, subtotal - discount),
|
||||
[subtotal, discount],
|
||||
)
|
||||
|
||||
function setQty(bookId, nextQty) {
|
||||
setQtyByBookId((prev) => ({ ...prev, [bookId]: nextQty }))
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
setError(null)
|
||||
setNotice(null)
|
||||
try {
|
||||
const order = createOrder({
|
||||
items,
|
||||
promotion: promo
|
||||
? { code: promo.code, type: promo.type, value: promo.value }
|
||||
: null,
|
||||
})
|
||||
setQtyByBookId({})
|
||||
setPromoInput('')
|
||||
const map = toQtyMap(order.items)
|
||||
setNotice(
|
||||
`Commande ${order.id.slice(0, 8)} créée (${map.size} livre${
|
||||
map.size > 1 ? 's' : ''
|
||||
}). Total: ${formatEUR(order.total)}.`,
|
||||
)
|
||||
} catch (e) {
|
||||
setError(e?.message || 'Impossible de créer la commande.')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="commande-page">
|
||||
<h2 className="page-title">Passer une commande</h2>
|
||||
<p className="page-lead">
|
||||
Équivalent local de <code>POST /api/orders</code> (aucun backend).
|
||||
</p>
|
||||
|
||||
{error ? (
|
||||
<p className="form-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
{notice ? <p className="form-notice">{notice}</p> : null}
|
||||
|
||||
{books.length === 0 ? (
|
||||
<p className="empty-state">
|
||||
Aucun livre dans le catalogue. Ajoute un livre dans “Mes livres”
|
||||
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,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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ function docToBookPayload(doc) {
|
||||
? doc.first_publish_year
|
||||
: new Date().getFullYear(),
|
||||
genre: Array.isArray(doc.subject) ? doc.subject[0] : '',
|
||||
price: 10,
|
||||
read: false,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user