Compact add store control #15

Merged
nalalangan merged 1 commits from feature/compact-add-store-control into feature-custom-store-locations 2026-05-31 19:07:59 -09:00
4 changed files with 102 additions and 133 deletions
Showing only changes of commit f80ea472ce - Show all commits

View File

@ -42,11 +42,7 @@ export default function ManageStores() {
refreshZones, refreshZones,
} = useContext(StoreContext); } = useContext(StoreContext);
const toast = useActionToast(); const toast = useActionToast();
const [createForm, setCreateForm] = useState({ const [newStoreName, setNewStoreName] = 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);
@ -62,18 +58,17 @@ export default function ManageStores() {
const handleCreateStore = async (event) => { const handleCreateStore = async (event) => {
event.preventDefault(); event.preventDefault();
if (!createForm.name.trim()) return; const storeName = newStoreName.trim();
if (!storeName) return;
setSaving(true); setSaving(true);
try { try {
await createHouseholdStore(activeHousehold.id, { await createHouseholdStore(activeHousehold.id, {
name: createForm.name.trim(), name: storeName,
location_name: createForm.location_name.trim() || "Default Location",
address: createForm.address.trim() || null,
}); });
setCreateForm({ name: "", location_name: "", address: "" }); setNewStoreName("");
await refreshAfterStoreChange(); await refreshAfterStoreChange();
toast.success("Created store", `Created store ${createForm.name.trim()}`); toast.success("Created store", `Created store ${storeName}`);
} 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}`);
@ -85,7 +80,23 @@ 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.
@ -152,34 +163,7 @@ export default function ManageStores() {
)} )}
</section> </section>
{isAdmin ? ( {!isAdmin && activeStore ? (
<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,6 +22,14 @@
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;
} }
@ -150,7 +158,7 @@
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
} }
.add-store-panel input, .add-store-inline input,
.store-management-create-row input, .store-management-create-row input,
.store-settings-form input { .store-settings-form input {
width: 100%; width: 100%;
@ -237,52 +245,15 @@
justify-content: flex-end; justify-content: flex-end;
} }
/* Add Store Panel */ .add-store-inline {
.add-store-panel {
display: flex;
flex-direction: column;
gap: 1rem;
margin-top: 1rem;
}
.available-stores {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); grid-template-columns: minmax(0, 1fr) auto;
gap: 1rem; gap: 0.5rem;
}
.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; align-items: center;
gap: 1rem;
transition: all 0.2s;
} }
.available-store-card:hover { .add-store-inline .btn-primary {
border-color: var(--primary); min-width: 4.5rem;
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 */
@ -295,22 +266,14 @@
/* 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 {
@ -321,6 +284,10 @@
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,6 +105,11 @@ 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,10 +47,15 @@ test("manage stores add success shows success toast", async ({ page }) => {
await seedAuthStorage(page); await seedAuthStorage(page);
await mockConfig(page); await mockConfig(page);
let linkedStoreIds = [10]; let stores = [
const allStores = [ {
{ id: 10, name: "Costco North", location: "North", is_default: true }, id: 10,
{ id: 11, name: "Costco South", location: "South", is_default: false }, household_store_id: 100,
name: "Costco",
location_name: "Default Location",
display_name: "Costco",
is_default: true,
},
]; ];
await page.route("**/households", async (route) => { await page.route("**/households", async (route) => {
@ -61,65 +66,63 @@ test("manage stores add success shows success toast", async ({ page }) => {
}); });
}); });
await page.route("**/stores/household/1", async (route) => { await page.route("**/households/1/stores", 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(payload), body: JSON.stringify(stores),
}); });
return; return;
} }
if (request.method() === "POST") { if (request.method() === "POST") {
const body = request.postDataJSON() as { storeId?: number }; const body = request.postDataJSON() as { name?: string };
if (body.storeId && !linkedStoreIds.includes(body.storeId)) { const name = body.name || "New Store";
linkedStoreIds = [...linkedStoreIds, body.storeId]; stores = [
} ...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: 200, status: 201,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ ok: true }), body: JSON.stringify({ store: stores[stores.length - 1] }),
}); });
return; return;
} }
await route.fallback(); await route.fulfill({ status: 405 });
}); });
await page.route("**/stores", async (route) => { await page.route("**/households/1/locations/10/zones", async (route) => {
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify(allStores), body: JSON.stringify({ zones: [] }),
}); });
}); });
await page.goto("/manage?tab=stores"); await page.goto("/manage?tab=stores");
await page.getByRole("button", { name: "+ Add Store" }).click(); const addStoreForm = page.locator(".add-store-inline");
await page.locator(".available-store-card").filter({ hasText: "Costco South" }).getByRole("button", { name: "Add" }).click(); await addStoreForm.getByLabel("Store name").fill("Stater Bros");
await addStoreForm.getByRole("button", { name: "Add" }).click();
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Added store"); await expect(page.locator(".action-toast.action-toast-success")).toContainText("Created store");
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Costco South"); await expect(page.locator(".action-toast.action-toast-success")).toContainText("Stater Bros");
}); });
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,
@ -128,13 +131,22 @@ test("manage stores add failure shows normalized error toast", async ({ page })
}); });
}); });
await page.route("**/stores/household/1", async (route) => { await page.route("**/households/1/stores", 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([{ id: 10, name: "Costco North", location: "North", is_default: true }]), body: JSON.stringify([
{
id: 10,
household_store_id: 100,
name: "Costco",
location_name: "Default Location",
display_name: "Costco",
is_default: true,
},
]),
}); });
return; return;
} }
@ -150,22 +162,23 @@ test("manage stores add failure shows normalized error toast", async ({ page })
return; return;
} }
await route.fallback(); await route.fulfill({ status: 405 });
}); });
await page.route("**/stores", async (route) => { await page.route("**/households/1/locations/10/zones", async (route) => {
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify(allStores), body: JSON.stringify({ zones: [] }),
}); });
}); });
await page.goto("/manage?tab=stores"); await page.goto("/manage?tab=stores");
await page.getByRole("button", { name: "+ Add Store" }).click(); const addStoreForm = page.locator(".add-store-inline");
await page.locator(".available-store-card").filter({ hasText: "Costco South" }).getByRole("button", { name: "Add" }).click(); await addStoreForm.getByLabel("Store name").fill("Costco");
await addStoreForm.getByRole("button", { name: "Add" }).click();
await expect(page.locator(".action-toast.action-toast-error")).toContainText("Add store failed"); await expect(page.locator(".action-toast.action-toast-error")).toContainText("Create 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");
}); });