fix: auto-advance buy modal by list order
All checks were successful
Build & Deploy Costco Grocery List / build (push) Successful in 49s
Build & Deploy Costco Grocery List / verify-images (push) Successful in 1s
Build & Deploy Costco Grocery List / deploy (push) Successful in 14s
Build & Deploy Costco Grocery List / notify (push) Successful in 0s
All checks were successful
Build & Deploy Costco Grocery List / build (push) Successful in 49s
Build & Deploy Costco Grocery List / verify-images (push) Successful in 1s
Build & Deploy Costco Grocery List / deploy (push) Successful in 14s
Build & Deploy Costco Grocery List / notify (push) Successful in 0s
This commit is contained in:
parent
5693570f33
commit
bd945568c8
@ -1,11 +1,15 @@
|
|||||||
import { memo, useRef, useState } from "react";
|
import { memo, useRef, useState } from "react";
|
||||||
import AddImageModal from "../modals/AddImageModal";
|
import AddImageModal from "../modals/AddImageModal";
|
||||||
import ConfirmBuyModal from "../modals/ConfirmBuyModal";
|
|
||||||
|
|
||||||
function GroceryListItem({ item, onClick, onImageAdded, onLongPress, allItems = [], compact = false }) {
|
function GroceryListItem({
|
||||||
|
item,
|
||||||
|
onClick,
|
||||||
|
onOpenBuyModal,
|
||||||
|
onImageAdded,
|
||||||
|
onLongPress,
|
||||||
|
compact = false
|
||||||
|
}) {
|
||||||
const [showAddImageModal, setShowAddImageModal] = useState(false);
|
const [showAddImageModal, setShowAddImageModal] = useState(false);
|
||||||
const [showConfirmBuyModal, setShowConfirmBuyModal] = useState(false);
|
|
||||||
const [currentItem, setCurrentItem] = useState(item);
|
|
||||||
|
|
||||||
const longPressTimer = useRef(null);
|
const longPressTimer = useRef(null);
|
||||||
const pressStartPos = useRef({ x: 0, y: 0 });
|
const pressStartPos = useRef({ x: 0, y: 0 });
|
||||||
@ -56,32 +60,14 @@ function GroceryListItem({ item, onClick, onImageAdded, onLongPress, allItems =
|
|||||||
|
|
||||||
const handleItemClick = () => {
|
const handleItemClick = () => {
|
||||||
if (onClick) {
|
if (onClick) {
|
||||||
setCurrentItem(item);
|
onClick(item);
|
||||||
setShowConfirmBuyModal(true);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConfirmBuy = (quantity) => {
|
|
||||||
if (onClick) {
|
|
||||||
onClick(currentItem.id, quantity);
|
|
||||||
}
|
|
||||||
setShowConfirmBuyModal(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancelBuy = () => {
|
|
||||||
setShowConfirmBuyModal(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNavigate = (newItem) => {
|
|
||||||
setCurrentItem(newItem);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleImageClick = (e) => {
|
const handleImageClick = (e) => {
|
||||||
e.stopPropagation(); // Prevent triggering the bought action
|
e.stopPropagation(); // Prevent triggering the bought action
|
||||||
if (item.item_image) {
|
if (item.item_image && onOpenBuyModal) {
|
||||||
// Open buy modal which now shows the image
|
onOpenBuyModal(item);
|
||||||
setCurrentItem(item);
|
|
||||||
setShowConfirmBuyModal(true);
|
|
||||||
} else {
|
} else {
|
||||||
setShowAddImageModal(true);
|
setShowAddImageModal(true);
|
||||||
}
|
}
|
||||||
@ -168,16 +154,6 @@ function GroceryListItem({ item, onClick, onImageAdded, onLongPress, allItems =
|
|||||||
onAddImage={handleAddImage}
|
onAddImage={handleAddImage}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showConfirmBuyModal && (
|
|
||||||
<ConfirmBuyModal
|
|
||||||
item={currentItem}
|
|
||||||
onConfirm={handleConfirmBuy}
|
|
||||||
onCancel={handleCancelBuy}
|
|
||||||
allItems={allItems}
|
|
||||||
onNavigate={handleNavigate}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -195,8 +171,9 @@ export default memo(GroceryListItem, (prevProps, nextProps) => {
|
|||||||
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.onImageAdded === nextProps.onImageAdded &&
|
prevProps.onImageAdded === nextProps.onImageAdded &&
|
||||||
prevProps.onLongPress === nextProps.onLongPress &&
|
prevProps.onLongPress === nextProps.onLongPress &&
|
||||||
prevProps.allItems?.length === nextProps.allItems?.length
|
prevProps.compact === nextProps.compact
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -9,45 +9,54 @@ export default function ConfirmBuyModal({
|
|||||||
onNavigate
|
onNavigate
|
||||||
}) {
|
}) {
|
||||||
const [quantity, setQuantity] = useState(item.quantity);
|
const [quantity, setQuantity] = useState(item.quantity);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const maxQuantity = item.quantity;
|
const maxQuantity = item.quantity;
|
||||||
|
|
||||||
// Update quantity when item changes (navigation)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setQuantity(item.quantity);
|
setQuantity(item.quantity);
|
||||||
|
setIsSubmitting(false);
|
||||||
}, [item.id, item.quantity]);
|
}, [item.id, item.quantity]);
|
||||||
|
|
||||||
// Find current index and check for prev/next
|
const currentIndex = allItems.findIndex((listItem) => listItem.id === item.id);
|
||||||
const currentIndex = allItems.findIndex(i => i.id === item.id);
|
|
||||||
const hasPrev = currentIndex > 0;
|
const hasPrev = currentIndex > 0;
|
||||||
const hasNext = currentIndex < allItems.length - 1;
|
const hasNext = currentIndex < allItems.length - 1;
|
||||||
|
|
||||||
const handleIncrement = () => {
|
const handleIncrement = () => {
|
||||||
if (quantity < maxQuantity) {
|
if (!isSubmitting && quantity < maxQuantity) {
|
||||||
setQuantity(prev => prev + 1);
|
setQuantity((prev) => prev + 1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDecrement = () => {
|
const handleDecrement = () => {
|
||||||
if (quantity > 1) {
|
if (!isSubmitting && quantity > 1) {
|
||||||
setQuantity(prev => prev - 1);
|
setQuantity((prev) => prev - 1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConfirm = () => {
|
const handleConfirm = async () => {
|
||||||
onConfirm(quantity);
|
if (isSubmitting) return;
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await onConfirm(quantity);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePrev = () => {
|
const handlePrev = () => {
|
||||||
|
if (isSubmitting) return;
|
||||||
|
|
||||||
if (hasPrev && onNavigate) {
|
if (hasPrev && onNavigate) {
|
||||||
const prevItem = allItems[currentIndex - 1];
|
onNavigate(allItems[currentIndex - 1]);
|
||||||
onNavigate(prevItem);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNext = () => {
|
const handleNext = () => {
|
||||||
|
if (isSubmitting) return;
|
||||||
|
|
||||||
if (hasNext && onNavigate) {
|
if (hasNext && onNavigate) {
|
||||||
const nextItem = allItems[currentIndex + 1];
|
onNavigate(allItems[currentIndex + 1]);
|
||||||
onNavigate(nextItem);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -56,8 +65,15 @@ export default function ConfirmBuyModal({
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="confirm-buy-modal-overlay" onClick={onCancel}>
|
<div
|
||||||
<div className="confirm-buy-modal" onClick={(e) => e.stopPropagation()}>
|
className="confirm-buy-modal-overlay"
|
||||||
|
onClick={() => {
|
||||||
|
if (!isSubmitting) {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="confirm-buy-modal" onClick={(event) => event.stopPropagation()}>
|
||||||
<div className="confirm-buy-header">
|
<div className="confirm-buy-header">
|
||||||
{item.zone && <div className="confirm-buy-zone">{item.zone}</div>}
|
{item.zone && <div className="confirm-buy-zone">{item.zone}</div>}
|
||||||
<h2 className="confirm-buy-item-name">{item.item_name}</h2>
|
<h2 className="confirm-buy-item-name">{item.item_name}</h2>
|
||||||
@ -67,27 +83,27 @@ export default function ConfirmBuyModal({
|
|||||||
<button
|
<button
|
||||||
className="confirm-buy-nav-btn confirm-buy-nav-prev"
|
className="confirm-buy-nav-btn confirm-buy-nav-prev"
|
||||||
onClick={handlePrev}
|
onClick={handlePrev}
|
||||||
style={{ visibility: hasPrev ? 'visible' : 'hidden' }}
|
style={{ visibility: hasPrev ? "visible" : "hidden" }}
|
||||||
disabled={!hasPrev}
|
disabled={!hasPrev || isSubmitting}
|
||||||
>
|
>
|
||||||
‹
|
{"<"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="confirm-buy-image-container">
|
<div className="confirm-buy-image-container">
|
||||||
{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">[ ]</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="confirm-buy-nav-btn confirm-buy-nav-next"
|
className="confirm-buy-nav-btn confirm-buy-nav-next"
|
||||||
onClick={handleNext}
|
onClick={handleNext}
|
||||||
style={{ visibility: hasNext ? 'visible' : 'hidden' }}
|
style={{ visibility: hasNext ? "visible" : "hidden" }}
|
||||||
disabled={!hasNext}
|
disabled={!hasNext || isSubmitting}
|
||||||
>
|
>
|
||||||
›
|
{">"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -96,9 +112,9 @@ export default function ConfirmBuyModal({
|
|||||||
<button
|
<button
|
||||||
onClick={handleDecrement}
|
onClick={handleDecrement}
|
||||||
className="confirm-buy-counter-btn"
|
className="confirm-buy-counter-btn"
|
||||||
disabled={quantity <= 1}
|
disabled={quantity <= 1 || isSubmitting}
|
||||||
>
|
>
|
||||||
−
|
-
|
||||||
</button>
|
</button>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@ -109,7 +125,7 @@ export default function ConfirmBuyModal({
|
|||||||
<button
|
<button
|
||||||
onClick={handleIncrement}
|
onClick={handleIncrement}
|
||||||
className="confirm-buy-counter-btn"
|
className="confirm-buy-counter-btn"
|
||||||
disabled={quantity >= maxQuantity}
|
disabled={quantity >= maxQuantity || isSubmitting}
|
||||||
>
|
>
|
||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
@ -117,11 +133,11 @@ export default function ConfirmBuyModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="confirm-buy-actions">
|
<div className="confirm-buy-actions">
|
||||||
<button onClick={onCancel} className="confirm-buy-cancel">
|
<button onClick={onCancel} className="confirm-buy-cancel" disabled={isSubmitting}>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleConfirm} className="confirm-buy-confirm">
|
<button onClick={handleConfirm} className="confirm-buy-confirm" disabled={isSubmitting}>
|
||||||
Mark as Bought
|
{isSubmitting ? "Saving..." : "Mark as Bought"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -15,12 +15,12 @@ import SortDropdown from "../components/common/SortDropdown";
|
|||||||
import AddItemForm from "../components/forms/AddItemForm";
|
import AddItemForm from "../components/forms/AddItemForm";
|
||||||
import GroceryListItem from "../components/items/GroceryListItem";
|
import GroceryListItem from "../components/items/GroceryListItem";
|
||||||
import AddItemWithDetailsModal from "../components/modals/AddItemWithDetailsModal";
|
import AddItemWithDetailsModal from "../components/modals/AddItemWithDetailsModal";
|
||||||
|
import ConfirmBuyModal from "../components/modals/ConfirmBuyModal";
|
||||||
import ConfirmAddExistingModal from "../components/modals/ConfirmAddExistingModal";
|
import ConfirmAddExistingModal from "../components/modals/ConfirmAddExistingModal";
|
||||||
import EditItemModal from "../components/modals/EditItemModal";
|
import EditItemModal from "../components/modals/EditItemModal";
|
||||||
import SimilarItemModal from "../components/modals/SimilarItemModal";
|
import SimilarItemModal from "../components/modals/SimilarItemModal";
|
||||||
import StoreTabs from "../components/store/StoreTabs";
|
import StoreTabs from "../components/store/StoreTabs";
|
||||||
import { ZONE_FLOW } from "../constants/classifications";
|
import { ZONE_FLOW } from "../constants/classifications";
|
||||||
import { ROLES } from "../constants/roles";
|
|
||||||
import { AuthContext } from "../context/AuthContext";
|
import { AuthContext } from "../context/AuthContext";
|
||||||
import { HouseholdContext } from "../context/HouseholdContext";
|
import { HouseholdContext } from "../context/HouseholdContext";
|
||||||
import { IMAGE_UPLOAD_SUCCESS_EVENT } from "../context/UploadQueueContext";
|
import { IMAGE_UPLOAD_SUCCESS_EVENT } from "../context/UploadQueueContext";
|
||||||
@ -32,6 +32,50 @@ import getApiErrorMessage from "../lib/getApiErrorMessage";
|
|||||||
import "../styles/pages/GroceryList.css";
|
import "../styles/pages/GroceryList.css";
|
||||||
import { findSimilarItems } from "../utils/stringSimilarity";
|
import { findSimilarItems } from "../utils/stringSimilarity";
|
||||||
|
|
||||||
|
function sortItemsForMode(items, sortMode) {
|
||||||
|
const sorted = [...items];
|
||||||
|
|
||||||
|
if (sortMode === "az") sorted.sort((a, b) => a.item_name.localeCompare(b.item_name));
|
||||||
|
if (sortMode === "za") sorted.sort((a, b) => b.item_name.localeCompare(a.item_name));
|
||||||
|
if (sortMode === "qty-high") sorted.sort((a, b) => b.quantity - a.quantity);
|
||||||
|
if (sortMode === "qty-low") sorted.sort((a, b) => a.quantity - b.quantity);
|
||||||
|
if (sortMode === "zone") {
|
||||||
|
sorted.sort((a, b) => {
|
||||||
|
if (!a.zone && b.zone) return 1;
|
||||||
|
if (a.zone && !b.zone) return -1;
|
||||||
|
if (!a.zone && !b.zone) return a.item_name.localeCompare(b.item_name);
|
||||||
|
|
||||||
|
const aZoneIndex = ZONE_FLOW.indexOf(a.zone);
|
||||||
|
const bZoneIndex = ZONE_FLOW.indexOf(b.zone);
|
||||||
|
const aIndex = aZoneIndex === -1 ? ZONE_FLOW.length : aZoneIndex;
|
||||||
|
const bIndex = bZoneIndex === -1 ? ZONE_FLOW.length : bZoneIndex;
|
||||||
|
|
||||||
|
const zoneCompare = aIndex - bIndex;
|
||||||
|
if (zoneCompare !== 0) return zoneCompare;
|
||||||
|
|
||||||
|
const typeCompare = (a.item_type || "").localeCompare(b.item_type || "");
|
||||||
|
if (typeCompare !== 0) return typeCompare;
|
||||||
|
|
||||||
|
const groupCompare = (a.item_group || "").localeCompare(b.item_group || "");
|
||||||
|
if (groupCompare !== 0) return groupCompare;
|
||||||
|
|
||||||
|
return a.item_name.localeCompare(b.item_name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNextModalItem(sortedItems, currentIndex, excludedItemId) {
|
||||||
|
const remainingItems = sortedItems.filter((item) => item.id !== excludedItemId);
|
||||||
|
|
||||||
|
if (remainingItems.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return remainingItems[currentIndex] || remainingItems[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export default function GroceryList() {
|
export default function GroceryList() {
|
||||||
const pageTitle = "Grocery List";
|
const pageTitle = "Grocery List";
|
||||||
@ -46,6 +90,7 @@ export default function GroceryList() {
|
|||||||
// 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");
|
||||||
|
|
||||||
// === State === //
|
// === State === //
|
||||||
const [items, setItems] = useState([]);
|
const [items, setItems] = useState([]);
|
||||||
@ -66,6 +111,7 @@ export default function GroceryList() {
|
|||||||
const [collapsedZones, setCollapsedZones] = useState({});
|
const [collapsedZones, setCollapsedZones] = useState({});
|
||||||
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);
|
||||||
|
|
||||||
|
|
||||||
// === Data Loading ===
|
// === Data Loading ===
|
||||||
@ -106,6 +152,10 @@ export default function GroceryList() {
|
|||||||
loadRecentlyBought();
|
loadRecentlyBought();
|
||||||
}, [activeHousehold?.id, activeStore?.id]);
|
}, [activeHousehold?.id, activeStore?.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setBuyModalState(null);
|
||||||
|
}, [activeHousehold?.id, activeStore?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadHouseholdMembers = async () => {
|
const loadHouseholdMembers = async () => {
|
||||||
if (!activeHousehold?.id) {
|
if (!activeHousehold?.id) {
|
||||||
@ -184,45 +234,36 @@ export default function GroceryList() {
|
|||||||
|
|
||||||
// === Sorted Items Computation ===
|
// === Sorted Items Computation ===
|
||||||
const sortedItems = useMemo(() => {
|
const sortedItems = useMemo(() => {
|
||||||
const sorted = [...items];
|
return sortItemsForMode(items, sortMode);
|
||||||
|
}, [items, sortMode]);
|
||||||
|
|
||||||
if (sortMode === "az") sorted.sort((a, b) => a.item_name.localeCompare(b.item_name));
|
const visibleRecentlyBoughtItems = useMemo(
|
||||||
if (sortMode === "za") sorted.sort((a, b) => b.item_name.localeCompare(a.item_name));
|
() => recentlyBoughtItems.slice(0, recentlyBoughtDisplayCount),
|
||||||
if (sortMode === "qty-high") sorted.sort((a, b) => b.quantity - a.quantity);
|
[recentlyBoughtItems, recentlyBoughtDisplayCount]
|
||||||
if (sortMode === "qty-low") sorted.sort((a, b) => a.quantity - b.quantity);
|
);
|
||||||
if (sortMode === "zone") {
|
|
||||||
sorted.sort((a, b) => {
|
|
||||||
// Items without classification go to the end
|
|
||||||
if (!a.zone && b.zone) return 1;
|
|
||||||
if (a.zone && !b.zone) return -1;
|
|
||||||
if (!a.zone && !b.zone) return a.item_name.localeCompare(b.item_name);
|
|
||||||
|
|
||||||
// Sort by ZONE_FLOW order
|
const buyModalItems = useMemo(() => {
|
||||||
const aZoneIndex = ZONE_FLOW.indexOf(a.zone);
|
if (!buyModalState) return [];
|
||||||
const bZoneIndex = ZONE_FLOW.indexOf(b.zone);
|
|
||||||
|
|
||||||
// If zone not in ZONE_FLOW, put at end
|
return buyModalState.source === "active"
|
||||||
const aIndex = aZoneIndex === -1 ? ZONE_FLOW.length : aZoneIndex;
|
? sortedItems
|
||||||
const bIndex = bZoneIndex === -1 ? ZONE_FLOW.length : bZoneIndex;
|
: visibleRecentlyBoughtItems;
|
||||||
|
}, [buyModalState, sortedItems, visibleRecentlyBoughtItems]);
|
||||||
|
|
||||||
const zoneCompare = aIndex - bIndex;
|
useEffect(() => {
|
||||||
if (zoneCompare !== 0) return zoneCompare;
|
if (!buyModalState) return;
|
||||||
|
|
||||||
// Then by item_type
|
const refreshedItem = buyModalItems.find((item) => item.id === buyModalState.item.id);
|
||||||
const typeCompare = (a.item_type || "").localeCompare(b.item_type || "");
|
if (!refreshedItem || refreshedItem === buyModalState.item) return;
|
||||||
if (typeCompare !== 0) return typeCompare;
|
|
||||||
|
|
||||||
// Then by item_group
|
setBuyModalState((prev) => {
|
||||||
const groupCompare = (a.item_group || "").localeCompare(b.item_group || "");
|
if (!prev || prev.item.id !== refreshedItem.id || prev.source !== buyModalState.source) {
|
||||||
if (groupCompare !== 0) return groupCompare;
|
return prev;
|
||||||
|
|
||||||
// Finally by name
|
|
||||||
return a.item_name.localeCompare(b.item_name);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return sorted;
|
return { ...prev, item: refreshedItem };
|
||||||
}, [items, sortMode]);
|
});
|
||||||
|
}, [buyModalItems, buyModalState]);
|
||||||
|
|
||||||
|
|
||||||
// === Suggestion Handler ===
|
// === Suggestion Handler ===
|
||||||
@ -536,35 +577,90 @@ export default function GroceryList() {
|
|||||||
|
|
||||||
|
|
||||||
// === Item Action Handlers ===
|
// === Item Action Handlers ===
|
||||||
const handleBought = useCallback(async (id, quantity) => {
|
const handleBought = useCallback(async (quantity) => {
|
||||||
if (!activeHousehold?.id || !activeStore?.id) return;
|
if (!activeHousehold?.id || !activeStore?.id) return;
|
||||||
|
if (!buyModalState || buyModalState.source !== "active") {
|
||||||
|
setBuyModalState(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const item = items.find(i => i.id === id);
|
const item = items.find((listItem) => listItem.id === buyModalState.item.id) || buyModalState.item;
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const currentIndex = sortedItems.findIndex((listItem) => listItem.id === item.id);
|
||||||
|
const resolvedIndex = currentIndex >= 0 ? currentIndex : 0;
|
||||||
|
|
||||||
await markBought(activeHousehold.id, activeStore.id, item.item_name, quantity, true);
|
await markBought(activeHousehold.id, activeStore.id, item.item_name, quantity, true);
|
||||||
|
|
||||||
// If buying full quantity, remove from list
|
let nextItems = items;
|
||||||
|
|
||||||
if (quantity >= item.quantity) {
|
if (quantity >= item.quantity) {
|
||||||
setItems(prevItems => prevItems.filter((existingItem) => existingItem.id !== id));
|
nextItems = items.filter((existingItem) => existingItem.id !== item.id);
|
||||||
} else {
|
} else {
|
||||||
// If partial, fetch updated item
|
|
||||||
const response = await getItemByName(activeHousehold.id, activeStore.id, item.item_name);
|
const response = await getItemByName(activeHousehold.id, activeStore.id, item.item_name);
|
||||||
const updatedItem = response.data;
|
const updatedItem = response.data;
|
||||||
|
|
||||||
setItems((prevItems) =>
|
nextItems = items.map((existingItem) =>
|
||||||
prevItems.map((existingItem) => (existingItem.id === id ? updatedItem : existingItem))
|
existingItem.id === item.id ? updatedItem : existingItem
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setItems(nextItems);
|
||||||
|
|
||||||
|
const nextSortedItems = sortItemsForMode(nextItems, sortMode);
|
||||||
|
const nextModalItem = getNextModalItem(nextSortedItems, resolvedIndex, item.id);
|
||||||
|
|
||||||
|
setBuyModalState(
|
||||||
|
nextModalItem
|
||||||
|
? {
|
||||||
|
item: nextModalItem,
|
||||||
|
source: "active",
|
||||||
|
canConfirm: true,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
toast.success("Marked item bought", `Marked item ${item.item_name} as bought`);
|
toast.success("Marked item bought", `Marked item ${item.item_name} as bought`);
|
||||||
loadRecentlyBought();
|
loadRecentlyBought();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
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, items, toast]);
|
}, [activeHousehold?.id, activeStore?.id, buyModalState, items, sortMode, sortedItems, toast]);
|
||||||
|
|
||||||
|
const openActiveBuyModal = useCallback((item) => {
|
||||||
|
setBuyModalState({
|
||||||
|
item,
|
||||||
|
source: "active",
|
||||||
|
canConfirm: canEditList,
|
||||||
|
});
|
||||||
|
}, [canEditList]);
|
||||||
|
|
||||||
|
const openRecentBuyModal = useCallback((item) => {
|
||||||
|
setBuyModalState({
|
||||||
|
item,
|
||||||
|
source: "recent",
|
||||||
|
canConfirm: false,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleBuyModalCancel = useCallback(() => {
|
||||||
|
setBuyModalState(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleBuyModalNavigate = useCallback((item) => {
|
||||||
|
setBuyModalState((prev) => (prev ? { ...prev, item } : prev));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleBuyModalConfirm = useCallback(async (quantity) => {
|
||||||
|
if (!buyModalState?.canConfirm) {
|
||||||
|
setBuyModalState(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await handleBought(quantity);
|
||||||
|
}, [buyModalState?.canConfirm, handleBought]);
|
||||||
|
|
||||||
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;
|
||||||
@ -754,7 +850,7 @@ export default function GroceryList() {
|
|||||||
|
|
||||||
<StoreTabs />
|
<StoreTabs />
|
||||||
|
|
||||||
{householdRole && householdRole !== 'viewer' && (
|
{canEditList && (
|
||||||
<AddItemForm
|
<AddItemForm
|
||||||
onAdd={handleAdd}
|
onAdd={handleAdd}
|
||||||
onSuggest={handleSuggest}
|
onSuggest={handleSuggest}
|
||||||
@ -793,16 +889,14 @@ export default function GroceryList() {
|
|||||||
<GroceryListItem
|
<GroceryListItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
item={item}
|
item={item}
|
||||||
allItems={sortedItems}
|
|
||||||
compact={settings.compactView}
|
compact={settings.compactView}
|
||||||
onClick={(id, quantity) =>
|
onClick={canEditList ? openActiveBuyModal : null}
|
||||||
householdRole && householdRole !== 'viewer' && handleBought(id, quantity)
|
onOpenBuyModal={openActiveBuyModal}
|
||||||
}
|
|
||||||
onImageAdded={
|
onImageAdded={
|
||||||
householdRole && householdRole !== 'viewer' ? handleImageAdded : null
|
canEditList ? handleImageAdded : null
|
||||||
}
|
}
|
||||||
onLongPress={
|
onLongPress={
|
||||||
householdRole && householdRole !== 'viewer' ? handleLongPress : null
|
canEditList ? handleLongPress : null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@ -818,16 +912,14 @@ export default function GroceryList() {
|
|||||||
<GroceryListItem
|
<GroceryListItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
item={item}
|
item={item}
|
||||||
allItems={sortedItems}
|
|
||||||
compact={settings.compactView}
|
compact={settings.compactView}
|
||||||
onClick={(id, quantity) =>
|
onClick={canEditList ? openActiveBuyModal : null}
|
||||||
[ROLES.ADMIN, ROLES.EDITOR].includes(role) && handleBought(id, quantity)
|
onOpenBuyModal={openActiveBuyModal}
|
||||||
}
|
|
||||||
onImageAdded={
|
onImageAdded={
|
||||||
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null
|
canEditList ? handleImageAdded : null
|
||||||
}
|
}
|
||||||
onLongPress={
|
onLongPress={
|
||||||
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleLongPress : null
|
canEditList ? handleLongPress : null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@ -849,18 +941,18 @@ export default function GroceryList() {
|
|||||||
{!recentlyBoughtCollapsed && (
|
{!recentlyBoughtCollapsed && (
|
||||||
<>
|
<>
|
||||||
<ul className={`glist-ul ${settings.compactView ? 'compact' : ''}`}>
|
<ul className={`glist-ul ${settings.compactView ? 'compact' : ''}`}>
|
||||||
{recentlyBoughtItems.slice(0, recentlyBoughtDisplayCount).map((item) => (
|
{visibleRecentlyBoughtItems.map((item) => (
|
||||||
<GroceryListItem
|
<GroceryListItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
item={item}
|
item={item}
|
||||||
allItems={recentlyBoughtItems}
|
|
||||||
compact={settings.compactView}
|
compact={settings.compactView}
|
||||||
onClick={null}
|
onClick={null}
|
||||||
|
onOpenBuyModal={openRecentBuyModal}
|
||||||
onImageAdded={
|
onImageAdded={
|
||||||
householdRole && householdRole !== 'viewer' ? handleImageAdded : null
|
canEditList ? handleImageAdded : null
|
||||||
}
|
}
|
||||||
onLongPress={
|
onLongPress={
|
||||||
householdRole && householdRole !== 'viewer' ? handleLongPress : null
|
canEditList ? handleLongPress : null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@ -909,6 +1001,16 @@ export default function GroceryList() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{buyModalState && (
|
||||||
|
<ConfirmBuyModal
|
||||||
|
item={buyModalState.item}
|
||||||
|
onConfirm={handleBuyModalConfirm}
|
||||||
|
onCancel={handleBuyModalCancel}
|
||||||
|
allItems={buyModalItems}
|
||||||
|
onNavigate={handleBuyModalNavigate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{showConfirmAddExisting && confirmAddExistingData && (
|
{showConfirmAddExisting && confirmAddExistingData && (
|
||||||
<ConfirmAddExistingModal
|
<ConfirmAddExistingModal
|
||||||
itemName={confirmAddExistingData.itemName}
|
itemName={confirmAddExistingData.itemName}
|
||||||
|
|||||||
279
frontend/tests/buy-modal-auto-advance.spec.ts
Normal file
279
frontend/tests/buy-modal-auto-advance.spec.ts
Normal file
@ -0,0 +1,279 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
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 seedAuthStorage(page: import("@playwright/test").Page) {
|
||||||
|
return page.addInitScript(() => {
|
||||||
|
localStorage.setItem("token", "test-token");
|
||||||
|
localStorage.setItem("userId", "1");
|
||||||
|
localStorage.setItem("role", "admin");
|
||||||
|
localStorage.setItem("username", "buy-modal-user");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mockConfig(page: import("@playwright/test").Page) {
|
||||||
|
await page.route("**/config", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
maxFileSizeMB: 20,
|
||||||
|
maxImageDimension: 800,
|
||||||
|
imageQuality: 85,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeItem(
|
||||||
|
id: number,
|
||||||
|
itemName: string,
|
||||||
|
quantity: number,
|
||||||
|
overrides: Partial<MockItem> = {}
|
||||||
|
): 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",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupBuyModalRoutes(
|
||||||
|
page: import("@playwright/test").Page,
|
||||||
|
initialItems: MockItem[]
|
||||||
|
) {
|
||||||
|
let activeItems = initialItems.map((item) => ({ ...item }));
|
||||||
|
let recentItems: MockItem[] = [];
|
||||||
|
|
||||||
|
await page.route("**/households", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify([
|
||||||
|
{ id: 1, name: "Auto Advance House", role: "admin", invite_code: "ABCD1234" },
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/stores/household/1", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify([
|
||||||
|
{ 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/stores/10/list/recent", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(recentItems),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/households/1/stores/10/list/suggestions**", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify([]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/households/1/stores/10/list/classification**", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ classification: null }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/households/1/stores/10/list/item", async (route) => {
|
||||||
|
const request = route.request();
|
||||||
|
|
||||||
|
if (request.method() === "PATCH") {
|
||||||
|
const body = request.postDataJSON() as {
|
||||||
|
item_name?: string;
|
||||||
|
quantity_bought?: number | null;
|
||||||
|
};
|
||||||
|
const itemName = String(body.item_name || "").toLowerCase();
|
||||||
|
const quantityBought = Number(body.quantity_bought ?? 0);
|
||||||
|
const currentItem = activeItems.find((item) => item.item_name === itemName);
|
||||||
|
|
||||||
|
if (!currentItem) {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 404,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ error: { message: "Item not found" } }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingQuantity = currentItem.quantity - quantityBought;
|
||||||
|
recentItems = [
|
||||||
|
{
|
||||||
|
...currentItem,
|
||||||
|
quantity: quantityBought,
|
||||||
|
bought: true,
|
||||||
|
},
|
||||||
|
...recentItems,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (remainingQuantity <= 0) {
|
||||||
|
activeItems = activeItems.filter((item) => item.id !== currentItem.id);
|
||||||
|
} else {
|
||||||
|
activeItems = activeItems.map((item) =>
|
||||||
|
item.id === currentItem.id
|
||||||
|
? { ...item, quantity: remainingQuantity }
|
||||||
|
: item
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
message: "Item updated",
|
||||||
|
item: {
|
||||||
|
id: currentItem.id,
|
||||||
|
item_name: currentItem.item_name,
|
||||||
|
quantity: Math.max(remainingQuantity, 0),
|
||||||
|
bought: remainingQuantity <= 0,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(request.url());
|
||||||
|
const itemName = (url.searchParams.get("item_name") || "").toLowerCase();
|
||||||
|
const item = activeItems.find((entry) => entry.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/stores/10/list", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
items: activeItems,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openBuyModal(page: import("@playwright/test").Page, itemName: string) {
|
||||||
|
const row = page.locator(".glist-li").filter({ hasText: itemName });
|
||||||
|
await row.click();
|
||||||
|
await expect(page.locator(".confirm-buy-modal")).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("buying an item advances to the next one in the current sort order", async ({ page }) => {
|
||||||
|
await seedAuthStorage(page);
|
||||||
|
await mockConfig(page);
|
||||||
|
await setupBuyModalRoutes(page, [
|
||||||
|
makeItem(1, "milk", 2),
|
||||||
|
makeItem(2, "bread", 5),
|
||||||
|
makeItem(3, "apples", 3),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
await page.locator(".glist-sort").selectOption("qty-high");
|
||||||
|
|
||||||
|
await openBuyModal(page, "bread");
|
||||||
|
await page.getByRole("button", { name: "Mark as Bought" }).click();
|
||||||
|
|
||||||
|
await expect(page.locator(".confirm-buy-item-name")).toHaveText("apples");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buying the last item in the current order wraps to the first remaining item", async ({ page }) => {
|
||||||
|
await seedAuthStorage(page);
|
||||||
|
await mockConfig(page);
|
||||||
|
await setupBuyModalRoutes(page, [
|
||||||
|
makeItem(1, "apples", 3),
|
||||||
|
makeItem(2, "bread", 5),
|
||||||
|
makeItem(3, "milk", 2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
await page.locator(".glist-sort").selectOption("az");
|
||||||
|
|
||||||
|
await openBuyModal(page, "milk");
|
||||||
|
await page.getByRole("button", { name: "Mark as Bought" }).click();
|
||||||
|
|
||||||
|
await expect(page.locator(".confirm-buy-item-name")).toHaveText("apples");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("partial buy keeps the item on the list and advances past it", async ({ page }) => {
|
||||||
|
await seedAuthStorage(page);
|
||||||
|
await mockConfig(page);
|
||||||
|
await setupBuyModalRoutes(page, [
|
||||||
|
makeItem(1, "alpha", 1),
|
||||||
|
makeItem(2, "bravo", 3),
|
||||||
|
makeItem(3, "charlie", 5),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
await page.locator(".glist-sort").selectOption("qty-low");
|
||||||
|
|
||||||
|
await openBuyModal(page, "bravo");
|
||||||
|
await page.locator(".confirm-buy-counter-btn").nth(0).click();
|
||||||
|
await page.getByRole("button", { name: "Mark as Bought" }).click();
|
||||||
|
|
||||||
|
await expect(page.locator(".confirm-buy-item-name")).toHaveText("charlie");
|
||||||
|
await expect(page.locator(".glist-li").filter({ hasText: "bravo" })).toContainText("x2");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buying the only remaining item closes the modal", async ({ page }) => {
|
||||||
|
await seedAuthStorage(page);
|
||||||
|
await mockConfig(page);
|
||||||
|
await setupBuyModalRoutes(page, [
|
||||||
|
makeItem(1, "solo", 1),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
await openBuyModal(page, "solo");
|
||||||
|
await page.getByRole("button", { name: "Mark as Bought" }).click();
|
||||||
|
|
||||||
|
await expect(page.locator(".confirm-buy-modal")).toBeHidden();
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user