diff --git a/frontend/src/components/BottomSheet.tsx b/frontend/src/components/BottomSheet.tsx new file mode 100644 index 0000000..ff227b1 --- /dev/null +++ b/frontend/src/components/BottomSheet.tsx @@ -0,0 +1,42 @@ +interface BottomSheetProps { + onClose: () => void + children: React.ReactNode +} + +export default function BottomSheet({ onClose, children }: BottomSheetProps) { + return ( + <> +
+
+
+
+
+ {children} +
+ + ) +} diff --git a/frontend/src/pages/ShoppingPage.tsx b/frontend/src/pages/ShoppingPage.tsx index b2c8d46..7c13e5d 100644 --- a/frontend/src/pages/ShoppingPage.tsx +++ b/frontend/src/pages/ShoppingPage.tsx @@ -5,9 +5,10 @@ import type { ShoppingListDetail, ShoppingList, Store, Product, ShoppingItem } f import { fetchLists, createList, fetchListDetail, deleteList, addItem, updateItem, deleteItem, finishShopping, fetchStores, generateMagicList, - searchProducts, createProduct, + searchProducts, } from '../api/shopping' import Modal from '../components/Modal' +import BottomSheet from '../components/BottomSheet' import ItemRow from '../components/shopping/ItemRow' import CatalogueModal from '../components/shopping/CatalogueModal' import BoutiquesModal from '../components/shopping/BoutiquesModal' @@ -36,7 +37,7 @@ export default function ShoppingPage() { const [error, setError] = useState(null) const [generating, setGenerating] = useState(false) - const [showAddItemModal, setShowAddItemModal] = useState(false) + const [showAddSheet, setShowAddSheet] = useState(false) const [showHistoryModal, setShowHistoryModal] = useState(false) const [showCatalogueModal, setShowCatalogueModal] = useState(false) const [showBoutiquesModal, setShowBoutiquesModal] = useState(false) @@ -44,11 +45,10 @@ export default function ShoppingPage() { const [editQty, setEditQty] = useState('') const [editUnit, setEditUnit] = useState('') + type Selection = { type: 'product'; product: Product } | { type: 'custom'; name: string } const [itemSearch, setItemSearch] = useState('') - const [selectedProduct, setSelectedProduct] = useState(null) - const [newItemQty, setNewItemQty] = useState('') - const [newItemUnit, setNewItemUnit] = useState('') - const [addToCatalogue, setAddToCatalogue] = useState(true) + const [selections, setSelections] = useState([]) + const [addSaving, setAddSaving] = useState(false) useWakeLock(currentList !== null) @@ -109,49 +109,58 @@ export default function ShoppingPage() { } } - function closeAddItemModal() { + function closeAddSheet() { setItemSearch('') - setSelectedProduct(null) - setNewItemQty('') - setNewItemUnit('') - setAddToCatalogue(true) - setShowAddItemModal(false) + setSelections([]) + setShowAddSheet(false) } - function selectProduct(p: Product) { - setSelectedProduct(p) - setItemSearch(p.name) - setNewItemUnit(p.default_unit ?? '') + function toggleProduct(p: Product) { + setSelections(prev => { + const exists = prev.some(s => s.type === 'product' && s.product.id === p.id) + if (exists) return prev.filter(s => !(s.type === 'product' && s.product.id === p.id)) + return [...prev, { type: 'product' as const, product: p }] + }) } - async function handleAddItem() { - if (!currentList) return - const customName = itemSearch.trim() - if (!selectedProduct && !customName) return + function addCustomItem() { + const name = itemSearch.trim() + if (!name) return + setSelections(prev => { + const exists = prev.some(s => s.type === 'custom' && s.name === name) + if (exists) return prev + return [...prev, { type: 'custom' as const, name }] + }) + setItemSearch('') + } + + function removeSelection(key: string) { + setSelections(prev => prev.filter(s => { + if (s.type === 'product') return s.product.id !== key + return s.name !== key + })) + } + + async function handleConfirmAdd() { + if (!currentList || selections.length === 0) return + setAddSaving(true) try { - let productId = selectedProduct?.id - - // Article libre + case "Ajouter au catalogue" cochée → créer le produit d'abord - if (!selectedProduct && customName && addToCatalogue) { - const newProduct = await createProduct({ - name: customName, - default_unit: newItemUnit || undefined, - }) - productId = newProduct.id - // Met à jour la liste locale des produits pour les prochains ajouts - setProducts(prev => [...prev, newProduct].sort((a, b) => a.name.localeCompare(b.name, 'fr'))) + for (const sel of selections) { + if (sel.type === 'product') { + await addItem(currentList.id, { + product_id: sel.product.id, + unit: sel.product.default_unit || undefined, + }) + } else { + await addItem(currentList.id, { custom_name: sel.name }) + } } - - await addItem(currentList.id, { - product_id: productId, - custom_name: !productId ? customName : undefined, - quantity: newItemQty || undefined, - unit: newItemUnit || undefined, - }) - closeAddItemModal() + closeAddSheet() void refreshCurrentList() } catch { setError("Erreur lors de l'ajout") + } finally { + setAddSaving(false) } } @@ -422,18 +431,20 @@ export default function ShoppingPage() { )}
- {/* FAB + */} - + {/* FAB + — masqué quand le sheet est ouvert */} + {!showAddSheet && ( + + )} )} @@ -475,118 +486,131 @@ export default function ShoppingPage() { )} - {showAddItemModal && ( - - {/* Barre de recherche / nom personnalisé */} - { setItemSearch(e.target.value); setSelectedProduct(null) }} - autoFocus - autoComplete="off" - autoCorrect="off" - autoCapitalize="off" - spellCheck={false} - onKeyDown={e => e.key === 'Enter' && void handleAddItem()} - /> + {showAddSheet && ( + + {/* Recherche */} +
+ setItemSearch(e.target.value)} + autoComplete="off" + autoCorrect="off" + autoCapitalize="off" + spellCheck={false} + /> +
- {/* Liste scrollable alphabétique du catalogue */} -
- {filteredProducts.length === 0 && ( -

- {itemSearch.trim() ? `"${itemSearch}" — article libre` : 'Catalogue vide'} -

- )} - {filteredProducts.map((p, idx) => { - const isSelected = selectedProduct?.id === p.id + {/* Liste scrollable */} +
+ + {/* Articles libres sélectionnés (affichés en tête) */} + {selections.filter(s => s.type === 'custom').map(s => ( +
removeSelection(s.name)} + style={{ + display: 'flex', alignItems: 'center', gap: 12, + padding: '12px 16px', cursor: 'pointer', minHeight: 56, + background: 'rgba(142,192,124,0.12)', + borderBottom: '1px solid var(--bg-4)', + }} + > +
+ +
+
+
{s.name}
+
article libre
+
+
+ ))} + + {/* Produits du catalogue */} + {filteredProducts.map(p => { + const selected = selections.some(s => s.type === 'product' && s.product.id === p.id) return (
isSelected ? (setSelectedProduct(null), setItemSearch('')) : selectProduct(p)} + onClick={() => toggleProduct(p)} style={{ - padding: '10px 14px', - cursor: 'pointer', - background: isSelected ? 'var(--ok)' : 'transparent', - color: isSelected ? '#1d2021' : 'var(--ink-1)', - borderBottom: idx < filteredProducts.length - 1 ? '1px solid var(--bg-4)' : 'none', - display: 'flex', alignItems: 'center', gap: 10, - userSelect: 'none', + display: 'flex', alignItems: 'center', gap: 12, + padding: '12px 16px', cursor: 'pointer', minHeight: 56, + background: selected ? 'rgba(142,192,124,0.12)' : 'transparent', + borderBottom: '1px solid var(--bg-4)', + transition: 'background 0.1s', }} > +
+ {selected && } +
-
+
{p.name}
- {(p.default_unit || p.brand) && ( -
+ {(p.brand || p.default_unit) && ( +
{[p.brand, p.default_unit].filter(Boolean).join(' · ')}
)}
- {isSelected && }
) })} + + {/* Article libre si aucun match */} + {itemSearch.trim() && filteredProducts.length === 0 && ( +
+ + + + Ajouter "{itemSearch.trim()}" + +
+ )} + + {filteredProducts.length === 0 && !itemSearch.trim() && ( +

+ Catalogue vide — utilisez le bouton Articles +

+ )}
- {/* Case "Ajouter au catalogue" — uniquement si article libre sans correspondance */} - {!selectedProduct && itemSearch.trim() && filteredProducts.length === 0 && ( - - )} - - {/* Quantité + Unité */} -
- setNewItemQty(e.target.value)} - /> - setNewItemUnit(e.target.value)} - /> -
- -
+ {/* Bouton confirm sticky */} +
- + > + {addSaving ? 'Ajout en cours…' : selections.length === 0 ? 'Sélectionner des articles' : `Ajouter (${selections.length})`} +
- + )} {showHistoryModal && (