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,
} = useContext(StoreContext);
const toast = useActionToast();
const [createForm, setCreateForm] = useState({
name: "",
location_name: "",
address: "",
});
const [newStoreName, setNewStoreName] = useState("");
const [saving, setSaving] = useState(false);
const isAdmin = ["owner", "admin"].includes(activeHousehold?.role);
@ -62,18 +58,17 @@ export default function ManageStores() {
const handleCreateStore = async (event) => {
event.preventDefault();
if (!createForm.name.trim()) return;
const storeName = newStoreName.trim();
if (!storeName) return;
setSaving(true);
try {
await createHouseholdStore(activeHousehold.id, {
name: createForm.name.trim(),
location_name: createForm.location_name.trim() || "Default Location",
address: createForm.address.trim() || null,
name: storeName,
});
setCreateForm({ name: "", location_name: "", address: "" });
setNewStoreName("");
await refreshAfterStoreChange();
toast.success("Created store", `Created store ${createForm.name.trim()}`);
toast.success("Created store", `Created store ${storeName}`);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to create store");
toast.error("Create store failed", `Create store failed: ${message}`);
@ -85,7 +80,23 @@ 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.
@ -152,34 +163,7 @@ export default function ManageStores() {
)}
</section>
{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 ? (
{!isAdmin && 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,6 +22,14 @@
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;
}
@ -150,7 +158,7 @@
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
}
.add-store-panel input,
.add-store-inline input,
.store-management-create-row input,
.store-settings-form input {
width: 100%;
@ -237,52 +245,15 @@
justify-content: flex-end;
}
/* Add Store Panel */
.add-store-panel {
display: flex;
flex-direction: column;
gap: 1rem;
margin-top: 1rem;
}
.available-stores {
.add-store-inline {
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;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.5rem;
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;
.add-store-inline .btn-primary {
min-width: 4.5rem;
}
/* Empty State */
@ -295,22 +266,14 @@
/* Responsive */
@media (max-width: 600px) {
.stores-list,
.available-stores {
.stores-list {
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 {
@ -321,6 +284,10 @@
width: 100%;
}
.add-store-inline .btn-primary {
width: 100%;
}
.store-management-order {
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 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,10 +47,15 @@ test("manage stores add success shows success toast", async ({ page }) => {
await seedAuthStorage(page);
await mockConfig(page);
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 },
let stores = [
{
id: 10,
household_store_id: 100,
name: "Costco",
location_name: "Default Location",
display_name: "Costco",
is_default: true,
},
];
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();
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(payload),
body: JSON.stringify(stores),
});
return;
}
if (request.method() === "POST") {
const body = request.postDataJSON() as { storeId?: number };
if (body.storeId && !linkedStoreIds.includes(body.storeId)) {
linkedStoreIds = [...linkedStoreIds, body.storeId];
}
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,
},
];
await route.fulfill({
status: 200,
status: 201,
contentType: "application/json",
body: JSON.stringify({ ok: true }),
body: JSON.stringify({ store: stores[stores.length - 1] }),
});
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({
status: 200,
contentType: "application/json",
body: JSON.stringify(allStores),
body: JSON.stringify({ zones: [] }),
});
});
await page.goto("/manage?tab=stores");
await page.getByRole("button", { name: "+ Add Store" }).click();
await page.locator(".available-store-card").filter({ hasText: "Costco South" }).getByRole("button", { name: "Add" }).click();
const addStoreForm = page.locator(".add-store-inline");
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("Costco South");
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Created store");
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Stater Bros");
});
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,
@ -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();
if (request.method() === "GET") {
await route.fulfill({
status: 200,
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;
}
@ -150,22 +162,23 @@ test("manage stores add failure shows normalized error toast", async ({ page })
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({
status: 200,
contentType: "application/json",
body: JSON.stringify(allStores),
body: JSON.stringify({ zones: [] }),
});
});
await page.goto("/manage?tab=stores");
await page.getByRole("button", { name: "+ Add Store" }).click();
await page.locator(".available-store-card").filter({ hasText: "Costco South" }).getByRole("button", { name: "Add" }).click();
const addStoreForm = page.locator(".add-store-inline");
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");
});