feat: support assigning grocery items to other household members
This commit is contained in:
parent
a1beb486cb
commit
9fa48e6eb3
@ -1,4 +1,5 @@
|
||||
const List = require("../models/list.model.v2");
|
||||
const householdModel = require("../models/household.model");
|
||||
const { isValidItemType, isValidItemGroup, isValidZone } = require("../constants/classifications");
|
||||
const { sendError } = require("../utils/http");
|
||||
const { logError } = require("../utils/logger");
|
||||
@ -50,13 +51,29 @@ exports.getItemByName = async (req, res) => {
|
||||
exports.addItem = async (req, res) => {
|
||||
try {
|
||||
const { householdId, storeId } = req.params;
|
||||
const { item_name, quantity, notes } = req.body;
|
||||
const { item_name, quantity, notes, added_for_user_id } = req.body;
|
||||
const userId = req.user.id;
|
||||
let historyUserId = userId;
|
||||
|
||||
if (!item_name || item_name.trim() === "") {
|
||||
return sendError(res, 400, "Item name is required");
|
||||
}
|
||||
|
||||
if (added_for_user_id !== undefined && added_for_user_id !== null && String(added_for_user_id).trim() !== "") {
|
||||
const parsedUserId = Number.parseInt(String(added_for_user_id), 10);
|
||||
|
||||
if (!Number.isInteger(parsedUserId) || parsedUserId <= 0) {
|
||||
return sendError(res, 400, "Added-for user ID must be a positive integer");
|
||||
}
|
||||
|
||||
const isMember = await householdModel.isHouseholdMember(householdId, parsedUserId);
|
||||
if (!isMember) {
|
||||
return sendError(res, 400, "Selected user is not a member of this household");
|
||||
}
|
||||
|
||||
historyUserId = parsedUserId;
|
||||
}
|
||||
|
||||
// Get processed image if uploaded
|
||||
const imageBuffer = req.processedImage?.buffer || null;
|
||||
const mimeType = req.processedImage?.mimeType || null;
|
||||
@ -73,7 +90,7 @@ exports.addItem = async (req, res) => {
|
||||
);
|
||||
|
||||
// Add history record
|
||||
await List.addHistoryRecord(result.listId, quantity || "1", userId);
|
||||
await List.addHistoryRecord(result.listId, quantity || "1", historyUserId);
|
||||
|
||||
res.json({
|
||||
message: result.isNew ? "Item added" : "Item updated",
|
||||
|
||||
101
backend/tests/lists.controller.v2.test.js
Normal file
101
backend/tests/lists.controller.v2.test.js
Normal file
@ -0,0 +1,101 @@
|
||||
jest.mock("../models/list.model.v2", () => ({
|
||||
addHistoryRecord: jest.fn(),
|
||||
addOrUpdateItem: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../models/household.model", () => ({
|
||||
isHouseholdMember: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../utils/logger", () => ({
|
||||
logError: jest.fn(),
|
||||
}));
|
||||
|
||||
const List = require("../models/list.model.v2");
|
||||
const householdModel = require("../models/household.model");
|
||||
const controller = require("../controllers/lists.controller.v2");
|
||||
|
||||
function createResponse() {
|
||||
const res = {};
|
||||
res.status = jest.fn().mockReturnValue(res);
|
||||
res.json = jest.fn().mockReturnValue(res);
|
||||
return res;
|
||||
}
|
||||
|
||||
describe("lists.controller.v2 addItem", () => {
|
||||
beforeEach(() => {
|
||||
List.addOrUpdateItem.mockResolvedValue({
|
||||
listId: 42,
|
||||
itemName: "milk",
|
||||
isNew: true,
|
||||
});
|
||||
List.addHistoryRecord.mockResolvedValue(undefined);
|
||||
householdModel.isHouseholdMember.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
test("records history for selected added_for_user_id when member is valid", async () => {
|
||||
const req = {
|
||||
params: { householdId: "1", storeId: "2" },
|
||||
body: { item_name: "milk", quantity: "1", added_for_user_id: "9" },
|
||||
user: { id: 7 },
|
||||
processedImage: null,
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await controller.addItem(req, res);
|
||||
|
||||
expect(householdModel.isHouseholdMember).toHaveBeenCalledWith("1", 9);
|
||||
expect(List.addOrUpdateItem).toHaveBeenCalled();
|
||||
expect(List.addHistoryRecord).toHaveBeenCalledWith(42, "1", 9);
|
||||
expect(res.status).not.toHaveBeenCalledWith(400);
|
||||
});
|
||||
|
||||
test("rejects invalid added_for_user_id", async () => {
|
||||
const req = {
|
||||
params: { householdId: "1", storeId: "2" },
|
||||
body: { item_name: "milk", quantity: "1", added_for_user_id: "abc" },
|
||||
user: { id: 7 },
|
||||
processedImage: null,
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await controller.addItem(req, res);
|
||||
|
||||
expect(List.addOrUpdateItem).not.toHaveBeenCalled();
|
||||
expect(List.addHistoryRecord).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.objectContaining({
|
||||
message: "Added-for user ID must be a positive integer",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects added_for_user_id when target user is not household member", async () => {
|
||||
householdModel.isHouseholdMember.mockResolvedValue(false);
|
||||
|
||||
const req = {
|
||||
params: { householdId: "1", storeId: "2" },
|
||||
body: { item_name: "milk", quantity: "1", added_for_user_id: "11" },
|
||||
user: { id: 7 },
|
||||
processedImage: null,
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await controller.addItem(req, res);
|
||||
|
||||
expect(householdModel.isHouseholdMember).toHaveBeenCalledWith("1", 11);
|
||||
expect(List.addOrUpdateItem).not.toHaveBeenCalled();
|
||||
expect(List.addHistoryRecord).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.objectContaining({
|
||||
message: "Selected user is not a member of this household",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -17,13 +17,24 @@ export const getItemByName = (householdId, storeId, itemName) =>
|
||||
/**
|
||||
* Add item to list
|
||||
*/
|
||||
export const addItem = (householdId, storeId, itemName, quantity, imageFile = null, notes = null) => {
|
||||
export const addItem = (
|
||||
householdId,
|
||||
storeId,
|
||||
itemName,
|
||||
quantity,
|
||||
imageFile = null,
|
||||
notes = null,
|
||||
addedForUserId = null
|
||||
) => {
|
||||
const formData = new FormData();
|
||||
formData.append("item_name", itemName);
|
||||
formData.append("quantity", quantity);
|
||||
if (notes) {
|
||||
formData.append("notes", notes);
|
||||
}
|
||||
if (addedForUserId != null) {
|
||||
formData.append("added_for_user_id", addedForUserId);
|
||||
}
|
||||
if (imageFile) {
|
||||
formData.append("image", imageFile);
|
||||
}
|
||||
@ -108,7 +119,14 @@ export const getRecentlyBought = (householdId, storeId) =>
|
||||
/**
|
||||
* Update item image
|
||||
*/
|
||||
export const updateItemImage = (householdId, storeId, itemName, quantity, imageFile) => {
|
||||
export const updateItemImage = (
|
||||
householdId,
|
||||
storeId,
|
||||
itemName,
|
||||
quantity,
|
||||
imageFile,
|
||||
options = {}
|
||||
) => {
|
||||
const formData = new FormData();
|
||||
formData.append("item_name", itemName);
|
||||
formData.append("quantity", quantity);
|
||||
@ -118,5 +136,8 @@ export const updateItemImage = (householdId, storeId, itemName, quantity, imageF
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
onUploadProgress: options.onUploadProgress,
|
||||
signal: options.signal,
|
||||
timeout: options.timeoutMs,
|
||||
});
|
||||
};
|
||||
67
frontend/src/components/common/ToggleButtonGroup.jsx
Normal file
67
frontend/src/components/common/ToggleButtonGroup.jsx
Normal file
@ -0,0 +1,67 @@
|
||||
import "../../styles/components/ToggleButtonGroup.css";
|
||||
|
||||
function joinClasses(parts) {
|
||||
return parts.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
export default function ToggleButtonGroup({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
role = "group",
|
||||
className = "tbg-group",
|
||||
buttonBaseClassName = "tbg-button",
|
||||
buttonClassName,
|
||||
activeClassName = "is-active",
|
||||
inactiveClassName = "is-inactive",
|
||||
sizeClassName = "tbg-size-default"
|
||||
}) {
|
||||
const optionCount = Math.max(options.length, 1);
|
||||
const activeIndex =
|
||||
value == null ? -1 : options.findIndex((option) => option.value === value);
|
||||
|
||||
const groupStyle = {
|
||||
"--tbg-option-count": optionCount,
|
||||
"--tbg-active-index": activeIndex >= 0 ? activeIndex : 0
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={joinClasses([className, activeIndex >= 0 && "has-active"])}
|
||||
role={role}
|
||||
aria-label={ariaLabel}
|
||||
style={groupStyle}
|
||||
>
|
||||
<span className="tbg-indicator" aria-hidden="true" />
|
||||
{options.map((option) => {
|
||||
const isActive = value != null && option.value === value;
|
||||
const handleClick = option.onClick
|
||||
? option.onClick
|
||||
: onChange
|
||||
? () => onChange(option.value)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={joinClasses([
|
||||
buttonBaseClassName,
|
||||
sizeClassName,
|
||||
buttonClassName,
|
||||
isActive ? (option.activeClassName || activeClassName) : (option.inactiveClassName || inactiveClassName),
|
||||
option.className
|
||||
])}
|
||||
onClick={handleClick}
|
||||
disabled={option.disabled}
|
||||
aria-pressed={value != null ? isActive : undefined}
|
||||
aria-label={option.ariaLabel}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -3,5 +3,6 @@ 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 ToggleButtonGroup } from './ToggleButtonGroup.jsx';
|
||||
export { default as UserRoleCard } from './UserRoleCard.jsx';
|
||||
|
||||
|
||||
@ -1,19 +1,49 @@
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { ToggleButtonGroup } from "../common";
|
||||
import AssignItemForModal from "../modals/AssignItemForModal";
|
||||
import "../../styles/components/AddItemForm.css";
|
||||
import SuggestionList from "../items/SuggestionList";
|
||||
|
||||
export default function AddItemForm({ onAdd, onSuggest, suggestions, buttonText = "Add" }) {
|
||||
export default function AddItemForm({
|
||||
onAdd,
|
||||
onSuggest,
|
||||
suggestions,
|
||||
buttonText = "Add",
|
||||
householdMembers = [],
|
||||
currentUserId = null
|
||||
}) {
|
||||
const [itemName, setItemName] = useState("");
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [assignmentMode, setAssignmentMode] = useState("me");
|
||||
const [assignedUserId, setAssignedUserId] = useState(null);
|
||||
const [showAssignModal, setShowAssignModal] = useState(false);
|
||||
|
||||
const numericCurrentUserId =
|
||||
currentUserId == null ? null : Number.parseInt(String(currentUserId), 10);
|
||||
|
||||
const otherMembers = useMemo(
|
||||
() => householdMembers.filter((member) => Number(member.id) !== numericCurrentUserId),
|
||||
[householdMembers, numericCurrentUserId]
|
||||
);
|
||||
|
||||
const assignedMemberLabel = useMemo(() => {
|
||||
if (assignmentMode !== "others" || assignedUserId == null) return "";
|
||||
const member = otherMembers.find((item) => Number(item.id) === Number(assignedUserId));
|
||||
return member ? (member.display_name || member.name || member.username || `User ${member.id}`) : "";
|
||||
}, [assignmentMode, assignedUserId, otherMembers]);
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
if (!itemName.trim()) return;
|
||||
|
||||
onAdd(itemName, quantity);
|
||||
const targetUserId = assignmentMode === "others" ? assignedUserId : null;
|
||||
onAdd(itemName, quantity, targetUserId);
|
||||
setItemName("");
|
||||
setQuantity(1);
|
||||
setAssignmentMode("me");
|
||||
setAssignedUserId(null);
|
||||
setShowAssignModal(false);
|
||||
};
|
||||
|
||||
const handleInputChange = (text) => {
|
||||
@ -35,30 +65,78 @@ export default function AddItemForm({ onAdd, onSuggest, suggestions, buttonText
|
||||
setQuantity(prev => Math.max(1, prev - 1));
|
||||
};
|
||||
|
||||
const handleAssignmentModeChange = (mode) => {
|
||||
if (mode === "me") {
|
||||
setAssignmentMode("me");
|
||||
setAssignedUserId(null);
|
||||
setShowAssignModal(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (otherMembers.length === 0) {
|
||||
setAssignmentMode("me");
|
||||
setAssignedUserId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setAssignmentMode("others");
|
||||
setShowAssignModal(true);
|
||||
};
|
||||
|
||||
const handleAssignCancel = () => {
|
||||
setShowAssignModal(false);
|
||||
setAssignmentMode("me");
|
||||
setAssignedUserId(null);
|
||||
};
|
||||
|
||||
const handleAssignConfirm = (memberId) => {
|
||||
setShowAssignModal(false);
|
||||
setAssignmentMode("others");
|
||||
setAssignedUserId(Number(memberId));
|
||||
};
|
||||
|
||||
const isDisabled = !itemName.trim();
|
||||
|
||||
return (
|
||||
<div className="add-item-form-container">
|
||||
<form onSubmit={handleSubmit} className="add-item-form">
|
||||
<div className="add-item-form-field">
|
||||
<input
|
||||
type="text"
|
||||
className="add-item-form-input"
|
||||
placeholder="Enter item name"
|
||||
value={itemName}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
onBlur={() => setTimeout(() => setShowSuggestions(false), 150)}
|
||||
onClick={() => setShowSuggestions(true)}
|
||||
/>
|
||||
|
||||
{showSuggestions && suggestions.length > 0 && (
|
||||
<SuggestionList
|
||||
suggestions={suggestions}
|
||||
onSelect={handleSuggestionSelect}
|
||||
<div className="add-item-form-input-row">
|
||||
<div className="add-item-form-field">
|
||||
<input
|
||||
type="text"
|
||||
className="add-item-form-input"
|
||||
placeholder="Enter item name"
|
||||
value={itemName}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
onBlur={() => setTimeout(() => setShowSuggestions(false), 150)}
|
||||
onClick={() => setShowSuggestions(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showSuggestions && suggestions.length > 0 && (
|
||||
<SuggestionList
|
||||
suggestions={suggestions}
|
||||
onSelect={handleSuggestionSelect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ToggleButtonGroup
|
||||
value={assignmentMode}
|
||||
ariaLabel="Item assignment mode"
|
||||
className="tbg-group add-item-form-assignee-toggle"
|
||||
sizeClassName="tbg-size-xs"
|
||||
options={[
|
||||
{ value: "me", label: "Me" },
|
||||
{ value: "others", label: "Others", disabled: otherMembers.length === 0 }
|
||||
]}
|
||||
onChange={handleAssignmentModeChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{assignmentMode === "others" && assignedMemberLabel ? (
|
||||
<p className="add-item-form-assignee-hint">Adding for: {assignedMemberLabel}</p>
|
||||
) : null}
|
||||
|
||||
<div className="add-item-form-actions">
|
||||
<div className="add-item-form-quantity-control">
|
||||
<button
|
||||
@ -94,6 +172,13 @@ export default function AddItemForm({ onAdd, onSuggest, suggestions, buttonText
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<AssignItemForModal
|
||||
isOpen={showAssignModal}
|
||||
members={otherMembers}
|
||||
onCancel={handleAssignCancel}
|
||||
onConfirm={handleAssignConfirm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
95
frontend/src/components/modals/AssignItemForModal.jsx
Normal file
95
frontend/src/components/modals/AssignItemForModal.jsx
Normal file
@ -0,0 +1,95 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import "../../styles/components/AssignItemForModal.css";
|
||||
|
||||
function getMemberLabel(member) {
|
||||
return member.display_name || member.name || member.username || `User ${member.id}`;
|
||||
}
|
||||
|
||||
export default function AssignItemForModal({
|
||||
isOpen,
|
||||
members,
|
||||
onCancel,
|
||||
onConfirm
|
||||
}) {
|
||||
const [selectedUserId, setSelectedUserId] = useState("");
|
||||
|
||||
const hasMembers = members.length > 0;
|
||||
const selectedMember = useMemo(
|
||||
() => members.find((member) => String(member.id) === String(selectedUserId)) || null,
|
||||
[members, selectedUserId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setSelectedUserId(members[0] ? String(members[0].id) : "");
|
||||
}, [isOpen, members]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return undefined;
|
||||
|
||||
const handleEscape = (event) => {
|
||||
if (event.key === "Escape") {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleEscape);
|
||||
return () => window.removeEventListener("keydown", handleEscape);
|
||||
}, [isOpen, onCancel]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!selectedMember) return;
|
||||
onConfirm(selectedMember.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onCancel}>
|
||||
<div className="modal assign-item-for-modal" onClick={(event) => event.stopPropagation()}>
|
||||
<h2 className="modal-title">Add Item For Someone Else</h2>
|
||||
<p className="assign-item-for-modal-description">
|
||||
Who should this item be assigned to?
|
||||
</p>
|
||||
|
||||
{hasMembers ? (
|
||||
<div className="assign-item-for-modal-field">
|
||||
<label htmlFor="assign-item-for-member" className="form-label">
|
||||
Household member
|
||||
</label>
|
||||
<select
|
||||
id="assign-item-for-member"
|
||||
className="form-select"
|
||||
value={selectedUserId}
|
||||
onChange={(event) => setSelectedUserId(event.target.value)}
|
||||
>
|
||||
{members.map((member) => (
|
||||
<option key={member.id} value={member.id}>
|
||||
{getMemberLabel(member)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<p className="assign-item-for-modal-empty">
|
||||
No other household members are available.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn btn-outline flex-1" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary flex-1"
|
||||
onClick={handleConfirm}
|
||||
disabled={!selectedMember}
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,7 +1,9 @@
|
||||
// Barrel export for modal components
|
||||
export { default as AddImageModal } from './AddImageModal.jsx';
|
||||
export { default as AddItemWithDetailsModal } from './AddItemWithDetailsModal.jsx';
|
||||
export { default as AssignItemForModal } from './AssignItemForModal.jsx';
|
||||
export { default as ConfirmBuyModal } from './ConfirmBuyModal.jsx';
|
||||
export { default as ConfirmSlideModal } from './ConfirmSlideModal.jsx';
|
||||
export { default as EditItemModal } from './EditItemModal.jsx';
|
||||
export { default as ImageModal } from './ImageModal.jsx';
|
||||
export { default as ImageUploadModal } from './ImageUploadModal.jsx';
|
||||
|
||||
@ -8,10 +8,9 @@ import {
|
||||
getRecentlyBought,
|
||||
getSuggestions,
|
||||
markBought,
|
||||
updateItemImage,
|
||||
updateItemWithClassification
|
||||
} from "../api/list";
|
||||
import FloatingActionButton from "../components/common/FloatingActionButton";
|
||||
import { getHouseholdMembers } from "../api/households";
|
||||
import SortDropdown from "../components/common/SortDropdown";
|
||||
import AddItemForm from "../components/forms/AddItemForm";
|
||||
import GroceryListItem from "../components/items/GroceryListItem";
|
||||
@ -24,31 +23,34 @@ import { ZONE_FLOW } from "../constants/classifications";
|
||||
import { ROLES } from "../constants/roles";
|
||||
import { AuthContext } from "../context/AuthContext";
|
||||
import { HouseholdContext } from "../context/HouseholdContext";
|
||||
import { IMAGE_UPLOAD_SUCCESS_EVENT } from "../context/UploadQueueContext";
|
||||
import { SettingsContext } from "../context/SettingsContext";
|
||||
import { StoreContext } from "../context/StoreContext";
|
||||
import useUploadQueue from "../hooks/useUploadQueue";
|
||||
import "../styles/pages/GroceryList.css";
|
||||
import { findSimilarItems } from "../utils/stringSimilarity";
|
||||
|
||||
|
||||
export default function GroceryList() {
|
||||
const pageTitle = "Grocery List";
|
||||
const { role: systemRole } = useContext(AuthContext);
|
||||
const { userId } = useContext(AuthContext);
|
||||
const { activeHousehold } = useContext(HouseholdContext);
|
||||
const { activeStore, stores, loading: storeLoading } = useContext(StoreContext);
|
||||
const { settings } = useContext(SettingsContext);
|
||||
const { enqueueImageUpload } = useUploadQueue();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Get household role for permissions
|
||||
const householdRole = activeHousehold?.role;
|
||||
const isHouseholdAdmin = householdRole === "admin";
|
||||
const isHouseholdAdmin = ["owner", "admin"].includes(householdRole);
|
||||
|
||||
// === State === //
|
||||
const [items, setItems] = useState([]);
|
||||
const [recentlyBoughtItems, setRecentlyBoughtItems] = useState([]);
|
||||
const [householdMembers, setHouseholdMembers] = useState([]);
|
||||
const [recentlyBoughtDisplayCount, setRecentlyBoughtDisplayCount] = useState(settings.recentlyBoughtCount);
|
||||
const [sortMode, setSortMode] = useState(settings.defaultSortMode);
|
||||
const [suggestions, setSuggestions] = useState([]);
|
||||
const [showAddForm, setShowAddForm] = useState(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [buttonText, setButtonText] = useState("Add Item");
|
||||
const [pendingItem, setPendingItem] = useState(null);
|
||||
@ -101,6 +103,73 @@ export default function GroceryList() {
|
||||
loadRecentlyBought();
|
||||
}, [activeHousehold?.id, activeStore?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadHouseholdMembers = async () => {
|
||||
if (!activeHousehold?.id) {
|
||||
setHouseholdMembers([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await getHouseholdMembers(activeHousehold.id);
|
||||
setHouseholdMembers(response.data || []);
|
||||
} catch (error) {
|
||||
console.error("Failed to load household members:", error);
|
||||
setHouseholdMembers([]);
|
||||
}
|
||||
};
|
||||
|
||||
loadHouseholdMembers();
|
||||
}, [activeHousehold?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleUploadSuccess = async (event) => {
|
||||
const detail = event?.detail || {};
|
||||
if (!activeHousehold?.id || !activeStore?.id) return;
|
||||
if (String(detail.householdId) !== String(activeHousehold.id)) return;
|
||||
if (String(detail.storeId) !== String(activeStore.id)) return;
|
||||
if (!detail.itemName) return;
|
||||
|
||||
try {
|
||||
const response = await getItemByName(activeHousehold.id, activeStore.id, detail.itemName);
|
||||
const refreshedItem = response.data;
|
||||
|
||||
setItems((prev) =>
|
||||
prev.map((item) => {
|
||||
const byId =
|
||||
detail.localItemId !== null &&
|
||||
detail.localItemId !== undefined &&
|
||||
item.id === detail.localItemId;
|
||||
const byName =
|
||||
String(item.item_name || "").toLowerCase() ===
|
||||
String(detail.itemName || "").toLowerCase();
|
||||
return byId || byName ? { ...item, ...refreshedItem } : item;
|
||||
})
|
||||
);
|
||||
|
||||
setRecentlyBoughtItems((prev) =>
|
||||
prev.map((item) => {
|
||||
const byId =
|
||||
detail.localItemId !== null &&
|
||||
detail.localItemId !== undefined &&
|
||||
item.id === detail.localItemId;
|
||||
const byName =
|
||||
String(item.item_name || "").toLowerCase() ===
|
||||
String(detail.itemName || "").toLowerCase();
|
||||
return byId || byName ? { ...item, ...refreshedItem } : item;
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh item after upload success:", error);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener(IMAGE_UPLOAD_SUCCESS_EVENT, handleUploadSuccess);
|
||||
return () => {
|
||||
window.removeEventListener(IMAGE_UPLOAD_SUCCESS_EVENT, handleUploadSuccess);
|
||||
};
|
||||
}, [activeHousehold?.id, activeStore?.id]);
|
||||
|
||||
|
||||
// === Zone Collapse Handler ===
|
||||
const toggleZoneCollapse = (zone) => {
|
||||
@ -185,49 +254,60 @@ export default function GroceryList() {
|
||||
|
||||
|
||||
// === Item Addition Handlers ===
|
||||
const handleAdd = useCallback(async (itemName, quantity) => {
|
||||
if (!itemName.trim()) return;
|
||||
const handleAdd = useCallback(async (itemName, quantity, addedForUserId = null) => {
|
||||
const normalizedItemName = itemName.trim().toLowerCase();
|
||||
if (!normalizedItemName) return;
|
||||
if (!activeHousehold?.id || !activeStore?.id) return;
|
||||
|
||||
// Check if item already exists
|
||||
let existingItem = null;
|
||||
try {
|
||||
const response = await getItemByName(activeHousehold.id, activeStore.id, itemName);
|
||||
existingItem = response.data;
|
||||
} catch {
|
||||
// Item doesn't exist, continue
|
||||
}
|
||||
const allItems = [...items, ...recentlyBoughtItems];
|
||||
const existingLocalItem = allItems.find(
|
||||
(item) => String(item.item_name || "").toLowerCase() === normalizedItemName
|
||||
);
|
||||
|
||||
if (existingItem) {
|
||||
await processItemAddition(itemName, quantity);
|
||||
if (existingLocalItem) {
|
||||
await processItemAddition(itemName, quantity, {
|
||||
existingItem: existingLocalItem,
|
||||
addedForUserId
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setItems(prevItems => {
|
||||
const allItems = [...prevItems, ...recentlyBoughtItems];
|
||||
const similar = findSimilarItems(itemName, allItems, 70);
|
||||
if (similar.length > 0) {
|
||||
setSimilarItemSuggestion({ originalName: itemName, suggestedItem: similar[0], quantity });
|
||||
setShowSimilarModal(true);
|
||||
return prevItems;
|
||||
}
|
||||
const similar = findSimilarItems(itemName, allItems, 70);
|
||||
if (similar.length > 0) {
|
||||
setSimilarItemSuggestion({
|
||||
originalName: itemName,
|
||||
suggestedItem: similar[0],
|
||||
quantity,
|
||||
addedForUserId
|
||||
});
|
||||
setShowSimilarModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
processItemAddition(itemName, quantity);
|
||||
return prevItems;
|
||||
const shouldSkipLookup = buttonText === "Create + Add";
|
||||
await processItemAddition(itemName, quantity, {
|
||||
skipLookup: shouldSkipLookup,
|
||||
addedForUserId
|
||||
});
|
||||
}, [activeHousehold?.id, activeStore?.id, recentlyBoughtItems]);
|
||||
}, [activeHousehold?.id, activeStore?.id, items, recentlyBoughtItems, buttonText]);
|
||||
|
||||
|
||||
const processItemAddition = useCallback(async (itemName, quantity) => {
|
||||
const processItemAddition = useCallback(async (itemName, quantity, options = {}) => {
|
||||
if (!activeHousehold?.id || !activeStore?.id) return;
|
||||
const {
|
||||
existingItem: providedItem = null,
|
||||
skipLookup = false,
|
||||
addedForUserId = null
|
||||
} = options;
|
||||
|
||||
// Fetch current item state from backend
|
||||
let existingItem = null;
|
||||
try {
|
||||
const response = await getItemByName(activeHousehold.id, activeStore.id, itemName);
|
||||
existingItem = response.data;
|
||||
} catch {
|
||||
// Item doesn't exist, continue with add
|
||||
let existingItem = providedItem;
|
||||
if (!existingItem && !skipLookup) {
|
||||
try {
|
||||
const response = await getItemByName(activeHousehold.id, activeStore.id, itemName);
|
||||
existingItem = response.data;
|
||||
} catch {
|
||||
// Item doesn't exist, continue with add
|
||||
}
|
||||
}
|
||||
|
||||
if (existingItem?.bought === false) {
|
||||
@ -240,11 +320,20 @@ export default function GroceryList() {
|
||||
currentQuantity,
|
||||
addingQuantity: quantity,
|
||||
newQuantity,
|
||||
existingItem
|
||||
existingItem,
|
||||
addedForUserId
|
||||
});
|
||||
setShowConfirmAddExisting(true);
|
||||
} else if (existingItem) {
|
||||
await addItem(activeHousehold.id, activeStore.id, itemName, quantity, null);
|
||||
await addItem(
|
||||
activeHousehold.id,
|
||||
activeStore.id,
|
||||
itemName,
|
||||
quantity,
|
||||
null,
|
||||
null,
|
||||
addedForUserId
|
||||
);
|
||||
setSuggestions([]);
|
||||
setButtonText("Add Item");
|
||||
|
||||
@ -252,10 +341,10 @@ export default function GroceryList() {
|
||||
await loadItems();
|
||||
await loadRecentlyBought();
|
||||
} else {
|
||||
setPendingItem({ itemName, quantity });
|
||||
setPendingItem({ itemName, quantity, addedForUserId });
|
||||
setShowAddDetailsModal(true);
|
||||
}
|
||||
}, [activeHousehold?.id, activeStore?.id, items, loadItems]);
|
||||
}, [activeHousehold?.id, activeStore?.id, loadItems]);
|
||||
|
||||
|
||||
// === Similar Item Modal Handlers ===
|
||||
@ -268,7 +357,10 @@ export default function GroceryList() {
|
||||
const handleSimilarNo = useCallback(async () => {
|
||||
if (!similarItemSuggestion) return;
|
||||
setShowSimilarModal(false);
|
||||
await processItemAddition(similarItemSuggestion.originalName, similarItemSuggestion.quantity);
|
||||
await processItemAddition(similarItemSuggestion.originalName, similarItemSuggestion.quantity, {
|
||||
skipLookup: true,
|
||||
addedForUserId: similarItemSuggestion.addedForUserId || null
|
||||
});
|
||||
setSimilarItemSuggestion(null);
|
||||
}, [similarItemSuggestion, processItemAddition]);
|
||||
|
||||
@ -276,7 +368,9 @@ export default function GroceryList() {
|
||||
const handleSimilarYes = useCallback(async () => {
|
||||
if (!similarItemSuggestion) return;
|
||||
setShowSimilarModal(false);
|
||||
await processItemAddition(similarItemSuggestion.suggestedItem.item_name, similarItemSuggestion.quantity);
|
||||
await processItemAddition(similarItemSuggestion.suggestedItem.item_name, similarItemSuggestion.quantity, {
|
||||
addedForUserId: similarItemSuggestion.addedForUserId || null
|
||||
});
|
||||
setSimilarItemSuggestion(null);
|
||||
}, [similarItemSuggestion, processItemAddition]);
|
||||
|
||||
@ -286,13 +380,21 @@ export default function GroceryList() {
|
||||
if (!confirmAddExistingData) return;
|
||||
if (!activeHousehold?.id || !activeStore?.id) return;
|
||||
|
||||
const { itemName, newQuantity, existingItem } = confirmAddExistingData;
|
||||
const { itemName, newQuantity, existingItem, addedForUserId } = confirmAddExistingData;
|
||||
|
||||
setShowConfirmAddExisting(false);
|
||||
setConfirmAddExistingData(null);
|
||||
|
||||
try {
|
||||
await addItem(activeHousehold.id, activeStore.id, itemName, newQuantity, null);
|
||||
await addItem(
|
||||
activeHousehold.id,
|
||||
activeStore.id,
|
||||
itemName,
|
||||
newQuantity,
|
||||
null,
|
||||
null,
|
||||
addedForUserId || null
|
||||
);
|
||||
|
||||
const response = await getItemByName(activeHousehold.id, activeStore.id, itemName);
|
||||
const updatedItem = response.data;
|
||||
@ -318,7 +420,16 @@ export default function GroceryList() {
|
||||
if (!activeHousehold?.id || !activeStore?.id) return;
|
||||
|
||||
try {
|
||||
await addItem(activeHousehold.id, activeStore.id, pendingItem.itemName, pendingItem.quantity, imageFile);
|
||||
// Create the list item first, upload image separately in background.
|
||||
await addItem(
|
||||
activeHousehold.id,
|
||||
activeStore.id,
|
||||
pendingItem.itemName,
|
||||
pendingItem.quantity,
|
||||
null,
|
||||
null,
|
||||
pendingItem.addedForUserId || null
|
||||
);
|
||||
|
||||
if (classification) {
|
||||
// Apply classification if provided
|
||||
@ -337,19 +448,42 @@ export default function GroceryList() {
|
||||
// Add to state
|
||||
if (newItem) {
|
||||
setItems(prevItems => [...prevItems, newItem]);
|
||||
|
||||
if (imageFile) {
|
||||
enqueueImageUpload({
|
||||
householdId: activeHousehold.id,
|
||||
storeId: activeStore.id,
|
||||
itemName: newItem.item_name || pendingItem.itemName,
|
||||
quantity: newItem.quantity || pendingItem.quantity,
|
||||
fileBlob: imageFile,
|
||||
fileName: imageFile.name || "upload.jpg",
|
||||
fileType: imageFile.type || "image/jpeg",
|
||||
fileSize: imageFile.size || 0,
|
||||
source: "add_details",
|
||||
localItemId: newItem.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to add item:", error);
|
||||
alert("Failed to add item. Please try again.");
|
||||
}
|
||||
}, [activeHousehold?.id, activeStore?.id, pendingItem]);
|
||||
}, [activeHousehold?.id, activeStore?.id, pendingItem, enqueueImageUpload]);
|
||||
|
||||
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);
|
||||
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);
|
||||
@ -403,28 +537,28 @@ export default function GroceryList() {
|
||||
loadRecentlyBought();
|
||||
}, [activeHousehold?.id, activeStore?.id, items]);
|
||||
|
||||
const handleImageAdded = useCallback(async (id, itemName, quantity, imageFile) => {
|
||||
const handleImageAdded = useCallback(async (id, itemName, quantity, imageFile, source = "add_image_modal") => {
|
||||
if (!activeHousehold?.id || !activeStore?.id) return;
|
||||
if (!imageFile) return;
|
||||
|
||||
try {
|
||||
const response = await updateItemImage(activeHousehold.id, activeStore.id, id, itemName, quantity, imageFile);
|
||||
|
||||
setItems(prevItems =>
|
||||
prevItems.map(item =>
|
||||
item.id === id ? { ...item, ...response.data } : item
|
||||
)
|
||||
);
|
||||
|
||||
setRecentlyBoughtItems(prevItems =>
|
||||
prevItems.map(item =>
|
||||
item.id === id ? { ...item, ...response.data } : item
|
||||
)
|
||||
);
|
||||
enqueueImageUpload({
|
||||
householdId: activeHousehold.id,
|
||||
storeId: activeStore.id,
|
||||
itemName,
|
||||
quantity,
|
||||
fileBlob: imageFile,
|
||||
fileName: imageFile.name || "upload.jpg",
|
||||
fileType: imageFile.type || "image/jpeg",
|
||||
fileSize: imageFile.size || 0,
|
||||
source,
|
||||
localItemId: id,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to add image:", error);
|
||||
alert("Failed to add image. Please try again.");
|
||||
}
|
||||
}, [activeHousehold?.id, activeStore?.id]);
|
||||
}, [activeHousehold?.id, activeStore?.id, enqueueImageUpload]);
|
||||
|
||||
|
||||
const handleLongPress = useCallback(async (item) => {
|
||||
@ -586,12 +720,14 @@ export default function GroceryList() {
|
||||
|
||||
<StoreTabs />
|
||||
|
||||
{householdRole && householdRole !== 'viewer' && showAddForm && (
|
||||
{householdRole && householdRole !== 'viewer' && (
|
||||
<AddItemForm
|
||||
onAdd={handleAdd}
|
||||
onSuggest={handleSuggest}
|
||||
suggestions={suggestions}
|
||||
buttonText={buttonText}
|
||||
householdMembers={householdMembers}
|
||||
currentUserId={userId}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -711,13 +847,6 @@ export default function GroceryList() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{householdRole && householdRole !== 'viewer' && (
|
||||
<FloatingActionButton
|
||||
isOpen={showAddForm}
|
||||
onClick={() => setShowAddForm(!showAddForm)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showAddDetailsModal && pendingItem && (
|
||||
<AddItemWithDetailsModal
|
||||
itemName={pendingItem.itemName}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
/* Add Item Form Container */
|
||||
.add-item-form-container {
|
||||
background: var(--color-bg-surface);
|
||||
padding: var(--spacing-lg);
|
||||
padding: var(--spacing-md);
|
||||
border-radius: var(--border-radius-lg);
|
||||
box-shadow: var(--shadow-md);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
@ -11,7 +11,7 @@
|
||||
.add-item-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
/* Form Fields */
|
||||
@ -21,6 +21,28 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.add-item-form-input-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.add-item-form-input-row .add-item-form-field {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.add-item-form-assignee-toggle {
|
||||
flex: 0 0 auto;
|
||||
width: 134px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.add-item-form-assignee-hint {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.add-item-form-input {
|
||||
padding: var(--input-padding-y) var(--input-padding-x);
|
||||
border: var(--border-width-thin) solid var(--input-border-color);
|
||||
@ -58,7 +80,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-md);
|
||||
gap: var(--spacing-sm);
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
/* Quantity Control */
|
||||
@ -66,11 +89,12 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.quantity-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
height: 100%;
|
||||
border: var(--border-width-thin) solid var(--color-border-medium);
|
||||
background: var(--color-bg-surface);
|
||||
color: var(--color-text-primary);
|
||||
@ -106,6 +130,8 @@
|
||||
.add-item-form-quantity-input {
|
||||
width: 40px;
|
||||
max-width: 40px;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: var(--input-padding-y) var(--input-padding-x);
|
||||
border: var(--border-width-thin) solid var(--input-border-color);
|
||||
border-radius: var(--input-border-radius);
|
||||
@ -142,9 +168,9 @@
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--button-font-weight);
|
||||
flex: 1;
|
||||
min-width: 120px
|
||||
min-width: 120px;
|
||||
transition: var(--transition-base);
|
||||
margin-top: var(--spacing-sm);
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.add-item-form-submit:hover:not(:disabled) {
|
||||
@ -174,9 +200,22 @@
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.add-item-form-assignee-toggle {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.add-item-form-quantity-control {
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.quantity-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
height: 100%;
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
.add-item-form-quantity-input,
|
||||
.add-item-form-submit {
|
||||
height: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
19
frontend/src/styles/components/AssignItemForModal.css
Normal file
19
frontend/src/styles/components/AssignItemForModal.css
Normal file
@ -0,0 +1,19 @@
|
||||
.assign-item-for-modal {
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.assign-item-for-modal-description {
|
||||
margin: 0 0 var(--spacing-md) 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.assign-item-for-modal-field {
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.assign-item-for-modal-empty {
|
||||
margin: 0 0 var(--spacing-sm) 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
81
frontend/src/styles/components/ToggleButtonGroup.css
Normal file
81
frontend/src/styles/components/ToggleButtonGroup.css
Normal file
@ -0,0 +1,81 @@
|
||||
.tbg-group {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--tbg-option-count, 1), minmax(0, 1fr));
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
padding: 2px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--background);
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.tbg-indicator {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
bottom: 2px;
|
||||
left: 2px;
|
||||
width: calc((100% - 4px) / var(--tbg-option-count, 1));
|
||||
border-radius: 999px;
|
||||
background: var(--primary);
|
||||
transform: translateX(calc(var(--tbg-active-index, 0) * 100%));
|
||||
transition: transform 0.22s ease, opacity 0.2s ease;
|
||||
opacity: 0;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.tbg-group.has-active .tbg-indicator {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tbg-button {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-fast), background-color var(--transition-fast);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tbg-button.tbg-size-default {
|
||||
padding: 0.5rem 0.8rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tbg-button.tbg-size-xs {
|
||||
padding: 0.35rem 0.5rem;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.tbg-button.is-active {
|
||||
color: var(--color-text-inverse);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.tbg-button.is-inactive:hover:not(:disabled) {
|
||||
color: var(--text-primary);
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .tbg-button.is-inactive:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.tbg-button:focus-visible {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.tbg-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user