chore: harden reliability checks #2

Merged
nalalangan merged 67 commits from main-new into main 2026-05-25 14:28:32 -09:00
4 changed files with 551 additions and 177 deletions
Showing only changes of commit bd945568c8 - Show all commits

View File

@ -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
); );
}); });

View File

@ -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>

View File

@ -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}

View 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();
});