- householdRole && householdRole !== 'viewer' && handleBought(id, quantity)
- }
- onImageAdded={
- householdRole && householdRole !== 'viewer' ? handleImageAdded : null
- }
- onLongPress={
- householdRole && householdRole !== 'viewer' ? handleLongPress : null
- }
- />
+
))}
)}
@@ -815,21 +909,19 @@ export default function GroceryList() {
) : (
{sortedItems.map((item) => (
-
- [ROLES.ADMIN, ROLES.EDITOR].includes(role) && handleBought(id, quantity)
- }
- onImageAdded={
- [ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null
- }
- onLongPress={
- [ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleLongPress : null
- }
- />
+
))}
)}
@@ -849,21 +941,21 @@ export default function GroceryList() {
{!recentlyBoughtCollapsed && (
<>
- {recentlyBoughtItems.slice(0, recentlyBoughtDisplayCount).map((item) => (
-
- ))}
+ {visibleRecentlyBoughtItems.map((item) => (
+
+ ))}
{recentlyBoughtDisplayCount < recentlyBoughtItems.length && (
@@ -900,15 +992,25 @@ export default function GroceryList() {
/>
)}
- {showEditModal && editingItem && (
-
- )}
-
+ />
+ )}
+
+ {buyModalState && (
+
+ )}
+
{showConfirmAddExisting && confirmAddExistingData && (
{
+ 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 {
+ 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();
+});