Compare commits

..

No commits in common. "27a6a50744080d70a1cbf49aa6b83130dca1c68b" and "8387e22b4f118a8753a3daa8148c430854659245" have entirely different histories.

4 changed files with 135 additions and 104 deletions

View File

@ -42,7 +42,11 @@ export default function ManageStores() {
refreshZones,
} = useContext(StoreContext);
const toast = useActionToast();
const [newStoreName, setNewStoreName] = useState("");
const [createForm, setCreateForm] = useState({
name: "",
location_name: "",
address: "",
});
const [saving, setSaving] = useState(false);
const isAdmin = ["owner", "admin"].includes(activeHousehold?.role);
@ -58,17 +62,18 @@ export default function ManageStores() {
const handleCreateStore = async (event) => {
event.preventDefault();
const storeName = newStoreName.trim();
if (!storeName) return;
if (!createForm.name.trim()) return;
setSaving(true);
try {
await createHouseholdStore(activeHousehold.id, {
name: storeName,
name: createForm.name.trim(),
location_name: createForm.location_name.trim() || "Default Location",
address: createForm.address.trim() || null,
});
setNewStoreName("");
setCreateForm({ name: "", location_name: "", address: "" });
await refreshAfterStoreChange();
toast.success("Created store", `Created store ${storeName}`);
toast.success("Created store", `Created store ${createForm.name.trim()}`);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to create store");
toast.error("Create store failed", `Create store failed: ${message}`);
@ -80,23 +85,7 @@ export default function ManageStores() {
return (
<div className="manage-stores">
<section className="manage-section">
<div className="manage-stores-topline">
<h2>Store Locations ({householdStores.length})</h2>
{isAdmin ? (
<form className="add-store-inline" onSubmit={handleCreateStore}>
<input
value={newStoreName}
onChange={(event) => setNewStoreName(event.target.value)}
placeholder="Store name"
aria-label="Store name"
required
/>
<button type="submit" className="btn-primary" disabled={saving}>
{saving ? "Adding..." : "Add"}
</button>
</form>
) : null}
</div>
<p className="manage-stores-help">
Stores are private to this household. Locations define map-specific zones, item
placement, and shopping order.
@ -163,7 +152,34 @@ export default function ManageStores() {
)}
</section>
{!isAdmin && activeStore ? (
{isAdmin ? (
<section className="manage-section">
<h2>Add Store</h2>
<form className="add-store-panel" onSubmit={handleCreateStore}>
<input
value={createForm.name}
onChange={(event) => setCreateForm((current) => ({ ...current, name: event.target.value }))}
placeholder="Store name, e.g. Costco"
required
/>
<input
value={createForm.location_name}
onChange={(event) =>
setCreateForm((current) => ({ ...current, location_name: event.target.value }))
}
placeholder="Location name, e.g. Fontana"
/>
<input
value={createForm.address}
onChange={(event) => setCreateForm((current) => ({ ...current, address: event.target.value }))}
placeholder="Address or notes"
/>
<button type="submit" className="btn-primary" disabled={saving}>
{saving ? "Adding..." : "+ Add Store"}
</button>
</form>
</section>
) : activeStore ? (
<p className="manage-stores-note">
Household members can manage item defaults. Only owners and admins can manage stores,
locations, zones, and item deletion.

View File

@ -22,14 +22,6 @@
font-size: 1.3rem;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.manage-stores-topline {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(240px, 360px);
gap: 1rem;
align-items: center;
margin-bottom: 1rem;
}
@ -158,7 +150,7 @@
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
}
.add-store-inline input,
.add-store-panel input,
.store-management-create-row input,
.store-settings-form input {
width: 100%;
@ -245,15 +237,52 @@
justify-content: flex-end;
}
.add-store-inline {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.5rem;
align-items: center;
/* Add Store Panel */
.add-store-panel {
display: flex;
flex-direction: column;
gap: 1rem;
margin-top: 1rem;
}
.add-store-inline .btn-primary {
min-width: 4.5rem;
.available-stores {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.available-store-card {
background: var(--background);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.25rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
transition: all 0.2s;
}
.available-store-card:hover {
border-color: var(--primary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.available-store-card .store-info {
flex: 1;
}
.available-store-card h3 {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 0.25rem 0;
}
.available-store-card .store-location {
color: var(--text-secondary);
font-size: 0.85rem;
margin: 0;
}
/* Empty State */
@ -266,14 +295,22 @@
/* Responsive */
@media (max-width: 600px) {
.stores-list {
.stores-list,
.available-stores {
grid-template-columns: 1fr;
}
.available-store-card {
flex-direction: column;
align-items: flex-start;
}
.available-store-card button {
width: 100%;
}
.store-card-header,
.store-location-controls,
.manage-stores-topline,
.add-store-inline,
.store-items-modal-toolbar.store-management-create-row,
.store-items-modal-toolbar.store-location-create-row,
.store-management-row {
@ -284,10 +321,6 @@
width: 100%;
}
.add-store-inline .btn-primary {
width: 100%;
}
.store-management-order {
text-align: left;
}

View File

@ -105,11 +105,6 @@ test("manage stores opens a modal to edit and delete household store items", asy
await page.goto("/manage?tab=stores");
await expect(page.getByRole("heading", { name: "Add Store" })).toHaveCount(0);
await expect(page.getByPlaceholder("Store name")).toBeVisible();
await expect(page.getByPlaceholder("Location name")).toHaveCount(0);
await expect(page.getByPlaceholder("Address or notes")).toHaveCount(0);
const storeCard = page.locator(".store-card").filter({ hasText: "Costco" });
await expect(storeCard).toBeVisible();
await expect(storeCard.getByText("Costco", { exact: true })).toHaveCount(1);

View File

@ -47,15 +47,10 @@ test("manage stores add success shows success toast", async ({ page }) => {
await seedAuthStorage(page);
await mockConfig(page);
let stores = [
{
id: 10,
household_store_id: 100,
name: "Costco",
location_name: "Default Location",
display_name: "Costco",
is_default: true,
},
let linkedStoreIds = [10];
const allStores = [
{ id: 10, name: "Costco North", location: "North", is_default: true },
{ id: 11, name: "Costco South", location: "South", is_default: false },
];
await page.route("**/households", async (route) => {
@ -66,63 +61,65 @@ test("manage stores add success shows success toast", async ({ page }) => {
});
});
await page.route("**/households/1/stores", async (route) => {
await page.route("**/stores/household/1", async (route) => {
const request = route.request();
if (request.method() === "GET") {
const payload = linkedStoreIds.map((id, index) => {
const store = allStores.find((candidate) => candidate.id === id);
return {
...store,
is_default: index === 0,
};
});
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(stores),
body: JSON.stringify(payload),
});
return;
}
if (request.method() === "POST") {
const body = request.postDataJSON() as { name?: string };
const name = body.name || "New Store";
stores = [
...stores,
{
id: 11,
household_store_id: 101,
name,
location_name: "Default Location",
display_name: name,
is_default: false,
},
];
const body = request.postDataJSON() as { storeId?: number };
if (body.storeId && !linkedStoreIds.includes(body.storeId)) {
linkedStoreIds = [...linkedStoreIds, body.storeId];
}
await route.fulfill({
status: 201,
status: 200,
contentType: "application/json",
body: JSON.stringify({ store: stores[stores.length - 1] }),
body: JSON.stringify({ ok: true }),
});
return;
}
await route.fulfill({ status: 405 });
await route.fallback();
});
await page.route("**/households/1/locations/10/zones", async (route) => {
await page.route("**/stores", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ zones: [] }),
body: JSON.stringify(allStores),
});
});
await page.goto("/manage?tab=stores");
const addStoreForm = page.locator(".add-store-inline");
await addStoreForm.getByLabel("Store name").fill("Stater Bros");
await addStoreForm.getByRole("button", { name: "Add" }).click();
await page.getByRole("button", { name: "+ Add Store" }).click();
await page.locator(".available-store-card").filter({ hasText: "Costco South" }).getByRole("button", { name: "Add" }).click();
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Created store");
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Stater Bros");
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Added store");
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Costco South");
});
test("manage stores add failure shows normalized error toast", async ({ page }) => {
await seedAuthStorage(page);
await mockConfig(page);
const allStores = [
{ id: 10, name: "Costco North", location: "North", is_default: true },
{ id: 11, name: "Costco South", location: "South", is_default: false },
];
await page.route("**/households", async (route) => {
await route.fulfill({
status: 200,
@ -131,22 +128,13 @@ test("manage stores add failure shows normalized error toast", async ({ page })
});
});
await page.route("**/households/1/stores", async (route) => {
await page.route("**/stores/household/1", async (route) => {
const request = route.request();
if (request.method() === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{
id: 10,
household_store_id: 100,
name: "Costco",
location_name: "Default Location",
display_name: "Costco",
is_default: true,
},
]),
body: JSON.stringify([{ id: 10, name: "Costco North", location: "North", is_default: true }]),
});
return;
}
@ -162,23 +150,22 @@ test("manage stores add failure shows normalized error toast", async ({ page })
return;
}
await route.fulfill({ status: 405 });
await route.fallback();
});
await page.route("**/households/1/locations/10/zones", async (route) => {
await page.route("**/stores", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ zones: [] }),
body: JSON.stringify(allStores),
});
});
await page.goto("/manage?tab=stores");
const addStoreForm = page.locator(".add-store-inline");
await addStoreForm.getByLabel("Store name").fill("Costco");
await addStoreForm.getByRole("button", { name: "Add" }).click();
await page.getByRole("button", { name: "+ Add Store" }).click();
await page.locator(".available-store-card").filter({ hasText: "Costco South" }).getByRole("button", { name: "Add" }).click();
await expect(page.locator(".action-toast.action-toast-error")).toContainText("Create store failed");
await expect(page.locator(".action-toast.action-toast-error")).toContainText("Add store failed");
await expect(page.locator(".action-toast.action-toast-error")).toContainText("Store already linked to household");
});