From f45473cbff77ad846bd3059d00fb9dbe5abd9423 Mon Sep 17 00:00:00 2001 From: Nico Date: Tue, 26 May 2026 00:38:53 -0700 Subject: [PATCH] feat: add custom store location UI --- frontend/src/api/availableItems.js | 10 +- frontend/src/api/list.js | 38 +- frontend/src/api/stores.js | 93 ++-- .../src/components/common/ListSearchInput.jsx | 37 ++ .../src/components/common/SortDropdown.jsx | 11 - frontend/src/components/common/index.js | 2 +- .../forms/ClassificationSection.jsx | 8 +- .../components/forms/ImageUploadSection.jsx | 22 +- .../src/components/manage/ManageHousehold.jsx | 41 +- .../src/components/manage/ManageStores.jsx | 497 +++++++++++++----- .../manage/StoreAvailableItemsManager.jsx | 73 ++- .../modals/AddItemWithDetailsModal.jsx | 19 +- .../modals/AvailableItemEditorModal.jsx | 3 +- .../src/components/modals/EditItemModal.jsx | 7 +- frontend/src/components/store/StoreTabs.jsx | 2 +- frontend/src/context/HouseholdContext.jsx | 13 +- frontend/src/context/SettingsContext.jsx | 18 +- frontend/src/context/StoreContext.jsx | 52 +- frontend/src/pages/GroceryList.jsx | 196 +++---- frontend/src/pages/Settings.jsx | 24 - .../components/AddItemWithDetailsModal.css | 137 ++++- .../components/manage/ManageHousehold.css | 53 +- .../styles/components/manage/ManageStores.css | 107 ++++ frontend/src/styles/pages/GroceryList.css | 74 ++- 24 files changed, 1059 insertions(+), 478 deletions(-) create mode 100644 frontend/src/components/common/ListSearchInput.jsx delete mode 100644 frontend/src/components/common/SortDropdown.jsx diff --git a/frontend/src/api/availableItems.js b/frontend/src/api/availableItems.js index bac18d9..6eb63bd 100644 --- a/frontend/src/api/availableItems.js +++ b/frontend/src/api/availableItems.js @@ -9,7 +9,7 @@ function appendClassification(formData, classification) { } export const getAvailableItems = (householdId, storeId, query = "") => - api.get(`/households/${householdId}/stores/${storeId}/available-items`, { + api.get(`/households/${householdId}/locations/${storeId}/available-items`, { params: query ? { query } : undefined, }); @@ -21,7 +21,7 @@ export const createAvailableItem = (householdId, storeId, payload) => { formData.append("image", payload.imageFile); } - return api.post(`/households/${householdId}/stores/${storeId}/available-items`, formData, { + return api.post(`/households/${householdId}/locations/${storeId}/available-items`, formData, { headers: { "Content-Type": "multipart/form-data", }, @@ -41,7 +41,7 @@ export const updateAvailableItem = (householdId, storeId, itemId, payload) => { formData.append("image", payload.imageFile); } - return api.patch(`/households/${householdId}/stores/${storeId}/available-items/${itemId}`, formData, { + return api.patch(`/households/${householdId}/locations/${storeId}/available-items/${itemId}`, formData, { headers: { "Content-Type": "multipart/form-data", }, @@ -49,7 +49,7 @@ export const updateAvailableItem = (householdId, storeId, itemId, payload) => { }; export const deleteAvailableItem = (householdId, storeId, itemId) => - api.delete(`/households/${householdId}/stores/${storeId}/available-items/${itemId}`); + api.delete(`/households/${householdId}/locations/${storeId}/available-items/${itemId}`); export const importCurrentAvailableItems = (householdId, storeId) => - api.post(`/households/${householdId}/stores/${storeId}/available-items/import-current`); + api.post(`/households/${householdId}/locations/${storeId}/available-items/import-current`); diff --git a/frontend/src/api/list.js b/frontend/src/api/list.js index 259d402..66f79c7 100644 --- a/frontend/src/api/list.js +++ b/frontend/src/api/list.js @@ -3,14 +3,14 @@ import api from "./axios"; /** * Get grocery list for household and store */ -export const getList = (householdId, storeId) => - api.get(`/households/${householdId}/stores/${storeId}/list`); +export const getList = (householdId, storeId) => + api.get(`/households/${householdId}/locations/${storeId}/list`); /** * Get specific item by name */ -export const getItemByName = (householdId, storeId, itemName) => - api.get(`/households/${householdId}/stores/${storeId}/list/item`, { +export const getItemByName = (householdId, storeId, itemName) => + api.get(`/households/${householdId}/locations/${storeId}/list/item`, { params: { item_name: itemName } }); @@ -39,7 +39,7 @@ export const addItem = ( formData.append("image", imageFile); } - return api.post(`/households/${householdId}/stores/${storeId}/list/add`, formData, { + return api.post(`/households/${householdId}/locations/${storeId}/list/add`, formData, { headers: { "Content-Type": "multipart/form-data", }, @@ -49,8 +49,8 @@ export const addItem = ( /** * Get item classification */ -export const getClassification = (householdId, storeId, itemName) => - api.get(`/households/${householdId}/stores/${storeId}/list/classification`, { +export const getClassification = (householdId, storeId, itemName) => + api.get(`/households/${householdId}/locations/${storeId}/list/classification`, { params: { item_name: itemName } }); @@ -58,7 +58,7 @@ export const getClassification = (householdId, storeId, itemName) => * Set item classification */ export const setClassification = (householdId, storeId, itemName, classification) => - api.post(`/households/${householdId}/stores/${storeId}/list/classification`, { + api.post(`/households/${householdId}/locations/${storeId}/list/classification`, { item_name: itemName, classification }); @@ -103,8 +103,8 @@ export const updateItemWithClassification = (householdId, storeId, itemName, qua /** * Update item details (quantity, notes) */ -export const updateItem = (householdId, storeId, itemName, quantity, notes) => - api.put(`/households/${householdId}/stores/${storeId}/list/item`, { +export const updateItem = (householdId, storeId, itemName, quantity, notes) => + api.put(`/households/${householdId}/locations/${storeId}/list/item`, { item_name: itemName, quantity, notes @@ -113,8 +113,8 @@ export const updateItem = (householdId, storeId, 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`, { +export const markBought = (householdId, storeId, itemName, quantityBought = null, bought = true) => + api.patch(`/households/${householdId}/locations/${storeId}/list/item`, { item_name: itemName, bought, quantity_bought: quantityBought @@ -123,24 +123,24 @@ export const markBought = (householdId, storeId, itemName, quantityBought = null /** * Delete item from list */ -export const deleteItem = (householdId, storeId, itemName) => - api.delete(`/households/${householdId}/stores/${storeId}/list/item`, { +export const deleteItem = (householdId, storeId, itemName) => + api.delete(`/households/${householdId}/locations/${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`, { +export const getSuggestions = (householdId, storeId, query) => + api.get(`/households/${householdId}/locations/${storeId}/list/suggestions`, { params: { query } }); /** * Get recently bought items */ -export const getRecentlyBought = (householdId, storeId) => - api.get(`/households/${householdId}/stores/${storeId}/list/recent`); +export const getRecentlyBought = (householdId, storeId) => + api.get(`/households/${householdId}/locations/${storeId}/list/recent`); /** * Update item image @@ -158,7 +158,7 @@ export const updateItemImage = ( formData.append("quantity", quantity); formData.append("image", imageFile); - return api.post(`/households/${householdId}/stores/${storeId}/list/update-image`, formData, { + return api.post(`/households/${householdId}/locations/${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 index 2b92f18..61da518 100644 --- a/frontend/src/api/stores.js +++ b/frontend/src/api/stores.js @@ -1,48 +1,55 @@ import api from "./axios"; -/** - * Get all stores in the system - */ +// Legacy global store catalog for the system-admin page. 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}`, { storeId: storeId, isDefault: 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 createStore = (name, default_zones) => + api.post("/stores", { name, default_zones }); +export const updateStore = (storeId, name, default_zones) => + api.patch(`/stores/${storeId}`, { name, default_zones }); export const deleteStore = (storeId) => api.delete(`/stores/${storeId}`); + +// Household-owned store locations used by the grocery flow. +export const getHouseholdStores = (householdId) => + api.get(`/households/${householdId}/stores`); + +export const createHouseholdStore = (householdId, payload) => + api.post(`/households/${householdId}/stores`, payload); + +export const updateHouseholdStore = (householdId, householdStoreId, payload) => + api.patch(`/households/${householdId}/stores/${householdStoreId}`, payload); + +export const deleteHouseholdStore = (householdId, householdStoreId) => + api.delete(`/households/${householdId}/stores/${householdStoreId}`); + +export const addLocationToStore = (householdId, householdStoreId, payload) => + api.post(`/households/${householdId}/stores/${householdStoreId}/locations`, payload); + +export const updateLocation = (householdId, locationId, payload) => + api.patch(`/households/${householdId}/locations/${locationId}`, payload); + +export const removeLocation = (householdId, locationId) => + api.delete(`/households/${householdId}/locations/${locationId}`); + +export const setDefaultLocation = (householdId, locationId) => + api.patch(`/households/${householdId}/locations/${locationId}/default`); + +export const getLocationZones = (householdId, locationId) => + api.get(`/households/${householdId}/locations/${locationId}/zones`); + +export const createLocationZone = (householdId, locationId, payload) => + api.post(`/households/${householdId}/locations/${locationId}/zones`, payload); + +export const updateLocationZone = (householdId, locationId, zoneId, payload) => + api.patch(`/households/${householdId}/locations/${locationId}/zones/${zoneId}`, payload); + +export const deleteLocationZone = (householdId, locationId, zoneId) => + api.delete(`/households/${householdId}/locations/${locationId}/zones/${zoneId}`); + +// Compatibility aliases for older callers. +export const addStoreToHousehold = (householdId, storeId, isDefault = false) => + api.post(`/stores/household/${householdId}`, { storeId, isDefault }); +export const removeStoreFromHousehold = (householdId, storeId) => + api.delete(`/stores/household/${householdId}/${storeId}`); +export const setDefaultStore = (householdId, storeId) => + api.patch(`/stores/household/${householdId}/${storeId}/default`); diff --git a/frontend/src/components/common/ListSearchInput.jsx b/frontend/src/components/common/ListSearchInput.jsx new file mode 100644 index 0000000..c3fc6db --- /dev/null +++ b/frontend/src/components/common/ListSearchInput.jsx @@ -0,0 +1,37 @@ +export default function ListSearchInput({ value, onChange, resultCount, totalCount }) { + const hasSearch = value.trim().length > 0; + + return ( +
+ +
+ onChange(event.target.value)} + placeholder="Search list" + autoComplete="off" + /> + {hasSearch && ( + + )} +
+ {hasSearch && ( +

+ {resultCount} of {totalCount} item{totalCount === 1 ? "" : "s"} +

+ )} +
+ ); +} diff --git a/frontend/src/components/common/SortDropdown.jsx b/frontend/src/components/common/SortDropdown.jsx deleted file mode 100644 index 21e6ad4..0000000 --- a/frontend/src/components/common/SortDropdown.jsx +++ /dev/null @@ -1,11 +0,0 @@ -export default function SortDropdown({ value, onChange }) { - return ( - - ); -} diff --git a/frontend/src/components/common/index.js b/frontend/src/components/common/index.js index d553900..2b0bcd0 100644 --- a/frontend/src/components/common/index.js +++ b/frontend/src/components/common/index.js @@ -2,7 +2,7 @@ export { default as ErrorMessage } from './ErrorMessage.jsx'; export { default as FloatingActionButton } from './FloatingActionButton.jsx'; export { default as FormInput } from './FormInput.jsx'; -export { default as SortDropdown } from './SortDropdown.jsx'; +export { default as ListSearchInput } from './ListSearchInput.jsx'; export { default as ToggleButtonGroup } from './ToggleButtonGroup.jsx'; export { default as UserRoleCard } from './UserRoleCard.jsx'; diff --git a/frontend/src/components/forms/ClassificationSection.jsx b/frontend/src/components/forms/ClassificationSection.jsx index f701dd2..9396650 100644 --- a/frontend/src/components/forms/ClassificationSection.jsx +++ b/frontend/src/components/forms/ClassificationSection.jsx @@ -21,11 +21,15 @@ export default function ClassificationSection({ onItemTypeChange, onItemGroupChange, onZoneChange, + zones = null, title = "Item Classification (Optional)", fieldClass = "classification-field", selectClass = "classification-select" }) { const availableGroups = itemType ? ITEM_GROUPS[itemType] || [] : []; + const zoneOptions = Array.isArray(zones) && zones.length > 0 + ? zones.map((candidate) => candidate.name || candidate).filter(Boolean) + : getZoneValues(); const handleTypeChange = (e) => { const newType = e.target.value; @@ -35,7 +39,7 @@ export default function ClassificationSection({ return (
-

{title}

+ {title &&

{title}

}
@@ -79,7 +83,7 @@ export default function ClassificationSection({ className={selectClass} > - {getZoneValues().map((z) => ( + {zoneOptions.map((z) => ( diff --git a/frontend/src/components/forms/ImageUploadSection.jsx b/frontend/src/components/forms/ImageUploadSection.jsx index c83bae4..7598864 100644 --- a/frontend/src/components/forms/ImageUploadSection.jsx +++ b/frontend/src/components/forms/ImageUploadSection.jsx @@ -9,12 +9,16 @@ import "../../styles/components/ImageUploadSection.css"; * @param {Function} props.onImageChange - Callback when image is selected (file) * @param {Function} props.onImageRemove - Callback to remove image * @param {string} props.title - Section title (optional) + * @param {string} props.cameraLabel - Camera button label (optional) + * @param {string} props.galleryLabel - Gallery button label (optional) */ export default function ImageUploadSection({ imagePreview, onImageChange, onImageRemove, - title = "Item Image (Optional)" + title = "Item Image (Optional)", + cameraLabel = "Use Camera", + galleryLabel = "Choose from Gallery" }) { const cameraInputRef = useRef(null); const galleryInputRef = useRef(null); @@ -51,7 +55,7 @@ export default function ImageUploadSection({ return (
-

{title}

+ {title &&

{title}

} {sizeError && (
{sizeError} @@ -60,10 +64,20 @@ export default function ImageUploadSection({
{!imagePreview ? (
- -
diff --git a/frontend/src/components/manage/ManageHousehold.jsx b/frontend/src/components/manage/ManageHousehold.jsx index 8971d3a..e75ed0e 100644 --- a/frontend/src/components/manage/ManageHousehold.jsx +++ b/frontend/src/components/manage/ManageHousehold.jsx @@ -70,6 +70,7 @@ export default function ManageHousehold() { const [pendingDecisionId, setPendingDecisionId] = useState(null); const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false); const [pendingRoleChange, setPendingRoleChange] = useState(null); + const [pendingMemberRemoval, setPendingMemberRemoval] = useState(null); const isManager = ["owner", "admin"].includes(activeHousehold?.role); const isOwner = activeHousehold?.role === "owner"; @@ -307,12 +308,19 @@ export default function ManageHousehold() { setPendingRoleChange({ memberId, nextRole, memberName }); }; - const handleRemoveMember = async (memberId, username) => { - if (!confirm(`Remove ${username} from this household?`)) return; + const handleRemoveMember = (memberId, username) => { + setPendingMemberRemoval({ memberId, username }); + }; + + const handleConfirmRemoveMember = async () => { + if (!pendingMemberRemoval) return; + + const { memberId, username } = pendingMemberRemoval; try { await removeMember(activeHousehold.id, memberId); await loadMembers(); + setPendingMemberRemoval(null); toast.success("Removed member", `Removed member ${username}`); } catch (error) { const message = getApiErrorMessage(error, "Failed to remove member"); @@ -360,9 +368,6 @@ export default function ManageHousehold() {

Household

Identity

-

- Keep the household name crisp and easy to recognize across invites and shared lists. -

{editingName ? ( @@ -408,9 +413,6 @@ export default function ManageHousehold() {

Entry Rules

Invite Links

-

- Decide how new people can enter, review manual approvals, then create invite links for the flow you want. -

{inviteError &&

{inviteError}

} @@ -547,9 +549,6 @@ export default function ManageHousehold() {

People

Members ({members.length})

-

- Role badges and compact actions make it easier to see who runs the household and who just shops. -

{loading ? ( @@ -563,16 +562,12 @@ export default function ManageHousehold() { return (
-
-
{roleMeta.icon} {roleMeta.label} - {isSelf && ✨ You} -
{member.username} - ID #{member.id} + {isSelf && You}
{isManager && !isSelf && member.role !== "owner" && ( @@ -616,11 +611,6 @@ export default function ManageHousehold() {

Final Actions

Danger Zone

-

- {isMemberOnly - ? "Leaving removes your access to this household." - : "Deleting a household is permanent and will delete all lists, items, and history."} -

{isMemberOnly ? (
); } diff --git a/frontend/src/components/manage/ManageStores.jsx b/frontend/src/components/manage/ManageStores.jsx index 1f73f23..1da5467 100644 --- a/frontend/src/components/manage/ManageStores.jsx +++ b/frontend/src/components/manage/ManageStores.jsx @@ -1,9 +1,13 @@ -import { useContext, useEffect, useState } from "react"; +import { useContext, useEffect, useMemo, useState } from "react"; import { - addStoreToHousehold, - getAllStores, - removeStoreFromHousehold, - setDefaultStore + addLocationToStore, + createHouseholdStore, + createLocationZone, + deleteLocationZone, + getLocationZones, + removeLocation, + setDefaultLocation, + updateLocationZone, } from "../../api/stores"; import StoreAvailableItemsManager from "./StoreAvailableItemsManager"; import { HouseholdContext } from "../../context/HouseholdContext"; @@ -13,172 +17,427 @@ import getApiErrorMessage from "../../lib/getApiErrorMessage"; import "../../styles/components/manage/ManageStores.css"; import "../../styles/components/manage/StoreAvailableItemsManager.css"; -export default function ManageStores() { - const { activeHousehold } = useContext(HouseholdContext); - const { stores: householdStores, refreshStores } = useContext(StoreContext); +function groupLocationsByStore(locations) { + const grouped = new Map(); + + for (const location of locations) { + const key = location.household_store_id; + if (!grouped.has(key)) { + grouped.set(key, { + household_store_id: location.household_store_id, + name: location.name, + locations: [], + }); + } + + grouped.get(key).locations.push(location); + } + + return Array.from(grouped.values()).sort((a, b) => a.name.localeCompare(b.name)); +} + +function ZoneManager({ householdId, location, canManage, refreshActiveZones }) { const toast = useActionToast(); - const [allStores, setAllStores] = useState([]); - const [loading, setLoading] = useState(true); - const [showAddStore, setShowAddStore] = useState(false); + const [isOpen, setIsOpen] = useState(false); + const [zones, setZones] = useState([]); + const [loading, setLoading] = useState(false); + const [newZoneName, setNewZoneName] = useState(""); - const isAdmin = ["owner", "admin"].includes(activeHousehold?.role); - - useEffect(() => { - loadAllStores(); - }, []); - - const loadAllStores = async () => { + const loadZones = async () => { + if (!householdId || !location?.id) return; setLoading(true); try { - const response = await getAllStores(); - setAllStores(response.data); + const response = await getLocationZones(householdId, location.id); + setZones(response.data?.zones || []); } catch (error) { - console.error("Failed to load stores:", error); + const message = getApiErrorMessage(error, "Failed to load zones"); + toast.error("Load zones failed", `Load zones failed: ${message}`); } finally { setLoading(false); } }; - const handleAddStore = async (storeId) => { - const storeName = allStores.find((store) => store.id === storeId)?.name || `store #${storeId}`; + useEffect(() => { + if (isOpen) { + loadZones(); + } + }, [isOpen, householdId, location?.id]); + + const handleCreateZone = async () => { + const name = newZoneName.trim(); + if (!name) return; + try { - console.log("Adding store with ID:", storeId); - await addStoreToHousehold(activeHousehold.id, storeId, false); - await refreshStores(); - toast.success("Added store", `Added store ${storeName}`); - setShowAddStore(false); + const nextSortOrder = + zones.length > 0 ? Math.max(...zones.map((zone) => zone.sort_order || 0)) + 10 : 10; + await createLocationZone(householdId, location.id, { + name, + sort_order: nextSortOrder, + }); + setNewZoneName(""); + await loadZones(); + await refreshActiveZones(); + toast.success("Added zone", `Added zone ${name}`); } catch (error) { - console.error("Failed to add store:", error); - const message = getApiErrorMessage(error, "Failed to add store"); - toast.error("Add store failed", `Add store failed: ${message}`); + const message = getApiErrorMessage(error, "Failed to add zone"); + toast.error("Add zone failed", `Add zone failed: ${message}`); } }; - const handleRemoveStore = async (storeId, storeName) => { - if (!confirm(`Remove ${storeName} from this household?`)) return; + const handleMoveZone = async (zone, direction) => { + const currentIndex = zones.findIndex((candidate) => candidate.id === zone.id); + const swapIndex = currentIndex + direction; + if (currentIndex < 0 || swapIndex < 0 || swapIndex >= zones.length) return; + const other = zones[swapIndex]; try { - await removeStoreFromHousehold(activeHousehold.id, storeId); - await refreshStores(); - toast.success("Removed store", `Removed store ${storeName}`); + await Promise.all([ + updateLocationZone(householdId, location.id, zone.id, { + sort_order: other.sort_order, + }), + updateLocationZone(householdId, location.id, other.id, { + sort_order: zone.sort_order, + }), + ]); + await loadZones(); + await refreshActiveZones(); } catch (error) { - console.error("Failed to remove store:", error); - const message = getApiErrorMessage(error, "Failed to remove store"); - toast.error("Remove store failed", `Remove store failed: ${message}`); + const message = getApiErrorMessage(error, "Failed to reorder zones"); + toast.error("Reorder zones failed", `Reorder zones failed: ${message}`); } }; - const handleSetDefault = async (storeId) => { - const storeName = - householdStores.find((store) => store.id === storeId)?.name || `store #${storeId}`; + const handleDeleteZone = async (zone) => { + if (!confirm(`Remove zone "${zone.name}" from ${location.display_name || location.name}?`)) { + return; + } + try { - await setDefaultStore(activeHousehold.id, storeId); - await refreshStores(); - toast.success("Updated default store", `Default store set to ${storeName}`); + await deleteLocationZone(householdId, location.id, zone.id); + await loadZones(); + await refreshActiveZones(); + toast.success("Removed zone", `Removed zone ${zone.name}`); } catch (error) { - console.error("Failed to set default store:", error); - const message = getApiErrorMessage(error, "Failed to set default store"); - toast.error("Set default store failed", `Set default store failed: ${message}`); + const message = getApiErrorMessage(error, "Failed to remove zone"); + toast.error("Remove zone failed", `Remove zone failed: ${message}`); } }; - const availableStores = allStores.filter( - store => !householdStores.some(hs => hs.id === store.id) + return ( +
+ + + {isOpen ? ( +
+ {canManage ? ( +
+ setNewZoneName(event.target.value)} + placeholder="New zone name" + /> + +
+ ) : null} + + {loading ? ( +

Loading zones...

+ ) : zones.length === 0 ? ( +

No zones for this location.

+ ) : ( +
+ {zones.map((zone, index) => ( +
+ {index + 1} + {zone.name} + {canManage ? ( +
+ + + +
+ ) : null} +
+ ))} +
+ )} +
+ ) : null} +
); +} + +export default function ManageStores() { + const { activeHousehold } = useContext(HouseholdContext); + const { + activeStore, + stores: householdStores, + refreshStores, + refreshZones, + } = useContext(StoreContext); + const toast = useActionToast(); + const [createForm, setCreateForm] = useState({ + name: "", + location_name: "", + address: "", + }); + const [locationDrafts, setLocationDrafts] = useState({}); + const [saving, setSaving] = useState(false); + + const isAdmin = ["owner", "admin"].includes(activeHousehold?.role); + const groupedStores = useMemo( + () => groupLocationsByStore(householdStores), + [householdStores] + ); + + const refreshAfterStoreChange = async () => { + await refreshStores(); + await refreshZones(); + }; + + const handleCreateStore = async (event) => { + event.preventDefault(); + if (!createForm.name.trim()) return; + + setSaving(true); + try { + await createHouseholdStore(activeHousehold.id, { + name: createForm.name.trim(), + location_name: createForm.location_name.trim() || "Default Location", + address: createForm.address.trim() || null, + }); + setCreateForm({ name: "", location_name: "", address: "" }); + await refreshAfterStoreChange(); + toast.success("Created store", `Created store ${createForm.name.trim()}`); + } catch (error) { + const message = getApiErrorMessage(error, "Failed to create store"); + toast.error("Create store failed", `Create store failed: ${message}`); + } finally { + setSaving(false); + } + }; + + const handleAddLocation = async (householdStoreId, storeName) => { + const draft = locationDrafts[householdStoreId] || {}; + const name = String(draft.name || "").trim(); + if (!name) return; + + try { + await addLocationToStore(activeHousehold.id, householdStoreId, { + name, + address: String(draft.address || "").trim() || null, + }); + setLocationDrafts((current) => ({ + ...current, + [householdStoreId]: { name: "", address: "" }, + })); + await refreshAfterStoreChange(); + toast.success("Added location", `Added ${name} to ${storeName}`); + } catch (error) { + const message = getApiErrorMessage(error, "Failed to add location"); + toast.error("Add location failed", `Add location failed: ${message}`); + } + }; + + const handleSetDefault = async (location) => { + try { + await setDefaultLocation(activeHousehold.id, location.id); + await refreshAfterStoreChange(); + toast.success("Updated default location", `Default location set to ${location.display_name || location.name}`); + } catch (error) { + const message = getApiErrorMessage(error, "Failed to set default location"); + toast.error("Set default failed", `Set default failed: ${message}`); + } + }; + + const handleRemoveLocation = async (location) => { + const label = location.display_name || location.name; + if (!confirm(`Remove ${label} from this household?`)) return; + + try { + await removeLocation(activeHousehold.id, location.id); + await refreshAfterStoreChange(); + toast.success("Removed location", `Removed ${label}`); + } catch (error) { + const message = getApiErrorMessage(error, "Failed to remove location"); + toast.error("Remove location failed", `Remove location failed: ${message}`); + } + }; return (
- {/* Current Stores Section */}
-

Your Stores ({householdStores.length})

+

Store Locations ({householdStores.length})

- Use each store card's Manage Items button to edit or delete the household/store item list. + Stores and locations are private to this household. Each location has its own zones, + item defaults, and shopping order.

- {!isAdmin && ( -

- Only household owners and admins can manage store item catalogs. -

- )} {householdStores.length === 0 ? ( -

No stores added yet.

+

No store locations added yet.

) : (
- {householdStores.map((store) => ( -
+ {groupedStores.map((storeGroup) => ( +
-

{store.name}

- {store.location &&

{store.location}

} +

{storeGroup.name}

- {isAdmin && ( -
- {!store.is_default && ( - - )} + +
+ {storeGroup.locations.map((location) => ( +
+
+ {location.display_name || location.name} + {location.address ? ( +

{location.address}

+ ) : null} + {location.is_default ? ( +

Default shopping location

+ ) : null} +
+ +
+ {isAdmin && !location.is_default ? ( + + ) : null} + {isAdmin ? ( + + ) : null} +
+ + + + +
+ ))} +
+ + {isAdmin ? ( +
+ + setLocationDrafts((current) => ({ + ...current, + [storeGroup.household_store_id]: { + ...(current[storeGroup.household_store_id] || {}), + name: event.target.value, + }, + })) + } + placeholder="Location name" + /> + + setLocationDrafts((current) => ({ + ...current, + [storeGroup.household_store_id]: { + ...(current[storeGroup.household_store_id] || {}), + address: event.target.value, + }, + })) + } + placeholder="Address or notes" + />
- )} - + ) : null}
))}
)}
- {/* Add Store Section */} - {isAdmin && ( + {isAdmin ? (

Add Store

- {!showAddStore ? ( - - ) : ( -
- - {loading ? ( -

Loading stores...

- ) : availableStores.length === 0 ? ( -

All available stores have been added.

- ) : ( -
- {availableStores.map((store) => ( -
-
-

{store.name}

- {store.location &&

{store.location}

} -
- -
- ))} -
- )} -
- )} +
- )} + ) : activeStore ? ( +

+ Household members can manage item defaults. Only owners and admins can manage stores, + locations, zones, and item deletion. +

+ ) : null}
); } diff --git a/frontend/src/components/manage/StoreAvailableItemsManager.jsx b/frontend/src/components/manage/StoreAvailableItemsManager.jsx index a886fed..7651bd6 100644 --- a/frontend/src/components/manage/StoreAvailableItemsManager.jsx +++ b/frontend/src/components/manage/StoreAvailableItemsManager.jsx @@ -1,9 +1,11 @@ import { useCallback, useEffect, useState } from "react"; import { + createAvailableItem, deleteAvailableItem, getAvailableItems, updateAvailableItem, } from "../../api/availableItems"; +import { getLocationZones } from "../../api/stores"; import useActionToast from "../../hooks/useActionToast"; import getApiErrorMessage from "../../lib/getApiErrorMessage"; import AvailableItemEditorModal from "../modals/AvailableItemEditorModal"; @@ -22,6 +24,7 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin const toast = useActionToast(); const [isOpen, setIsOpen] = useState(false); const [items, setItems] = useState([]); + const [zones, setZones] = useState([]); const [catalogReady, setCatalogReady] = useState(true); const [catalogMessage, setCatalogMessage] = useState(""); const [query, setQuery] = useState(""); @@ -53,13 +56,29 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin } }, [householdId, query, store?.id, toast]); + const loadZones = useCallback(async () => { + if (!householdId || !store?.id) { + setZones([]); + return; + } + + try { + const response = await getLocationZones(householdId, store.id); + setZones(response.data?.zones || []); + } catch (error) { + console.error("Failed to load location zones:", error); + setZones([]); + } + }, [householdId, store?.id]); + useEffect(() => { if (!isOpen) { return; } loadItems(query); - }, [isOpen, query, loadItems]); + loadZones(); + }, [isOpen, query, loadItems, loadZones]); const closeManager = () => { setIsOpen(false); @@ -76,8 +95,16 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin } try { - await updateAvailableItem(householdId, store.id, editorItem.item_id, payload); - toast.success("Updated store item", `Updated ${editorItem.item_name} for ${store.name}`); + if (editorItem?.item_id) { + await updateAvailableItem(householdId, store.id, editorItem.item_id, payload); + toast.success("Updated store item", `Updated ${editorItem.item_name} for ${store.display_name || store.name}`); + } else { + const response = await createAvailableItem(householdId, store.id, payload); + toast.success( + "Created store item", + `Created ${response.data?.item?.item_name || payload.itemName} for ${store.display_name || store.name}` + ); + } setShowEditor(false); setEditorItem(null); await loadItems(query); @@ -95,7 +122,7 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin try { await deleteAvailableItem(householdId, store.id, pendingDeleteItem.item_id); - toast.success("Deleted store item", `Deleted ${pendingDeleteItem.item_name} from ${store.name}`); + toast.success("Deleted store item", `Deleted ${pendingDeleteItem.item_name} from ${store.display_name || store.name}`); setPendingDeleteItem(null); await loadItems(query); } catch (error) { @@ -104,10 +131,6 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin } }; - if (!isAdmin) { - return null; - } - return ( <>
@@ -209,13 +243,15 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin > Edit Settings - + {isAdmin ? ( + + ) : null}
@@ -232,6 +268,7 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin { setShowEditor(false); setEditorItem(null); @@ -244,7 +281,7 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin title={pendingDeleteItem ? `Delete ${pendingDeleteItem.item_name}?` : "Delete item?"} description={ pendingDeleteItem - ? `Slide to confirm. This permanently deletes ${pendingDeleteItem.item_name} from ${store.name} for this household, including current list entries and history.` + ? `Slide to confirm. This permanently deletes ${pendingDeleteItem.item_name} from ${store.display_name || store.name} for this household, including current list entries and history.` : "" } confirmLabel="Delete Item" diff --git a/frontend/src/components/modals/AddItemWithDetailsModal.jsx b/frontend/src/components/modals/AddItemWithDetailsModal.jsx index fab0ab9..899b99c 100644 --- a/frontend/src/components/modals/AddItemWithDetailsModal.jsx +++ b/frontend/src/components/modals/AddItemWithDetailsModal.jsx @@ -4,7 +4,7 @@ import ClassificationSection from "../forms/ClassificationSection"; import useActionToast from "../../hooks/useActionToast"; import ImageUploadSection from "../forms/ImageUploadSection"; -export default function AddItemWithDetailsModal({ itemName, onConfirm, onSkip, onCancel }) { +export default function AddItemWithDetailsModal({ itemName, zones = [], onConfirm, onCancel }) { const toast = useActionToast(); const [selectedImage, setSelectedImage] = useState(null); const [imagePreview, setImagePreview] = useState(null); @@ -47,15 +47,12 @@ export default function AddItemWithDetailsModal({ itemName, onConfirm, onSkip, o onConfirm(selectedImage, classification); }; - const handleSkip = () => { - onSkip(); - }; - return (
e.stopPropagation()}> -

Add Details for "{itemName}"

-

Add an image and classification to help organize your list

+
+ {itemName} +
{/* Image Section */}
@@ -63,6 +60,9 @@ export default function AddItemWithDetailsModal({ itemName, onConfirm, onSkip, o imagePreview={imagePreview} onImageChange={handleImageChange} onImageRemove={handleImageRemove} + title={null} + cameraLabel="Use Image" + galleryLabel="Choose Photo" />
@@ -75,6 +75,8 @@ export default function AddItemWithDetailsModal({ itemName, onConfirm, onSkip, o onItemTypeChange={handleItemTypeChange} onItemGroupChange={setItemGroup} onZoneChange={setZone} + zones={zones} + title={null} fieldClass="add-item-details-field" selectClass="add-item-details-select" /> @@ -85,9 +87,6 @@ export default function AddItemWithDetailsModal({ itemName, onConfirm, onSkip, o - diff --git a/frontend/src/components/modals/AvailableItemEditorModal.jsx b/frontend/src/components/modals/AvailableItemEditorModal.jsx index 582a0c6..c682b93 100644 --- a/frontend/src/components/modals/AvailableItemEditorModal.jsx +++ b/frontend/src/components/modals/AvailableItemEditorModal.jsx @@ -13,7 +13,7 @@ function buildPreview(item) { return `data:${mimeType};base64,${item.item_image}`; } -export default function AvailableItemEditorModal({ isOpen, item = null, onCancel, onSave }) { +export default function AvailableItemEditorModal({ isOpen, item = null, zones = [], onCancel, onSave }) { const toast = useActionToast(); const [itemName, setItemName] = useState(""); const [itemType, setItemType] = useState(""); @@ -136,6 +136,7 @@ export default function AvailableItemEditorModal({ isOpen, item = null, onCancel onItemTypeChange={handleItemTypeChange} onItemGroupChange={setItemGroup} onZoneChange={setZone} + zones={zones} fieldClass="available-item-editor-field" selectClass="available-item-editor-select" title="Store Classification (Optional)" diff --git a/frontend/src/components/modals/EditItemModal.jsx b/frontend/src/components/modals/EditItemModal.jsx index b56b236..a2c6339 100644 --- a/frontend/src/components/modals/EditItemModal.jsx +++ b/frontend/src/components/modals/EditItemModal.jsx @@ -4,7 +4,7 @@ import useActionToast from "../../hooks/useActionToast"; import "../../styles/components/EditItemModal.css"; import AddImageModal from "./AddImageModal"; -export default function EditItemModal({ item, onSave, onCancel, onImageUpdate }) { +export default function EditItemModal({ item, zones = [], onSave, onCancel, onImageUpdate }) { const toast = useActionToast(); const [itemName, setItemName] = useState(item.item_name || ""); const [quantity, setQuantity] = useState(item.quantity || 1); @@ -89,6 +89,9 @@ export default function EditItemModal({ item, onSave, onCancel, onImageUpdate }) }; const availableGroups = itemType ? (ITEM_GROUPS[itemType] || []) : []; + const zoneOptions = Array.isArray(zones) && zones.length > 0 + ? zones.map((candidateZone) => candidateZone.name || candidateZone).filter(Boolean) + : getZoneValues(); return (
@@ -172,7 +175,7 @@ export default function EditItemModal({ item, onSave, onCancel, onImageUpdate }) className="edit-modal-select" > - {getZoneValues().map((candidateZone) => ( + {zoneOptions.map((candidateZone) => ( diff --git a/frontend/src/components/store/StoreTabs.jsx b/frontend/src/components/store/StoreTabs.jsx index 4da17e3..59c6691 100644 --- a/frontend/src/components/store/StoreTabs.jsx +++ b/frontend/src/components/store/StoreTabs.jsx @@ -25,7 +25,7 @@ export default function StoreTabs() { onClick={() => setActiveStore(store)} disabled={loading} > - {store.name} + {store.display_name || store.name} ))}
diff --git a/frontend/src/context/HouseholdContext.jsx b/frontend/src/context/HouseholdContext.jsx index cedda4f..9171bae 100644 --- a/frontend/src/context/HouseholdContext.jsx +++ b/frontend/src/context/HouseholdContext.jsx @@ -1,5 +1,6 @@ import { createContext, useCallback, useContext, useEffect, useState } from 'react'; import { createHousehold as createHouseholdApi, getUserHouseholds } from '../api/households'; +import { isTransientApiError } from '../api/offlineCache'; import { AuthContext } from './AuthContext'; const ACTIVE_HOUSEHOLD_STORAGE_KEY = 'activeHouseholdId'; @@ -43,9 +44,15 @@ export const HouseholdProvider = ({ children }) => { } } catch (err) { console.error('[HouseholdContext] Failed to load households:', err); - setError(err.response?.data?.message || 'Failed to load households'); - setHouseholds([]); - clearActiveHousehold(); + setError( + err.response?.data?.error?.message || + err.response?.data?.message || + 'Failed to load households' + ); + if (!isTransientApiError(err)) { + setHouseholds([]); + clearActiveHousehold(); + } } finally { setLoading(false); setHasLoaded(true); diff --git a/frontend/src/context/SettingsContext.jsx b/frontend/src/context/SettingsContext.jsx index 59dcf90..d3ebf40 100644 --- a/frontend/src/context/SettingsContext.jsx +++ b/frontend/src/context/SettingsContext.jsx @@ -8,7 +8,6 @@ const DEFAULT_SETTINGS = { compactView: false, // List Display - defaultSortMode: "zone", showRecentlyBought: true, recentlyBoughtCount: 10, recentlyBoughtCollapsed: false, @@ -22,6 +21,17 @@ const DEFAULT_SETTINGS = { debugMode: false, }; +const SETTINGS_KEYS = Object.keys(DEFAULT_SETTINGS); + +function normalizeSettings(savedSettings = {}) { + return SETTINGS_KEYS.reduce((normalized, key) => { + normalized[key] = Object.prototype.hasOwnProperty.call(savedSettings, key) + ? savedSettings[key] + : DEFAULT_SETTINGS[key]; + return normalized; + }, {}); +} + export const SettingsContext = createContext({ settings: DEFAULT_SETTINGS, @@ -48,7 +58,9 @@ export const SettingsProvider = ({ children }) => { if (savedSettings) { try { const parsed = JSON.parse(savedSettings); - setSettings({ ...DEFAULT_SETTINGS, ...parsed }); + const normalized = normalizeSettings(parsed); + setSettings(normalized); + localStorage.setItem(storageKey, JSON.stringify(normalized)); } catch (error) { console.error("Failed to parse settings:", error); setSettings(DEFAULT_SETTINGS); @@ -88,7 +100,7 @@ export const SettingsProvider = ({ children }) => { const updateSettings = (newSettings) => { if (!username) return; - const updated = { ...settings, ...newSettings }; + const updated = normalizeSettings({ ...settings, ...newSettings }); setSettings(updated); const storageKey = `user_preferences_${username}`; diff --git a/frontend/src/context/StoreContext.jsx b/frontend/src/context/StoreContext.jsx index 5a97a03..26664e7 100644 --- a/frontend/src/context/StoreContext.jsx +++ b/frontend/src/context/StoreContext.jsx @@ -1,15 +1,19 @@ import { createContext, useContext, useEffect, useState } from 'react'; -import { getHouseholdStores } from '../api/stores'; +import { isTransientApiError } from '../api/offlineCache'; +import { getHouseholdStores, getLocationZones } from '../api/stores'; import { AuthContext } from './AuthContext'; import { HouseholdContext } from './HouseholdContext'; export const StoreContext = createContext({ stores: [], activeStore: null, + zones: [], loading: false, + zonesLoading: false, error: null, setActiveStore: () => { }, refreshStores: () => { }, + refreshZones: () => { }, }); export const StoreProvider = ({ children }) => { @@ -17,7 +21,9 @@ export const StoreProvider = ({ children }) => { const { activeHousehold } = useContext(HouseholdContext); const [stores, setStores] = useState([]); const [activeStore, setActiveStoreState] = useState(null); + const [zones, setZones] = useState([]); const [loading, setLoading] = useState(false); + const [zonesLoading, setZonesLoading] = useState(false); const [error, setError] = useState(null); // Load stores when household changes @@ -28,6 +34,7 @@ export const StoreProvider = ({ children }) => { // Clear state when logged out or no household setStores([]); setActiveStoreState(null); + setZones([]); } }, [token, activeHousehold?.id]); @@ -40,7 +47,7 @@ export const StoreProvider = ({ children }) => { const savedStoreId = localStorage.getItem(storageKey); if (savedStoreId) { - const store = stores.find(s => s.id === parseInt(savedStoreId)); + const store = stores.find(s => String(s.id) === String(savedStoreId)); if (store) { console.log('[StoreContext] Found saved store:', store); setActiveStoreState(store); @@ -55,6 +62,14 @@ export const StoreProvider = ({ children }) => { localStorage.setItem(storageKey, defaultStore.id); }, [stores, activeHousehold]); + useEffect(() => { + if (token && activeHousehold?.id && activeStore?.id) { + loadZones(); + } else { + setZones([]); + } + }, [token, activeHousehold?.id, activeStore?.id]); + const loadStores = async () => { if (!token || !activeHousehold) return; @@ -67,8 +82,15 @@ export const StoreProvider = ({ children }) => { setStores(response.data); } catch (err) { console.error('[StoreContext] Failed to load stores:', err); - setError(err.response?.data?.message || 'Failed to load stores'); - setStores([]); + setError( + err.response?.data?.error?.message || + err.response?.data?.message || + 'Failed to load stores' + ); + if (!isTransientApiError(err)) { + setStores([]); + setActiveStoreState(null); + } } finally { setLoading(false); } @@ -78,17 +100,37 @@ export const StoreProvider = ({ children }) => { setActiveStoreState(store); if (store && activeHousehold) { const storageKey = `activeStoreId_${activeHousehold.id}`; - localStorage.setItem(storageKey, store.id); + localStorage.setItem(storageKey, String(store.id)); + } + }; + + const loadZones = async () => { + if (!token || !activeHousehold?.id || !activeStore?.id) return; + + setZonesLoading(true); + try { + const response = await getLocationZones(activeHousehold.id, activeStore.id); + setZones(response.data?.zones || []); + } catch (err) { + console.error('[StoreContext] Failed to load zones:', err); + if (!isTransientApiError(err)) { + setZones([]); + } + } finally { + setZonesLoading(false); } }; const value = { stores, activeStore, + zones, loading, + zonesLoading, error, setActiveStore, refreshStores: loadStores, + refreshZones: loadZones, }; return ( diff --git a/frontend/src/pages/GroceryList.jsx b/frontend/src/pages/GroceryList.jsx index 20cad76..c0316fd 100644 --- a/frontend/src/pages/GroceryList.jsx +++ b/frontend/src/pages/GroceryList.jsx @@ -11,7 +11,7 @@ import { updateItemWithClassification } from "../api/list"; import { getHouseholdMembers } from "../api/households"; -import SortDropdown from "../components/common/SortDropdown"; +import ListSearchInput from "../components/common/ListSearchInput"; import AddItemForm from "../components/forms/AddItemForm"; import NoHouseholdState from "../components/household/NoHouseholdState"; import GroceryListItem from "../components/items/GroceryListItem"; @@ -33,40 +33,54 @@ import getApiErrorMessage from "../lib/getApiErrorMessage"; import "../styles/pages/GroceryList.css"; import { findSimilarItems } from "../utils/stringSimilarity"; -function sortItemsForMode(items, sortMode) { +function sortItemsByZone(items) { 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); + 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 aZoneIndex = Number.isInteger(a.zone_sort_order) ? a.zone_sort_order : ZONE_FLOW.indexOf(a.zone); + const bZoneIndex = Number.isInteger(b.zone_sort_order) ? b.zone_sort_order : 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 zoneCompare = aIndex - bIndex; + if (zoneCompare !== 0) return zoneCompare; - const typeCompare = (a.item_type || "").localeCompare(b.item_type || ""); - if (typeCompare !== 0) return typeCompare; + 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; + const groupCompare = (a.item_group || "").localeCompare(b.item_group || ""); + if (groupCompare !== 0) return groupCompare; - return a.item_name.localeCompare(b.item_name); - }); - } + return a.item_name.localeCompare(b.item_name); + }); return sorted; } +function getSearchableItemText(item) { + return [ + item.item_name, + item.item_type, + item.item_group, + item.zone, + ...(Array.isArray(item.added_by_users) ? item.added_by_users : []), + ] + .filter(Boolean) + .join(" ") + .toLowerCase(); +} + +function filterItemsForSearch(items, query) { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) return items; + + return items.filter((item) => getSearchableItemText(item).includes(normalizedQuery)); +} + function getNextModalItem(sortedItems, currentIndex, excludedItemId) { const remainingItems = sortedItems.filter((item) => item.id !== excludedItemId); @@ -79,7 +93,6 @@ function getNextModalItem(sortedItems, currentIndex, excludedItemId) { export default function GroceryList() { - const pageTitle = "Grocery List"; const { userId } = useContext(AuthContext); const { activeHousehold, @@ -87,7 +100,7 @@ export default function GroceryList() { loading: householdLoading, hasLoaded: householdsLoaded } = useContext(HouseholdContext); - const { activeStore, stores, loading: storeLoading } = useContext(StoreContext); + const { activeStore, stores, zones, loading: storeLoading } = useContext(StoreContext); const { settings } = useContext(SettingsContext); const toast = useActionToast(); const { enqueueImageUpload } = useUploadQueue(); @@ -103,7 +116,7 @@ export default function GroceryList() { const [recentlyBoughtItems, setRecentlyBoughtItems] = useState([]); const [householdMembers, setHouseholdMembers] = useState([]); const [recentlyBoughtDisplayCount, setRecentlyBoughtDisplayCount] = useState(settings.recentlyBoughtCount); - const [sortMode, setSortMode] = useState(settings.defaultSortMode); + const [listSearchQuery, setListSearchQuery] = useState(""); const [suggestions, setSuggestions] = useState([]); const [loading, setLoading] = useState(true); const [buttonText, setButtonText] = useState("Add Item"); @@ -238,10 +251,13 @@ export default function GroceryList() { })); }; - // === Sorted Items Computation === + // === Visible Items Computation === + const normalizedListSearchQuery = listSearchQuery.trim().toLowerCase(); + const isListSearchActive = normalizedListSearchQuery.length > 0; + const sortedItems = useMemo(() => { - return sortItemsForMode(items, sortMode); - }, [items, sortMode]); + return sortItemsByZone(filterItemsForSearch(items, normalizedListSearchQuery)); + }, [items, normalizedListSearchQuery]); const visibleRecentlyBoughtItems = useMemo( () => recentlyBoughtItems.slice(0, recentlyBoughtDisplayCount), @@ -538,45 +554,9 @@ export default function GroceryList() { } }, [activeHousehold?.id, activeStore?.id, pendingItem, enqueueImageUpload, toast]); - 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, - null, - pendingItem.addedForUserId || 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([]); - setButtonText("Add Item"); - - if (newItem) { - setItems(prevItems => [...prevItems, newItem]); - toast.success("Added item", `Added item ${newItem.item_name || pendingItem.itemName}`); - } - } catch (error) { - console.error("Failed to add item:", error); - const message = getApiErrorMessage(error, "Failed to add item"); - toast.error("Add item failed", `Add item failed: ${message}`); - } - }, [activeHousehold?.id, activeStore?.id, pendingItem, toast]); - - - const handleAddDetailsCancel = useCallback(() => { - setShowAddDetailsModal(false); - setPendingItem(null); + const handleAddDetailsCancel = useCallback(() => { + setShowAddDetailsModal(false); + setPendingItem(null); setSuggestions([]); setButtonText("Add Item"); }, []); @@ -614,7 +594,9 @@ export default function GroceryList() { setItems(nextItems); - const nextSortedItems = sortItemsForMode(nextItems, sortMode); + const nextSortedItems = sortItemsByZone( + filterItemsForSearch(nextItems, normalizedListSearchQuery) + ); const nextModalItem = getNextModalItem(nextSortedItems, resolvedIndex, item.id); setBuyModalState( @@ -633,7 +615,7 @@ export default function GroceryList() { const message = getApiErrorMessage(error, "Failed to mark item as bought"); toast.error("Mark item bought failed", `Mark item bought failed: ${message}`); } - }, [activeHousehold?.id, activeStore?.id, buyModalState, items, sortMode, sortedItems, toast]); + }, [activeHousehold?.id, activeStore?.id, buyModalState, items, normalizedListSearchQuery, sortedItems, toast]); const openActiveBuyModal = useCallback((item) => { setBuyModalState({ @@ -772,7 +754,6 @@ export default function GroceryList() { return (
-

{pageTitle}

Loading households...

@@ -785,7 +766,6 @@ export default function GroceryList() { return (
-

{pageTitle}

@@ -796,8 +776,7 @@ export default function GroceryList() { return (
-

{pageTitle}

-

+

Loading stores...

@@ -809,8 +788,7 @@ export default function GroceryList() { return (
-

{pageTitle}

-
+

No stores found

This household doesn’t have any stores yet. @@ -837,8 +815,7 @@ export default function GroceryList() { return (

-

{pageTitle}

- +

Loading stores...

@@ -851,8 +828,7 @@ export default function GroceryList() { return (
-

{pageTitle}

- +

Loading grocery list...

@@ -863,9 +839,7 @@ export default function GroceryList() { return (
-

{pageTitle}

- - + {canEditList && ( )} - - - {sortMode === "zone" ? ( + + + {sortedItems.length === 0 ? ( +

+ {isListSearchActive + ? `No list items match "${listSearchQuery.trim()}".` + : "No items in this store yet."} +

+ ) : ( (() => { const grouped = groupItemsByZone(sortedItems); return Object.keys(grouped).map(zone => { - const isCollapsed = collapsedZones[zone]; + const isCollapsed = isListSearchActive ? false : collapsedZones[zone]; const itemCount = grouped[zone].length; return (
@@ -923,25 +908,7 @@ export default function GroceryList() { ); }); })() - ) : ( -
    - {sortedItems.map((item) => ( - - ))} -
- )} + )} {recentlyBoughtItems.length > 0 && settings.showRecentlyBought && ( <> @@ -992,11 +959,11 @@ export default function GroceryList() { {showAddDetailsModal && pendingItem && ( + itemName={pendingItem.itemName} + zones={zones} + onConfirm={handleAddWithDetails} + onCancel={handleAddDetailsCancel} + /> )} {showSimilarModal && similarItemSuggestion && ( @@ -1012,8 +979,9 @@ export default function GroceryList() { {showEditModal && editingItem && ( )} diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index d4fa7cc..4f28963 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -137,12 +137,6 @@ export default function Settings() { updateSettings({ [key]: parseInt(value, 10) }); }; - - const handleSelectChange = (key, value) => { - updateSettings({ [key]: value }); - }; - - const handleReset = () => { if (window.confirm("Reset all settings to defaults?")) { resetSettings(); @@ -252,24 +246,6 @@ export default function Settings() {

List Display

-
- - -

- Your preferred sorting method when opening the list -

-
-