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, refreshZones,
} = useContext(StoreContext); } = useContext(StoreContext);
const toast = useActionToast(); const toast = useActionToast();
const [newStoreName, setNewStoreName] = useState(""); const [createForm, setCreateForm] = useState({
name: "",
location_name: "",
address: "",
});
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const isAdmin = ["owner", "admin"].includes(activeHousehold?.role); const isAdmin = ["owner", "admin"].includes(activeHousehold?.role);
@ -58,17 +62,18 @@ export default function ManageStores() {
const handleCreateStore = async (event) => { const handleCreateStore = async (event) => {
event.preventDefault(); event.preventDefault();
const storeName = newStoreName.trim(); if (!createForm.name.trim()) return;
if (!storeName) return;
setSaving(true); setSaving(true);
try { try {
await createHouseholdStore(activeHousehold.id, { 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(); await refreshAfterStoreChange();
toast.success("Created store", `Created store ${storeName}`); toast.success("Created store", `Created store ${createForm.name.trim()}`);
} catch (error) { } catch (error) {
const message = getApiErrorMessage(error, "Failed to create store"); const message = getApiErrorMessage(error, "Failed to create store");
toast.error("Create store failed", `Create store failed: ${message}`); toast.error("Create store failed", `Create store failed: ${message}`);
@ -80,23 +85,7 @@ export default function ManageStores() {
return ( return (
<div className="manage-stores"> <div className="manage-stores">
<section className="manage-section"> <section className="manage-section">
<div className="manage-stores-topline"> <h2>Store Locations ({householdStores.length})</h2>
<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"> <p className="manage-stores-help">
Stores are private to this household. Locations define map-specific zones, item Stores are private to this household. Locations define map-specific zones, item
placement, and shopping order. placement, and shopping order.
@ -163,7 +152,34 @@ export default function ManageStores() {
)} )}
</section> </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"> <p className="manage-stores-note">
Household members can manage item defaults. Only owners and admins can manage stores, Household members can manage item defaults. Only owners and admins can manage stores,
locations, zones, and item deletion. locations, zones, and item deletion.

View File

@ -22,14 +22,6 @@
font-size: 1.3rem; font-size: 1.3rem;
font-weight: 600; font-weight: 600;
color: var(--text-primary); 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; margin-bottom: 1rem;
} }
@ -158,7 +150,7 @@
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
} }
.add-store-inline input, .add-store-panel input,
.store-management-create-row input, .store-management-create-row input,
.store-settings-form input { .store-settings-form input {
width: 100%; width: 100%;
@ -245,15 +237,52 @@
justify-content: flex-end; justify-content: flex-end;
} }
.add-store-inline { /* Add Store Panel */
display: grid; .add-store-panel {
grid-template-columns: minmax(0, 1fr) auto; display: flex;
gap: 0.5rem; flex-direction: column;
align-items: center; gap: 1rem;
margin-top: 1rem;
} }
.add-store-inline .btn-primary { .available-stores {
min-width: 4.5rem; 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 */ /* Empty State */
@ -266,14 +295,22 @@
/* Responsive */ /* Responsive */
@media (max-width: 600px) { @media (max-width: 600px) {
.stores-list { .stores-list,
.available-stores {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.available-store-card {
flex-direction: column;
align-items: flex-start;
}
.available-store-card button {
width: 100%;
}
.store-card-header, .store-card-header,
.store-location-controls, .store-location-controls,
.manage-stores-topline,
.add-store-inline,
.store-items-modal-toolbar.store-management-create-row, .store-items-modal-toolbar.store-management-create-row,
.store-items-modal-toolbar.store-location-create-row, .store-items-modal-toolbar.store-location-create-row,
.store-management-row { .store-management-row {
@ -284,10 +321,6 @@
width: 100%; width: 100%;
} }
.add-store-inline .btn-primary {
width: 100%;
}
.store-management-order { .store-management-order {
text-align: left; 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 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" }); const storeCard = page.locator(".store-card").filter({ hasText: "Costco" });
await expect(storeCard).toBeVisible(); await expect(storeCard).toBeVisible();
await expect(storeCard.getByText("Costco", { exact: true })).toHaveCount(1); 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 seedAuthStorage(page);
await mockConfig(page); await mockConfig(page);
let stores = [ let linkedStoreIds = [10];
{ const allStores = [
id: 10, { id: 10, name: "Costco North", location: "North", is_default: true },
household_store_id: 100, { id: 11, name: "Costco South", location: "South", is_default: false },
name: "Costco",
location_name: "Default Location",
display_name: "Costco",
is_default: true,
},
]; ];
await page.route("**/households", async (route) => { 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(); const request = route.request();
if (request.method() === "GET") { 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({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify(stores), body: JSON.stringify(payload),
}); });
return; return;
} }
if (request.method() === "POST") { if (request.method() === "POST") {
const body = request.postDataJSON() as { name?: string }; const body = request.postDataJSON() as { storeId?: number };
const name = body.name || "New Store"; if (body.storeId && !linkedStoreIds.includes(body.storeId)) {
stores = [ linkedStoreIds = [...linkedStoreIds, body.storeId];
...stores, }
{
id: 11,
household_store_id: 101,
name,
location_name: "Default Location",
display_name: name,
is_default: false,
},
];
await route.fulfill({ await route.fulfill({
status: 201, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ store: stores[stores.length - 1] }), body: JSON.stringify({ ok: true }),
}); });
return; 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({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ zones: [] }), body: JSON.stringify(allStores),
}); });
}); });
await page.goto("/manage?tab=stores"); await page.goto("/manage?tab=stores");
const addStoreForm = page.locator(".add-store-inline"); await page.getByRole("button", { name: "+ Add Store" }).click();
await addStoreForm.getByLabel("Store name").fill("Stater Bros"); await page.locator(".available-store-card").filter({ hasText: "Costco South" }).getByRole("button", { name: "Add" }).click();
await addStoreForm.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("Added store");
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Stater Bros"); await expect(page.locator(".action-toast.action-toast-success")).toContainText("Costco South");
}); });
test("manage stores add failure shows normalized error toast", async ({ page }) => { test("manage stores add failure shows normalized error toast", async ({ page }) => {
await seedAuthStorage(page); await seedAuthStorage(page);
await mockConfig(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 page.route("**/households", async (route) => {
await route.fulfill({ await route.fulfill({
status: 200, 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(); const request = route.request();
if (request.method() === "GET") { if (request.method() === "GET") {
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify([ body: JSON.stringify([{ id: 10, name: "Costco North", location: "North", is_default: true }]),
{
id: 10,
household_store_id: 100,
name: "Costco",
location_name: "Default Location",
display_name: "Costco",
is_default: true,
},
]),
}); });
return; return;
} }
@ -162,23 +150,22 @@ test("manage stores add failure shows normalized error toast", async ({ page })
return; 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({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ zones: [] }), body: JSON.stringify(allStores),
}); });
}); });
await page.goto("/manage?tab=stores"); await page.goto("/manage?tab=stores");
const addStoreForm = page.locator(".add-store-inline"); await page.getByRole("button", { name: "+ Add Store" }).click();
await addStoreForm.getByLabel("Store name").fill("Costco"); await page.locator(".available-store-card").filter({ hasText: "Costco South" }).getByRole("button", { name: "Add" }).click();
await addStoreForm.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"); await expect(page.locator(".action-toast.action-toast-error")).toContainText("Store already linked to household");
}); });