From 9fc25f22740176ecfb64471f8faa58e64150c0f1 Mon Sep 17 00:00:00 2001 From: Nico Date: Sun, 25 Jan 2026 23:23:00 -0800 Subject: [PATCH] phase 3 - create minimal hooks to tie the new architecture between backend and frontend --- POST_MIGRATION_UPDATES.md | 83 +++++ backend/controllers/lists.controller.v2.js | 324 ++++++++++++++++++ backend/migrations/add_notes_column.sql | 7 + backend/models/list.model.v2.js | 157 +++++---- backend/public/api-test.html | 112 +++--- backend/public/api-tests.html | 5 +- backend/public/test-definitions.js | 4 +- backend/public/test-runner.js | 6 +- backend/public/test-ui.js | 4 +- backend/routes/households.routes.js | 109 ++++++ frontend/src/App.jsx | 62 ++-- frontend/src/api/households.js | 58 ++++ frontend/src/api/list.js | 114 +++++- frontend/src/api/stores.js | 48 +++ .../household/HouseholdSwitcher.jsx | 50 +++ frontend/src/components/layout/Navbar.jsx | 3 + frontend/src/components/store/StoreTabs.jsx | 35 ++ frontend/src/context/HouseholdContext.jsx | 115 +++++++ frontend/src/context/StoreContext.jsx | 99 ++++++ frontend/src/pages/GroceryList.jsx | 216 ++++++++---- .../styles/components/HouseholdSwitcher.css | 98 ++++++ frontend/src/styles/components/StoreTabs.css | 74 ++++ 22 files changed, 1521 insertions(+), 262 deletions(-) create mode 100644 POST_MIGRATION_UPDATES.md create mode 100644 backend/controllers/lists.controller.v2.js create mode 100644 backend/migrations/add_notes_column.sql create mode 100644 frontend/src/api/households.js create mode 100644 frontend/src/api/stores.js create mode 100644 frontend/src/components/household/HouseholdSwitcher.jsx create mode 100644 frontend/src/components/store/StoreTabs.jsx create mode 100644 frontend/src/context/HouseholdContext.jsx create mode 100644 frontend/src/context/StoreContext.jsx create mode 100644 frontend/src/styles/components/HouseholdSwitcher.css create mode 100644 frontend/src/styles/components/StoreTabs.css diff --git a/POST_MIGRATION_UPDATES.md b/POST_MIGRATION_UPDATES.md new file mode 100644 index 0000000..2dc249d --- /dev/null +++ b/POST_MIGRATION_UPDATES.md @@ -0,0 +1,83 @@ +# Post-Migration Updates Required + +This document outlines the remaining updates needed after migrating to the multi-household architecture. + +## โœ… Completed Fixes + +1. **Column name corrections** in `list.model.v2.js`: + - Fixed `item_image` โ†’ `custom_image` + - Fixed `image_mime_type` โ†’ `custom_image_mime_type` + - Fixed `hlh.list_id` โ†’ `hlh.household_list_id` + +2. **SQL query fixes**: + - Fixed ORDER BY with DISTINCT in `getSuggestions` + - Fixed `setBought` to use boolean instead of quantity logic + +3. **Created migration**: `add_notes_column.sql` for missing notes column + +## ๐Ÿ”ง Required Database Migration + +**Run this SQL on your PostgreSQL database:** + +```sql +-- From backend/migrations/add_notes_column.sql +ALTER TABLE household_lists +ADD COLUMN IF NOT EXISTS notes TEXT; + +COMMENT ON COLUMN household_lists.notes IS 'Optional user notes/description for the item'; +``` + +## ๐Ÿงน Optional Cleanup (Not Critical) + +### Legacy Files Still Present + +These files reference the old `grocery_list` table but are not actively used by the frontend: + +- `backend/models/list.model.js` - Old model +- `backend/controllers/lists.controller.js` - Old controller +- `backend/routes/list.routes.js` - Old routes (still mounted at `/list`) + +**Recommendation**: Can be safely removed once you confirm the new architecture is working, or kept as fallback. + +### Route Cleanup in app.js + +The old `/list` route is still mounted in `backend/app.js`: + +```javascript +const listRoutes = require("./routes/list.routes"); +app.use("/list", listRoutes); // โ† Not used by frontend anymore +``` + +**Recommendation**: Comment out or remove once migration is confirmed successful. + +## โœ… No Frontend Changes Needed + +The frontend is already correctly calling the new household-scoped endpoints: +- All calls use `/households/:householdId/stores/:storeId/list/*` pattern +- No references to old `/list/*` endpoints + +## ๐ŸŽฏ Next Steps + +1. **Run the notes column migration** (required for notes feature to work) +2. **Test the application** thoroughly: + - Add items with images + - Mark items as bought/unbought + - Update item quantities and notes + - Test suggestions/autocomplete + - Test recently bought items +3. **Remove legacy files** (optional, once confirmed working) + +## ๐Ÿ“ Architecture Notes + +**Current Structure:** +- All list operations are scoped to `household_id + store_id` +- History tracking uses `household_list_history` table +- Image storage uses `custom_image` and `custom_image_mime_type` columns +- Classifications use `household_item_classifications` table (per household+store) + +**Middleware Chain:** +```javascript +auth โ†’ householdAccess โ†’ storeAccess โ†’ controller +``` + +This ensures users can only access data for households they belong to and stores linked to those households. diff --git a/backend/controllers/lists.controller.v2.js b/backend/controllers/lists.controller.v2.js new file mode 100644 index 0000000..41ccf7d --- /dev/null +++ b/backend/controllers/lists.controller.v2.js @@ -0,0 +1,324 @@ +const List = require("../models/list.model.v2"); +const { isValidItemType, isValidItemGroup, isValidZone } = require("../constants/classifications"); + +/** + * Get list items for household and store + * GET /households/:householdId/stores/:storeId/list + */ +exports.getList = async (req, res) => { + try { + const { householdId, storeId } = req.params; + const items = await List.getHouseholdStoreList(householdId, storeId); + res.json({ items }); + } catch (error) { + console.error("Error getting list:", error); + res.status(500).json({ message: "Failed to get list" }); + } +}; + +/** + * Get specific item by name + * GET /households/:householdId/stores/:storeId/list/item + */ +exports.getItemByName = async (req, res) => { + try { + const { householdId, storeId } = req.params; + const { item_name } = req.query; + + if (!item_name) { + return res.status(400).json({ message: "Item name is required" }); + } + + const item = await List.getItemByName(householdId, storeId, item_name); + if (!item) { + return res.status(404).json({ message: "Item not found" }); + } + + res.json(item); + } catch (error) { + console.error("Error getting item:", error); + res.status(500).json({ message: "Failed to get item" }); + } +}; + +/** + * Add or update item in household store list + * POST /households/:householdId/stores/:storeId/list/add + */ +exports.addItem = async (req, res) => { + try { + const { householdId, storeId } = req.params; + const { item_name, quantity, notes } = req.body; + const userId = req.user.id; + + if (!item_name || item_name.trim() === "") { + return res.status(400).json({ message: "Item name is required" }); + } + + // Get processed image if uploaded + const imageBuffer = req.processedImage?.buffer || null; + const mimeType = req.processedImage?.mimeType || null; + + const result = await List.addOrUpdateItem( + householdId, + storeId, + item_name, + quantity || "1", + userId, + imageBuffer, + mimeType, + notes + ); + + // Add history record + await List.addHistoryRecord(result.listId, quantity || "1", userId); + + res.json({ + message: result.isNew ? "Item added" : "Item updated", + item: { + id: result.listId, + item_name: result.itemName, + quantity: quantity || "1", + bought: false + } + }); + } catch (error) { + console.error("Error adding item:", error); + res.status(500).json({ message: "Failed to add item" }); + } +}; + +/** + * Mark item as bought or unbought + * PATCH /households/:householdId/stores/:storeId/list/item + */ +exports.markBought = async (req, res) => { + try { + const { householdId, storeId } = req.params; + const { item_name, bought, quantity_bought } = req.body; + + if (!item_name) return res.status(400).json({ message: "Item name is required" }); + + const item = await List.getItemByName(householdId, storeId, item_name); + console.log('requesting mark ', { item, householdId, storeId, item_name, bought, quantity_bought }); + if (!item) return res.status(404).json({ message: "Item not found" }); + + + // Update bought status (with optional partial purchase) + await List.setBought(item.id, bought, quantity_bought); + + res.json({ message: bought ? "Item marked as bought" : "Item unmarked" }); + } catch (error) { + console.error("Error marking bought:", error); + res.status(500).json({ message: "Failed to update item" }); + } +}; + +/** + * Update item details (quantity, notes) + * PUT /households/:householdId/stores/:storeId/list/item + */ +exports.updateItem = async (req, res) => { + try { + const { householdId, storeId } = req.params; + const { item_name, quantity, notes } = req.body; + + if (!item_name) { + return res.status(400).json({ message: "Item name is required" }); + } + + // Get the list item + const item = await List.getItemByName(householdId, storeId, item_name); + if (!item) { + return res.status(404).json({ message: "Item not found" }); + } + + // Update item + await List.updateItem(item.id, item_name, quantity, notes); + + res.json({ + message: "Item updated", + item: { + id: item.id, + item_name, + quantity, + notes + } + }); + } catch (error) { + console.error("Error updating item:", error); + res.status(500).json({ message: "Failed to update item" }); + } +}; + +/** + * Delete item from list + * DELETE /households/:householdId/stores/:storeId/list/item + */ +exports.deleteItem = async (req, res) => { + try { + const { householdId, storeId } = req.params; + const { item_name } = req.body; + + if (!item_name) { + return res.status(400).json({ message: "Item name is required" }); + } + + // Get the list item + const item = await List.getItemByName(householdId, storeId, item_name); + if (!item) { + return res.status(404).json({ message: "Item not found" }); + } + + await List.deleteItem(item.id); + + res.json({ message: "Item deleted" }); + } catch (error) { + console.error("Error deleting item:", error); + res.status(500).json({ message: "Failed to delete item" }); + } +}; + +/** + * Get item suggestions based on query + * GET /households/:householdId/stores/:storeId/list/suggestions + */ +exports.getSuggestions = async (req, res) => { + try { + const { householdId, storeId } = req.params; + const { query } = req.query; + + const suggestions = await List.getSuggestions(query || "", householdId, storeId); + res.json(suggestions); + } catch (error) { + console.error("Error getting suggestions:", error); + res.status(500).json({ message: "Failed to get suggestions" }); + } +}; + +/** + * Get recently bought items + * GET /households/:householdId/stores/:storeId/list/recent + */ +exports.getRecentlyBought = async (req, res) => { + try { + const { householdId, storeId } = req.params; + const items = await List.getRecentlyBoughtItems(householdId, storeId); + res.json(items); + } catch (error) { + console.error("Error getting recent items:", error); + res.status(500).json({ message: "Failed to get recent items" }); + } +}; + +/** + * Get item classification + * GET /households/:householdId/stores/:storeId/list/classification + */ +exports.getClassification = async (req, res) => { + try { + const { householdId, storeId } = req.params; + const { item_name } = req.query; + + if (!item_name) { + return res.status(400).json({ message: "Item name is required" }); + } + + // Get item ID from name + const item = await List.getItemByName(householdId, storeId, item_name); + if (!item) { + return res.json({ classification: null }); + } + + const classification = await List.getClassification(householdId, item.item_id); + res.json({ classification }); + } catch (error) { + console.error("Error getting classification:", error); + res.status(500).json({ message: "Failed to get classification" }); + } +}; + +/** + * Set/update item classification + * POST /households/:householdId/stores/:storeId/list/classification + */ +exports.setClassification = async (req, res) => { + try { + const { householdId, storeId } = req.params; + const { item_name, classification } = req.body; + + if (!item_name) { + return res.status(400).json({ message: "Item name is required" }); + } + + if (!classification) { + return res.status(400).json({ message: "Classification is required" }); + } + + // Validate classification + const validClassifications = ['produce', 'dairy', 'meat', 'bakery', 'frozen', 'pantry', 'snacks', 'beverages', 'household', 'other']; + if (!validClassifications.includes(classification)) { + return res.status(400).json({ message: "Invalid classification value" }); + } + + // Get item - add to master items if not exists + const item = await List.getItemByName(householdId, storeId, item_name); + let itemId; + + if (!item) { + // Item doesn't exist in list, need to get from items table or create + const itemResult = await List.addOrUpdateItem( + householdId, + storeId, + item_name, + "1", + req.user.id, + null, + null + ); + itemId = itemResult.itemId; + } else { + itemId = item.item_id; + } + + // Set classification (using item_type field for simplicity) + await List.upsertClassification(householdId, itemId, { + item_type: classification, + item_group: null, + zone: null + }); + + res.json({ message: "Classification set", classification }); + } catch (error) { + console.error("Error setting classification:", error); + res.status(500).json({ message: "Failed to set classification" }); + } +}; + +/** + * Update item image + * POST /households/:householdId/stores/:storeId/list/update-image + */ +exports.updateItemImage = async (req, res) => { + try { + const { householdId, storeId } = req.params; + const { item_name, quantity } = req.body; + const userId = req.user.id; + + // Get processed image + const imageBuffer = req.processedImage?.buffer || null; + const mimeType = req.processedImage?.mimeType || null; + + if (!imageBuffer) { + return res.status(400).json({ message: "No image provided" }); + } + + // Update the item with new image + await List.addOrUpdateItem(householdId, storeId, item_name, quantity, userId, imageBuffer, mimeType); + + res.json({ message: "Image updated successfully" }); + } catch (error) { + console.error("Error updating image:", error); + res.status(500).json({ message: "Failed to update image" }); + } +}; diff --git a/backend/migrations/add_notes_column.sql b/backend/migrations/add_notes_column.sql new file mode 100644 index 0000000..977dd22 --- /dev/null +++ b/backend/migrations/add_notes_column.sql @@ -0,0 +1,7 @@ +-- Add notes column to household_lists table +-- This allows users to add custom notes/descriptions to list items + +ALTER TABLE household_lists +ADD COLUMN IF NOT EXISTS notes TEXT; + +COMMENT ON COLUMN household_lists.notes IS 'Optional user notes/description for the item'; diff --git a/backend/models/list.model.v2.js b/backend/models/list.model.v2.js index 5af045f..aa01a43 100644 --- a/backend/models/list.model.v2.js +++ b/backend/models/list.model.v2.js @@ -14,15 +14,15 @@ exports.getHouseholdStoreList = async (householdId, storeId, includeHistory = tr i.name AS item_name, hl.quantity, hl.bought, - ENCODE(hl.item_image, 'base64') as item_image, - hl.image_mime_type, + ENCODE(hl.custom_image, 'base64') as item_image, + hl.custom_image_mime_type as image_mime_type, ${includeHistory ? ` ( SELECT ARRAY_AGG(DISTINCT u.name) FROM ( SELECT DISTINCT hlh.added_by FROM household_list_history hlh - WHERE hlh.list_id = hl.id + WHERE hlh.household_list_id = hl.id ORDER BY hlh.added_by ) hlh JOIN users u ON hlh.added_by = u.id @@ -65,22 +65,20 @@ exports.getItemByName = async (householdId, storeId, itemName) => { } const itemId = itemResult.rows[0].id; - - // Check if item exists in household list const result = await pool.query( `SELECT hl.id, i.name AS item_name, hl.quantity, hl.bought, - ENCODE(hl.item_image, 'base64') as item_image, - hl.image_mime_type, + ENCODE(hl.custom_image, 'base64') as item_image, + hl.custom_image_mime_type as image_mime_type, ( SELECT ARRAY_AGG(DISTINCT u.name) FROM ( SELECT DISTINCT hlh.added_by FROM household_list_history hlh - WHERE hlh.list_id = hl.id + WHERE hlh.household_list_id = hl.id ORDER BY hlh.added_by ) hlh JOIN users u ON hlh.added_by = u.id @@ -99,7 +97,7 @@ exports.getItemByName = async (householdId, storeId, itemName) => { AND hl.item_id = $3`, [householdId, storeId, itemId] ); - + console.log(result.rows); return result.rows[0] || null; }; @@ -125,7 +123,6 @@ exports.addOrUpdateItem = async ( ) => { const lowerItemName = itemName.toLowerCase(); - // First, ensure item exists in master catalog let itemResult = await pool.query( "SELECT id FROM items WHERE name ILIKE $1", [lowerItemName] @@ -133,7 +130,6 @@ exports.addOrUpdateItem = async ( let itemId; if (itemResult.rowCount === 0) { - // Create new item in master catalog const insertItem = await pool.query( "INSERT INTO items (name) VALUES ($1) RETURNING id", [lowerItemName] @@ -143,7 +139,6 @@ exports.addOrUpdateItem = async ( itemId = itemResult.rows[0].id; } - // Check if item exists in household list const listResult = await pool.query( `SELECT id, bought FROM household_lists WHERE household_id = $1 @@ -153,15 +148,14 @@ exports.addOrUpdateItem = async ( ); if (listResult.rowCount > 0) { - // Update existing list item const listId = listResult.rows[0].id; if (imageBuffer && mimeType) { await pool.query( `UPDATE household_lists SET quantity = $1, bought = FALSE, - item_image = $2, - image_mime_type = $3, + custom_image = $2, + custom_image_mime_type = $3, modified_on = NOW() WHERE id = $4`, [quantity, imageBuffer, mimeType, listId] @@ -178,10 +172,9 @@ exports.addOrUpdateItem = async ( } return listId; } else { - // Insert new list item const insert = await pool.query( `INSERT INTO household_lists - (household_id, store_id, item_id, quantity, item_image, image_mime_type) + (household_id, store_id, item_id, quantity, custom_image, custom_image_mime_type) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`, [householdId, storeId, itemId, quantity, imageBuffer, mimeType] @@ -193,32 +186,51 @@ exports.addOrUpdateItem = async ( /** * Mark item as bought (full or partial) * @param {number} listId - List item ID - * @param {number} quantityBought - Quantity bought + * @param {boolean} bought - True to mark as bought, false to unmark + * @param {number} quantityBought - Optional quantity bought (for partial purchases) */ -exports.setBought = async (listId, quantityBought) => { - // Get current item - const item = await pool.query( - "SELECT quantity FROM household_lists WHERE id = $1", - [listId] - ); +exports.setBought = async (listId, bought, quantityBought = null) => { + if (bought === false) { + // Unmarking - just set bought to false + await pool.query( + "UPDATE household_lists SET bought = FALSE, modified_on = NOW() WHERE id = $1", + [listId] + ); + return; + } - if (!item.rows[0]) return; + // Marking as bought + if (quantityBought && quantityBought > 0) { + // Partial purchase - reduce quantity + const item = await pool.query( + "SELECT quantity FROM household_lists WHERE id = $1", + [listId] + ); - const currentQuantity = item.rows[0].quantity; - const remainingQuantity = currentQuantity - quantityBought; + if (!item.rows[0]) return; - if (remainingQuantity <= 0) { - // Mark as bought if all quantity is purchased + const currentQuantity = item.rows[0].quantity; + const remainingQuantity = currentQuantity - quantityBought; + + if (remainingQuantity <= 0) { + // All bought - mark as bought + await pool.query( + "UPDATE household_lists SET bought = TRUE, modified_on = NOW() WHERE id = $1", + [listId] + ); + } else { + // Partial - reduce quantity + await pool.query( + "UPDATE household_lists SET quantity = $1, modified_on = NOW() WHERE id = $2", + [remainingQuantity, listId] + ); + } + } else { + // Full purchase - mark as bought await pool.query( "UPDATE household_lists SET bought = TRUE, modified_on = NOW() WHERE id = $1", [listId] ); - } else { - // Reduce quantity if partial purchase - await pool.query( - "UPDATE household_lists SET quantity = $1, modified_on = NOW() WHERE id = $2", - [remainingQuantity, listId] - ); } }; @@ -230,7 +242,7 @@ exports.setBought = async (listId, quantityBought) => { */ exports.addHistoryRecord = async (listId, quantity, userId) => { await pool.query( - `INSERT INTO household_list_history (list_id, quantity, added_by, added_on) + `INSERT INTO household_list_history (household_list_id, quantity, added_by, added_on) VALUES ($1, $2, $3, NOW())`, [listId, quantity, userId] ); @@ -246,16 +258,16 @@ exports.addHistoryRecord = async (listId, quantity, userId) => { exports.getSuggestions = async (query, householdId, storeId) => { // Get items from both master catalog and household history const result = await pool.query( - `SELECT DISTINCT i.name as item_name + `SELECT DISTINCT + i.name as item_name, + CASE WHEN hl.id IS NOT NULL THEN 0 ELSE 1 END as sort_order FROM items i LEFT JOIN household_lists hl ON i.id = hl.item_id AND hl.household_id = $2 AND hl.store_id = $3 WHERE i.name ILIKE $1 - ORDER BY - CASE WHEN hl.id IS NOT NULL THEN 0 ELSE 1 END, - i.name + ORDER BY sort_order, i.name LIMIT 10`, [`%${query}%`, householdId, storeId] ); @@ -275,14 +287,14 @@ exports.getRecentlyBoughtItems = async (householdId, storeId) => { i.name AS item_name, hl.quantity, hl.bought, - ENCODE(hl.item_image, 'base64') as item_image, - hl.image_mime_type, + ENCODE(hl.custom_image, 'base64') as item_image, + hl.custom_image_mime_type as image_mime_type, ( SELECT ARRAY_AGG(DISTINCT u.name) FROM ( SELECT DISTINCT hlh.added_by FROM household_list_history hlh - WHERE hlh.list_id = hl.id + WHERE hlh.household_list_id = hl.id ORDER BY hlh.added_by ) hlh JOIN users u ON hlh.added_by = u.id @@ -346,49 +358,44 @@ exports.upsertClassification = async (householdId, itemId, classification) => { /** * Update list item details * @param {number} listId - List item ID - * @param {string} itemName - New item name - * @param {number} quantity - New quantity + * @param {string} itemName - New item name (optional) + * @param {number} quantity - New quantity (optional) + * @param {string} notes - Notes (optional) * @returns {Promise} Updated item */ -exports.updateItem = async (listId, itemName, quantity) => { - // This is more complex now because we need to handle the master catalog - // Get current list item - const listItem = await pool.query( - "SELECT item_id FROM household_lists WHERE id = $1", - [listId] - ); +exports.updateItem = async (listId, itemName, quantity, notes) => { + // Build dynamic update query + const updates = []; + const values = [listId]; + let paramCount = 1; - if (listItem.rowCount === 0) { - throw new Error("List item not found"); + if (quantity !== undefined) { + paramCount++; + updates.push(`quantity = $${paramCount}`); + values.push(quantity); } - const oldItemId = listItem.rows[0].item_id; + if (notes !== undefined) { + paramCount++; + updates.push(`notes = $${paramCount}`); + values.push(notes); + } - // Check if new item name exists in catalog - let newItemId; - const itemResult = await pool.query( - "SELECT id FROM items WHERE name ILIKE $1", - [itemName.toLowerCase()] - ); + // Always update modified_on + updates.push(`modified_on = NOW()`); - if (itemResult.rowCount === 0) { - // Create new item - const insertItem = await pool.query( - "INSERT INTO items (name) VALUES ($1) RETURNING id", - [itemName.toLowerCase()] + if (updates.length === 1) { + // Only modified_on update + const result = await pool.query( + `UPDATE household_lists SET modified_on = NOW() WHERE id = $1 RETURNING *`, + [listId] ); - newItemId = insertItem.rows[0].id; - } else { - newItemId = itemResult.rows[0].id; + return result.rows[0]; } - // Update list item const result = await pool.query( - `UPDATE household_lists - SET item_id = $2, quantity = $3, modified_on = NOW() - WHERE id = $1 - RETURNING *`, - [listId, newItemId, quantity] + `UPDATE household_lists SET ${updates.join(', ')} WHERE id = $1 RETURNING *`, + values ); return result.rows[0]; diff --git a/backend/public/api-test.html b/backend/public/api-test.html index 1a4d68e..55c2f66 100644 --- a/backend/public/api-test.html +++ b/backend/public/api-test.html @@ -558,8 +558,8 @@ auth: true, body: { name: `Workflow Test ${Date.now()}` }, expect: (res) => res.household && res.household.id, - onSuccess: (res) => { - createdHouseholdId = res.household.id; + onSuccess: (res) => { + createdHouseholdId = res.household.id; inviteCode = res.household.invite_code; } }, @@ -820,61 +820,61 @@ const contentEl = document.getElementById(`${testId}-content`); const toggleEl = document.getElementById(`${testId}-toggle`); const resultEl = testEl.querySelector('.test-result'); - + // Auto-expand when running contentEl.classList.add('expanded'); toggleEl.classList.add('expanded'); - resultEl.style.display = 'block'; - resultEl.className = 'test-result'; - resultEl.innerHTML = 'โš ๏ธ Prerequisites not met'; - return 'skip'; - } + resultEl.style.display = 'block'; + resultEl.className = 'test-result'; + resultEl.innerHTML = 'โš ๏ธ Prerequisites not met'; + return 'skip'; + } - testEl.className = 'test-case running'; - testEl.querySelector('.test-status').textContent = 'RUNNING'; - testEl.querySelector('.test-status').className = 'test-status running'; - resultEl.style.display = 'none'; + testEl.className = 'test-case running'; + testEl.querySelector('.test-status').textContent = 'RUNNING'; + testEl.querySelector('.test-status').className = 'test-status running'; + resultEl.style.display = 'none'; - try { - const { data, status } = await makeRequest(test); + try { + const { data, status } = await makeRequest(test); - const expectFail = test.expectFail || false; - const passed = test.expect(data, status); + const expectFail = test.expectFail || false; + const passed = test.expect(data, status); - const success = expectFail ? !passed || status >= 400 : passed; + const success = expectFail ? !passed || status >= 400 : passed; - testEl.className = success ? 'test-case pass' : 'test-case fail'; - testEl.querySelector('.test-status').textContent = success ? 'PASS' : 'FAIL'; - testEl.querySelector('.test-status').className = `test-status ${success ? 'pass' : 'fail'}`; + testEl.className = success ? 'test-case pass' : 'test-case fail'; + testEl.querySelector('.test-status').textContent = success ? 'PASS' : 'FAIL'; + testEl.querySelector('.test-status').className = `test-status ${success ? 'pass' : 'fail'}`; - // Determine status code class - let statusClass = 'status-5xx'; - if (status >= 200 && status < 300) statusClass = 'status-2xx'; - else if (status >= 300 && status < 400) statusClass = 'status-3xx'; - else if (status >= 400 && status < 500) statusClass = 'status-4xx'; + // Determine status code class + let statusClass = 'status-5xx'; + if (status >= 200 && status < 300) statusClass = 'status-2xx'; + else if (status >= 300 && status < 400) statusClass = 'status-3xx'; + else if (status >= 400 && status < 500) statusClass = 'status-4xx'; - resultEl.style.display = 'block'; - resultEl.className = 'test-result'; - - // Check expected fields if defined - let expectedFieldsHTML = ''; - if (test.expectedFields) { - const fieldChecks = test.expectedFields.map(field => { - const exists = field.split('.').reduce((obj, key) => obj?.[key], data) !== undefined; - const icon = exists ? 'โœ“' : 'โœ—'; - const className = exists ? 'pass' : 'fail'; - return `
${icon} ${field}
`; - }).join(''); - - expectedFieldsHTML = ` + resultEl.style.display = 'block'; + resultEl.className = 'test-result'; + + // Check expected fields if defined + let expectedFieldsHTML = ''; + if (test.expectedFields) { + const fieldChecks = test.expectedFields.map(field => { + const exists = field.split('.').reduce((obj, key) => obj?.[key], data) !== undefined; + const icon = exists ? 'โœ“' : 'โœ—'; + const className = exists ? 'pass' : 'fail'; + return `
${icon} ${field}
`; + }).join(''); + + expectedFieldsHTML = `
Expected Fields:
${fieldChecks}
`; - } - - resultEl.innerHTML = ` + } + + resultEl.innerHTML = `
HTTP ${status} ${success ? 'โœ“ Test passed' : 'โœ— Test failed'} @@ -884,25 +884,25 @@
${JSON.stringify(data, null, 2)}
`; - if (success && test.onSuccess) { - test.onSuccess(data); - } + if (success && test.onSuccess) { + test.onSuccess(data); + } - return success ? 'pass' : 'fail'; - } catch (error) { - testEl.className = 'test-case fail'; - testEl.querySelector('.test-status').textContent = 'ERROR'; - testEl.querySelector('.test-status').className = 'test-status fail'; + return success ? 'pass' : 'fail'; + } catch (error) { + testEl.className = 'test-case fail'; + testEl.querySelector('.test-status').textContent = 'ERROR'; + testEl.querySelector('.test-status').className = 'test-status fail'; - resultEl.style.display = 'block'; - resultEl.className = 'test-error'; - resultEl.innerHTML = ` + resultEl.style.display = 'block'; + resultEl.className = 'test-error'; + resultEl.innerHTML = `
โŒ Network/Request Error
${error.message}
${error.stack ? `
${error.stack}
` : ''} `; - return 'fail'; - } + return 'fail'; + } } async function runAllTests(event) { @@ -945,7 +945,7 @@ function toggleTest(testId) { const content = document.getElementById(`${testId}-content`); const toggle = document.getElementById(`${testId}-toggle`); - + if (content.classList.contains('expanded')) { content.classList.remove('expanded'); toggle.classList.remove('expanded'); diff --git a/backend/public/api-tests.html b/backend/public/api-tests.html index 84ccb46..f554bb7 100644 --- a/backend/public/api-tests.html +++ b/backend/public/api-tests.html @@ -1,11 +1,13 @@ + API Test Suite - Grocery List +

๐Ÿงช API Test Suite

@@ -57,4 +59,5 @@ - + + \ No newline at end of file diff --git a/backend/public/test-definitions.js b/backend/public/test-definitions.js index 959a2bf..d11d922 100644 --- a/backend/public/test-definitions.js +++ b/backend/public/test-definitions.js @@ -184,8 +184,8 @@ const tests = [ auth: true, body: { name: `Workflow Test ${Date.now()}` }, expect: (res) => res.household && res.household.id, - onSuccess: (res) => { - createdHouseholdId = res.household.id; + onSuccess: (res) => { + createdHouseholdId = res.household.id; inviteCode = res.household.invite_code; } }, diff --git a/backend/public/test-runner.js b/backend/public/test-runner.js index 138f633..58ec72f 100644 --- a/backend/public/test-runner.js +++ b/backend/public/test-runner.js @@ -31,7 +31,7 @@ async function runTest(categoryIdx, testIdx) { const contentEl = document.getElementById(`${testId}-content`); const toggleEl = document.getElementById(`${testId}-toggle`); const resultEl = testEl.querySelector('.test-result'); - + if (test.skip && test.skip()) { testEl.querySelector('.test-status').textContent = 'SKIPPED'; testEl.querySelector('.test-status').className = 'test-status pending'; @@ -73,7 +73,7 @@ async function runTest(categoryIdx, testIdx) { const className = exists ? 'pass' : 'fail'; return `
${icon} ${field}
`; }).join(''); - + expectedFieldsHTML = `
Expected Fields:
@@ -81,7 +81,7 @@ async function runTest(categoryIdx, testIdx) {
`; } - + resultEl.style.display = 'block'; resultEl.className = 'test-result'; resultEl.innerHTML = ` diff --git a/backend/public/test-ui.js b/backend/public/test-ui.js index 65dce49..35f9c53 100644 --- a/backend/public/test-ui.js +++ b/backend/public/test-ui.js @@ -1,7 +1,7 @@ function toggleTest(testId) { const content = document.getElementById(`${testId}-content`); const toggle = document.getElementById(`${testId}-toggle`); - + if (content.classList.contains('expanded')) { content.classList.remove('expanded'); toggle.classList.remove('expanded'); @@ -80,6 +80,6 @@ function renderTests() { } // Initialize on page load -document.addEventListener('DOMContentLoaded', function() { +document.addEventListener('DOMContentLoaded', function () { renderTests(); }); diff --git a/backend/routes/households.routes.js b/backend/routes/households.routes.js index 24ada70..25480bf 100644 --- a/backend/routes/households.routes.js +++ b/backend/routes/households.routes.js @@ -1,11 +1,14 @@ const express = require("express"); const router = express.Router(); const controller = require("../controllers/households.controller"); +const listsController = require("../controllers/lists.controller.v2"); const auth = require("../middleware/auth"); const { householdAccess, requireHouseholdAdmin, + storeAccess, } = require("../middleware/household"); +const { upload, processImage } = require("../middleware/image"); // Public routes (authenticated only) router.get("/", auth, controller.getUserHouseholds); @@ -57,4 +60,110 @@ router.delete( controller.removeMember ); +// ==================== List Operations Routes ==================== +// All list routes require household access AND store access + +// Get grocery list +router.get( + "/:householdId/stores/:storeId/list", + auth, + householdAccess, + storeAccess, + listsController.getList +); + +// Get specific item by name +router.get( + "/:householdId/stores/:storeId/list/item", + auth, + householdAccess, + storeAccess, + listsController.getItemByName +); + +// Add item to list +router.post( + "/:householdId/stores/:storeId/list/add", + auth, + householdAccess, + storeAccess, + upload.single("image"), + processImage, + listsController.addItem +); + +// Mark item as bought/unbought +router.patch( + "/:householdId/stores/:storeId/list/item", + auth, + householdAccess, + storeAccess, + listsController.markBought +); + +// Update item details (quantity, notes) +router.put( + "/:householdId/stores/:storeId/list/item", + auth, + householdAccess, + storeAccess, + listsController.updateItem +); + +// Delete item +router.delete( + "/:householdId/stores/:storeId/list/item", + auth, + householdAccess, + storeAccess, + listsController.deleteItem +); + +// Get suggestions +router.get( + "/:householdId/stores/:storeId/list/suggestions", + auth, + householdAccess, + storeAccess, + listsController.getSuggestions +); + +// Get recently bought items +router.get( + "/:householdId/stores/:storeId/list/recent", + auth, + householdAccess, + storeAccess, + listsController.getRecentlyBought +); + +// Get item classification +router.get( + "/:householdId/stores/:storeId/list/classification", + auth, + householdAccess, + storeAccess, + listsController.getClassification +); + +// Set item classification +router.post( + "/:householdId/stores/:storeId/list/classification", + auth, + householdAccess, + storeAccess, + listsController.setClassification +); + +// Update item image +router.post( + "/:householdId/stores/:storeId/list/update-image", + auth, + householdAccess, + storeAccess, + upload.single("image"), + processImage, + listsController.updateItemImage +); + module.exports = router; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index f9595cd..81a1dd3 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -2,7 +2,9 @@ import { BrowserRouter, Route, Routes } from "react-router-dom"; import { ROLES } from "./constants/roles"; import { AuthProvider } from "./context/AuthContext.jsx"; import { ConfigProvider } from "./context/ConfigContext.jsx"; +import { HouseholdProvider } from "./context/HouseholdContext.jsx"; import { SettingsProvider } from "./context/SettingsContext.jsx"; +import { StoreProvider } from "./context/StoreContext.jsx"; import AdminPanel from "./pages/AdminPanel.jsx"; import GroceryList from "./pages/GroceryList.jsx"; @@ -20,38 +22,42 @@ function App() { return ( - - - + + + + + - {/* Public route */} - } /> - } /> + {/* Public route */} + } /> + } /> - {/* Private routes with layout */} - - - - } - > - } /> - } /> + {/* Private routes with layout */} + + + + } + > + } /> + } /> - - - - } - /> - + + + + } + /> + - - - + + + + + ); diff --git a/frontend/src/api/households.js b/frontend/src/api/households.js new file mode 100644 index 0000000..d85966d --- /dev/null +++ b/frontend/src/api/households.js @@ -0,0 +1,58 @@ +import api from "./axios"; + +/** + * Get all households for the current user + */ +export const getUserHouseholds = () => api.get("/households"); + +/** + * Get details of a specific household + */ +export const getHousehold = (householdId) => api.get(`/households/${householdId}`); + +/** + * Create a new household + */ +export const createHousehold = (name) => api.post("/households", { name }); + +/** + * Update household name + */ +export const updateHousehold = (householdId, name) => + api.patch(`/households/${householdId}`, { name }); + +/** + * Delete a household + */ +export const deleteHousehold = (householdId) => + api.delete(`/households/${householdId}`); + +/** + * Refresh household invite code + */ +export const refreshInviteCode = (householdId) => + api.post(`/households/${householdId}/invite/refresh`); + +/** + * Join a household using invite code + */ +export const joinHousehold = (inviteCode) => + api.post(`/households/join/${inviteCode}`); + +/** + * Get household members + */ +export const getHouseholdMembers = (householdId) => + api.get(`/households/${householdId}/members`); + +/** + * Update member role + */ +export const updateMemberRole = (householdId, userId, role) => + api.patch(`/households/${householdId}/members/${userId}/role`, { role }); + +/** + * Remove member from household + */ +export const removeMember = (householdId, userId) => + api.delete(`/households/${householdId}/members/${userId}`); diff --git a/frontend/src/api/list.js b/frontend/src/api/list.js index f202292..9356365 100644 --- a/frontend/src/api/list.js +++ b/frontend/src/api/list.js @@ -1,44 +1,120 @@ import api from "./axios"; -export const getList = () => api.get("/list"); -export const getItemByName = (itemName) => api.get("/list/item-by-name", { params: { itemName: itemName } }); +/** + * Get grocery list for household and store + */ +export const getList = (householdId, storeId) => + api.get(`/households/${householdId}/stores/${storeId}/list`); -export const addItem = (itemName, quantity, imageFile = null) => { +/** + * Get specific item by name + */ +export const getItemByName = (householdId, storeId, itemName) => + api.get(`/households/${householdId}/stores/${storeId}/list/item`, { + params: { item_name: itemName } + }); + +/** + * Add item to list + */ +export const addItem = (householdId, storeId, itemName, quantity, imageFile = null, notes = null) => { const formData = new FormData(); - formData.append("itemName", itemName); + formData.append("item_name", itemName); formData.append("quantity", quantity); - + if (notes) { + formData.append("notes", notes); + } if (imageFile) { formData.append("image", imageFile); } - return api.post("/list/add", formData, { + return api.post(`/households/${householdId}/stores/${storeId}/list/add`, formData, { headers: { "Content-Type": "multipart/form-data", }, }); }; -export const getClassification = (id) => api.get(`/list/item/${id}/classification`); -export const updateItemWithClassification = (id, itemName, quantity, classification) => { - return api.put(`/list/item/${id}`, { - itemName, - quantity, +/** + * Get item classification + */ +export const getClassification = (householdId, storeId, itemName) => + api.get(`/households/${householdId}/stores/${storeId}/list/classification`, { + params: { item_name: itemName } + }); + +/** + * Set item classification + */ +export const setClassification = (householdId, storeId, itemName, classification) => + api.post(`/households/${householdId}/stores/${storeId}/list/classification`, { + item_name: itemName, classification }); -}; -export const markBought = (id, quantity) => api.post("/list/mark-bought", { id, quantity }); -export const getSuggestions = (query) => api.get("/list/suggest", { params: { query: query } }); -export const getRecentlyBought = () => api.get("/list/recently-bought"); -export const updateItemImage = (id, itemName, quantity, imageFile) => { +/** + * Update item with classification (legacy method - split into separate calls) + */ +export const updateItemWithClassification = (householdId, storeId, itemName, quantity, classification) => { + // This is now two operations: update item + set classification + return Promise.all([ + updateItem(householdId, storeId, itemName, quantity), + classification ? setClassification(householdId, storeId, itemName, classification) : Promise.resolve() + ]); +}; + +/** + * Update item details (quantity, notes) + */ +export const updateItem = (householdId, storeId, itemName, quantity, notes) => + api.put(`/households/${householdId}/stores/${storeId}/list/item`, { + item_name: itemName, + quantity, + notes + }); + +/** + * Mark item as bought or unbought + */ +export const markBought = (householdId, storeId, itemName, quantityBought = null, bought = true) => + api.patch(`/households/${householdId}/stores/${storeId}/list/item`, { + item_name: itemName, + bought, + quantity_bought: quantityBought + }); + +/** + * Delete item from list + */ +export const deleteItem = (householdId, storeId, itemName) => + api.delete(`/households/${householdId}/stores/${storeId}/list/item`, { + data: { item_name: itemName } + }); + +/** + * Get suggestions based on query + */ +export const getSuggestions = (householdId, storeId, query) => + api.get(`/households/${householdId}/stores/${storeId}/list/suggestions`, { + params: { query } + }); + +/** + * Get recently bought items + */ +export const getRecentlyBought = (householdId, storeId) => + api.get(`/households/${householdId}/stores/${storeId}/list/recent`); + +/** + * Update item image + */ +export const updateItemImage = (householdId, storeId, itemName, quantity, imageFile) => { const formData = new FormData(); - formData.append("id", id); - formData.append("itemName", itemName); + formData.append("item_name", itemName); formData.append("quantity", quantity); formData.append("image", imageFile); - return api.post("/list/update-image", formData, { + return api.post(`/households/${householdId}/stores/${storeId}/list/update-image`, formData, { headers: { "Content-Type": "multipart/form-data", }, diff --git a/frontend/src/api/stores.js b/frontend/src/api/stores.js new file mode 100644 index 0000000..057384f --- /dev/null +++ b/frontend/src/api/stores.js @@ -0,0 +1,48 @@ +import api from "./axios"; + +/** + * Get all stores in the system + */ +export const getAllStores = () => api.get("/stores"); + +/** + * Get stores linked to a household + */ +export const getHouseholdStores = (householdId) => + api.get(`/stores/household/${householdId}`); + +/** + * Add a store to a household + */ +export const addStoreToHousehold = (householdId, storeId, isDefault = false) => + api.post(`/stores/household/${householdId}`, { store_id: storeId, is_default: isDefault }); + +/** + * Remove a store from a household + */ +export const removeStoreFromHousehold = (householdId, storeId) => + api.delete(`/stores/household/${householdId}/${storeId}`); + +/** + * Set a store as default for a household + */ +export const setDefaultStore = (householdId, storeId) => + api.patch(`/stores/household/${householdId}/${storeId}/default`); + +/** + * Create a new store (system admin only) + */ +export const createStore = (name, location) => + api.post("/stores", { name, location }); + +/** + * Update store details (system admin only) + */ +export const updateStore = (storeId, name, location) => + api.patch(`/stores/${storeId}`, { name, location }); + +/** + * Delete a store (system admin only) + */ +export const deleteStore = (storeId) => + api.delete(`/stores/${storeId}`); diff --git a/frontend/src/components/household/HouseholdSwitcher.jsx b/frontend/src/components/household/HouseholdSwitcher.jsx new file mode 100644 index 0000000..3be7d19 --- /dev/null +++ b/frontend/src/components/household/HouseholdSwitcher.jsx @@ -0,0 +1,50 @@ +import { useContext, useState } from 'react'; +import { HouseholdContext } from '../../context/HouseholdContext'; +import '../../styles/components/HouseholdSwitcher.css'; + +export default function HouseholdSwitcher() { + const { households, activeHousehold, setActiveHousehold, loading } = useContext(HouseholdContext); + const [isOpen, setIsOpen] = useState(false); + + if (!activeHousehold || households.length === 0) { + return null; + } + + const handleSelect = (household) => { + setActiveHousehold(household); + setIsOpen(false); + }; + + return ( +
+ + + {isOpen && ( + <> +
setIsOpen(false)} /> +
+ {households.map(household => ( + + ))} +
+ + )} +
+ ); +} diff --git a/frontend/src/components/layout/Navbar.jsx b/frontend/src/components/layout/Navbar.jsx index f85df2a..1148c16 100644 --- a/frontend/src/components/layout/Navbar.jsx +++ b/frontend/src/components/layout/Navbar.jsx @@ -3,6 +3,7 @@ import "../../styles/components/Navbar.css"; import { useContext } from "react"; import { Link } from "react-router-dom"; import { AuthContext } from "../../context/AuthContext"; +import HouseholdSwitcher from "../household/HouseholdSwitcher"; export default function Navbar() { const { role, logout, username } = useContext(AuthContext); @@ -16,6 +17,8 @@ export default function Navbar() { {role === "admin" && Admin}
+ +
{username} diff --git a/frontend/src/components/store/StoreTabs.jsx b/frontend/src/components/store/StoreTabs.jsx new file mode 100644 index 0000000..5f9a755 --- /dev/null +++ b/frontend/src/components/store/StoreTabs.jsx @@ -0,0 +1,35 @@ +import { useContext } from 'react'; +import { StoreContext } from '../../context/StoreContext'; +import '../../styles/components/StoreTabs.css'; + +export default function StoreTabs() { + const { stores, activeStore, setActiveStore, loading } = useContext(StoreContext); + + if (!stores || stores.length === 0) { + return ( +
+
+ No stores available for this household +
+
+ ); + } + + return ( +
+
+ {stores.map(store => ( + + ))} +
+
+ ); +} diff --git a/frontend/src/context/HouseholdContext.jsx b/frontend/src/context/HouseholdContext.jsx new file mode 100644 index 0000000..4a97959 --- /dev/null +++ b/frontend/src/context/HouseholdContext.jsx @@ -0,0 +1,115 @@ +import { createContext, useContext, useEffect, useState } from 'react'; +import { createHousehold as createHouseholdApi, getUserHouseholds } from '../api/households'; +import { AuthContext } from './AuthContext'; + +export const HouseholdContext = createContext({ + households: [], + activeHousehold: null, + loading: false, + error: null, + setActiveHousehold: () => { }, + refreshHouseholds: () => { }, + createHousehold: () => { }, +}); + +export const HouseholdProvider = ({ children }) => { + const { token } = useContext(AuthContext); + const [households, setHouseholds] = useState([]); + const [activeHousehold, setActiveHouseholdState] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Load households on mount and when token changes + useEffect(() => { + if (token) { + loadHouseholds(); + } else { + // Clear state when logged out + setHouseholds([]); + setActiveHouseholdState(null); + } + }, [token]); + + // Load active household from localStorage on mount + useEffect(() => { + if (households.length === 0) return; + + console.log('[HouseholdContext] Setting active household from:', households); + const savedHouseholdId = localStorage.getItem('activeHouseholdId'); + if (savedHouseholdId) { + const household = households.find(h => h.id === parseInt(savedHouseholdId)); + if (household) { + console.log('[HouseholdContext] Found saved household:', household); + setActiveHouseholdState(household); + return; + } + } + + // No saved household or not found, use first one + console.log('[HouseholdContext] Using first household:', households[0]); + setActiveHouseholdState(households[0]); + localStorage.setItem('activeHouseholdId', households[0].id); + }, [households]); + + const loadHouseholds = async () => { + if (!token) return; + + setLoading(true); + setError(null); + try { + console.log('[HouseholdContext] Loading households...'); + const response = await getUserHouseholds(); + console.log('[HouseholdContext] Loaded households:', response.data); + setHouseholds(response.data); + } catch (err) { + console.error('[HouseholdContext] Failed to load households:', err); + setError(err.response?.data?.message || 'Failed to load households'); + setHouseholds([]); + } finally { + setLoading(false); + } + }; + + const setActiveHousehold = (household) => { + setActiveHouseholdState(household); + if (household) { + localStorage.setItem('activeHouseholdId', household.id); + } else { + localStorage.removeItem('activeHouseholdId'); + } + }; + + const createHousehold = async (name) => { + try { + const response = await createHouseholdApi(name); + const newHousehold = response.data.household; + + // Refresh households list + await loadHouseholds(); + + // Set new household as active + setActiveHousehold(newHousehold); + + return newHousehold; + } catch (err) { + console.error('Failed to create household:', err); + throw err; + } + }; + + const value = { + households, + activeHousehold, + loading, + error, + setActiveHousehold, + refreshHouseholds: loadHouseholds, + createHousehold, + }; + + return ( + + {children} + + ); +}; diff --git a/frontend/src/context/StoreContext.jsx b/frontend/src/context/StoreContext.jsx new file mode 100644 index 0000000..5a97a03 --- /dev/null +++ b/frontend/src/context/StoreContext.jsx @@ -0,0 +1,99 @@ +import { createContext, useContext, useEffect, useState } from 'react'; +import { getHouseholdStores } from '../api/stores'; +import { AuthContext } from './AuthContext'; +import { HouseholdContext } from './HouseholdContext'; + +export const StoreContext = createContext({ + stores: [], + activeStore: null, + loading: false, + error: null, + setActiveStore: () => { }, + refreshStores: () => { }, +}); + +export const StoreProvider = ({ children }) => { + const { token } = useContext(AuthContext); + const { activeHousehold } = useContext(HouseholdContext); + const [stores, setStores] = useState([]); + const [activeStore, setActiveStoreState] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Load stores when household changes + useEffect(() => { + if (token && activeHousehold) { + loadStores(); + } else { + // Clear state when logged out or no household + setStores([]); + setActiveStoreState(null); + } + }, [token, activeHousehold?.id]); + + // Load active store from localStorage on mount (per household) + useEffect(() => { + if (!activeHousehold || stores.length === 0) return; + + console.log('[StoreContext] Setting active store from:', stores); + const storageKey = `activeStoreId_${activeHousehold.id}`; + const savedStoreId = localStorage.getItem(storageKey); + + if (savedStoreId) { + const store = stores.find(s => s.id === parseInt(savedStoreId)); + if (store) { + console.log('[StoreContext] Found saved store:', store); + setActiveStoreState(store); + return; + } + } + + // No saved store or not found, use default or first one + const defaultStore = stores.find(s => s.is_default) || stores[0]; + console.log('[StoreContext] Using store:', defaultStore); + setActiveStoreState(defaultStore); + localStorage.setItem(storageKey, defaultStore.id); + }, [stores, activeHousehold]); + + const loadStores = async () => { + if (!token || !activeHousehold) return; + + setLoading(true); + setError(null); + try { + console.log('[StoreContext] Loading stores for household:', activeHousehold.id); + const response = await getHouseholdStores(activeHousehold.id); + console.log('[StoreContext] Loaded stores:', response.data); + setStores(response.data); + } catch (err) { + console.error('[StoreContext] Failed to load stores:', err); + setError(err.response?.data?.message || 'Failed to load stores'); + setStores([]); + } finally { + setLoading(false); + } + }; + + const setActiveStore = (store) => { + setActiveStoreState(store); + if (store && activeHousehold) { + const storageKey = `activeStoreId_${activeHousehold.id}`; + localStorage.setItem(storageKey, store.id); + } + }; + + const value = { + stores, + activeStore, + loading, + error, + setActiveStore, + refreshStores: loadStores, + }; + + return ( + + {children} + + ); +}; diff --git a/frontend/src/pages/GroceryList.jsx b/frontend/src/pages/GroceryList.jsx index 293cd10..ba10b05 100644 --- a/frontend/src/pages/GroceryList.jsx +++ b/frontend/src/pages/GroceryList.jsx @@ -18,16 +18,21 @@ import AddItemWithDetailsModal from "../components/modals/AddItemWithDetailsModa import ConfirmAddExistingModal from "../components/modals/ConfirmAddExistingModal"; import EditItemModal from "../components/modals/EditItemModal"; import SimilarItemModal from "../components/modals/SimilarItemModal"; +import StoreTabs from "../components/store/StoreTabs"; import { ZONE_FLOW } from "../constants/classifications"; import { ROLES } from "../constants/roles"; import { AuthContext } from "../context/AuthContext"; +import { HouseholdContext } from "../context/HouseholdContext"; import { SettingsContext } from "../context/SettingsContext"; +import { StoreContext } from "../context/StoreContext"; import "../styles/pages/GroceryList.css"; import { findSimilarItems } from "../utils/stringSimilarity"; export default function GroceryList() { const { role } = useContext(AuthContext); + const { activeHousehold } = useContext(HouseholdContext); + const { activeStore } = useContext(StoreContext); const { settings } = useContext(SettingsContext); // === State === // @@ -53,17 +58,29 @@ export default function GroceryList() { // === Data Loading === const loadItems = async () => { + if (!activeHousehold?.id || !activeStore?.id) { + setLoading(false); + return; + } + setLoading(true); - const res = await getList(); - console.log(res.data); - setItems(res.data); - setLoading(false); + try { + const res = await getList(activeHousehold.id, activeStore.id); + console.log('[GroceryList] Items loaded:', res.data); + setItems(res.data.items || res.data || []); + } catch (error) { + console.error('[GroceryList] Failed to load items:', error); + setItems([]); + } finally { + setLoading(false); + } }; const loadRecentlyBought = async () => { + if (!activeHousehold?.id || !activeStore?.id) return; try { - const res = await getRecentlyBought(); + const res = await getRecentlyBought(activeHousehold.id, activeStore.id); setRecentlyBoughtItems(res.data); } catch (error) { console.error("Failed to load recently bought items:", error); @@ -75,7 +92,7 @@ export default function GroceryList() { useEffect(() => { loadItems(); loadRecentlyBought(); - }, []); + }, [activeHousehold?.id, activeStore?.id]); // === Zone Collapse Handler === @@ -137,10 +154,16 @@ export default function GroceryList() { return; } + if (!activeHousehold?.id || !activeStore?.id) { + setSuggestions([]); + setButtonText("Create + Add"); + return; + } + const lowerText = text.toLowerCase().trim(); try { - const response = await getSuggestions(text); + const response = await getSuggestions(activeHousehold.id, activeStore.id, text); const suggestionList = response.data.map(s => s.item_name); setSuggestions(suggestionList); @@ -157,13 +180,15 @@ export default function GroceryList() { // === Item Addition Handlers === const handleAdd = useCallback(async (itemName, quantity) => { if (!itemName.trim()) return; + if (!activeHousehold?.id || !activeStore?.id) return; + // Check if item already exists let existingItem = null; try { - const response = await getItemByName(itemName); + const response = await getItemByName(activeHousehold.id, activeStore.id, itemName); existingItem = response.data; } catch { - existingItem = null; + // Item doesn't exist, continue } if (existingItem) { @@ -183,16 +208,19 @@ export default function GroceryList() { processItemAddition(itemName, quantity); return prevItems; }); - }, [recentlyBoughtItems]); + }, [activeHousehold?.id, activeStore?.id, recentlyBoughtItems]); const processItemAddition = useCallback(async (itemName, quantity) => { + if (!activeHousehold?.id || !activeStore?.id) return; + + // Fetch current item state from backend let existingItem = null; try { - const response = await getItemByName(itemName); + const response = await getItemByName(activeHousehold.id, activeStore.id, itemName); existingItem = response.data; } catch { - existingItem = null; + // Item doesn't exist, continue with add } if (existingItem?.bought === false) { @@ -209,7 +237,7 @@ export default function GroceryList() { }); setShowConfirmAddExisting(true); } else if (existingItem) { - await addItem(itemName, quantity, null); + await addItem(activeHousehold.id, activeStore.id, itemName, quantity, null); setSuggestions([]); setButtonText("Add Item"); @@ -220,7 +248,7 @@ export default function GroceryList() { setPendingItem({ itemName, quantity }); setShowAddDetailsModal(true); } - }, []); + }, [activeHousehold?.id, activeStore?.id, items, loadItems]); // === Similar Item Modal Handlers === @@ -249,6 +277,7 @@ export default function GroceryList() { // === Confirm Add Existing Modal Handlers === const handleConfirmAddExisting = useCallback(async () => { if (!confirmAddExistingData) return; + if (!activeHousehold?.id || !activeStore?.id) return; const { itemName, newQuantity, existingItem } = confirmAddExistingData; @@ -256,14 +285,11 @@ export default function GroceryList() { setConfirmAddExistingData(null); try { - // Update the item - await addItem(itemName, newQuantity, null); + await addItem(activeHousehold.id, activeStore.id, itemName, newQuantity, null); - // Fetch the updated item with properly formatted data - const response = await getItemByName(itemName); + const response = await getItemByName(activeHousehold.id, activeStore.id, itemName); const updatedItem = response.data; - // Update state with the full item data setItems(prevItems => prevItems.map(item => item.id === existingItem.id ? updatedItem : item @@ -274,32 +300,54 @@ export default function GroceryList() { setButtonText("Add Item"); } catch (error) { console.error("Failed to update item:", error); - // Fallback to full reload on error await loadItems(); } - }, [confirmAddExistingData, loadItems]); - - const handleCancelAddExisting = useCallback(() => { - setShowConfirmAddExisting(false); - setConfirmAddExistingData(null); }, []); // === Add Details Modal Handlers === - const handleAddDetailsConfirm = useCallback(async (imageFile, classification) => { + const handleAddWithDetails = useCallback(async (imageFile, classification) => { if (!pendingItem) return; + if (!activeHousehold?.id || !activeStore?.id) return; try { - const addResponse = await addItem(pendingItem.itemName, pendingItem.quantity, imageFile); - let newItem = addResponse.data; + await addItem(activeHousehold.id, activeStore.id, pendingItem.itemName, pendingItem.quantity, imageFile); if (classification) { - const itemResponse = await getItemByName(pendingItem.itemName); - const itemId = itemResponse.data.id; - const updateResponse = await updateItemWithClassification(itemId, undefined, undefined, classification); - newItem = { ...newItem, ...updateResponse.data }; + // Apply classification if provided + await updateItemWithClassification(activeHousehold.id, activeStore.id, pendingItem.itemName, pendingItem.quantity, classification); } + // Fetch the newly added item + const itemResponse = await getItemByName(activeHousehold.id, activeStore.id, pendingItem.itemName); + const newItem = itemResponse.data; + + setShowAddDetailsModal(false); + setPendingItem(null); + setSuggestions([]); + setButtonText("Add Item"); + + // Add to state + if (newItem) { + setItems(prevItems => [...prevItems, newItem]); + } + } catch (error) { + console.error("Failed to add item:", error); + alert("Failed to add item. Please try again."); + } + }, [activeHousehold?.id, activeStore?.id, pendingItem]); + + const handleAddDetailsSkip = useCallback(async () => { + if (!pendingItem) return; + if (!activeHousehold?.id || !activeStore?.id) return; + + try { + await addItem(activeHousehold.id, activeStore.id, pendingItem.itemName, pendingItem.quantity, null); + + // Fetch the newly added item + const itemResponse = await getItemByName(activeHousehold.id, activeStore.id, pendingItem.itemName); + const newItem = itemResponse.data; + setShowAddDetailsModal(false); setPendingItem(null); setSuggestions([]); @@ -312,28 +360,7 @@ export default function GroceryList() { console.error("Failed to add item:", error); alert("Failed to add item. Please try again."); } - }, [pendingItem]); - - - const handleAddDetailsSkip = useCallback(async () => { - if (!pendingItem) return; - - try { - const response = await addItem(pendingItem.itemName, pendingItem.quantity, null); - - setShowAddDetailsModal(false); - setPendingItem(null); - setSuggestions([]); - setButtonText("Add Item"); - - if (response.data) { - setItems(prevItems => [...prevItems, response.data]); - } - } catch (error) { - console.error("Failed to add item:", error); - alert("Failed to add item. Please try again."); - } - }, [pendingItem]); + }, [activeHousehold?.id, activeStore?.id, pendingItem]); const handleAddDetailsCancel = useCallback(() => { @@ -346,31 +373,34 @@ export default function GroceryList() { // === Item Action Handlers === const handleBought = useCallback(async (id, quantity) => { + if (!activeHousehold?.id || !activeStore?.id) return; + const item = items.find(i => i.id === id); if (!item) return; - await markBought(id, quantity); + await markBought(activeHousehold.id, activeStore.id, item.item_name, quantity, true); // If buying full quantity, remove from list if (quantity >= item.quantity) { setItems(prevItems => prevItems.filter(item => item.id !== id)); } else { - // If partial, update quantity - const response = await getItemByName(item.item_name); - if (response.data) { - setItems(prevItems => - prevItems.map(item => item.id === id ? response.data : item) - ); - } + // If partial, fetch updated item + const response = await getItemByName(activeHousehold.id, activeStore.id, item.item_name); + const updatedItem = response.data; + + setItems(prevItems => + prevItems.map(i => i.id === id ? updatedItem : i) + ); } loadRecentlyBought(); - }, [items]); - + }, [activeHousehold?.id, activeStore?.id, items]); const handleImageAdded = useCallback(async (id, itemName, quantity, imageFile) => { + if (!activeHousehold?.id || !activeStore?.id) return; + try { - const response = await updateItemImage(id, itemName, quantity, imageFile); + const response = await updateItemImage(activeHousehold.id, activeStore.id, id, itemName, quantity, imageFile); setItems(prevItems => prevItems.map(item => @@ -387,14 +417,15 @@ export default function GroceryList() { console.error("Failed to add image:", error); alert("Failed to add image. Please try again."); } - }, []); + }, [activeHousehold?.id, activeStore?.id]); const handleLongPress = useCallback(async (item) => { if (![ROLES.ADMIN, ROLES.EDITOR].includes(role)) return; + if (!activeHousehold?.id || !activeStore?.id) return; try { - const classificationResponse = await getClassification(item.id); + const classificationResponse = await getClassification(activeHousehold.id, activeStore.id, item.id); setEditingItem({ ...item, classification: classificationResponse.data @@ -405,20 +436,26 @@ export default function GroceryList() { setEditingItem({ ...item, classification: null }); setShowEditModal(true); } - }, [role]); + }, [activeHousehold?.id, activeStore?.id, role]); // === Edit Modal Handlers === const handleEditSave = useCallback(async (id, itemName, quantity, classification) => { + if (!activeHousehold?.id || !activeStore?.id) return; + try { - const response = await updateItemWithClassification(id, itemName, quantity, classification); + await updateItemWithClassification(activeHousehold.id, activeStore.id, itemName, quantity, classification); + + // Fetch the updated item + const response = await getItemByName(activeHousehold.id, activeStore.id, itemName); + const updatedItem = response.data; + setShowEditModal(false); setEditingItem(null); - const updatedItem = response.data; setItems(prevItems => prevItems.map(item => - item.id === id ? { ...item, ...updatedItem } : item + item.id === id ? updatedItem : item ) ); @@ -431,7 +468,7 @@ export default function GroceryList() { console.error("Failed to update item:", error); throw error; } - }, []); + }, [activeHousehold?.id, activeStore?.id]); const handleEditCancel = useCallback(() => { @@ -454,7 +491,30 @@ export default function GroceryList() { }; - if (loading) return

Loading...

; + if (!activeHousehold || !activeStore) { + return ( +
+
+

Costco Grocery List

+

+ {!activeHousehold ? 'Loading households...' : 'Loading stores...'} +

+
+
+ ); + } + + if (loading) { + return ( +
+
+

Costco Grocery List

+ +

Loading grocery list...

+
+
+ ); + } return ( @@ -462,6 +522,7 @@ export default function GroceryList() {

Costco Grocery List

+ {[ROLES.ADMIN, ROLES.EDITOR].includes(role) && showAddForm && ( @@ -629,9 +690,12 @@ export default function GroceryList() { currentQuantity={confirmAddExistingData.currentQuantity} addingQuantity={confirmAddExistingData.addingQuantity} onConfirm={handleConfirmAddExisting} - onCancel={handleCancelAddExisting} + onCancel={() => { + setShowConfirmAddExisting(false); + setConfirmAddExistingData(null); + }} /> )}
); -} +} \ No newline at end of file diff --git a/frontend/src/styles/components/HouseholdSwitcher.css b/frontend/src/styles/components/HouseholdSwitcher.css new file mode 100644 index 0000000..ce1469e --- /dev/null +++ b/frontend/src/styles/components/HouseholdSwitcher.css @@ -0,0 +1,98 @@ +.household-switcher { + position: relative; + display: inline-block; +} + +.household-switcher-toggle { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: var(--surface-color); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + color: var(--text-color); + font-size: 1rem; + cursor: pointer; + transition: all 0.2s ease; +} + +.household-switcher-toggle:hover { + background: var(--hover-color); + border-color: var(--primary-color); +} + +.household-switcher-toggle:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.household-name { + font-weight: 500; +} + +.dropdown-icon { + font-size: 0.75rem; + transition: transform 0.2s ease; +} + +.dropdown-icon.open { + transform: rotate(180deg); +} + +.household-switcher-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 999; +} + +.household-switcher-dropdown { + position: absolute; + top: calc(100% + 0.5rem); + left: 0; + min-width: 200px; + background: var(--surface-color); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 1000; + overflow: hidden; +} + +.household-option { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 0.75rem 1rem; + background: transparent; + border: none; + border-bottom: 1px solid var(--border-color); + color: var(--text-color); + font-size: 1rem; + text-align: left; + cursor: pointer; + transition: background 0.2s ease; +} + +.household-option:last-child { + border-bottom: none; +} + +.household-option:hover { + background: var(--hover-color); +} + +.household-option.active { + background: var(--primary-color-light); + color: var(--primary-color); + font-weight: 500; +} + +.check-mark { + color: var(--primary-color); + font-weight: bold; +} diff --git a/frontend/src/styles/components/StoreTabs.css b/frontend/src/styles/components/StoreTabs.css new file mode 100644 index 0000000..1be8412 --- /dev/null +++ b/frontend/src/styles/components/StoreTabs.css @@ -0,0 +1,74 @@ +.store-tabs { + background: var(--surface-color); + border-bottom: 2px solid var(--border-color); + margin-bottom: 1.5rem; +} + +.store-tabs-container { + display: flex; + gap: 0.25rem; + overflow-x: auto; + padding: 0.5rem 1rem 0; +} + +.store-tabs-container::-webkit-scrollbar { + height: 4px; +} + +.store-tabs-container::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 2px; +} + +.store-tab { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1.5rem; + background: transparent; + border: none; + border-bottom: 3px solid transparent; + color: var(--text-secondary); + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; +} + +.store-tab:hover { + color: var(--text-color); + background: var(--hover-color); +} + +.store-tab.active { + color: var(--primary-color); + border-bottom-color: var(--primary-color); +} + +.store-tab:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.store-name { + font-weight: 500; +} + +.default-badge { + padding: 0.125rem 0.5rem; + background: var(--primary-color-light); + color: var(--primary-color); + font-size: 0.75rem; + font-weight: 600; + border-radius: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.store-tabs-empty { + padding: 1rem; + text-align: center; + color: var(--text-secondary); + font-style: italic; +}