feat(shopping): bottom sheet multi-select pour l'ajout d'articles

- BottomSheet.tsx: panneau ancré en bas, max-height 85dvh (clavier-aware),
  centré sur laptop (max-width 600px), backdrop, drag handle visuel
- ShoppingPage: remplace le modal centré par le BottomSheet multi-select
  · sélection multiple avec toggle (cercle vert + fond teinté)
  · articles libres affichés en tête avec tag "article libre"
  · bouton "Ajouter (N)" sticky, grisé à 0 sélection
  · pas d'autoFocus → liste visible d'emblée, clavier fermé
  · FAB + masqué quand le sheet est ouvert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 08:06:48 +02:00
parent a821b27fc6
commit c9cf452585
2 changed files with 206 additions and 140 deletions
+42
View File
@@ -0,0 +1,42 @@
interface BottomSheetProps {
onClose: () => void
children: React.ReactNode
}
export default function BottomSheet({ onClose, children }: BottomSheetProps) {
return (
<>
<div
onClick={onClose}
style={{
position: 'fixed', inset: 0,
background: 'rgba(0,0,0,0.55)',
zIndex: 40,
}}
/>
<div
style={{
position: 'fixed',
bottom: 0,
left: '50%',
transform: 'translateX(-50%)',
width: '100%',
maxWidth: 600,
maxHeight: '85dvh',
background: 'var(--bg-2)',
borderRadius: '16px 16px 0 0',
boxShadow: '0 -4px 24px rgba(0,0,0,0.4)',
zIndex: 50,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
<div style={{ display: 'flex', justifyContent: 'center', padding: '10px 0 4px', flexShrink: 0 }}>
<div style={{ width: 36, height: 4, borderRadius: 2, background: 'var(--bg-5)' }} />
</div>
{children}
</div>
</>
)
}
+164 -140
View File
@@ -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<string | null>(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<Product | null>(null)
const [newItemQty, setNewItemQty] = useState('')
const [newItemUnit, setNewItemUnit] = useState('')
const [addToCatalogue, setAddToCatalogue] = useState(true)
const [selections, setSelections] = useState<Selection[]>([])
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() {
)}
</div>
{/* FAB + */}
<button
onClick={() => setShowAddItemModal(true)}
aria-label="Ajouter un article"
style={{
position: 'fixed', bottom: 72, right: 20,
width: 56, height: 56, borderRadius: '50%',
background: 'var(--accent)', color: '#1d2021', border: 'none',
fontSize: 28, cursor: 'pointer', boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>+</button>
{/* FAB + — masqué quand le sheet est ouvert */}
{!showAddSheet && (
<button
onClick={() => setShowAddSheet(true)}
aria-label="Ajouter un article"
style={{
position: 'fixed', bottom: 72, right: 20,
width: 56, height: 56, borderRadius: '50%',
background: 'var(--accent)', color: '#1d2021', border: 'none',
fontSize: 28, cursor: 'pointer', boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>+</button>
)}
</>
)}
@@ -475,118 +486,131 @@ export default function ShoppingPage() {
</Modal>
)}
{showAddItemModal && (
<Modal title="Ajouter un article" onClose={closeAddItemModal} width={420}>
{/* Barre de recherche / nom personnalisé */}
<input
style={{
...inputStyle,
borderColor: selectedProduct ? 'var(--ok)' : 'var(--bg-5)',
borderWidth: selectedProduct ? 2 : 1,
}}
placeholder="Rechercher ou saisir un article…"
value={itemSearch}
onChange={e => { setItemSearch(e.target.value); setSelectedProduct(null) }}
autoFocus
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
onKeyDown={e => e.key === 'Enter' && void handleAddItem()}
/>
{showAddSheet && (
<BottomSheet onClose={closeAddSheet}>
{/* Recherche */}
<div style={{ padding: '4px 16px 10px', flexShrink: 0 }}>
<input
style={inputStyle}
placeholder="Rechercher ou saisir un article…"
value={itemSearch}
onChange={e => setItemSearch(e.target.value)}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
/>
</div>
{/* Liste scrollable alphabétique du catalogue */}
<div style={{
maxHeight: 'min(240px, 35dvh)', overflowY: 'auto',
border: '1px solid var(--bg-5)', borderRadius: 8,
background: 'var(--bg-3)',
}}>
{filteredProducts.length === 0 && (
<p style={{
color: 'var(--ink-4)', fontFamily: 'var(--font-ui)', fontSize: 13,
textAlign: 'center', padding: '12px 16px', margin: 0, ...noSelect,
}}>
{itemSearch.trim() ? `"${itemSearch}" — article libre` : 'Catalogue vide'}
</p>
)}
{filteredProducts.map((p, idx) => {
const isSelected = selectedProduct?.id === p.id
{/* Liste scrollable */}
<div style={{ flex: 1, overflowY: 'auto' }}>
{/* Articles libres sélectionnés (affichés en tête) */}
{selections.filter(s => s.type === 'custom').map(s => (
<div
key={`custom:${s.name}`}
onClick={() => 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)',
}}
>
<div style={{
width: 24, height: 24, borderRadius: '50%',
border: '2px solid var(--ok)', background: 'var(--ok)',
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
<span style={{ color: '#1d2021', fontSize: 12, fontWeight: 700 }}></span>
</div>
<div style={{ flex: 1 }}>
<div style={{ fontFamily: 'var(--font-ui)', fontSize: 15, color: 'var(--ink-1)' }}>{s.name}</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--ink-3)', marginTop: 2 }}>article libre</div>
</div>
</div>
))}
{/* Produits du catalogue */}
{filteredProducts.map(p => {
const selected = selections.some(s => s.type === 'product' && s.product.id === p.id)
return (
<div
key={p.id}
onClick={() => 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',
}}
>
<div style={{
width: 24, height: 24, borderRadius: '50%',
border: `2px solid ${selected ? 'var(--ok)' : 'var(--bg-5)'}`,
background: selected ? 'var(--ok)' : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
transition: 'all 0.15s',
}}>
{selected && <span style={{ color: '#1d2021', fontSize: 12, fontWeight: 700 }}></span>}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontFamily: 'var(--font-ui)', fontSize: 14, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<div style={{ fontFamily: 'var(--font-ui)', fontSize: 15, color: 'var(--ink-1)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{p.name}
</div>
{(p.default_unit || p.brand) && (
<div style={{ fontSize: 11, color: isSelected ? '#1d2021' : 'var(--ink-3)', fontFamily: 'var(--font-mono)', marginTop: 1 }}>
{(p.brand || p.default_unit) && (
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--ink-3)', marginTop: 2 }}>
{[p.brand, p.default_unit].filter(Boolean).join(' · ')}
</div>
)}
</div>
{isSelected && <span style={{ fontSize: 16, fontWeight: 700 }}></span>}
</div>
)
})}
{/* Article libre si aucun match */}
{itemSearch.trim() && filteredProducts.length === 0 && (
<div
onClick={addCustomItem}
style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '14px 16px', cursor: 'pointer',
color: 'var(--info)', borderBottom: '1px solid var(--bg-4)',
}}
>
<span style={{ fontSize: 22, fontWeight: 300, lineHeight: 1 }}>+</span>
<span style={{ fontFamily: 'var(--font-ui)', fontSize: 14 }}>
Ajouter <strong style={{ color: 'var(--ink-1)' }}>"{itemSearch.trim()}"</strong>
</span>
</div>
)}
{filteredProducts.length === 0 && !itemSearch.trim() && (
<p style={{ color: 'var(--ink-4)', textAlign: 'center', padding: '24px 16px', fontFamily: 'var(--font-ui)', fontSize: 13 }}>
Catalogue vide utilisez le bouton Articles
</p>
)}
</div>
{/* Case "Ajouter au catalogue" — uniquement si article libre sans correspondance */}
{!selectedProduct && itemSearch.trim() && filteredProducts.length === 0 && (
<label style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer', userSelect: 'none', padding: '6px 2px' }}>
<input
type="checkbox"
checked={addToCatalogue}
onChange={e => setAddToCatalogue(e.target.checked)}
style={{ width: 18, height: 18, accentColor: 'var(--ok)', cursor: 'pointer', flexShrink: 0 }}
/>
<span style={{ fontFamily: 'var(--font-ui)', fontSize: 13, color: 'var(--ink-2)' }}>
Ajouter <strong style={{ color: 'var(--ink-1)' }}>{itemSearch.trim()}</strong> au catalogue
</span>
</label>
)}
{/* Quantité + Unité */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<input
style={inputStyle} placeholder="Quantité"
inputMode="decimal"
value={newItemQty}
onChange={e => setNewItemQty(e.target.value)}
/>
<input
style={inputStyle} placeholder="Unité (kg, L…)"
value={newItemUnit}
onChange={e => setNewItemUnit(e.target.value)}
/>
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
{/* Bouton confirm sticky */}
<div style={{ padding: '12px 16px', borderTop: '1px solid var(--bg-4)', flexShrink: 0 }}>
<button
onClick={closeAddItemModal}
style={{ padding: '10px 16px', borderRadius: 8, border: '1px solid var(--bg-5)', background: 'transparent', color: 'var(--ink-2)', cursor: 'pointer', fontFamily: 'var(--font-ui)', minHeight: 48 }}
>Annuler</button>
<button
onClick={() => void handleAddItem()}
disabled={!selectedProduct && !itemSearch.trim()}
onClick={() => void handleConfirmAdd()}
disabled={selections.length === 0 || addSaving}
style={{
padding: '10px 20px', borderRadius: 8, border: 'none',
background: (!selectedProduct && !itemSearch.trim()) ? 'var(--bg-5)' : 'var(--accent)',
color: '#1d2021', cursor: 'pointer', fontFamily: 'var(--font-ui)', fontWeight: 600, minHeight: 48,
width: '100%', padding: '14px', borderRadius: 12, border: 'none',
background: selections.length === 0 ? 'var(--bg-4)' : 'var(--accent)',
color: selections.length === 0 ? 'var(--ink-4)' : '#1d2021',
fontFamily: 'var(--font-ui)', fontWeight: 700, fontSize: 16,
cursor: selections.length === 0 ? 'default' : 'pointer',
minHeight: 52, transition: 'background 0.15s',
}}
>Ajouter</button>
>
{addSaving ? 'Ajout en cours…' : selections.length === 0 ? 'Sélectionner des articles' : `Ajouter (${selections.length})`}
</button>
</div>
</Modal>
</BottomSheet>
)}
{showHistoryModal && (