feat: improve grocery list interactions

This commit is contained in:
Nico 2026-06-04 00:00:53 -07:00
parent 4c8c197e17
commit 61120dc270
13 changed files with 1001 additions and 210 deletions

View File

@ -7,39 +7,49 @@ function GroceryListItem({
onOpenBuyModal, onOpenBuyModal,
onImageAdded, onImageAdded,
onLongPress, onLongPress,
onSkip,
compact = false compact = false
}) { }) {
const [showAddImageModal, setShowAddImageModal] = useState(false); const [showAddImageModal, setShowAddImageModal] = useState(false);
const [actionsOpen, setActionsOpen] = useState(false);
const longPressTimer = useRef(null); const longPressTimer = useRef(null);
const pressStartPos = useRef({ x: 0, y: 0 }); const pressStartPos = useRef({ x: 0, y: 0 });
const handleTouchStart = (e) => { const hasActions = Boolean(onClick || onLongPress || onImageAdded || onSkip);
const touch = e.touches[0];
const clearLongPressTimer = () => {
clearTimeout(longPressTimer.current);
};
const handleTouchStart = (event) => {
const touch = event.touches[0];
pressStartPos.current = { x: touch.clientX, y: touch.clientY }; pressStartPos.current = { x: touch.clientX, y: touch.clientY };
longPressTimer.current = setTimeout(() => { longPressTimer.current = setTimeout(() => {
if (onLongPress) { if (onLongPress) {
onLongPress(item); onLongPress(item);
} }
}, 500); // 500ms for long press }, 500);
}; };
const handleTouchMove = (e) => { const handleTouchMove = (event) => {
// Cancel long press if finger moves too much const touch = event.touches[0];
const touch = e.touches[0]; const deltaX = touch.clientX - pressStartPos.current.x;
const moveDistance = Math.sqrt( const deltaY = touch.clientY - pressStartPos.current.y;
Math.pow(touch.clientX - pressStartPos.current.x, 2) + const moveDistance = Math.sqrt(Math.pow(deltaX, 2) + Math.pow(deltaY, 2));
Math.pow(touch.clientY - pressStartPos.current.y, 2)
);
if (moveDistance > 10) { if (moveDistance > 10) {
clearTimeout(longPressTimer.current); clearLongPressTimer();
}
if (hasActions && Math.abs(deltaX) > 45 && Math.abs(deltaX) > Math.abs(deltaY) * 1.25) {
setActionsOpen(true);
} }
}; };
const handleTouchEnd = () => { const handleTouchEnd = () => {
clearTimeout(longPressTimer.current); clearLongPressTimer();
}; };
const handleMouseDown = () => { const handleMouseDown = () => {
@ -51,24 +61,42 @@ function GroceryListItem({
}; };
const handleMouseUp = () => { const handleMouseUp = () => {
clearTimeout(longPressTimer.current); clearLongPressTimer();
}; };
const handleMouseLeave = () => { const handleMouseLeave = () => {
clearTimeout(longPressTimer.current); clearLongPressTimer();
}; };
const handleItemClick = () => { const handleItemClick = () => {
if (actionsOpen) {
setActionsOpen(false);
return;
}
if (onClick) { if (onClick) {
onClick(item); onClick(item);
} }
}; };
const handleImageClick = (e) => { const handleItemKeyDown = (event) => {
e.stopPropagation(); // Prevent triggering the bought action if (event.key === "Escape" && actionsOpen) {
setActionsOpen(false);
event.stopPropagation();
return;
}
if ((event.key === "Enter" || event.key === " ") && onClick) {
event.preventDefault();
handleItemClick();
}
};
const handleImageClick = (event) => {
event.stopPropagation();
if (item.item_image && onOpenBuyModal) { if (item.item_image && onOpenBuyModal) {
onOpenBuyModal(item); onOpenBuyModal(item);
} else { } else if (onImageAdded) {
setShowAddImageModal(true); setShowAddImageModal(true);
} }
}; };
@ -80,6 +108,52 @@ function GroceryListItem({
setShowAddImageModal(false); setShowAddImageModal(false);
}; };
const handleActionToggle = (event) => {
event.stopPropagation();
clearLongPressTimer();
setActionsOpen((prev) => !prev);
};
const handleBoughtAction = (event) => {
event.stopPropagation();
clearLongPressTimer();
setActionsOpen(false);
if (onClick) {
onClick(item);
}
};
const handleEditAction = (event) => {
event.stopPropagation();
clearLongPressTimer();
setActionsOpen(false);
if (onLongPress) {
onLongPress(item);
}
};
const handleImageAction = (event) => {
event.stopPropagation();
clearLongPressTimer();
setActionsOpen(false);
if (onImageAdded) {
setShowAddImageModal(true);
}
};
const handleSkipAction = (event) => {
event.stopPropagation();
clearLongPressTimer();
setActionsOpen(false);
if (onSkip) {
onSkip(item);
}
};
const stopActionEvent = (event) => {
event.stopPropagation();
};
const imageUrl = item.item_image && item.image_mime_type const imageUrl = item.item_image && item.image_mime_type
? `data:${item.image_mime_type};base64,${item.item_image}` ? `data:${item.image_mime_type};base64,${item.item_image}`
: null; : null;
@ -99,37 +173,39 @@ function GroceryListItem({
if (diffDays < 7) { if (diffDays < 7) {
return `${diffDays}d ago`; return `${diffDays}d ago`;
} else if (diffDays < 30) { }
if (diffDays < 30) {
const weeks = Math.floor(diffDays / 7); const weeks = Math.floor(diffDays / 7);
return `${weeks}w ago`; return `${weeks}w ago`;
} else { }
const months = Math.floor(diffDays / 30); const months = Math.floor(diffDays / 30);
return `${months}m ago`; return `${months}m ago`;
}
}; };
return ( return (
<> <>
<li <li
className={`glist-li ${compact ? 'compact' : ''}`} className={`glist-li ${compact ? "compact" : ""} ${actionsOpen ? "actions-open" : ""}`}
onClick={handleItemClick} onClick={handleItemClick}
onKeyDown={handleItemKeyDown}
onTouchStart={handleTouchStart} onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove} onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd} onTouchEnd={handleTouchEnd}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp} onMouseUp={handleMouseUp}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
tabIndex={onClick ? 0 : undefined}
> >
<div className="glist-item-layout"> <div className="glist-item-layout">
<div <div
className={`glist-item-image ${item.item_image ? "has-image" : ""}`} className={`glist-item-image ${item.item_image ? "has-image" : ""}`}
onClick={handleImageClick} onClick={handleImageClick}
style={{ cursor: "pointer" }} style={{ cursor: onImageAdded || (item.item_image && onOpenBuyModal) ? "pointer" : "default" }}
> >
{item.item_image ? ( {item.item_image ? (
<img src={imageUrl} alt={item.item_name} /> <img src={imageUrl} alt={item.item_name} />
) : ( ) : (
<span>📦</span> <span className="glist-item-image-placeholder">Img</span>
)} )}
<span className="glist-item-quantity">x{item.quantity}</span> <span className="glist-item-quantity">x{item.quantity}</span>
</div> </div>
@ -139,12 +215,55 @@ function GroceryListItem({
</div> </div>
{addedByUsers.length > 0 && ( {addedByUsers.length > 0 && (
<div className="glist-item-users"> <div className="glist-item-users">
{item.last_added_on && `${getTimeAgo(item.last_added_on)} -- `} {item.last_added_on && `${getTimeAgo(item.last_added_on)} - `}
{addedByUsers.join(" | ")} {addedByUsers.join(" | ")}
</div> </div>
)} )}
</div> </div>
{hasActions && (
<button
type="button"
className="glist-item-action-toggle"
aria-label={`Actions for ${item.item_name}`}
aria-expanded={actionsOpen}
onClick={handleActionToggle}
onMouseDown={stopActionEvent}
onTouchStart={stopActionEvent}
>
...
</button>
)}
</div> </div>
{actionsOpen && (
<div
className="glist-item-action-tray"
onClick={stopActionEvent}
onMouseDown={stopActionEvent}
onTouchStart={stopActionEvent}
>
{onClick && (
<button type="button" className="glist-item-action" onClick={handleBoughtAction}>
Bought
</button>
)}
{onLongPress && (
<button type="button" className="glist-item-action" onClick={handleEditAction}>
Edit
</button>
)}
{onImageAdded && (
<button type="button" className="glist-item-action" onClick={handleImageAction}>
{item.item_image ? "Image" : "Add image"}
</button>
)}
{onSkip && (
<button type="button" className="glist-item-action secondary" onClick={handleSkipAction}>
Skip
</button>
)}
</div>
)}
</li> </li>
{showAddImageModal && ( {showAddImageModal && (
@ -158,10 +277,7 @@ function GroceryListItem({
); );
} }
// Memoize component to prevent re-renders when props haven't changed export default memo(GroceryListItem, (prevProps, nextProps) => (
export default memo(GroceryListItem, (prevProps, nextProps) => {
// Only re-render if the item data or handlers have changed
return (
prevProps.item.id === nextProps.item.id && prevProps.item.id === nextProps.item.id &&
prevProps.item.item_name === nextProps.item.item_name && prevProps.item.item_name === nextProps.item.item_name &&
prevProps.item.quantity === nextProps.item.quantity && prevProps.item.quantity === nextProps.item.quantity &&
@ -169,11 +285,11 @@ export default memo(GroceryListItem, (prevProps, nextProps) => {
prevProps.item.bought === nextProps.item.bought && prevProps.item.bought === nextProps.item.bought &&
prevProps.item.last_added_on === nextProps.item.last_added_on && prevProps.item.last_added_on === nextProps.item.last_added_on &&
prevProps.item.zone === nextProps.item.zone && prevProps.item.zone === nextProps.item.zone &&
prevProps.item.added_by_users?.join(',') === nextProps.item.added_by_users?.join(',') && prevProps.item.added_by_users?.join(",") === nextProps.item.added_by_users?.join(",") &&
prevProps.onClick === nextProps.onClick && prevProps.onClick === nextProps.onClick &&
prevProps.onOpenBuyModal === nextProps.onOpenBuyModal && prevProps.onOpenBuyModal === nextProps.onOpenBuyModal &&
prevProps.onImageAdded === nextProps.onImageAdded && prevProps.onImageAdded === nextProps.onImageAdded &&
prevProps.onLongPress === nextProps.onLongPress && prevProps.onLongPress === nextProps.onLongPress &&
prevProps.onSkip === nextProps.onSkip &&
prevProps.compact === nextProps.compact prevProps.compact === nextProps.compact
); ));
});

View File

@ -1,16 +1,15 @@
import { useState } from "react"; import { useState } from "react";
import "../../styles/components/AddItemWithDetailsModal.css"; import "../../styles/components/AddItemWithDetailsModal.css";
import ClassificationSection from "../forms/ClassificationSection"; import { getZoneValues } from "../../constants/classifications";
import useActionToast from "../../hooks/useActionToast";
import ImageUploadSection from "../forms/ImageUploadSection"; import ImageUploadSection from "../forms/ImageUploadSection";
export default function AddItemWithDetailsModal({ itemName, zones = [], onConfirm, onCancel }) { export default function AddItemWithDetailsModal({ itemName, zones = [], onConfirm, onCancel }) {
const toast = useActionToast();
const [selectedImage, setSelectedImage] = useState(null); const [selectedImage, setSelectedImage] = useState(null);
const [imagePreview, setImagePreview] = useState(null); const [imagePreview, setImagePreview] = useState(null);
const [itemType, setItemType] = useState("");
const [itemGroup, setItemGroup] = useState("");
const [zone, setZone] = useState(""); const [zone, setZone] = useState("");
const zoneOptions = Array.isArray(zones) && zones.length > 0
? zones.map((candidateZone) => candidateZone.name || candidateZone).filter(Boolean)
: getZoneValues();
const handleImageChange = (file) => { const handleImageChange = (file) => {
setSelectedImage(file); setSelectedImage(file);
@ -26,22 +25,12 @@ export default function AddItemWithDetailsModal({ itemName, zones = [], onConfir
setImagePreview(null); setImagePreview(null);
}; };
const handleItemTypeChange = (newType) => {
setItemType(newType);
setItemGroup(""); // Reset group when type changes
};
const handleConfirm = () => { const handleConfirm = () => {
if (itemType && !itemGroup) { const hasClassificationDetails = Boolean(zone);
toast.error("Add item failed", `Add item failed: Select an item group for ${itemName}`);
return;
}
const hasClassificationDetails = Boolean(itemType || itemGroup || zone);
const classification = hasClassificationDetails ? { const classification = hasClassificationDetails ? {
item_type: itemType, item_type: null,
item_group: itemGroup || null, item_group: null,
zone: zone || null zone
} : null; } : null;
onConfirm(selectedImage, classification); onConfirm(selectedImage, classification);
@ -68,18 +57,22 @@ export default function AddItemWithDetailsModal({ itemName, zones = [], onConfir
{/* Classification Section */} {/* Classification Section */}
<div className="add-item-details-section"> <div className="add-item-details-section">
<ClassificationSection <div className="add-item-details-field add-item-details-zone-field">
itemType={itemType} <label htmlFor="add-item-zone">Store Zone</label>
itemGroup={itemGroup} <select
zone={zone} id="add-item-zone"
onItemTypeChange={handleItemTypeChange} value={zone}
onItemGroupChange={setItemGroup} onChange={(event) => setZone(event.target.value)}
onZoneChange={setZone} className="add-item-details-select"
zones={zones} >
title={null} <option value="">No zone yet</option>
fieldClass="add-item-details-field" {zoneOptions.map((candidateZone) => (
selectClass="add-item-details-select" <option key={candidateZone} value={candidateZone}>
/> {candidateZone}
</option>
))}
</select>
</div>
</div> </div>
{/* Actions */} {/* Actions */}

View File

@ -85,15 +85,16 @@ export default function ConfirmBuyModal({
onClick={handlePrev} onClick={handlePrev}
style={{ visibility: hasPrev ? "visible" : "hidden" }} style={{ visibility: hasPrev ? "visible" : "hidden" }}
disabled={!hasPrev || isSubmitting} disabled={!hasPrev || isSubmitting}
aria-label="Previous item"
> >
{"<"} {"<"}
</button> </button>
<div className="confirm-buy-image-container"> <div className={`confirm-buy-image-container ${imageUrl ? "has-image" : "no-image"}`}>
{imageUrl ? ( {imageUrl ? (
<img src={imageUrl} alt={item.item_name} className="confirm-buy-image" /> <img src={imageUrl} alt={item.item_name} className="confirm-buy-image" />
) : ( ) : (
<div className="confirm-buy-image-placeholder">[ ]</div> <div className="confirm-buy-image-placeholder">No image</div>
)} )}
</div> </div>
@ -102,6 +103,7 @@ export default function ConfirmBuyModal({
onClick={handleNext} onClick={handleNext}
style={{ visibility: hasNext ? "visible" : "hidden" }} style={{ visibility: hasNext ? "visible" : "hidden" }}
disabled={!hasNext || isSubmitting} disabled={!hasNext || isSubmitting}
aria-label="Next item"
> >
{">"} {">"}
</button> </button>

View File

@ -133,6 +133,24 @@ export default function EditItemModal({ item, zones = [], onSave, onCancel, onIm
<div className="edit-modal-divider" /> <div className="edit-modal-divider" />
<div className="edit-modal-inline-field">
<label>Zone</label>
<select
value={zone}
onChange={(e) => setZone(e.target.value)}
className="edit-modal-select"
>
<option value="">No zone yet</option>
{zoneOptions.map((candidateZone) => (
<option key={candidateZone} value={candidateZone}>
{candidateZone}
</option>
))}
</select>
</div>
<details className="edit-modal-advanced" open={Boolean(itemType || itemGroup)}>
<summary>More classification options</summary>
<div className="edit-modal-inline-field"> <div className="edit-modal-inline-field">
<label>Type</label> <label>Type</label>
<select <select
@ -140,7 +158,7 @@ export default function EditItemModal({ item, zones = [], onSave, onCancel, onIm
onChange={(e) => handleItemTypeChange(e.target.value)} onChange={(e) => handleItemTypeChange(e.target.value)}
className="edit-modal-select" className="edit-modal-select"
> >
<option value="">-- Select Type --</option> <option value="">No type</option>
{Object.values(ITEM_TYPES).map((type) => ( {Object.values(ITEM_TYPES).map((type) => (
<option key={type} value={type}> <option key={type} value={type}>
{getItemTypeLabel(type)} {getItemTypeLabel(type)}
@ -157,7 +175,7 @@ export default function EditItemModal({ item, zones = [], onSave, onCancel, onIm
onChange={(e) => setItemGroup(e.target.value)} onChange={(e) => setItemGroup(e.target.value)}
className="edit-modal-select" className="edit-modal-select"
> >
<option value="">-- Select Group --</option> <option value="">No group</option>
{availableGroups.map((group) => ( {availableGroups.map((group) => (
<option key={group} value={group}> <option key={group} value={group}>
{group} {group}
@ -166,22 +184,7 @@ export default function EditItemModal({ item, zones = [], onSave, onCancel, onIm
</select> </select>
</div> </div>
)} )}
</details>
<div className="edit-modal-inline-field">
<label>Zone</label>
<select
value={zone}
onChange={(e) => setZone(e.target.value)}
className="edit-modal-select"
>
<option value="">-- Select Zone --</option>
{zoneOptions.map((candidateZone) => (
<option key={candidateZone} value={candidateZone}>
{candidateZone}
</option>
))}
</select>
</div>
<div className="edit-modal-divider" /> <div className="edit-modal-divider" />

View File

@ -91,6 +91,21 @@ function getNextModalItem(sortedItems, currentIndex, excludedItemId) {
return remainingItems[currentIndex] || remainingItems[0]; return remainingItems[currentIndex] || remainingItems[0];
} }
function getListItemStorageId(item) {
return String(item?.id ?? item?.item_id ?? item?.item_name ?? "");
}
function readSkippedItemIds(storageKey) {
if (!storageKey || typeof window === "undefined") return [];
try {
const parsed = JSON.parse(window.localStorage.getItem(storageKey) || "[]");
return Array.isArray(parsed) ? parsed.map(String).filter(Boolean) : [];
} catch {
return [];
}
}
export default function GroceryList() { export default function GroceryList() {
const { userId } = useContext(AuthContext); const { userId } = useContext(AuthContext);
@ -103,13 +118,16 @@ export default function GroceryList() {
const { activeStore, stores, zones, loading: storeLoading } = useContext(StoreContext); const { activeStore, stores, zones, loading: storeLoading } = useContext(StoreContext);
const { settings } = useContext(SettingsContext); const { settings } = useContext(SettingsContext);
const toast = useActionToast(); const toast = useActionToast();
const { enqueueImageUpload } = useUploadQueue(); const { enqueueImageUpload, isOnline } = useUploadQueue();
const navigate = useNavigate(); const navigate = useNavigate();
// Get household role for permissions // Get household role for permissions
const householdRole = activeHousehold?.role; const householdRole = activeHousehold?.role;
const isHouseholdAdmin = ["owner", "admin"].includes(householdRole); const isHouseholdAdmin = ["owner", "admin"].includes(householdRole);
const canEditList = Boolean(householdRole && householdRole !== "viewer"); const canEditList = Boolean(householdRole && householdRole !== "viewer");
const skippedStorageKey = activeHousehold?.id && activeStore?.id
? `fiddy:grocery-skipped:${activeHousehold.id}:${activeStore.id}`
: null;
// === State === // // === State === //
const [items, setItems] = useState([]); const [items, setItems] = useState([]);
@ -131,6 +149,8 @@ export default function GroceryList() {
const [showConfirmAddExisting, setShowConfirmAddExisting] = useState(false); const [showConfirmAddExisting, setShowConfirmAddExisting] = useState(false);
const [confirmAddExistingData, setConfirmAddExistingData] = useState(null); const [confirmAddExistingData, setConfirmAddExistingData] = useState(null);
const [buyModalState, setBuyModalState] = useState(null); const [buyModalState, setBuyModalState] = useState(null);
const [skippedItemIds, setSkippedItemIds] = useState([]);
const [loadedSkippedStorageKey, setLoadedSkippedStorageKey] = useState(null);
// === Data Loading === // === Data Loading ===
@ -175,6 +195,16 @@ export default function GroceryList() {
setBuyModalState(null); setBuyModalState(null);
}, [activeHousehold?.id, activeStore?.id]); }, [activeHousehold?.id, activeStore?.id]);
useEffect(() => {
setSkippedItemIds(readSkippedItemIds(skippedStorageKey));
setLoadedSkippedStorageKey(skippedStorageKey);
}, [skippedStorageKey]);
useEffect(() => {
if (!skippedStorageKey || loadedSkippedStorageKey !== skippedStorageKey) return;
window.localStorage.setItem(skippedStorageKey, JSON.stringify(skippedItemIds));
}, [loadedSkippedStorageKey, skippedItemIds, skippedStorageKey]);
useEffect(() => { useEffect(() => {
const loadHouseholdMembers = async () => { const loadHouseholdMembers = async () => {
if (!activeHousehold?.id) { if (!activeHousehold?.id) {
@ -254,10 +284,21 @@ export default function GroceryList() {
// === Visible Items Computation === // === Visible Items Computation ===
const normalizedListSearchQuery = listSearchQuery.trim().toLowerCase(); const normalizedListSearchQuery = listSearchQuery.trim().toLowerCase();
const isListSearchActive = normalizedListSearchQuery.length > 0; const isListSearchActive = normalizedListSearchQuery.length > 0;
const skippedItemIdSet = useMemo(() => (
canEditList ? new Set(skippedItemIds) : new Set()
), [canEditList, skippedItemIds]);
const activeItems = useMemo(() => {
if (skippedItemIdSet.size === 0) return items;
return items.filter((item) => !skippedItemIdSet.has(getListItemStorageId(item)));
}, [items, skippedItemIdSet]);
const skippedItems = useMemo(() => {
if (!canEditList || skippedItemIdSet.size === 0) return [];
return items.filter((item) => skippedItemIdSet.has(getListItemStorageId(item)));
}, [canEditList, items, skippedItemIdSet]);
const sortedItems = useMemo(() => { const sortedItems = useMemo(() => {
return sortItemsByZone(filterItemsForSearch(items, normalizedListSearchQuery)); return sortItemsByZone(filterItemsForSearch(activeItems, normalizedListSearchQuery));
}, [items, normalizedListSearchQuery]); }, [activeItems, normalizedListSearchQuery]);
const visibleRecentlyBoughtItems = useMemo( const visibleRecentlyBoughtItems = useMemo(
() => recentlyBoughtItems.slice(0, recentlyBoughtDisplayCount), () => recentlyBoughtItems.slice(0, recentlyBoughtDisplayCount),
@ -287,6 +328,10 @@ export default function GroceryList() {
}); });
}, [buyModalItems, buyModalState]); }, [buyModalItems, buyModalState]);
const showOfflineMutationToast = useCallback((actionLabel) => {
toast.error("Offline", `${actionLabel} needs a connection. Try again when you are back online.`);
}, [toast]);
// === Suggestion Handler === // === Suggestion Handler ===
const handleSuggest = async (text) => { const handleSuggest = async (text) => {
@ -325,6 +370,10 @@ export default function GroceryList() {
const normalizedItemName = itemName.trim().toLowerCase(); const normalizedItemName = itemName.trim().toLowerCase();
if (!normalizedItemName) return; if (!normalizedItemName) return;
if (!activeHousehold?.id || !activeStore?.id) return; if (!activeHousehold?.id || !activeStore?.id) return;
if (!isOnline) {
showOfflineMutationToast("Adding items");
return;
}
const allItems = [...items, ...recentlyBoughtItems]; const allItems = [...items, ...recentlyBoughtItems];
const existingLocalItem = allItems.find( const existingLocalItem = allItems.find(
@ -359,11 +408,23 @@ export default function GroceryList() {
} catch (error) { } catch (error) {
console.error("Failed to process add item flow:", error); console.error("Failed to process add item flow:", error);
} }
}, [activeHousehold?.id, activeStore?.id, items, recentlyBoughtItems, buttonText]); }, [
activeHousehold?.id,
activeStore?.id,
buttonText,
isOnline,
items,
recentlyBoughtItems,
showOfflineMutationToast
]);
const processItemAddition = useCallback(async (itemName, quantity, options = {}) => { const processItemAddition = useCallback(async (itemName, quantity, options = {}) => {
if (!activeHousehold?.id || !activeStore?.id) return; if (!activeHousehold?.id || !activeStore?.id) return;
if (!isOnline) {
showOfflineMutationToast("Adding items");
return;
}
const { const {
existingItem: providedItem = null, existingItem: providedItem = null,
skipLookup = false, skipLookup = false,
@ -421,7 +482,15 @@ export default function GroceryList() {
setPendingItem({ itemName, quantity, addedForUserId }); setPendingItem({ itemName, quantity, addedForUserId });
setShowAddDetailsModal(true); setShowAddDetailsModal(true);
} }
}, [activeHousehold?.id, activeStore?.id, loadItems, loadRecentlyBought, toast]); }, [
activeHousehold?.id,
activeStore?.id,
isOnline,
loadItems,
loadRecentlyBought,
showOfflineMutationToast,
toast
]);
// === Similar Item Modal Handlers === // === Similar Item Modal Handlers ===
@ -456,6 +525,10 @@ export default function GroceryList() {
const handleConfirmAddExisting = useCallback(async () => { const handleConfirmAddExisting = useCallback(async () => {
if (!confirmAddExistingData) return; if (!confirmAddExistingData) return;
if (!activeHousehold?.id || !activeStore?.id) return; if (!activeHousehold?.id || !activeStore?.id) return;
if (!isOnline) {
showOfflineMutationToast("Updating item quantities");
return;
}
const { itemName, newQuantity, existingItem, addedForUserId } = confirmAddExistingData; const { itemName, newQuantity, existingItem, addedForUserId } = confirmAddExistingData;
@ -484,6 +557,9 @@ export default function GroceryList() {
setSuggestions([]); setSuggestions([]);
setButtonText("Add Item"); setButtonText("Add Item");
setSkippedItemIds((prev) =>
prev.filter((id) => id !== getListItemStorageId(existingItem))
);
toast.success("Updated item quantity", `Updated item ${itemName}`); toast.success("Updated item quantity", `Updated item ${itemName}`);
} catch (error) { } catch (error) {
console.error("Failed to update item:", error); console.error("Failed to update item:", error);
@ -491,13 +567,25 @@ export default function GroceryList() {
toast.error("Update item failed", `Update item failed: ${message}`); toast.error("Update item failed", `Update item failed: ${message}`);
await loadItems(); await loadItems();
} }
}, [activeHousehold?.id, activeStore?.id, confirmAddExistingData, loadItems, toast]); }, [
activeHousehold?.id,
activeStore?.id,
confirmAddExistingData,
isOnline,
loadItems,
showOfflineMutationToast,
toast
]);
// === Add Details Modal Handlers === // === Add Details Modal Handlers ===
const handleAddWithDetails = useCallback(async (imageFile, classification) => { const handleAddWithDetails = useCallback(async (imageFile, classification) => {
if (!pendingItem) return; if (!pendingItem) return;
if (!activeHousehold?.id || !activeStore?.id) return; if (!activeHousehold?.id || !activeStore?.id) return;
if (!isOnline) {
showOfflineMutationToast("Adding items");
return;
}
try { try {
// Create the list item first, upload image separately in background. // Create the list item first, upload image separately in background.
@ -552,7 +640,15 @@ export default function GroceryList() {
const message = getApiErrorMessage(error, "Failed to add item"); const message = getApiErrorMessage(error, "Failed to add item");
toast.error("Add item failed", `Add item failed: ${message}`); toast.error("Add item failed", `Add item failed: ${message}`);
} }
}, [activeHousehold?.id, activeStore?.id, pendingItem, enqueueImageUpload, toast]); }, [
activeHousehold?.id,
activeStore?.id,
enqueueImageUpload,
isOnline,
pendingItem,
showOfflineMutationToast,
toast
]);
const handleAddDetailsCancel = useCallback(() => { const handleAddDetailsCancel = useCallback(() => {
setShowAddDetailsModal(false); setShowAddDetailsModal(false);
@ -565,6 +661,11 @@ export default function GroceryList() {
// === Item Action Handlers === // === Item Action Handlers ===
const handleBought = useCallback(async (quantity) => { const handleBought = useCallback(async (quantity) => {
if (!activeHousehold?.id || !activeStore?.id) return; if (!activeHousehold?.id || !activeStore?.id) return;
if (!isOnline) {
showOfflineMutationToast("Marking items bought");
setBuyModalState(null);
return;
}
if (!buyModalState || buyModalState.source !== "active") { if (!buyModalState || buyModalState.source !== "active") {
setBuyModalState(null); setBuyModalState(null);
return; return;
@ -615,7 +716,17 @@ export default function GroceryList() {
const message = getApiErrorMessage(error, "Failed to mark item as bought"); const message = getApiErrorMessage(error, "Failed to mark item as bought");
toast.error("Mark item bought failed", `Mark item bought failed: ${message}`); toast.error("Mark item bought failed", `Mark item bought failed: ${message}`);
} }
}, [activeHousehold?.id, activeStore?.id, buyModalState, items, normalizedListSearchQuery, sortedItems, toast]); }, [
activeHousehold?.id,
activeStore?.id,
buyModalState,
isOnline,
items,
normalizedListSearchQuery,
showOfflineMutationToast,
sortedItems,
toast
]);
const openActiveBuyModal = useCallback((item) => { const openActiveBuyModal = useCallback((item) => {
setBuyModalState({ setBuyModalState({
@ -650,6 +761,27 @@ export default function GroceryList() {
await handleBought(quantity); await handleBought(quantity);
}, [buyModalState?.canConfirm, handleBought]); }, [buyModalState?.canConfirm, handleBought]);
const handleSkipItem = useCallback((item) => {
const itemId = getListItemStorageId(item);
if (!itemId) return;
setSkippedItemIds((prev) => (
prev.includes(itemId) ? prev : [...prev, itemId]
));
setBuyModalState((prev) => (
prev?.item && getListItemStorageId(prev.item) === itemId ? null : prev
));
toast.info("Moved to Skipped", `${item.item_name} is hidden locally for this store.`);
}, [toast]);
const handleRestoreSkippedItem = useCallback((item) => {
const itemId = getListItemStorageId(item);
if (!itemId) return;
setSkippedItemIds((prev) => prev.filter((id) => id !== itemId));
toast.info("Restored item", `${item.item_name} is back in the active list.`);
}, [toast]);
const handleImageAdded = useCallback(async (id, itemName, quantity, imageFile, source = "add_image_modal") => { const handleImageAdded = useCallback(async (id, itemName, quantity, imageFile, source = "add_image_modal") => {
if (!activeHousehold?.id || !activeStore?.id) return; if (!activeHousehold?.id || !activeStore?.id) return;
if (!imageFile) return; if (!imageFile) return;
@ -698,6 +830,10 @@ export default function GroceryList() {
// === Edit Modal Handlers === // === Edit Modal Handlers ===
const handleEditSave = useCallback(async (id, itemName, quantity, classification) => { const handleEditSave = useCallback(async (id, itemName, quantity, classification) => {
if (!activeHousehold?.id || !activeStore?.id) return; if (!activeHousehold?.id || !activeStore?.id) return;
if (!isOnline) {
showOfflineMutationToast("Saving item changes");
return;
}
try { try {
await updateItemWithClassification(activeHousehold.id, activeStore.id, itemName, quantity, classification); await updateItemWithClassification(activeHousehold.id, activeStore.id, itemName, quantity, classification);
@ -727,7 +863,7 @@ export default function GroceryList() {
toast.error("Update item failed", `Update item failed: ${message}`); toast.error("Update item failed", `Update item failed: ${message}`);
throw error; throw error;
} }
}, [activeHousehold?.id, activeStore?.id, toast]); }, [activeHousehold?.id, activeStore?.id, isOnline, showOfflineMutationToast, toast]);
const handleEditCancel = useCallback(() => { const handleEditCancel = useCallback(() => {
@ -841,6 +977,12 @@ export default function GroceryList() {
<div className="glist-container"> <div className="glist-container">
<StoreTabs /> <StoreTabs />
{!isOnline && (
<div className="glist-offline-banner" role="status">
Offline. Server-backed list changes are paused; local skipped items can still be restored.
</div>
)}
{canEditList && ( {canEditList && (
<AddItemForm <AddItemForm
onAdd={handleAdd} onAdd={handleAdd}
@ -856,13 +998,15 @@ export default function GroceryList() {
value={listSearchQuery} value={listSearchQuery}
onChange={setListSearchQuery} onChange={setListSearchQuery}
resultCount={sortedItems.length} resultCount={sortedItems.length}
totalCount={items.length} totalCount={activeItems.length}
/> />
{sortedItems.length === 0 ? ( {sortedItems.length === 0 ? (
<p className="glist-empty-search"> <p className="glist-empty-search">
{isListSearchActive {isListSearchActive
? `No list items match "${listSearchQuery.trim()}".` ? `No list items match "${listSearchQuery.trim()}".`
: skippedItems.length > 0
? "No active items in this store. Check Skipped below."
: "No items in this store yet."} : "No items in this store yet."}
</p> </p>
) : ( ) : (
@ -900,6 +1044,9 @@ export default function GroceryList() {
onLongPress={ onLongPress={
canEditList ? handleLongPress : null canEditList ? handleLongPress : null
} }
onSkip={
canEditList ? handleSkipItem : null
}
/> />
))} ))}
</ul> </ul>
@ -910,6 +1057,34 @@ export default function GroceryList() {
})() })()
)} )}
{skippedItems.length > 0 && (
<section className="glist-skipped-section" aria-label="Skipped items">
<div className="glist-skipped-header">
<div>
<h2 className="glist-skipped-title">Skipped / Not Found ({skippedItems.length})</h2>
<p className="glist-skipped-note">Local to this device and hidden from the active list.</p>
</div>
</div>
<ul className="glist-ul glist-skipped-ul">
{skippedItems.map((item) => (
<li key={item.id} className="glist-li glist-skipped-li">
<div className="glist-skipped-item-main">
<span className="glist-item-name">{item.item_name}</span>
<span className="glist-skipped-quantity">x{item.quantity}</span>
</div>
<button
type="button"
className="glist-restore-btn"
onClick={() => handleRestoreSkippedItem(item)}
>
Restore
</button>
</li>
))}
</ul>
</section>
)}
{recentlyBoughtItems.length > 0 && settings.showRecentlyBought && ( {recentlyBoughtItems.length > 0 && settings.showRecentlyBought && (
<> <>
<h2 <h2
@ -938,6 +1113,7 @@ export default function GroceryList() {
onLongPress={ onLongPress={
canEditList ? handleLongPress : null canEditList ? handleLongPress : null
} }
onSkip={null}
/> />
))} ))}
</ul> </ul>

View File

@ -15,7 +15,7 @@
.confirm-buy-modal { .confirm-buy-modal {
background: var(--modal-bg); background: var(--modal-bg);
padding: var(--spacing-md); padding: var(--spacing-md);
border-radius: var(--border-radius-xl); border-radius: var(--border-radius-lg);
max-width: 450px; max-width: 450px;
width: 90%; width: 90%;
box-shadow: var(--shadow-xl); box-shadow: var(--shadow-xl);
@ -93,6 +93,13 @@
background: var(--color-gray-100); background: var(--color-gray-100);
} }
.confirm-buy-image-container.no-image {
width: 100%;
max-width: 280px;
height: 76px;
border-style: dashed;
}
.confirm-buy-image { .confirm-buy-image {
max-width: 100%; max-width: 100%;
max-height: 100%; max-height: 100%;
@ -100,8 +107,9 @@
} }
.confirm-buy-image-placeholder { .confirm-buy-image-placeholder {
font-size: 4em; font-size: var(--font-size-sm);
color: var(--color-border-medium); color: var(--color-border-medium);
font-weight: var(--font-weight-semibold);
} }
.confirm-buy-quantity-section { .confirm-buy-quantity-section {
@ -222,8 +230,17 @@
/* Mobile optimizations */ /* Mobile optimizations */
@media (max-width: 480px) { @media (max-width: 480px) {
.confirm-buy-modal-overlay {
align-items: flex-end;
}
.confirm-buy-modal { .confirm-buy-modal {
padding: 0.8em; width: 100%;
max-width: 100%;
padding: var(--spacing-md);
border-radius: var(--border-radius-lg) var(--border-radius-lg) 0 0;
max-height: 92vh;
overflow-y: auto;
} }
.confirm-buy-header { .confirm-buy-header {
@ -255,13 +272,18 @@
} }
.confirm-buy-image-container { .confirm-buy-image-container {
width: 220px; width: min(220px, 62vw);
height: 220px; height: min(220px, 62vw);
}
.confirm-buy-image-container.no-image {
width: min(220px, 62vw);
height: 52px;
} }
.confirm-buy-nav-btn { .confirm-buy-nav-btn {
width: 30px; width: 40px;
height: 30px; height: 40px;
font-size: 1.6em; font-size: 1.6em;
} }
@ -284,7 +306,7 @@
@media (max-width: 360px) { @media (max-width: 360px) {
.confirm-buy-modal { .confirm-buy-modal {
padding: 0.7em; padding: var(--spacing-sm);
} }
.confirm-buy-cancel, .confirm-buy-cancel,
@ -294,13 +316,11 @@
} }
.confirm-buy-image-container { .confirm-buy-image-container {
width: 180px; width: min(180px, 58vw);
height: 180px; height: min(180px, 58vw);
} }
.confirm-buy-nav-btn { .confirm-buy-image-container.no-image {
width: 28px; height: 48px;
height: 28px;
font-size: 1.4em;
} }
} }

View File

@ -3,8 +3,8 @@
background: var(--color-bg-surface); background: var(--color-bg-surface);
padding: var(--spacing-md); padding: var(--spacing-md);
border-radius: var(--border-radius-lg); border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-md); box-shadow: var(--shadow-sm);
margin-bottom: var(--spacing-xs); margin-bottom: var(--spacing-sm);
border: var(--border-width-thin) solid var(--color-border-light); border: var(--border-width-thin) solid var(--color-border-light);
} }
@ -51,6 +51,7 @@
font-family: var(--font-family-base); font-family: var(--font-family-base);
transition: var(--transition-base); transition: var(--transition-base);
width: 100%; width: 100%;
min-height: 44px;
background: var(--color-bg-surface); background: var(--color-bg-surface);
color: var(--color-text-primary); color: var(--color-text-primary);
} }
@ -73,6 +74,8 @@
right: 0; right: 0;
margin-top: var(--spacing-xs); margin-top: var(--spacing-xs);
z-index: var(--z-dropdown); z-index: var(--z-dropdown);
max-height: min(260px, 45vh);
box-shadow: var(--shadow-lg);
} }
/* Actions Row */ /* Actions Row */
@ -81,7 +84,7 @@
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: var(--spacing-sm); gap: var(--spacing-sm);
min-height: 40px; min-height: 44px;
} }
/* Quantity Control */ /* Quantity Control */
@ -89,11 +92,11 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--spacing-xs); gap: var(--spacing-xs);
height: 40px; height: 44px;
} }
.quantity-btn { .quantity-btn {
width: 40px; width: 44px;
height: 100%; height: 100%;
border: var(--border-width-thin) solid var(--color-border-medium); border: var(--border-width-thin) solid var(--color-border-medium);
background: var(--color-bg-surface); background: var(--color-bg-surface);
@ -128,8 +131,8 @@
} }
.add-item-form-quantity-input { .add-item-form-quantity-input {
width: 40px; width: 44px;
max-width: 40px; max-width: 44px;
height: 100%; height: 100%;
box-sizing: border-box; box-sizing: border-box;
padding: var(--input-padding-y) var(--input-padding-x); padding: var(--input-padding-y) var(--input-padding-x);
@ -159,7 +162,7 @@
/* Submit Button */ /* Submit Button */
.add-item-form-submit { .add-item-form-submit {
height: 40px; min-height: 44px;
padding: 0 var(--spacing-lg); padding: 0 var(--spacing-lg);
background: var(--color-primary); background: var(--color-primary);
color: var(--color-text-inverse); color: var(--color-text-inverse);
@ -197,25 +200,39 @@
/* Responsive */ /* Responsive */
@media (max-width: 480px) { @media (max-width: 480px) {
.add-item-form-container { .add-item-form-container {
padding: var(--spacing-md); padding: var(--spacing-sm);
}
.add-item-form-input-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
} }
.add-item-form-assignee-toggle { .add-item-form-assignee-toggle {
width: 100px; width: 104px;
} }
.add-item-form-quantity-control { .add-item-form-quantity-control {
height: 36px; height: 44px;
} }
.quantity-btn { .quantity-btn {
width: 36px; width: 44px;
height: 100%; height: 100%;
font-size: var(--font-size-lg); font-size: var(--font-size-lg);
} }
.add-item-form-quantity-input, .add-item-form-quantity-input,
.add-item-form-submit { .add-item-form-submit {
height: 36px; min-height: 44px;
}
.add-item-form-actions {
gap: var(--spacing-xs);
}
.add-item-form-submit {
min-width: 0;
padding: 0 var(--spacing-sm);
} }
} }

View File

@ -209,6 +209,10 @@
margin-bottom: var(--spacing-sm); margin-bottom: var(--spacing-sm);
} }
.add-item-details-zone-field {
margin-bottom: 0;
}
.add-item-details-field label { .add-item-details-field label {
display: block; display: block;
margin: 0; margin: 0;
@ -328,9 +332,17 @@
} }
@media (max-width: 480px) { @media (max-width: 480px) {
.add-item-details-overlay {
align-items: flex-end;
padding: 0;
}
.add-item-details-modal { .add-item-details-modal {
padding: var(--spacing-md); padding: var(--spacing-md);
border-radius: var(--border-radius-lg); border-radius: var(--border-radius-lg) var(--border-radius-lg) 0 0;
width: 100%;
max-width: 100%;
max-height: 92vh;
} }
.add-item-details-title { .add-item-details-title {
@ -357,4 +369,8 @@
.add-item-details-field label { .add-item-details-field label {
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
} }
.add-item-details-field {
grid-template-columns: 1fr;
}
} }

View File

@ -118,6 +118,36 @@
margin-bottom: 0; margin-bottom: 0;
} }
.edit-modal-advanced {
margin-top: var(--spacing-sm);
border: var(--border-width-thin) solid var(--color-border-light);
border-radius: var(--border-radius-md);
background: var(--color-bg-surface);
}
.edit-modal-advanced summary {
min-height: 40px;
padding: var(--spacing-sm) var(--spacing-md);
cursor: pointer;
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
display: flex;
align-items: center;
}
.edit-modal-advanced[open] {
padding-bottom: var(--spacing-sm);
}
.edit-modal-advanced[open] summary {
margin-bottom: var(--spacing-xs);
}
.edit-modal-advanced .edit-modal-inline-field {
padding: 0 var(--spacing-md);
}
.edit-modal-divider { .edit-modal-divider {
height: 1px; height: 1px;
background: var(--color-border-light); background: var(--color-border-light);
@ -235,6 +265,10 @@
width: 100%; width: 100%;
} }
.edit-modal-advanced .edit-modal-inline-field {
padding: 0 var(--spacing-sm);
}
.edit-modal-actions { .edit-modal-actions {
flex-direction: column; flex-direction: column;
gap: var(--spacing-xs); gap: var(--spacing-xs);
@ -247,10 +281,16 @@
} }
@media (max-width: 480px) { @media (max-width: 480px) {
.edit-modal-overlay {
align-items: flex-end;
padding: 0;
}
.edit-modal-content { .edit-modal-content {
width: 100%; width: 100%;
padding: 1rem; padding: 1rem;
border-radius: 8px; border-radius: var(--border-radius-lg) var(--border-radius-lg) 0 0;
max-height: 92vh;
} }
.edit-modal-title { .edit-modal-title {

View File

@ -3,6 +3,7 @@
font-family: var(--font-family-base); font-family: var(--font-family-base);
padding: var(--spacing-sm); padding: var(--spacing-sm);
background: var(--color-bg-body); background: var(--color-bg-body);
min-height: 100vh;
} }
.glist-container { .glist-container {
@ -14,6 +15,31 @@
box-shadow: var(--shadow-card); box-shadow: var(--shadow-card);
} }
.glist-offline-banner {
margin: var(--spacing-sm) 0;
padding: var(--spacing-sm) var(--spacing-md);
border: var(--border-width-thin) solid var(--color-warning, #b7791f);
border-radius: var(--border-radius-sm);
background: var(--color-warning-light, #fff8dc);
color: var(--color-text-primary);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
}
.glist-container .store-tabs {
margin-bottom: var(--spacing-sm);
}
.glist-container .store-tabs-container {
align-items: stretch;
gap: var(--spacing-xs);
}
.glist-container .store-selector-trigger,
.glist-container .store-map-button {
min-height: 40px;
}
.glist-section-title { .glist-section-title {
text-align: center; text-align: center;
font-size: var(--font-size-xl); font-size: var(--font-size-xl);
@ -239,7 +265,7 @@
.glist-li { .glist-li {
background: var(--color-bg-surface); background: var(--color-bg-surface);
border: var(--border-width-thin) solid var(--color-border-light); border: var(--border-width-thin) solid var(--color-border-light);
border-radius: var(--border-radius-lg); border-radius: var(--border-radius-md);
margin-bottom: var(--spacing-sm); margin-bottom: var(--spacing-sm);
cursor: pointer; cursor: pointer;
transition: box-shadow var(--transition-base), transform var(--transition-base); transition: box-shadow var(--transition-base), transform var(--transition-base);
@ -251,6 +277,17 @@
transform: translateY(-2px); transform: translateY(-2px);
} }
.glist-li:focus-visible {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary-light);
}
.glist-li.actions-open {
border-color: var(--color-primary);
box-shadow: var(--shadow-md);
}
.glist-classification-group .glist-li { .glist-classification-group .glist-li {
margin-bottom: var(--spacing-xs); margin-bottom: var(--spacing-xs);
} }
@ -261,9 +298,10 @@
.glist-item-layout { .glist-item-layout {
display: flex; display: flex;
gap: 1em; gap: var(--spacing-sm);
padding: 0em; padding: var(--spacing-xs);
align-items: center; align-items: center;
min-height: 56px;
} }
.glist-item-image { .glist-item-image {
@ -282,6 +320,13 @@
position: relative; position: relative;
} }
.glist-item-image-placeholder {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
color: var(--color-text-muted);
text-transform: uppercase;
}
.glist-item-image.has-image { .glist-item-image.has-image {
border-color: var(--color-primary); border-color: var(--color-primary);
background: var(--color-bg-surface); background: var(--color-bg-surface);
@ -310,8 +355,9 @@
.glist-item-name { .glist-item-name {
font-weight: 800; font-weight: 800;
font-size: 0.8em; font-size: var(--font-size-base);
color: var(--color-text-primary); color: var(--color-text-primary);
overflow-wrap: anywhere;
} }
.glist-item-quantity { .glist-item-quantity {
@ -333,6 +379,140 @@
font-size: 0.7em; font-size: 0.7em;
color: var(--color-text-secondary); color: var(--color-text-secondary);
font-style: italic; font-style: italic;
overflow-wrap: anywhere;
}
.glist-item-action-toggle {
flex: 0 0 auto;
width: 40px;
min-width: 40px;
height: 40px;
border: var(--border-width-thin) solid var(--color-border-light);
border-radius: var(--border-radius-sm);
background: var(--color-bg-hover);
color: var(--color-text-primary);
font-size: var(--font-size-base);
font-weight: var(--font-weight-bold);
line-height: 1;
cursor: pointer;
}
.glist-item-action-toggle:hover,
.glist-item-action-toggle:focus-visible,
.glist-li.actions-open .glist-item-action-toggle {
border-color: var(--color-primary);
color: var(--color-primary);
outline: none;
}
.glist-item-action-tray {
display: flex;
gap: var(--spacing-xs);
padding: 0 var(--spacing-xs) var(--spacing-xs);
flex-wrap: wrap;
}
.glist-item-action {
flex: 1 1 5rem;
min-height: 40px;
border: var(--border-width-thin) solid var(--color-border-medium);
border-radius: var(--border-radius-sm);
background: var(--color-bg-surface);
color: var(--color-text-primary);
font-size: var(--font-size-sm);
font-weight: var(--button-font-weight);
cursor: pointer;
}
.glist-item-action:hover,
.glist-item-action:focus-visible {
border-color: var(--color-primary);
color: var(--color-primary);
outline: none;
}
.glist-item-action.secondary:hover,
.glist-item-action.secondary:focus-visible {
border-color: var(--color-warning, #b7791f);
color: var(--color-warning, #b7791f);
}
.glist-skipped-section {
margin-top: var(--spacing-lg);
padding-top: var(--spacing-md);
border-top: var(--border-width-medium) solid var(--color-border-light);
}
.glist-skipped-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-sm);
}
.glist-skipped-title {
margin: 0;
color: var(--color-text-primary);
font-size: var(--font-size-lg);
}
.glist-skipped-note {
margin: 0.2rem 0 0;
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
}
.glist-skipped-ul {
margin-top: var(--spacing-xs);
}
.glist-skipped-li {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-sm);
padding: var(--spacing-sm);
cursor: default;
background: var(--color-bg-hover);
border-style: dashed;
}
.glist-skipped-li:hover {
transform: none;
}
.glist-skipped-item-main {
display: flex;
align-items: baseline;
gap: var(--spacing-xs);
min-width: 0;
}
.glist-skipped-quantity {
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
}
.glist-restore-btn {
flex: 0 0 auto;
min-height: 40px;
padding: 0 var(--spacing-md);
border: var(--border-width-thin) solid var(--color-primary);
border-radius: var(--border-radius-sm);
background: var(--color-bg-surface);
color: var(--color-primary);
font-size: var(--font-size-sm);
font-weight: var(--button-font-weight);
cursor: pointer;
}
.glist-restore-btn:hover,
.glist-restore-btn:focus-visible {
background: var(--color-primary);
color: var(--color-text-inverse);
outline: none;
} }
/* Compact View */ /* Compact View */
@ -514,8 +694,51 @@
/* Mobile tweaks */ /* Mobile tweaks */
@media (max-width: 480px) { @media (max-width: 480px) {
.glist-body {
padding: var(--spacing-xs);
}
.glist-container { .glist-container {
padding: 1em 0.8em; padding: var(--spacing-sm);
border-radius: var(--border-radius-md);
}
.glist-classification-header,
.glist-section-title.clickable {
min-height: 44px;
}
.glist-container .store-tabs-container {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.glist-container .store-map-button {
grid-column: 1 / -1;
}
.glist-item-layout {
min-height: 60px;
gap: var(--spacing-xs);
}
.glist-item-image {
width: 48px;
height: 48px;
min-width: 48px;
}
.glist-item-name {
font-size: var(--font-size-sm);
}
.glist-item-action-tray {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.glist-skipped-li {
align-items: stretch;
} }
.glist-fab { .glist-fab {

View File

@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test"; import { expect, test, type Route } from "@playwright/test";
function seedAuthStorage(page: import("@playwright/test").Page) { function seedAuthStorage(page: import("@playwright/test").Page) {
return page.addInitScript(() => { return page.addInitScript(() => {
@ -55,13 +55,32 @@ async function setupGroceryListRoutes(page: import("@playwright/test").Page) {
}); });
}); });
await page.route("**/stores/household/1", async (route) => { const stores = [
{ id: 10, name: "Costco", location: "Warehouse", is_default: true },
];
const fulfillStores = async (route: Route) => {
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify([ body: JSON.stringify(stores),
{ id: 10, name: "Costco", location: "Warehouse", is_default: true }, });
]), };
await page.route("**/stores/household/1", fulfillStores);
await page.route("**/households/1/stores", fulfillStores);
await page.route("**/households/1/locations/10/zones", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
zones: [
{ id: 1, name: "Dairy & Refrigerated" },
{ id: 2, name: "Checkout Area" },
{ id: 3, name: "Bakery" },
],
}),
}); });
}); });
@ -75,7 +94,7 @@ async function setupGroceryListRoutes(page: import("@playwright/test").Page) {
}); });
}); });
await page.route("**/households/1/stores/10/list/recent", async (route) => { await page.route("**/households/1/locations/10/list/recent", async (route) => {
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
@ -83,7 +102,7 @@ async function setupGroceryListRoutes(page: import("@playwright/test").Page) {
}); });
}); });
await page.route("**/households/1/stores/10/list/suggestions**", async (route) => { await page.route("**/households/1/locations/10/list/suggestions**", async (route) => {
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
@ -91,7 +110,7 @@ async function setupGroceryListRoutes(page: import("@playwright/test").Page) {
}); });
}); });
await page.route("**/households/1/stores/10/list/classification**", async (route) => { await page.route("**/households/1/locations/10/list/classification**", async (route) => {
const request = route.request(); const request = route.request();
if (request.method() === "GET") { if (request.method() === "GET") {
@ -146,7 +165,7 @@ async function setupGroceryListRoutes(page: import("@playwright/test").Page) {
}); });
}); });
await page.route("**/households/1/stores/10/list/item**", async (route) => { await page.route("**/households/1/locations/10/list/item**", async (route) => {
const request = route.request(); const request = route.request();
if (request.method() === "PUT") { if (request.method() === "PUT") {
@ -185,7 +204,7 @@ async function setupGroceryListRoutes(page: import("@playwright/test").Page) {
}); });
}); });
await page.route("**/households/1/stores/10/list/add", async (route) => { await page.route("**/households/1/locations/10/list/add", async (route) => {
currentItem = { currentItem = {
id: 201, id: 201,
item_id: 501, item_id: 501,
@ -216,7 +235,7 @@ async function setupGroceryListRoutes(page: import("@playwright/test").Page) {
}); });
}); });
await page.route("**/households/1/stores/10/list", async (route) => { await page.route("**/households/1/locations/10/list", async (route) => {
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
@ -240,17 +259,11 @@ async function openEditModal(itemRow: ReturnType<import("@playwright/test").Page
await expect(page.locator(".edit-modal-content")).toBeVisible(); await expect(page.locator(".edit-modal-content")).toBeVisible();
} }
test("add-details modal validates with toasts and persists classification details", async ({ page }) => { test("add-details modal persists zone-first classification details", async ({ page }) => {
await seedAuthStorage(page); await seedAuthStorage(page);
await mockConfig(page); await mockConfig(page);
await setupGroceryListRoutes(page); await setupGroceryListRoutes(page);
let dialogSeen = false;
page.on("dialog", async (dialog) => {
dialogSeen = true;
await dialog.dismiss();
});
await page.goto("/"); await page.goto("/");
await page.getByPlaceholder("Enter item name").fill("yogurt"); await page.getByPlaceholder("Enter item name").fill("yogurt");
@ -259,14 +272,7 @@ test("add-details modal validates with toasts and persists classification detail
const addDetailsModal = page.locator(".add-item-details-modal"); const addDetailsModal = page.locator(".add-item-details-modal");
await expect(addDetailsModal).toBeVisible(); await expect(addDetailsModal).toBeVisible();
await addDetailsModal.locator(".add-item-details-select").nth(0).selectOption("dairy"); await addDetailsModal.locator(".add-item-details-select").selectOption("Dairy & Refrigerated");
await addDetailsModal.getByRole("button", { name: "Add Item" }).click();
await expect(page.locator(".action-toast.action-toast-error")).toContainText("Select an item group");
expect(dialogSeen).toBe(false);
await addDetailsModal.locator(".add-item-details-select").nth(1).selectOption("Milk");
await addDetailsModal.locator(".add-item-details-select").nth(2).selectOption("Dairy & Refrigerated");
await addDetailsModal.getByRole("button", { name: "Add Item" }).click(); await addDetailsModal.getByRole("button", { name: "Add Item" }).click();
const yogurtRow = page.locator(".glist-li").filter({ hasText: "yogurt" }); const yogurtRow = page.locator(".glist-li").filter({ hasText: "yogurt" });
@ -278,9 +284,10 @@ test("add-details modal validates with toasts and persists classification detail
await openEditModal(yogurtRow, page); await openEditModal(yogurtRow, page);
const editModal = page.locator(".edit-modal-content"); const editModal = page.locator(".edit-modal-content");
await expect(editModal.locator(".edit-modal-select").nth(0)).toHaveValue("dairy"); await expect(
await expect(editModal.locator(".edit-modal-select").nth(1)).toHaveValue("Milk"); editModal.locator(".edit-modal-inline-field", { hasText: "Zone" }).locator("select")
await expect(editModal.locator(".edit-modal-select").nth(2)).toHaveValue("Dairy & Refrigerated"); ).toHaveValue("Dairy & Refrigerated");
await expect(editModal.locator(".edit-modal-advanced")).toBeVisible();
}); });
test("edit modal supports zone-only updates and shows API error toasts", async ({ page }) => { test("edit modal supports zone-only updates and shows API error toasts", async ({ page }) => {
@ -292,7 +299,7 @@ test("edit modal supports zone-only updates and shows API error toasts", async (
await page.getByPlaceholder("Enter item name").fill("yogurt"); await page.getByPlaceholder("Enter item name").fill("yogurt");
await page.getByRole("button", { name: "Create + Add" }).click(); await page.getByRole("button", { name: "Create + Add" }).click();
await page.locator(".add-item-details-modal").getByRole("button", { name: "Skip All" }).click(); await page.locator(".add-item-details-modal").getByRole("button", { name: "Add Item" }).click();
const yogurtRow = page.locator(".glist-li").filter({ hasText: "yogurt" }); const yogurtRow = page.locator(".glist-li").filter({ hasText: "yogurt" });
await expect(yogurtRow).toBeVisible(); await expect(yogurtRow).toBeVisible();
@ -300,8 +307,8 @@ test("edit modal supports zone-only updates and shows API error toasts", async (
await openEditModal(yogurtRow, page); await openEditModal(yogurtRow, page);
let editModal = page.locator(".edit-modal-content"); let editModal = page.locator(".edit-modal-content");
await editModal.locator(".edit-modal-select").nth(0).selectOption(""); const zoneSelect = editModal.locator(".edit-modal-inline-field", { hasText: "Zone" }).locator("select");
await editModal.locator(".edit-modal-select").nth(1).selectOption("Checkout Area"); await zoneSelect.selectOption("Checkout Area");
await editModal.getByRole("button", { name: "Save Changes" }).click(); await editModal.getByRole("button", { name: "Save Changes" }).click();
await expect( await expect(
@ -311,11 +318,12 @@ test("edit modal supports zone-only updates and shows API error toasts", async (
await openEditModal(yogurtRow, page); await openEditModal(yogurtRow, page);
editModal = page.locator(".edit-modal-content"); editModal = page.locator(".edit-modal-content");
await expect(editModal.locator(".edit-modal-select").nth(0)).toHaveValue(""); await expect(
await expect(editModal.locator(".edit-modal-select").nth(1)).toHaveValue("Checkout Area"); editModal.locator(".edit-modal-inline-field", { hasText: "Zone" }).locator("select")
).toHaveValue("Checkout Area");
routes.setClassificationRequestMode("error"); routes.setClassificationRequestMode("error");
await editModal.locator(".edit-modal-select").nth(1).selectOption("Bakery"); await editModal.locator(".edit-modal-inline-field", { hasText: "Zone" }).locator("select").selectOption("Bakery");
await editModal.getByRole("button", { name: "Save Changes" }).click(); await editModal.getByRole("button", { name: "Save Changes" }).click();
await expect(page.locator(".action-toast.action-toast-error")).toContainText("Invalid zone"); await expect(page.locator(".action-toast.action-toast-error")).toContainText("Invalid zone");

View File

@ -0,0 +1,174 @@
import { expect, test, type Page } from "@playwright/test";
import {
mockConfig,
mockHouseholdAndStoreShell,
seedAuthStorage,
} from "./helpers/e2e";
type MockItem = {
id: number;
item_id: number;
item_name: string;
quantity: number;
bought: boolean;
item_image: string | null;
image_mime_type: string | null;
added_by_users: string[];
last_added_on: string;
item_type: string | null;
item_group: string | null;
zone: string | null;
};
function makeItem(id: number, itemName: string, quantity = 1): MockItem {
return {
id,
item_id: id + 500,
item_name: itemName,
quantity,
bought: false,
item_image: null,
image_mime_type: null,
added_by_users: ["Owner User"],
last_added_on: "2026-03-28T12:00:00.000Z",
item_type: null,
item_group: null,
zone: "Produce",
};
}
async function setupGroceryRoutes(
page: Page,
options: {
householdRole?: string;
items?: MockItem[];
} = {}
) {
const items = options.items || [
makeItem(1, "milk", 2),
makeItem(2, "bread", 1),
];
await mockConfig(page);
await mockHouseholdAndStoreShell(page, {
household: {
name: "Grocery Actions House",
role: options.householdRole || "admin",
},
stores: [
{
id: 10,
name: "Costco",
location: "Warehouse",
is_default: true,
},
],
});
await page.route("**/households/1/members", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{ id: 1, username: "owner", name: "Owner User", display_name: "Owner User", role: "owner" },
]),
});
});
await page.route("**/households/1/locations/10/zones", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ zones: [{ id: 1, name: "Produce" }] }),
});
});
await page.route("**/households/1/locations/10/list/recent", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([]),
});
});
await page.route("**/households/1/locations/10/list/suggestions**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([]),
});
});
await page.route("**/households/1/locations/10/list/item**", async (route) => {
const url = new URL(route.request().url());
const itemName = (url.searchParams.get("item_name") || "").toLowerCase();
const item = items.find((candidate) => candidate.item_name === itemName);
await route.fulfill({
status: item ? 200 : 404,
contentType: "application/json",
body: JSON.stringify(item || { message: "Item not found" }),
});
});
await page.route("**/households/1/locations/10/list", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ items }),
});
});
}
test("item actions expose bought flow and local skipped restore", async ({ page }) => {
await seedAuthStorage(page, { username: "grocery-actions-user" });
await setupGroceryRoutes(page);
await page.goto("/");
const milkRow = page.locator(".glist-classification-group .glist-li").filter({ hasText: "milk" });
await expect(milkRow).toBeVisible();
await milkRow.getByRole("button", { name: "Actions for milk" }).click();
await milkRow.getByRole("button", { name: "Bought" }).click();
await expect(page.locator(".confirm-buy-modal")).toBeVisible();
await page.getByRole("button", { name: "Cancel" }).click();
await milkRow.getByRole("button", { name: "Actions for milk" }).click();
await milkRow.getByRole("button", { name: "Skip" }).click();
await expect(milkRow).toHaveCount(0);
await expect(page.locator(".glist-skipped-section")).toContainText("milk");
await page.reload();
await expect(page.locator(".glist-skipped-section")).toContainText("milk");
await page.locator(".glist-skipped-section").getByRole("button", { name: "Restore" }).click();
await expect(page.locator(".glist-classification-group .glist-li").filter({ hasText: "milk" })).toBeVisible();
});
test("viewer role cannot see mutation controls", async ({ page }) => {
await seedAuthStorage(page, { username: "grocery-viewer-user" });
await setupGroceryRoutes(page, { householdRole: "viewer" });
await page.goto("/");
await expect(page.getByPlaceholder("Enter item name")).toHaveCount(0);
await expect(page.getByRole("button", { name: "Actions for milk" })).toHaveCount(0);
await expect(page.getByLabel("Search list")).toBeVisible();
});
test("offline banner blocks server-backed add flow with a clear toast", async ({ page }) => {
await seedAuthStorage(page, { username: "grocery-offline-user" });
await setupGroceryRoutes(page);
await page.goto("/");
await page.evaluate(() => window.dispatchEvent(new Event("offline")));
await expect(page.locator(".glist-offline-banner")).toContainText("Offline");
await page.getByPlaceholder("Enter item name").fill("bananas");
await page.getByRole("button", { name: "Create + Add" }).click();
await expect(page.locator(".add-item-details-modal")).toHaveCount(0);
await expect(page.locator(".action-toast.action-toast-error")).toContainText("Adding items needs a connection");
});

View File

@ -1,4 +1,4 @@
import { expect, type Page } from "@playwright/test"; import { expect, type Page, type Route } from "@playwright/test";
type AuthSeed = { type AuthSeed = {
token?: string; token?: string;
@ -69,13 +69,16 @@ export async function mockHouseholdAndStoreShell(
}); });
}); });
await page.route(`**/stores/household/${household.id}`, async (route) => { const fulfillStores = async (route: Route) => {
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify(stores), body: JSON.stringify(stores),
}); });
}); };
await page.route(`**/stores/household/${household.id}`, fulfillStores);
await page.route(`**/households/${household.id}/stores`, fulfillStores);
} }
export async function confirmSlide(page: Page) { export async function confirmSlide(page: Page) {