feat: improve grocery list interactions
This commit is contained in:
parent
4c8c197e17
commit
61120dc270
@ -7,39 +7,49 @@ function GroceryListItem({
|
|||||||
onOpenBuyModal,
|
onOpenBuyModal,
|
||||||
onImageAdded,
|
onImageAdded,
|
||||||
onLongPress,
|
onLongPress,
|
||||||
|
onSkip,
|
||||||
compact = false
|
compact = false
|
||||||
}) {
|
}) {
|
||||||
const [showAddImageModal, setShowAddImageModal] = useState(false);
|
const [showAddImageModal, setShowAddImageModal] = useState(false);
|
||||||
|
const [actionsOpen, setActionsOpen] = useState(false);
|
||||||
|
|
||||||
const longPressTimer = useRef(null);
|
const longPressTimer = useRef(null);
|
||||||
const pressStartPos = useRef({ x: 0, y: 0 });
|
const pressStartPos = useRef({ x: 0, y: 0 });
|
||||||
|
|
||||||
const handleTouchStart = (e) => {
|
const hasActions = Boolean(onClick || onLongPress || onImageAdded || onSkip);
|
||||||
const touch = e.touches[0];
|
|
||||||
|
const clearLongPressTimer = () => {
|
||||||
|
clearTimeout(longPressTimer.current);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchStart = (event) => {
|
||||||
|
const touch = event.touches[0];
|
||||||
pressStartPos.current = { x: touch.clientX, y: touch.clientY };
|
pressStartPos.current = { x: touch.clientX, y: touch.clientY };
|
||||||
|
|
||||||
longPressTimer.current = setTimeout(() => {
|
longPressTimer.current = setTimeout(() => {
|
||||||
if (onLongPress) {
|
if (onLongPress) {
|
||||||
onLongPress(item);
|
onLongPress(item);
|
||||||
}
|
}
|
||||||
}, 500); // 500ms for long press
|
}, 500);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTouchMove = (e) => {
|
const handleTouchMove = (event) => {
|
||||||
// Cancel long press if finger moves too much
|
const touch = event.touches[0];
|
||||||
const touch = e.touches[0];
|
const deltaX = touch.clientX - pressStartPos.current.x;
|
||||||
const moveDistance = Math.sqrt(
|
const deltaY = touch.clientY - pressStartPos.current.y;
|
||||||
Math.pow(touch.clientX - pressStartPos.current.x, 2) +
|
const moveDistance = Math.sqrt(Math.pow(deltaX, 2) + Math.pow(deltaY, 2));
|
||||||
Math.pow(touch.clientY - pressStartPos.current.y, 2)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (moveDistance > 10) {
|
if (moveDistance > 10) {
|
||||||
clearTimeout(longPressTimer.current);
|
clearLongPressTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasActions && Math.abs(deltaX) > 45 && Math.abs(deltaX) > Math.abs(deltaY) * 1.25) {
|
||||||
|
setActionsOpen(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTouchEnd = () => {
|
const handleTouchEnd = () => {
|
||||||
clearTimeout(longPressTimer.current);
|
clearLongPressTimer();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseDown = () => {
|
const handleMouseDown = () => {
|
||||||
@ -51,24 +61,42 @@ function GroceryListItem({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
const handleMouseUp = () => {
|
||||||
clearTimeout(longPressTimer.current);
|
clearLongPressTimer();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseLeave = () => {
|
const handleMouseLeave = () => {
|
||||||
clearTimeout(longPressTimer.current);
|
clearLongPressTimer();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleItemClick = () => {
|
const handleItemClick = () => {
|
||||||
|
if (actionsOpen) {
|
||||||
|
setActionsOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (onClick) {
|
if (onClick) {
|
||||||
onClick(item);
|
onClick(item);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImageClick = (e) => {
|
const handleItemKeyDown = (event) => {
|
||||||
e.stopPropagation(); // Prevent triggering the bought action
|
if (event.key === "Escape" && actionsOpen) {
|
||||||
|
setActionsOpen(false);
|
||||||
|
event.stopPropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((event.key === "Enter" || event.key === " ") && onClick) {
|
||||||
|
event.preventDefault();
|
||||||
|
handleItemClick();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageClick = (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
if (item.item_image && onOpenBuyModal) {
|
if (item.item_image && onOpenBuyModal) {
|
||||||
onOpenBuyModal(item);
|
onOpenBuyModal(item);
|
||||||
} else {
|
} else if (onImageAdded) {
|
||||||
setShowAddImageModal(true);
|
setShowAddImageModal(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -80,6 +108,52 @@ function GroceryListItem({
|
|||||||
setShowAddImageModal(false);
|
setShowAddImageModal(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleActionToggle = (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
clearLongPressTimer();
|
||||||
|
setActionsOpen((prev) => !prev);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBoughtAction = (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
clearLongPressTimer();
|
||||||
|
setActionsOpen(false);
|
||||||
|
if (onClick) {
|
||||||
|
onClick(item);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditAction = (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
clearLongPressTimer();
|
||||||
|
setActionsOpen(false);
|
||||||
|
if (onLongPress) {
|
||||||
|
onLongPress(item);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageAction = (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
clearLongPressTimer();
|
||||||
|
setActionsOpen(false);
|
||||||
|
if (onImageAdded) {
|
||||||
|
setShowAddImageModal(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSkipAction = (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
clearLongPressTimer();
|
||||||
|
setActionsOpen(false);
|
||||||
|
if (onSkip) {
|
||||||
|
onSkip(item);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopActionEvent = (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
const imageUrl = item.item_image && item.image_mime_type
|
const imageUrl = item.item_image && item.image_mime_type
|
||||||
? `data:${item.image_mime_type};base64,${item.item_image}`
|
? `data:${item.image_mime_type};base64,${item.item_image}`
|
||||||
: null;
|
: null;
|
||||||
@ -99,37 +173,39 @@ function GroceryListItem({
|
|||||||
|
|
||||||
if (diffDays < 7) {
|
if (diffDays < 7) {
|
||||||
return `${diffDays}d ago`;
|
return `${diffDays}d ago`;
|
||||||
} else if (diffDays < 30) {
|
}
|
||||||
|
if (diffDays < 30) {
|
||||||
const weeks = Math.floor(diffDays / 7);
|
const weeks = Math.floor(diffDays / 7);
|
||||||
return `${weeks}w ago`;
|
return `${weeks}w ago`;
|
||||||
} else {
|
|
||||||
const months = Math.floor(diffDays / 30);
|
|
||||||
return `${months}m ago`;
|
|
||||||
}
|
}
|
||||||
|
const months = Math.floor(diffDays / 30);
|
||||||
|
return `${months}m ago`;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<li
|
<li
|
||||||
className={`glist-li ${compact ? 'compact' : ''}`}
|
className={`glist-li ${compact ? "compact" : ""} ${actionsOpen ? "actions-open" : ""}`}
|
||||||
onClick={handleItemClick}
|
onClick={handleItemClick}
|
||||||
|
onKeyDown={handleItemKeyDown}
|
||||||
onTouchStart={handleTouchStart}
|
onTouchStart={handleTouchStart}
|
||||||
onTouchMove={handleTouchMove}
|
onTouchMove={handleTouchMove}
|
||||||
onTouchEnd={handleTouchEnd}
|
onTouchEnd={handleTouchEnd}
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
onMouseUp={handleMouseUp}
|
onMouseUp={handleMouseUp}
|
||||||
onMouseLeave={handleMouseLeave}
|
onMouseLeave={handleMouseLeave}
|
||||||
|
tabIndex={onClick ? 0 : undefined}
|
||||||
>
|
>
|
||||||
<div className="glist-item-layout">
|
<div className="glist-item-layout">
|
||||||
<div
|
<div
|
||||||
className={`glist-item-image ${item.item_image ? "has-image" : ""}`}
|
className={`glist-item-image ${item.item_image ? "has-image" : ""}`}
|
||||||
onClick={handleImageClick}
|
onClick={handleImageClick}
|
||||||
style={{ cursor: "pointer" }}
|
style={{ cursor: onImageAdded || (item.item_image && onOpenBuyModal) ? "pointer" : "default" }}
|
||||||
>
|
>
|
||||||
{item.item_image ? (
|
{item.item_image ? (
|
||||||
<img src={imageUrl} alt={item.item_name} />
|
<img src={imageUrl} alt={item.item_name} />
|
||||||
) : (
|
) : (
|
||||||
<span>📦</span>
|
<span className="glist-item-image-placeholder">Img</span>
|
||||||
)}
|
)}
|
||||||
<span className="glist-item-quantity">x{item.quantity}</span>
|
<span className="glist-item-quantity">x{item.quantity}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -139,12 +215,55 @@ function GroceryListItem({
|
|||||||
</div>
|
</div>
|
||||||
{addedByUsers.length > 0 && (
|
{addedByUsers.length > 0 && (
|
||||||
<div className="glist-item-users">
|
<div className="glist-item-users">
|
||||||
{item.last_added_on && `${getTimeAgo(item.last_added_on)} -- `}
|
{item.last_added_on && `${getTimeAgo(item.last_added_on)} - `}
|
||||||
{addedByUsers.join(" | ")}
|
{addedByUsers.join(" | ")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{hasActions && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="glist-item-action-toggle"
|
||||||
|
aria-label={`Actions for ${item.item_name}`}
|
||||||
|
aria-expanded={actionsOpen}
|
||||||
|
onClick={handleActionToggle}
|
||||||
|
onMouseDown={stopActionEvent}
|
||||||
|
onTouchStart={stopActionEvent}
|
||||||
|
>
|
||||||
|
...
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{actionsOpen && (
|
||||||
|
<div
|
||||||
|
className="glist-item-action-tray"
|
||||||
|
onClick={stopActionEvent}
|
||||||
|
onMouseDown={stopActionEvent}
|
||||||
|
onTouchStart={stopActionEvent}
|
||||||
|
>
|
||||||
|
{onClick && (
|
||||||
|
<button type="button" className="glist-item-action" onClick={handleBoughtAction}>
|
||||||
|
Bought
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onLongPress && (
|
||||||
|
<button type="button" className="glist-item-action" onClick={handleEditAction}>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onImageAdded && (
|
||||||
|
<button type="button" className="glist-item-action" onClick={handleImageAction}>
|
||||||
|
{item.item_image ? "Image" : "Add image"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onSkip && (
|
||||||
|
<button type="button" className="glist-item-action secondary" onClick={handleSkipAction}>
|
||||||
|
Skip
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
{showAddImageModal && (
|
{showAddImageModal && (
|
||||||
@ -158,22 +277,19 @@ function GroceryListItem({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Memoize component to prevent re-renders when props haven't changed
|
export default memo(GroceryListItem, (prevProps, nextProps) => (
|
||||||
export default memo(GroceryListItem, (prevProps, nextProps) => {
|
prevProps.item.id === nextProps.item.id &&
|
||||||
// Only re-render if the item data or handlers have changed
|
prevProps.item.item_name === nextProps.item.item_name &&
|
||||||
return (
|
prevProps.item.quantity === nextProps.item.quantity &&
|
||||||
prevProps.item.id === nextProps.item.id &&
|
prevProps.item.item_image === nextProps.item.item_image &&
|
||||||
prevProps.item.item_name === nextProps.item.item_name &&
|
prevProps.item.bought === nextProps.item.bought &&
|
||||||
prevProps.item.quantity === nextProps.item.quantity &&
|
prevProps.item.last_added_on === nextProps.item.last_added_on &&
|
||||||
prevProps.item.item_image === nextProps.item.item_image &&
|
prevProps.item.zone === nextProps.item.zone &&
|
||||||
prevProps.item.bought === nextProps.item.bought &&
|
prevProps.item.added_by_users?.join(",") === nextProps.item.added_by_users?.join(",") &&
|
||||||
prevProps.item.last_added_on === nextProps.item.last_added_on &&
|
prevProps.onClick === nextProps.onClick &&
|
||||||
prevProps.item.zone === nextProps.item.zone &&
|
prevProps.onOpenBuyModal === nextProps.onOpenBuyModal &&
|
||||||
prevProps.item.added_by_users?.join(',') === nextProps.item.added_by_users?.join(',') &&
|
prevProps.onImageAdded === nextProps.onImageAdded &&
|
||||||
prevProps.onClick === nextProps.onClick &&
|
prevProps.onLongPress === nextProps.onLongPress &&
|
||||||
prevProps.onOpenBuyModal === nextProps.onOpenBuyModal &&
|
prevProps.onSkip === nextProps.onSkip &&
|
||||||
prevProps.onImageAdded === nextProps.onImageAdded &&
|
prevProps.compact === nextProps.compact
|
||||||
prevProps.onLongPress === nextProps.onLongPress &&
|
));
|
||||||
prevProps.compact === nextProps.compact
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|||||||
@ -1,16 +1,15 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import "../../styles/components/AddItemWithDetailsModal.css";
|
import "../../styles/components/AddItemWithDetailsModal.css";
|
||||||
import ClassificationSection from "../forms/ClassificationSection";
|
import { getZoneValues } from "../../constants/classifications";
|
||||||
import useActionToast from "../../hooks/useActionToast";
|
|
||||||
import ImageUploadSection from "../forms/ImageUploadSection";
|
import ImageUploadSection from "../forms/ImageUploadSection";
|
||||||
|
|
||||||
export default function AddItemWithDetailsModal({ itemName, zones = [], onConfirm, onCancel }) {
|
export default function AddItemWithDetailsModal({ itemName, zones = [], onConfirm, onCancel }) {
|
||||||
const toast = useActionToast();
|
|
||||||
const [selectedImage, setSelectedImage] = useState(null);
|
const [selectedImage, setSelectedImage] = useState(null);
|
||||||
const [imagePreview, setImagePreview] = useState(null);
|
const [imagePreview, setImagePreview] = useState(null);
|
||||||
const [itemType, setItemType] = useState("");
|
|
||||||
const [itemGroup, setItemGroup] = useState("");
|
|
||||||
const [zone, setZone] = useState("");
|
const [zone, setZone] = useState("");
|
||||||
|
const zoneOptions = Array.isArray(zones) && zones.length > 0
|
||||||
|
? zones.map((candidateZone) => candidateZone.name || candidateZone).filter(Boolean)
|
||||||
|
: getZoneValues();
|
||||||
|
|
||||||
const handleImageChange = (file) => {
|
const handleImageChange = (file) => {
|
||||||
setSelectedImage(file);
|
setSelectedImage(file);
|
||||||
@ -26,22 +25,12 @@ export default function AddItemWithDetailsModal({ itemName, zones = [], onConfir
|
|||||||
setImagePreview(null);
|
setImagePreview(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleItemTypeChange = (newType) => {
|
|
||||||
setItemType(newType);
|
|
||||||
setItemGroup(""); // Reset group when type changes
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfirm = () => {
|
const handleConfirm = () => {
|
||||||
if (itemType && !itemGroup) {
|
const hasClassificationDetails = Boolean(zone);
|
||||||
toast.error("Add item failed", `Add item failed: Select an item group for ${itemName}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasClassificationDetails = Boolean(itemType || itemGroup || zone);
|
|
||||||
const classification = hasClassificationDetails ? {
|
const classification = hasClassificationDetails ? {
|
||||||
item_type: itemType,
|
item_type: null,
|
||||||
item_group: itemGroup || null,
|
item_group: null,
|
||||||
zone: zone || null
|
zone
|
||||||
} : null;
|
} : null;
|
||||||
|
|
||||||
onConfirm(selectedImage, classification);
|
onConfirm(selectedImage, classification);
|
||||||
@ -68,18 +57,22 @@ export default function AddItemWithDetailsModal({ itemName, zones = [], onConfir
|
|||||||
|
|
||||||
{/* Classification Section */}
|
{/* Classification Section */}
|
||||||
<div className="add-item-details-section">
|
<div className="add-item-details-section">
|
||||||
<ClassificationSection
|
<div className="add-item-details-field add-item-details-zone-field">
|
||||||
itemType={itemType}
|
<label htmlFor="add-item-zone">Store Zone</label>
|
||||||
itemGroup={itemGroup}
|
<select
|
||||||
zone={zone}
|
id="add-item-zone"
|
||||||
onItemTypeChange={handleItemTypeChange}
|
value={zone}
|
||||||
onItemGroupChange={setItemGroup}
|
onChange={(event) => setZone(event.target.value)}
|
||||||
onZoneChange={setZone}
|
className="add-item-details-select"
|
||||||
zones={zones}
|
>
|
||||||
title={null}
|
<option value="">No zone yet</option>
|
||||||
fieldClass="add-item-details-field"
|
{zoneOptions.map((candidateZone) => (
|
||||||
selectClass="add-item-details-select"
|
<option key={candidateZone} value={candidateZone}>
|
||||||
/>
|
{candidateZone}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
|
|||||||
@ -85,15 +85,16 @@ export default function ConfirmBuyModal({
|
|||||||
onClick={handlePrev}
|
onClick={handlePrev}
|
||||||
style={{ visibility: hasPrev ? "visible" : "hidden" }}
|
style={{ visibility: hasPrev ? "visible" : "hidden" }}
|
||||||
disabled={!hasPrev || isSubmitting}
|
disabled={!hasPrev || isSubmitting}
|
||||||
|
aria-label="Previous item"
|
||||||
>
|
>
|
||||||
{"<"}
|
{"<"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="confirm-buy-image-container">
|
<div className={`confirm-buy-image-container ${imageUrl ? "has-image" : "no-image"}`}>
|
||||||
{imageUrl ? (
|
{imageUrl ? (
|
||||||
<img src={imageUrl} alt={item.item_name} className="confirm-buy-image" />
|
<img src={imageUrl} alt={item.item_name} className="confirm-buy-image" />
|
||||||
) : (
|
) : (
|
||||||
<div className="confirm-buy-image-placeholder">[ ]</div>
|
<div className="confirm-buy-image-placeholder">No image</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -102,6 +103,7 @@ export default function ConfirmBuyModal({
|
|||||||
onClick={handleNext}
|
onClick={handleNext}
|
||||||
style={{ visibility: hasNext ? "visible" : "hidden" }}
|
style={{ visibility: hasNext ? "visible" : "hidden" }}
|
||||||
disabled={!hasNext || isSubmitting}
|
disabled={!hasNext || isSubmitting}
|
||||||
|
aria-label="Next item"
|
||||||
>
|
>
|
||||||
{">"}
|
{">"}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -133,40 +133,6 @@ export default function EditItemModal({ item, zones = [], onSave, onCancel, onIm
|
|||||||
|
|
||||||
<div className="edit-modal-divider" />
|
<div className="edit-modal-divider" />
|
||||||
|
|
||||||
<div className="edit-modal-inline-field">
|
|
||||||
<label>Type</label>
|
|
||||||
<select
|
|
||||||
value={itemType}
|
|
||||||
onChange={(e) => handleItemTypeChange(e.target.value)}
|
|
||||||
className="edit-modal-select"
|
|
||||||
>
|
|
||||||
<option value="">-- Select Type --</option>
|
|
||||||
{Object.values(ITEM_TYPES).map((type) => (
|
|
||||||
<option key={type} value={type}>
|
|
||||||
{getItemTypeLabel(type)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{itemType && (
|
|
||||||
<div className="edit-modal-inline-field">
|
|
||||||
<label>Group</label>
|
|
||||||
<select
|
|
||||||
value={itemGroup}
|
|
||||||
onChange={(e) => setItemGroup(e.target.value)}
|
|
||||||
className="edit-modal-select"
|
|
||||||
>
|
|
||||||
<option value="">-- Select Group --</option>
|
|
||||||
{availableGroups.map((group) => (
|
|
||||||
<option key={group} value={group}>
|
|
||||||
{group}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="edit-modal-inline-field">
|
<div className="edit-modal-inline-field">
|
||||||
<label>Zone</label>
|
<label>Zone</label>
|
||||||
<select
|
<select
|
||||||
@ -174,7 +140,7 @@ export default function EditItemModal({ item, zones = [], onSave, onCancel, onIm
|
|||||||
onChange={(e) => setZone(e.target.value)}
|
onChange={(e) => setZone(e.target.value)}
|
||||||
className="edit-modal-select"
|
className="edit-modal-select"
|
||||||
>
|
>
|
||||||
<option value="">-- Select Zone --</option>
|
<option value="">No zone yet</option>
|
||||||
{zoneOptions.map((candidateZone) => (
|
{zoneOptions.map((candidateZone) => (
|
||||||
<option key={candidateZone} value={candidateZone}>
|
<option key={candidateZone} value={candidateZone}>
|
||||||
{candidateZone}
|
{candidateZone}
|
||||||
@ -183,6 +149,43 @@ export default function EditItemModal({ item, zones = [], onSave, onCancel, onIm
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<details className="edit-modal-advanced" open={Boolean(itemType || itemGroup)}>
|
||||||
|
<summary>More classification options</summary>
|
||||||
|
<div className="edit-modal-inline-field">
|
||||||
|
<label>Type</label>
|
||||||
|
<select
|
||||||
|
value={itemType}
|
||||||
|
onChange={(e) => handleItemTypeChange(e.target.value)}
|
||||||
|
className="edit-modal-select"
|
||||||
|
>
|
||||||
|
<option value="">No type</option>
|
||||||
|
{Object.values(ITEM_TYPES).map((type) => (
|
||||||
|
<option key={type} value={type}>
|
||||||
|
{getItemTypeLabel(type)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{itemType && (
|
||||||
|
<div className="edit-modal-inline-field">
|
||||||
|
<label>Group</label>
|
||||||
|
<select
|
||||||
|
value={itemGroup}
|
||||||
|
onChange={(e) => setItemGroup(e.target.value)}
|
||||||
|
className="edit-modal-select"
|
||||||
|
>
|
||||||
|
<option value="">No group</option>
|
||||||
|
{availableGroups.map((group) => (
|
||||||
|
<option key={group} value={group}>
|
||||||
|
{group}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</details>
|
||||||
|
|
||||||
<div className="edit-modal-divider" />
|
<div className="edit-modal-divider" />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -91,6 +91,21 @@ function getNextModalItem(sortedItems, currentIndex, excludedItemId) {
|
|||||||
return remainingItems[currentIndex] || remainingItems[0];
|
return remainingItems[currentIndex] || remainingItems[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getListItemStorageId(item) {
|
||||||
|
return String(item?.id ?? item?.item_id ?? item?.item_name ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSkippedItemIds(storageKey) {
|
||||||
|
if (!storageKey || typeof window === "undefined") return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(window.localStorage.getItem(storageKey) || "[]");
|
||||||
|
return Array.isArray(parsed) ? parsed.map(String).filter(Boolean) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export default function GroceryList() {
|
export default function GroceryList() {
|
||||||
const { userId } = useContext(AuthContext);
|
const { userId } = useContext(AuthContext);
|
||||||
@ -103,13 +118,16 @@ export default function GroceryList() {
|
|||||||
const { activeStore, stores, zones, loading: storeLoading } = useContext(StoreContext);
|
const { activeStore, stores, zones, loading: storeLoading } = useContext(StoreContext);
|
||||||
const { settings } = useContext(SettingsContext);
|
const { settings } = useContext(SettingsContext);
|
||||||
const toast = useActionToast();
|
const toast = useActionToast();
|
||||||
const { enqueueImageUpload } = useUploadQueue();
|
const { enqueueImageUpload, isOnline } = useUploadQueue();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// Get household role for permissions
|
// Get household role for permissions
|
||||||
const householdRole = activeHousehold?.role;
|
const householdRole = activeHousehold?.role;
|
||||||
const isHouseholdAdmin = ["owner", "admin"].includes(householdRole);
|
const isHouseholdAdmin = ["owner", "admin"].includes(householdRole);
|
||||||
const canEditList = Boolean(householdRole && householdRole !== "viewer");
|
const canEditList = Boolean(householdRole && householdRole !== "viewer");
|
||||||
|
const skippedStorageKey = activeHousehold?.id && activeStore?.id
|
||||||
|
? `fiddy:grocery-skipped:${activeHousehold.id}:${activeStore.id}`
|
||||||
|
: null;
|
||||||
|
|
||||||
// === State === //
|
// === State === //
|
||||||
const [items, setItems] = useState([]);
|
const [items, setItems] = useState([]);
|
||||||
@ -131,6 +149,8 @@ export default function GroceryList() {
|
|||||||
const [showConfirmAddExisting, setShowConfirmAddExisting] = useState(false);
|
const [showConfirmAddExisting, setShowConfirmAddExisting] = useState(false);
|
||||||
const [confirmAddExistingData, setConfirmAddExistingData] = useState(null);
|
const [confirmAddExistingData, setConfirmAddExistingData] = useState(null);
|
||||||
const [buyModalState, setBuyModalState] = useState(null);
|
const [buyModalState, setBuyModalState] = useState(null);
|
||||||
|
const [skippedItemIds, setSkippedItemIds] = useState([]);
|
||||||
|
const [loadedSkippedStorageKey, setLoadedSkippedStorageKey] = useState(null);
|
||||||
|
|
||||||
|
|
||||||
// === Data Loading ===
|
// === Data Loading ===
|
||||||
@ -175,6 +195,16 @@ export default function GroceryList() {
|
|||||||
setBuyModalState(null);
|
setBuyModalState(null);
|
||||||
}, [activeHousehold?.id, activeStore?.id]);
|
}, [activeHousehold?.id, activeStore?.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSkippedItemIds(readSkippedItemIds(skippedStorageKey));
|
||||||
|
setLoadedSkippedStorageKey(skippedStorageKey);
|
||||||
|
}, [skippedStorageKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!skippedStorageKey || loadedSkippedStorageKey !== skippedStorageKey) return;
|
||||||
|
window.localStorage.setItem(skippedStorageKey, JSON.stringify(skippedItemIds));
|
||||||
|
}, [loadedSkippedStorageKey, skippedItemIds, skippedStorageKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadHouseholdMembers = async () => {
|
const loadHouseholdMembers = async () => {
|
||||||
if (!activeHousehold?.id) {
|
if (!activeHousehold?.id) {
|
||||||
@ -254,10 +284,21 @@ export default function GroceryList() {
|
|||||||
// === Visible Items Computation ===
|
// === Visible Items Computation ===
|
||||||
const normalizedListSearchQuery = listSearchQuery.trim().toLowerCase();
|
const normalizedListSearchQuery = listSearchQuery.trim().toLowerCase();
|
||||||
const isListSearchActive = normalizedListSearchQuery.length > 0;
|
const isListSearchActive = normalizedListSearchQuery.length > 0;
|
||||||
|
const skippedItemIdSet = useMemo(() => (
|
||||||
|
canEditList ? new Set(skippedItemIds) : new Set()
|
||||||
|
), [canEditList, skippedItemIds]);
|
||||||
|
const activeItems = useMemo(() => {
|
||||||
|
if (skippedItemIdSet.size === 0) return items;
|
||||||
|
return items.filter((item) => !skippedItemIdSet.has(getListItemStorageId(item)));
|
||||||
|
}, [items, skippedItemIdSet]);
|
||||||
|
const skippedItems = useMemo(() => {
|
||||||
|
if (!canEditList || skippedItemIdSet.size === 0) return [];
|
||||||
|
return items.filter((item) => skippedItemIdSet.has(getListItemStorageId(item)));
|
||||||
|
}, [canEditList, items, skippedItemIdSet]);
|
||||||
|
|
||||||
const sortedItems = useMemo(() => {
|
const sortedItems = useMemo(() => {
|
||||||
return sortItemsByZone(filterItemsForSearch(items, normalizedListSearchQuery));
|
return sortItemsByZone(filterItemsForSearch(activeItems, normalizedListSearchQuery));
|
||||||
}, [items, normalizedListSearchQuery]);
|
}, [activeItems, normalizedListSearchQuery]);
|
||||||
|
|
||||||
const visibleRecentlyBoughtItems = useMemo(
|
const visibleRecentlyBoughtItems = useMemo(
|
||||||
() => recentlyBoughtItems.slice(0, recentlyBoughtDisplayCount),
|
() => recentlyBoughtItems.slice(0, recentlyBoughtDisplayCount),
|
||||||
@ -287,6 +328,10 @@ export default function GroceryList() {
|
|||||||
});
|
});
|
||||||
}, [buyModalItems, buyModalState]);
|
}, [buyModalItems, buyModalState]);
|
||||||
|
|
||||||
|
const showOfflineMutationToast = useCallback((actionLabel) => {
|
||||||
|
toast.error("Offline", `${actionLabel} needs a connection. Try again when you are back online.`);
|
||||||
|
}, [toast]);
|
||||||
|
|
||||||
|
|
||||||
// === Suggestion Handler ===
|
// === Suggestion Handler ===
|
||||||
const handleSuggest = async (text) => {
|
const handleSuggest = async (text) => {
|
||||||
@ -325,6 +370,10 @@ export default function GroceryList() {
|
|||||||
const normalizedItemName = itemName.trim().toLowerCase();
|
const normalizedItemName = itemName.trim().toLowerCase();
|
||||||
if (!normalizedItemName) return;
|
if (!normalizedItemName) return;
|
||||||
if (!activeHousehold?.id || !activeStore?.id) return;
|
if (!activeHousehold?.id || !activeStore?.id) return;
|
||||||
|
if (!isOnline) {
|
||||||
|
showOfflineMutationToast("Adding items");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const allItems = [...items, ...recentlyBoughtItems];
|
const allItems = [...items, ...recentlyBoughtItems];
|
||||||
const existingLocalItem = allItems.find(
|
const existingLocalItem = allItems.find(
|
||||||
@ -359,11 +408,23 @@ export default function GroceryList() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to process add item flow:", error);
|
console.error("Failed to process add item flow:", error);
|
||||||
}
|
}
|
||||||
}, [activeHousehold?.id, activeStore?.id, items, recentlyBoughtItems, buttonText]);
|
}, [
|
||||||
|
activeHousehold?.id,
|
||||||
|
activeStore?.id,
|
||||||
|
buttonText,
|
||||||
|
isOnline,
|
||||||
|
items,
|
||||||
|
recentlyBoughtItems,
|
||||||
|
showOfflineMutationToast
|
||||||
|
]);
|
||||||
|
|
||||||
|
|
||||||
const processItemAddition = useCallback(async (itemName, quantity, options = {}) => {
|
const processItemAddition = useCallback(async (itemName, quantity, options = {}) => {
|
||||||
if (!activeHousehold?.id || !activeStore?.id) return;
|
if (!activeHousehold?.id || !activeStore?.id) return;
|
||||||
|
if (!isOnline) {
|
||||||
|
showOfflineMutationToast("Adding items");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const {
|
const {
|
||||||
existingItem: providedItem = null,
|
existingItem: providedItem = null,
|
||||||
skipLookup = false,
|
skipLookup = false,
|
||||||
@ -421,7 +482,15 @@ export default function GroceryList() {
|
|||||||
setPendingItem({ itemName, quantity, addedForUserId });
|
setPendingItem({ itemName, quantity, addedForUserId });
|
||||||
setShowAddDetailsModal(true);
|
setShowAddDetailsModal(true);
|
||||||
}
|
}
|
||||||
}, [activeHousehold?.id, activeStore?.id, loadItems, loadRecentlyBought, toast]);
|
}, [
|
||||||
|
activeHousehold?.id,
|
||||||
|
activeStore?.id,
|
||||||
|
isOnline,
|
||||||
|
loadItems,
|
||||||
|
loadRecentlyBought,
|
||||||
|
showOfflineMutationToast,
|
||||||
|
toast
|
||||||
|
]);
|
||||||
|
|
||||||
|
|
||||||
// === Similar Item Modal Handlers ===
|
// === Similar Item Modal Handlers ===
|
||||||
@ -456,6 +525,10 @@ export default function GroceryList() {
|
|||||||
const handleConfirmAddExisting = useCallback(async () => {
|
const handleConfirmAddExisting = useCallback(async () => {
|
||||||
if (!confirmAddExistingData) return;
|
if (!confirmAddExistingData) return;
|
||||||
if (!activeHousehold?.id || !activeStore?.id) return;
|
if (!activeHousehold?.id || !activeStore?.id) return;
|
||||||
|
if (!isOnline) {
|
||||||
|
showOfflineMutationToast("Updating item quantities");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const { itemName, newQuantity, existingItem, addedForUserId } = confirmAddExistingData;
|
const { itemName, newQuantity, existingItem, addedForUserId } = confirmAddExistingData;
|
||||||
|
|
||||||
@ -484,6 +557,9 @@ export default function GroceryList() {
|
|||||||
|
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
setButtonText("Add Item");
|
setButtonText("Add Item");
|
||||||
|
setSkippedItemIds((prev) =>
|
||||||
|
prev.filter((id) => id !== getListItemStorageId(existingItem))
|
||||||
|
);
|
||||||
toast.success("Updated item quantity", `Updated item ${itemName}`);
|
toast.success("Updated item quantity", `Updated item ${itemName}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update item:", error);
|
console.error("Failed to update item:", error);
|
||||||
@ -491,13 +567,25 @@ export default function GroceryList() {
|
|||||||
toast.error("Update item failed", `Update item failed: ${message}`);
|
toast.error("Update item failed", `Update item failed: ${message}`);
|
||||||
await loadItems();
|
await loadItems();
|
||||||
}
|
}
|
||||||
}, [activeHousehold?.id, activeStore?.id, confirmAddExistingData, loadItems, toast]);
|
}, [
|
||||||
|
activeHousehold?.id,
|
||||||
|
activeStore?.id,
|
||||||
|
confirmAddExistingData,
|
||||||
|
isOnline,
|
||||||
|
loadItems,
|
||||||
|
showOfflineMutationToast,
|
||||||
|
toast
|
||||||
|
]);
|
||||||
|
|
||||||
|
|
||||||
// === Add Details Modal Handlers ===
|
// === Add Details Modal Handlers ===
|
||||||
const handleAddWithDetails = useCallback(async (imageFile, classification) => {
|
const handleAddWithDetails = useCallback(async (imageFile, classification) => {
|
||||||
if (!pendingItem) return;
|
if (!pendingItem) return;
|
||||||
if (!activeHousehold?.id || !activeStore?.id) return;
|
if (!activeHousehold?.id || !activeStore?.id) return;
|
||||||
|
if (!isOnline) {
|
||||||
|
showOfflineMutationToast("Adding items");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create the list item first, upload image separately in background.
|
// Create the list item first, upload image separately in background.
|
||||||
@ -552,7 +640,15 @@ export default function GroceryList() {
|
|||||||
const message = getApiErrorMessage(error, "Failed to add item");
|
const message = getApiErrorMessage(error, "Failed to add item");
|
||||||
toast.error("Add item failed", `Add item failed: ${message}`);
|
toast.error("Add item failed", `Add item failed: ${message}`);
|
||||||
}
|
}
|
||||||
}, [activeHousehold?.id, activeStore?.id, pendingItem, enqueueImageUpload, toast]);
|
}, [
|
||||||
|
activeHousehold?.id,
|
||||||
|
activeStore?.id,
|
||||||
|
enqueueImageUpload,
|
||||||
|
isOnline,
|
||||||
|
pendingItem,
|
||||||
|
showOfflineMutationToast,
|
||||||
|
toast
|
||||||
|
]);
|
||||||
|
|
||||||
const handleAddDetailsCancel = useCallback(() => {
|
const handleAddDetailsCancel = useCallback(() => {
|
||||||
setShowAddDetailsModal(false);
|
setShowAddDetailsModal(false);
|
||||||
@ -565,6 +661,11 @@ export default function GroceryList() {
|
|||||||
// === Item Action Handlers ===
|
// === Item Action Handlers ===
|
||||||
const handleBought = useCallback(async (quantity) => {
|
const handleBought = useCallback(async (quantity) => {
|
||||||
if (!activeHousehold?.id || !activeStore?.id) return;
|
if (!activeHousehold?.id || !activeStore?.id) return;
|
||||||
|
if (!isOnline) {
|
||||||
|
showOfflineMutationToast("Marking items bought");
|
||||||
|
setBuyModalState(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!buyModalState || buyModalState.source !== "active") {
|
if (!buyModalState || buyModalState.source !== "active") {
|
||||||
setBuyModalState(null);
|
setBuyModalState(null);
|
||||||
return;
|
return;
|
||||||
@ -615,7 +716,17 @@ export default function GroceryList() {
|
|||||||
const message = getApiErrorMessage(error, "Failed to mark item as bought");
|
const message = getApiErrorMessage(error, "Failed to mark item as bought");
|
||||||
toast.error("Mark item bought failed", `Mark item bought failed: ${message}`);
|
toast.error("Mark item bought failed", `Mark item bought failed: ${message}`);
|
||||||
}
|
}
|
||||||
}, [activeHousehold?.id, activeStore?.id, buyModalState, items, normalizedListSearchQuery, sortedItems, toast]);
|
}, [
|
||||||
|
activeHousehold?.id,
|
||||||
|
activeStore?.id,
|
||||||
|
buyModalState,
|
||||||
|
isOnline,
|
||||||
|
items,
|
||||||
|
normalizedListSearchQuery,
|
||||||
|
showOfflineMutationToast,
|
||||||
|
sortedItems,
|
||||||
|
toast
|
||||||
|
]);
|
||||||
|
|
||||||
const openActiveBuyModal = useCallback((item) => {
|
const openActiveBuyModal = useCallback((item) => {
|
||||||
setBuyModalState({
|
setBuyModalState({
|
||||||
@ -650,6 +761,27 @@ export default function GroceryList() {
|
|||||||
await handleBought(quantity);
|
await handleBought(quantity);
|
||||||
}, [buyModalState?.canConfirm, handleBought]);
|
}, [buyModalState?.canConfirm, handleBought]);
|
||||||
|
|
||||||
|
const handleSkipItem = useCallback((item) => {
|
||||||
|
const itemId = getListItemStorageId(item);
|
||||||
|
if (!itemId) return;
|
||||||
|
|
||||||
|
setSkippedItemIds((prev) => (
|
||||||
|
prev.includes(itemId) ? prev : [...prev, itemId]
|
||||||
|
));
|
||||||
|
setBuyModalState((prev) => (
|
||||||
|
prev?.item && getListItemStorageId(prev.item) === itemId ? null : prev
|
||||||
|
));
|
||||||
|
toast.info("Moved to Skipped", `${item.item_name} is hidden locally for this store.`);
|
||||||
|
}, [toast]);
|
||||||
|
|
||||||
|
const handleRestoreSkippedItem = useCallback((item) => {
|
||||||
|
const itemId = getListItemStorageId(item);
|
||||||
|
if (!itemId) return;
|
||||||
|
|
||||||
|
setSkippedItemIds((prev) => prev.filter((id) => id !== itemId));
|
||||||
|
toast.info("Restored item", `${item.item_name} is back in the active list.`);
|
||||||
|
}, [toast]);
|
||||||
|
|
||||||
const handleImageAdded = useCallback(async (id, itemName, quantity, imageFile, source = "add_image_modal") => {
|
const handleImageAdded = useCallback(async (id, itemName, quantity, imageFile, source = "add_image_modal") => {
|
||||||
if (!activeHousehold?.id || !activeStore?.id) return;
|
if (!activeHousehold?.id || !activeStore?.id) return;
|
||||||
if (!imageFile) return;
|
if (!imageFile) return;
|
||||||
@ -698,6 +830,10 @@ export default function GroceryList() {
|
|||||||
// === Edit Modal Handlers ===
|
// === Edit Modal Handlers ===
|
||||||
const handleEditSave = useCallback(async (id, itemName, quantity, classification) => {
|
const handleEditSave = useCallback(async (id, itemName, quantity, classification) => {
|
||||||
if (!activeHousehold?.id || !activeStore?.id) return;
|
if (!activeHousehold?.id || !activeStore?.id) return;
|
||||||
|
if (!isOnline) {
|
||||||
|
showOfflineMutationToast("Saving item changes");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateItemWithClassification(activeHousehold.id, activeStore.id, itemName, quantity, classification);
|
await updateItemWithClassification(activeHousehold.id, activeStore.id, itemName, quantity, classification);
|
||||||
@ -727,7 +863,7 @@ export default function GroceryList() {
|
|||||||
toast.error("Update item failed", `Update item failed: ${message}`);
|
toast.error("Update item failed", `Update item failed: ${message}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}, [activeHousehold?.id, activeStore?.id, toast]);
|
}, [activeHousehold?.id, activeStore?.id, isOnline, showOfflineMutationToast, toast]);
|
||||||
|
|
||||||
|
|
||||||
const handleEditCancel = useCallback(() => {
|
const handleEditCancel = useCallback(() => {
|
||||||
@ -841,6 +977,12 @@ export default function GroceryList() {
|
|||||||
<div className="glist-container">
|
<div className="glist-container">
|
||||||
<StoreTabs />
|
<StoreTabs />
|
||||||
|
|
||||||
|
{!isOnline && (
|
||||||
|
<div className="glist-offline-banner" role="status">
|
||||||
|
Offline. Server-backed list changes are paused; local skipped items can still be restored.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{canEditList && (
|
{canEditList && (
|
||||||
<AddItemForm
|
<AddItemForm
|
||||||
onAdd={handleAdd}
|
onAdd={handleAdd}
|
||||||
@ -856,14 +998,16 @@ export default function GroceryList() {
|
|||||||
value={listSearchQuery}
|
value={listSearchQuery}
|
||||||
onChange={setListSearchQuery}
|
onChange={setListSearchQuery}
|
||||||
resultCount={sortedItems.length}
|
resultCount={sortedItems.length}
|
||||||
totalCount={items.length}
|
totalCount={activeItems.length}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{sortedItems.length === 0 ? (
|
{sortedItems.length === 0 ? (
|
||||||
<p className="glist-empty-search">
|
<p className="glist-empty-search">
|
||||||
{isListSearchActive
|
{isListSearchActive
|
||||||
? `No list items match "${listSearchQuery.trim()}".`
|
? `No list items match "${listSearchQuery.trim()}".`
|
||||||
: "No items in this store yet."}
|
: skippedItems.length > 0
|
||||||
|
? "No active items in this store. Check Skipped below."
|
||||||
|
: "No items in this store yet."}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
(() => {
|
(() => {
|
||||||
@ -900,6 +1044,9 @@ export default function GroceryList() {
|
|||||||
onLongPress={
|
onLongPress={
|
||||||
canEditList ? handleLongPress : null
|
canEditList ? handleLongPress : null
|
||||||
}
|
}
|
||||||
|
onSkip={
|
||||||
|
canEditList ? handleSkipItem : null
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@ -910,6 +1057,34 @@ export default function GroceryList() {
|
|||||||
})()
|
})()
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{skippedItems.length > 0 && (
|
||||||
|
<section className="glist-skipped-section" aria-label="Skipped items">
|
||||||
|
<div className="glist-skipped-header">
|
||||||
|
<div>
|
||||||
|
<h2 className="glist-skipped-title">Skipped / Not Found ({skippedItems.length})</h2>
|
||||||
|
<p className="glist-skipped-note">Local to this device and hidden from the active list.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul className="glist-ul glist-skipped-ul">
|
||||||
|
{skippedItems.map((item) => (
|
||||||
|
<li key={item.id} className="glist-li glist-skipped-li">
|
||||||
|
<div className="glist-skipped-item-main">
|
||||||
|
<span className="glist-item-name">{item.item_name}</span>
|
||||||
|
<span className="glist-skipped-quantity">x{item.quantity}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="glist-restore-btn"
|
||||||
|
onClick={() => handleRestoreSkippedItem(item)}
|
||||||
|
>
|
||||||
|
Restore
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{recentlyBoughtItems.length > 0 && settings.showRecentlyBought && (
|
{recentlyBoughtItems.length > 0 && settings.showRecentlyBought && (
|
||||||
<>
|
<>
|
||||||
<h2
|
<h2
|
||||||
@ -938,6 +1113,7 @@ export default function GroceryList() {
|
|||||||
onLongPress={
|
onLongPress={
|
||||||
canEditList ? handleLongPress : null
|
canEditList ? handleLongPress : null
|
||||||
}
|
}
|
||||||
|
onSkip={null}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@ -15,7 +15,7 @@
|
|||||||
.confirm-buy-modal {
|
.confirm-buy-modal {
|
||||||
background: var(--modal-bg);
|
background: var(--modal-bg);
|
||||||
padding: var(--spacing-md);
|
padding: var(--spacing-md);
|
||||||
border-radius: var(--border-radius-xl);
|
border-radius: var(--border-radius-lg);
|
||||||
max-width: 450px;
|
max-width: 450px;
|
||||||
width: 90%;
|
width: 90%;
|
||||||
box-shadow: var(--shadow-xl);
|
box-shadow: var(--shadow-xl);
|
||||||
@ -93,6 +93,13 @@
|
|||||||
background: var(--color-gray-100);
|
background: var(--color-gray-100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.confirm-buy-image-container.no-image {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 280px;
|
||||||
|
height: 76px;
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
.confirm-buy-image {
|
.confirm-buy-image {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
@ -100,8 +107,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buy-image-placeholder {
|
.confirm-buy-image-placeholder {
|
||||||
font-size: 4em;
|
font-size: var(--font-size-sm);
|
||||||
color: var(--color-border-medium);
|
color: var(--color-border-medium);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buy-quantity-section {
|
.confirm-buy-quantity-section {
|
||||||
@ -222,8 +230,17 @@
|
|||||||
|
|
||||||
/* Mobile optimizations */
|
/* Mobile optimizations */
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
|
.confirm-buy-modal-overlay {
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
.confirm-buy-modal {
|
.confirm-buy-modal {
|
||||||
padding: 0.8em;
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
border-radius: var(--border-radius-lg) var(--border-radius-lg) 0 0;
|
||||||
|
max-height: 92vh;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buy-header {
|
.confirm-buy-header {
|
||||||
@ -255,13 +272,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buy-image-container {
|
.confirm-buy-image-container {
|
||||||
width: 220px;
|
width: min(220px, 62vw);
|
||||||
height: 220px;
|
height: min(220px, 62vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-buy-image-container.no-image {
|
||||||
|
width: min(220px, 62vw);
|
||||||
|
height: 52px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buy-nav-btn {
|
.confirm-buy-nav-btn {
|
||||||
width: 30px;
|
width: 40px;
|
||||||
height: 30px;
|
height: 40px;
|
||||||
font-size: 1.6em;
|
font-size: 1.6em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -284,7 +306,7 @@
|
|||||||
|
|
||||||
@media (max-width: 360px) {
|
@media (max-width: 360px) {
|
||||||
.confirm-buy-modal {
|
.confirm-buy-modal {
|
||||||
padding: 0.7em;
|
padding: var(--spacing-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buy-cancel,
|
.confirm-buy-cancel,
|
||||||
@ -294,13 +316,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buy-image-container {
|
.confirm-buy-image-container {
|
||||||
width: 180px;
|
width: min(180px, 58vw);
|
||||||
height: 180px;
|
height: min(180px, 58vw);
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buy-nav-btn {
|
.confirm-buy-image-container.no-image {
|
||||||
width: 28px;
|
height: 48px;
|
||||||
height: 28px;
|
|
||||||
font-size: 1.4em;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,8 +3,8 @@
|
|||||||
background: var(--color-bg-surface);
|
background: var(--color-bg-surface);
|
||||||
padding: var(--spacing-md);
|
padding: var(--spacing-md);
|
||||||
border-radius: var(--border-radius-lg);
|
border-radius: var(--border-radius-lg);
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: var(--shadow-sm);
|
||||||
margin-bottom: var(--spacing-xs);
|
margin-bottom: var(--spacing-sm);
|
||||||
border: var(--border-width-thin) solid var(--color-border-light);
|
border: var(--border-width-thin) solid var(--color-border-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -51,6 +51,7 @@
|
|||||||
font-family: var(--font-family-base);
|
font-family: var(--font-family-base);
|
||||||
transition: var(--transition-base);
|
transition: var(--transition-base);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-height: 44px;
|
||||||
background: var(--color-bg-surface);
|
background: var(--color-bg-surface);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
@ -73,6 +74,8 @@
|
|||||||
right: 0;
|
right: 0;
|
||||||
margin-top: var(--spacing-xs);
|
margin-top: var(--spacing-xs);
|
||||||
z-index: var(--z-dropdown);
|
z-index: var(--z-dropdown);
|
||||||
|
max-height: min(260px, 45vh);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Actions Row */
|
/* Actions Row */
|
||||||
@ -81,7 +84,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: var(--spacing-sm);
|
gap: var(--spacing-sm);
|
||||||
min-height: 40px;
|
min-height: 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Quantity Control */
|
/* Quantity Control */
|
||||||
@ -89,11 +92,11 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--spacing-xs);
|
gap: var(--spacing-xs);
|
||||||
height: 40px;
|
height: 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quantity-btn {
|
.quantity-btn {
|
||||||
width: 40px;
|
width: 44px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border: var(--border-width-thin) solid var(--color-border-medium);
|
border: var(--border-width-thin) solid var(--color-border-medium);
|
||||||
background: var(--color-bg-surface);
|
background: var(--color-bg-surface);
|
||||||
@ -128,8 +131,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.add-item-form-quantity-input {
|
.add-item-form-quantity-input {
|
||||||
width: 40px;
|
width: 44px;
|
||||||
max-width: 40px;
|
max-width: 44px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: var(--input-padding-y) var(--input-padding-x);
|
padding: var(--input-padding-y) var(--input-padding-x);
|
||||||
@ -159,7 +162,7 @@
|
|||||||
|
|
||||||
/* Submit Button */
|
/* Submit Button */
|
||||||
.add-item-form-submit {
|
.add-item-form-submit {
|
||||||
height: 40px;
|
min-height: 44px;
|
||||||
padding: 0 var(--spacing-lg);
|
padding: 0 var(--spacing-lg);
|
||||||
background: var(--color-primary);
|
background: var(--color-primary);
|
||||||
color: var(--color-text-inverse);
|
color: var(--color-text-inverse);
|
||||||
@ -197,25 +200,39 @@
|
|||||||
/* Responsive */
|
/* Responsive */
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.add-item-form-container {
|
.add-item-form-container {
|
||||||
padding: var(--spacing-md);
|
padding: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-item-form-input-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-form-assignee-toggle {
|
.add-item-form-assignee-toggle {
|
||||||
width: 100px;
|
width: 104px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-form-quantity-control {
|
.add-item-form-quantity-control {
|
||||||
height: 36px;
|
height: 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quantity-btn {
|
.quantity-btn {
|
||||||
width: 36px;
|
width: 44px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
font-size: var(--font-size-lg);
|
font-size: var(--font-size-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-form-quantity-input,
|
.add-item-form-quantity-input,
|
||||||
.add-item-form-submit {
|
.add-item-form-submit {
|
||||||
height: 36px;
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-item-form-actions {
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-item-form-submit {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0 var(--spacing-sm);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -209,6 +209,10 @@
|
|||||||
margin-bottom: var(--spacing-sm);
|
margin-bottom: var(--spacing-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.add-item-details-zone-field {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.add-item-details-field label {
|
.add-item-details-field label {
|
||||||
display: block;
|
display: block;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@ -328,9 +332,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
|
.add-item-details-overlay {
|
||||||
|
align-items: flex-end;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.add-item-details-modal {
|
.add-item-details-modal {
|
||||||
padding: var(--spacing-md);
|
padding: var(--spacing-md);
|
||||||
border-radius: var(--border-radius-lg);
|
border-radius: var(--border-radius-lg) var(--border-radius-lg) 0 0;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 92vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-title {
|
.add-item-details-title {
|
||||||
@ -357,4 +369,8 @@
|
|||||||
.add-item-details-field label {
|
.add-item-details-field label {
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.add-item-details-field {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -118,6 +118,36 @@
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.edit-modal-advanced {
|
||||||
|
margin-top: var(--spacing-sm);
|
||||||
|
border: var(--border-width-thin) solid var(--color-border-light);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-modal-advanced summary {
|
||||||
|
min-height: 40px;
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-modal-advanced[open] {
|
||||||
|
padding-bottom: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-modal-advanced[open] summary {
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-modal-advanced .edit-modal-inline-field {
|
||||||
|
padding: 0 var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
.edit-modal-divider {
|
.edit-modal-divider {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: var(--color-border-light);
|
background: var(--color-border-light);
|
||||||
@ -235,6 +265,10 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.edit-modal-advanced .edit-modal-inline-field {
|
||||||
|
padding: 0 var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
.edit-modal-actions {
|
.edit-modal-actions {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--spacing-xs);
|
gap: var(--spacing-xs);
|
||||||
@ -247,10 +281,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
|
.edit-modal-overlay {
|
||||||
|
align-items: flex-end;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.edit-modal-content {
|
.edit-modal-content {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-radius: 8px;
|
border-radius: var(--border-radius-lg) var(--border-radius-lg) 0 0;
|
||||||
|
max-height: 92vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-modal-title {
|
.edit-modal-title {
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
font-family: var(--font-family-base);
|
font-family: var(--font-family-base);
|
||||||
padding: var(--spacing-sm);
|
padding: var(--spacing-sm);
|
||||||
background: var(--color-bg-body);
|
background: var(--color-bg-body);
|
||||||
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.glist-container {
|
.glist-container {
|
||||||
@ -14,6 +15,31 @@
|
|||||||
box-shadow: var(--shadow-card);
|
box-shadow: var(--shadow-card);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.glist-offline-banner {
|
||||||
|
margin: var(--spacing-sm) 0;
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
border: var(--border-width-thin) solid var(--color-warning, #b7791f);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
background: var(--color-warning-light, #fff8dc);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-container .store-tabs {
|
||||||
|
margin-bottom: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-container .store-tabs-container {
|
||||||
|
align-items: stretch;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-container .store-selector-trigger,
|
||||||
|
.glist-container .store-map-button {
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
.glist-section-title {
|
.glist-section-title {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: var(--font-size-xl);
|
font-size: var(--font-size-xl);
|
||||||
@ -239,7 +265,7 @@
|
|||||||
.glist-li {
|
.glist-li {
|
||||||
background: var(--color-bg-surface);
|
background: var(--color-bg-surface);
|
||||||
border: var(--border-width-thin) solid var(--color-border-light);
|
border: var(--border-width-thin) solid var(--color-border-light);
|
||||||
border-radius: var(--border-radius-lg);
|
border-radius: var(--border-radius-md);
|
||||||
margin-bottom: var(--spacing-sm);
|
margin-bottom: var(--spacing-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: box-shadow var(--transition-base), transform var(--transition-base);
|
transition: box-shadow var(--transition-base), transform var(--transition-base);
|
||||||
@ -251,6 +277,17 @@
|
|||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.glist-li:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 0 2px var(--color-primary-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-li.actions-open {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
.glist-classification-group .glist-li {
|
.glist-classification-group .glist-li {
|
||||||
margin-bottom: var(--spacing-xs);
|
margin-bottom: var(--spacing-xs);
|
||||||
}
|
}
|
||||||
@ -261,9 +298,10 @@
|
|||||||
|
|
||||||
.glist-item-layout {
|
.glist-item-layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1em;
|
gap: var(--spacing-sm);
|
||||||
padding: 0em;
|
padding: var(--spacing-xs);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
min-height: 56px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.glist-item-image {
|
.glist-item-image {
|
||||||
@ -282,6 +320,13 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.glist-item-image-placeholder {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
.glist-item-image.has-image {
|
.glist-item-image.has-image {
|
||||||
border-color: var(--color-primary);
|
border-color: var(--color-primary);
|
||||||
background: var(--color-bg-surface);
|
background: var(--color-bg-surface);
|
||||||
@ -310,8 +355,9 @@
|
|||||||
|
|
||||||
.glist-item-name {
|
.glist-item-name {
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
font-size: 0.8em;
|
font-size: var(--font-size-base);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.glist-item-quantity {
|
.glist-item-quantity {
|
||||||
@ -333,6 +379,140 @@
|
|||||||
font-size: 0.7em;
|
font-size: 0.7em;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-item-action-toggle {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 40px;
|
||||||
|
min-width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: var(--border-width-thin) solid var(--color-border-light);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
background: var(--color-bg-hover);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-item-action-toggle:hover,
|
||||||
|
.glist-item-action-toggle:focus-visible,
|
||||||
|
.glist-li.actions-open .glist-item-action-toggle {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
color: var(--color-primary);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-item-action-tray {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
padding: 0 var(--spacing-xs) var(--spacing-xs);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-item-action {
|
||||||
|
flex: 1 1 5rem;
|
||||||
|
min-height: 40px;
|
||||||
|
border: var(--border-width-thin) solid var(--color-border-medium);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: var(--button-font-weight);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-item-action:hover,
|
||||||
|
.glist-item-action:focus-visible {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
color: var(--color-primary);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-item-action.secondary:hover,
|
||||||
|
.glist-item-action.secondary:focus-visible {
|
||||||
|
border-color: var(--color-warning, #b7791f);
|
||||||
|
color: var(--color-warning, #b7791f);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-skipped-section {
|
||||||
|
margin-top: var(--spacing-lg);
|
||||||
|
padding-top: var(--spacing-md);
|
||||||
|
border-top: var(--border-width-medium) solid var(--color-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-skipped-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
margin-bottom: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-skipped-title {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-skipped-note {
|
||||||
|
margin: 0.2rem 0 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-skipped-ul {
|
||||||
|
margin-top: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-skipped-li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
cursor: default;
|
||||||
|
background: var(--color-bg-hover);
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-skipped-li:hover {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-skipped-item-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-skipped-quantity {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-restore-btn {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 0 var(--spacing-md);
|
||||||
|
border: var(--border-width-thin) solid var(--color-primary);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: var(--button-font-weight);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-restore-btn:hover,
|
||||||
|
.glist-restore-btn:focus-visible {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-text-inverse);
|
||||||
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Compact View */
|
/* Compact View */
|
||||||
@ -514,8 +694,51 @@
|
|||||||
|
|
||||||
/* Mobile tweaks */
|
/* Mobile tweaks */
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
|
.glist-body {
|
||||||
|
padding: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
.glist-container {
|
.glist-container {
|
||||||
padding: 1em 0.8em;
|
padding: var(--spacing-sm);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-classification-header,
|
||||||
|
.glist-section-title.clickable {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-container .store-tabs-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-container .store-map-button {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-item-layout {
|
||||||
|
min-height: 60px;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-item-image {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
min-width: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-item-name {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-item-action-tray {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-skipped-li {
|
||||||
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.glist-fab {
|
.glist-fab {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test, type Route } from "@playwright/test";
|
||||||
|
|
||||||
function seedAuthStorage(page: import("@playwright/test").Page) {
|
function seedAuthStorage(page: import("@playwright/test").Page) {
|
||||||
return page.addInitScript(() => {
|
return page.addInitScript(() => {
|
||||||
@ -55,13 +55,32 @@ async function setupGroceryListRoutes(page: import("@playwright/test").Page) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.route("**/stores/household/1", async (route) => {
|
const stores = [
|
||||||
|
{ id: 10, name: "Costco", location: "Warehouse", is_default: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const fulfillStores = async (route: Route) => {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
body: JSON.stringify([
|
body: JSON.stringify(stores),
|
||||||
{ id: 10, name: "Costco", location: "Warehouse", is_default: true },
|
});
|
||||||
]),
|
};
|
||||||
|
|
||||||
|
await page.route("**/stores/household/1", fulfillStores);
|
||||||
|
await page.route("**/households/1/stores", fulfillStores);
|
||||||
|
|
||||||
|
await page.route("**/households/1/locations/10/zones", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
zones: [
|
||||||
|
{ id: 1, name: "Dairy & Refrigerated" },
|
||||||
|
{ id: 2, name: "Checkout Area" },
|
||||||
|
{ id: 3, name: "Bakery" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -75,7 +94,7 @@ async function setupGroceryListRoutes(page: import("@playwright/test").Page) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.route("**/households/1/stores/10/list/recent", async (route) => {
|
await page.route("**/households/1/locations/10/list/recent", async (route) => {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
@ -83,7 +102,7 @@ async function setupGroceryListRoutes(page: import("@playwright/test").Page) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.route("**/households/1/stores/10/list/suggestions**", async (route) => {
|
await page.route("**/households/1/locations/10/list/suggestions**", async (route) => {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
@ -91,7 +110,7 @@ async function setupGroceryListRoutes(page: import("@playwright/test").Page) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.route("**/households/1/stores/10/list/classification**", async (route) => {
|
await page.route("**/households/1/locations/10/list/classification**", async (route) => {
|
||||||
const request = route.request();
|
const request = route.request();
|
||||||
|
|
||||||
if (request.method() === "GET") {
|
if (request.method() === "GET") {
|
||||||
@ -146,7 +165,7 @@ async function setupGroceryListRoutes(page: import("@playwright/test").Page) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.route("**/households/1/stores/10/list/item**", async (route) => {
|
await page.route("**/households/1/locations/10/list/item**", async (route) => {
|
||||||
const request = route.request();
|
const request = route.request();
|
||||||
|
|
||||||
if (request.method() === "PUT") {
|
if (request.method() === "PUT") {
|
||||||
@ -185,7 +204,7 @@ async function setupGroceryListRoutes(page: import("@playwright/test").Page) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.route("**/households/1/stores/10/list/add", async (route) => {
|
await page.route("**/households/1/locations/10/list/add", async (route) => {
|
||||||
currentItem = {
|
currentItem = {
|
||||||
id: 201,
|
id: 201,
|
||||||
item_id: 501,
|
item_id: 501,
|
||||||
@ -216,7 +235,7 @@ async function setupGroceryListRoutes(page: import("@playwright/test").Page) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.route("**/households/1/stores/10/list", async (route) => {
|
await page.route("**/households/1/locations/10/list", async (route) => {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
@ -240,17 +259,11 @@ async function openEditModal(itemRow: ReturnType<import("@playwright/test").Page
|
|||||||
await expect(page.locator(".edit-modal-content")).toBeVisible();
|
await expect(page.locator(".edit-modal-content")).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
test("add-details modal validates with toasts and persists classification details", async ({ page }) => {
|
test("add-details modal persists zone-first classification details", async ({ page }) => {
|
||||||
await seedAuthStorage(page);
|
await seedAuthStorage(page);
|
||||||
await mockConfig(page);
|
await mockConfig(page);
|
||||||
await setupGroceryListRoutes(page);
|
await setupGroceryListRoutes(page);
|
||||||
|
|
||||||
let dialogSeen = false;
|
|
||||||
page.on("dialog", async (dialog) => {
|
|
||||||
dialogSeen = true;
|
|
||||||
await dialog.dismiss();
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
|
|
||||||
await page.getByPlaceholder("Enter item name").fill("yogurt");
|
await page.getByPlaceholder("Enter item name").fill("yogurt");
|
||||||
@ -259,14 +272,7 @@ test("add-details modal validates with toasts and persists classification detail
|
|||||||
const addDetailsModal = page.locator(".add-item-details-modal");
|
const addDetailsModal = page.locator(".add-item-details-modal");
|
||||||
await expect(addDetailsModal).toBeVisible();
|
await expect(addDetailsModal).toBeVisible();
|
||||||
|
|
||||||
await addDetailsModal.locator(".add-item-details-select").nth(0).selectOption("dairy");
|
await addDetailsModal.locator(".add-item-details-select").selectOption("Dairy & Refrigerated");
|
||||||
await addDetailsModal.getByRole("button", { name: "Add Item" }).click();
|
|
||||||
|
|
||||||
await expect(page.locator(".action-toast.action-toast-error")).toContainText("Select an item group");
|
|
||||||
expect(dialogSeen).toBe(false);
|
|
||||||
|
|
||||||
await addDetailsModal.locator(".add-item-details-select").nth(1).selectOption("Milk");
|
|
||||||
await addDetailsModal.locator(".add-item-details-select").nth(2).selectOption("Dairy & Refrigerated");
|
|
||||||
await addDetailsModal.getByRole("button", { name: "Add Item" }).click();
|
await addDetailsModal.getByRole("button", { name: "Add Item" }).click();
|
||||||
|
|
||||||
const yogurtRow = page.locator(".glist-li").filter({ hasText: "yogurt" });
|
const yogurtRow = page.locator(".glist-li").filter({ hasText: "yogurt" });
|
||||||
@ -278,9 +284,10 @@ test("add-details modal validates with toasts and persists classification detail
|
|||||||
await openEditModal(yogurtRow, page);
|
await openEditModal(yogurtRow, page);
|
||||||
|
|
||||||
const editModal = page.locator(".edit-modal-content");
|
const editModal = page.locator(".edit-modal-content");
|
||||||
await expect(editModal.locator(".edit-modal-select").nth(0)).toHaveValue("dairy");
|
await expect(
|
||||||
await expect(editModal.locator(".edit-modal-select").nth(1)).toHaveValue("Milk");
|
editModal.locator(".edit-modal-inline-field", { hasText: "Zone" }).locator("select")
|
||||||
await expect(editModal.locator(".edit-modal-select").nth(2)).toHaveValue("Dairy & Refrigerated");
|
).toHaveValue("Dairy & Refrigerated");
|
||||||
|
await expect(editModal.locator(".edit-modal-advanced")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("edit modal supports zone-only updates and shows API error toasts", async ({ page }) => {
|
test("edit modal supports zone-only updates and shows API error toasts", async ({ page }) => {
|
||||||
@ -292,7 +299,7 @@ test("edit modal supports zone-only updates and shows API error toasts", async (
|
|||||||
|
|
||||||
await page.getByPlaceholder("Enter item name").fill("yogurt");
|
await page.getByPlaceholder("Enter item name").fill("yogurt");
|
||||||
await page.getByRole("button", { name: "Create + Add" }).click();
|
await page.getByRole("button", { name: "Create + Add" }).click();
|
||||||
await page.locator(".add-item-details-modal").getByRole("button", { name: "Skip All" }).click();
|
await page.locator(".add-item-details-modal").getByRole("button", { name: "Add Item" }).click();
|
||||||
|
|
||||||
const yogurtRow = page.locator(".glist-li").filter({ hasText: "yogurt" });
|
const yogurtRow = page.locator(".glist-li").filter({ hasText: "yogurt" });
|
||||||
await expect(yogurtRow).toBeVisible();
|
await expect(yogurtRow).toBeVisible();
|
||||||
@ -300,8 +307,8 @@ test("edit modal supports zone-only updates and shows API error toasts", async (
|
|||||||
await openEditModal(yogurtRow, page);
|
await openEditModal(yogurtRow, page);
|
||||||
|
|
||||||
let editModal = page.locator(".edit-modal-content");
|
let editModal = page.locator(".edit-modal-content");
|
||||||
await editModal.locator(".edit-modal-select").nth(0).selectOption("");
|
const zoneSelect = editModal.locator(".edit-modal-inline-field", { hasText: "Zone" }).locator("select");
|
||||||
await editModal.locator(".edit-modal-select").nth(1).selectOption("Checkout Area");
|
await zoneSelect.selectOption("Checkout Area");
|
||||||
await editModal.getByRole("button", { name: "Save Changes" }).click();
|
await editModal.getByRole("button", { name: "Save Changes" }).click();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@ -311,11 +318,12 @@ test("edit modal supports zone-only updates and shows API error toasts", async (
|
|||||||
|
|
||||||
await openEditModal(yogurtRow, page);
|
await openEditModal(yogurtRow, page);
|
||||||
editModal = page.locator(".edit-modal-content");
|
editModal = page.locator(".edit-modal-content");
|
||||||
await expect(editModal.locator(".edit-modal-select").nth(0)).toHaveValue("");
|
await expect(
|
||||||
await expect(editModal.locator(".edit-modal-select").nth(1)).toHaveValue("Checkout Area");
|
editModal.locator(".edit-modal-inline-field", { hasText: "Zone" }).locator("select")
|
||||||
|
).toHaveValue("Checkout Area");
|
||||||
|
|
||||||
routes.setClassificationRequestMode("error");
|
routes.setClassificationRequestMode("error");
|
||||||
await editModal.locator(".edit-modal-select").nth(1).selectOption("Bakery");
|
await editModal.locator(".edit-modal-inline-field", { hasText: "Zone" }).locator("select").selectOption("Bakery");
|
||||||
await editModal.getByRole("button", { name: "Save Changes" }).click();
|
await editModal.getByRole("button", { name: "Save Changes" }).click();
|
||||||
|
|
||||||
await expect(page.locator(".action-toast.action-toast-error")).toContainText("Invalid zone");
|
await expect(page.locator(".action-toast.action-toast-error")).toContainText("Invalid zone");
|
||||||
|
|||||||
174
frontend/tests/grocery-list-actions.spec.ts
Normal file
174
frontend/tests/grocery-list-actions.spec.ts
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
import {
|
||||||
|
mockConfig,
|
||||||
|
mockHouseholdAndStoreShell,
|
||||||
|
seedAuthStorage,
|
||||||
|
} from "./helpers/e2e";
|
||||||
|
|
||||||
|
type MockItem = {
|
||||||
|
id: number;
|
||||||
|
item_id: number;
|
||||||
|
item_name: string;
|
||||||
|
quantity: number;
|
||||||
|
bought: boolean;
|
||||||
|
item_image: string | null;
|
||||||
|
image_mime_type: string | null;
|
||||||
|
added_by_users: string[];
|
||||||
|
last_added_on: string;
|
||||||
|
item_type: string | null;
|
||||||
|
item_group: string | null;
|
||||||
|
zone: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeItem(id: number, itemName: string, quantity = 1): MockItem {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
item_id: id + 500,
|
||||||
|
item_name: itemName,
|
||||||
|
quantity,
|
||||||
|
bought: false,
|
||||||
|
item_image: null,
|
||||||
|
image_mime_type: null,
|
||||||
|
added_by_users: ["Owner User"],
|
||||||
|
last_added_on: "2026-03-28T12:00:00.000Z",
|
||||||
|
item_type: null,
|
||||||
|
item_group: null,
|
||||||
|
zone: "Produce",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupGroceryRoutes(
|
||||||
|
page: Page,
|
||||||
|
options: {
|
||||||
|
householdRole?: string;
|
||||||
|
items?: MockItem[];
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
|
const items = options.items || [
|
||||||
|
makeItem(1, "milk", 2),
|
||||||
|
makeItem(2, "bread", 1),
|
||||||
|
];
|
||||||
|
|
||||||
|
await mockConfig(page);
|
||||||
|
await mockHouseholdAndStoreShell(page, {
|
||||||
|
household: {
|
||||||
|
name: "Grocery Actions House",
|
||||||
|
role: options.householdRole || "admin",
|
||||||
|
},
|
||||||
|
stores: [
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
name: "Costco",
|
||||||
|
location: "Warehouse",
|
||||||
|
is_default: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/households/1/members", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify([
|
||||||
|
{ id: 1, username: "owner", name: "Owner User", display_name: "Owner User", role: "owner" },
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/households/1/locations/10/zones", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ zones: [{ id: 1, name: "Produce" }] }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/households/1/locations/10/list/recent", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify([]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/households/1/locations/10/list/suggestions**", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify([]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/households/1/locations/10/list/item**", async (route) => {
|
||||||
|
const url = new URL(route.request().url());
|
||||||
|
const itemName = (url.searchParams.get("item_name") || "").toLowerCase();
|
||||||
|
const item = items.find((candidate) => candidate.item_name === itemName);
|
||||||
|
|
||||||
|
await route.fulfill({
|
||||||
|
status: item ? 200 : 404,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(item || { message: "Item not found" }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/households/1/locations/10/list", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ items }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("item actions expose bought flow and local skipped restore", async ({ page }) => {
|
||||||
|
await seedAuthStorage(page, { username: "grocery-actions-user" });
|
||||||
|
await setupGroceryRoutes(page);
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
const milkRow = page.locator(".glist-classification-group .glist-li").filter({ hasText: "milk" });
|
||||||
|
await expect(milkRow).toBeVisible();
|
||||||
|
|
||||||
|
await milkRow.getByRole("button", { name: "Actions for milk" }).click();
|
||||||
|
await milkRow.getByRole("button", { name: "Bought" }).click();
|
||||||
|
await expect(page.locator(".confirm-buy-modal")).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "Cancel" }).click();
|
||||||
|
|
||||||
|
await milkRow.getByRole("button", { name: "Actions for milk" }).click();
|
||||||
|
await milkRow.getByRole("button", { name: "Skip" }).click();
|
||||||
|
|
||||||
|
await expect(milkRow).toHaveCount(0);
|
||||||
|
await expect(page.locator(".glist-skipped-section")).toContainText("milk");
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.locator(".glist-skipped-section")).toContainText("milk");
|
||||||
|
|
||||||
|
await page.locator(".glist-skipped-section").getByRole("button", { name: "Restore" }).click();
|
||||||
|
await expect(page.locator(".glist-classification-group .glist-li").filter({ hasText: "milk" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("viewer role cannot see mutation controls", async ({ page }) => {
|
||||||
|
await seedAuthStorage(page, { username: "grocery-viewer-user" });
|
||||||
|
await setupGroceryRoutes(page, { householdRole: "viewer" });
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
await expect(page.getByPlaceholder("Enter item name")).toHaveCount(0);
|
||||||
|
await expect(page.getByRole("button", { name: "Actions for milk" })).toHaveCount(0);
|
||||||
|
await expect(page.getByLabel("Search list")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("offline banner blocks server-backed add flow with a clear toast", async ({ page }) => {
|
||||||
|
await seedAuthStorage(page, { username: "grocery-offline-user" });
|
||||||
|
await setupGroceryRoutes(page);
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
await page.evaluate(() => window.dispatchEvent(new Event("offline")));
|
||||||
|
|
||||||
|
await expect(page.locator(".glist-offline-banner")).toContainText("Offline");
|
||||||
|
await page.getByPlaceholder("Enter item name").fill("bananas");
|
||||||
|
await page.getByRole("button", { name: "Create + Add" }).click();
|
||||||
|
|
||||||
|
await expect(page.locator(".add-item-details-modal")).toHaveCount(0);
|
||||||
|
await expect(page.locator(".action-toast.action-toast-error")).toContainText("Adding items needs a connection");
|
||||||
|
});
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type Page, type Route } from "@playwright/test";
|
||||||
|
|
||||||
type AuthSeed = {
|
type AuthSeed = {
|
||||||
token?: string;
|
token?: string;
|
||||||
@ -69,13 +69,16 @@ export async function mockHouseholdAndStoreShell(
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.route(`**/stores/household/${household.id}`, async (route) => {
|
const fulfillStores = async (route: Route) => {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
body: JSON.stringify(stores),
|
body: JSON.stringify(stores),
|
||||||
});
|
});
|
||||||
});
|
};
|
||||||
|
|
||||||
|
await page.route(`**/stores/household/${household.id}`, fulfillStores);
|
||||||
|
await page.route(`**/households/${household.id}/stores`, fulfillStores);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function confirmSlide(page: Page) {
|
export async function confirmSlide(page: Page) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user