Compare commits
3 Commits
b7e4ebc15a
...
f38530cf81
| Author | SHA1 | Date | |
|---|---|---|---|
| f38530cf81 | |||
| 1ce92f3c99 | |||
| 7c30c7db27 |
+25
-59
@@ -46,55 +46,31 @@ Speculative or longer-term ideas live in `ideas.md`.
|
||||
|
||||
## Product catalog
|
||||
|
||||
### Introduce Product entity + family-scoped overrides
|
||||
A `Product` becomes the canonical thing being bought. Shopping list items and recipe ingredients reference a product instead of (or in addition to) carrying a free-form name. Goals:
|
||||
1. Ship an extensive pre-populated grocery catalog so users don't enter "Bananas" from scratch.
|
||||
2. Allow free-form product creation when something isn't in the catalog.
|
||||
3. Allow editing of pre-populated product details (e.g. assigning a default section per store).
|
||||
4. **Custom products and edits are scoped to the family that made them** — never visible to other families.
|
||||
5. Long-term: track family-level edits/additions to surface promotion candidates for the global catalog.
|
||||
The product-catalog foundation has shipped. A `Product` is the canonical thing being bought; `ShoppingListItem` and `RecipeIngredient` carry an optional `ProductId`/`FamilyProductId` link with the free-form `Name` retained as a fallback for one-off text entries.
|
||||
|
||||
#### Data model sketch
|
||||
- `Product` (global catalog, read-only): `Id`, `Name`, `DefaultUnit?`, `Brand?`, `Notes?`, search keys.
|
||||
- `FamilyProduct` (family-owned): `Id`, `FamilyId`, `Name`, plus the same detail fields. Family-only products live here.
|
||||
- `FamilyProductOverride` (family-scoped edit of a global product): `FamilyId`, `ProductId`, overridden fields. The effective view = global + family override merged.
|
||||
- Per-store-per-product section assignment: `ProductStoreSection` (`FamilyId`, `ProductId`-or-`FamilyProductId`, `StoreId`, `StoreSectionId`). This is what auto-assigns "Bananas → Produce" on next add at that store, answering the open question in the sections feature.
|
||||
- `ShoppingListItem` and `RecipeIngredient` get optional `ProductId` (or `FamilyProductId`); free-form `Name` remains as a fallback for legacy rows / pure-text entries.
|
||||
#### Shipped
|
||||
- **Entities & schema:** `Product` (global, read-only), `FamilyProduct` (family-owned), `FamilyProductOverride` (per-family edit of a global product, composite PK on `FamilyId` + `ProductId`), and `ProductStoreSection` (per-store-per-product section memory). `AllowedUnitCategories` on each so the unit dropdown can be narrowed per product.
|
||||
- **API (`/api/products`):** search with transparent override merge (returns `isOverridden` per row); `POST` to create a `FamilyProduct`; `PUT /family/{id}` and `PUT /global/{id}` (the latter upserts a `FamilyProductOverride`); `DELETE /family/{id}` and `DELETE /global/{id}/override` to remove.
|
||||
- **Frontend typeahead:** wired into the shopping list add-item form and both recipe new/edit forms. Picking a product attaches the link; editing the input after a pick clears it. No-match Enter creates a pure-text item (`ProductId = null`).
|
||||
- **Catalog management page (`/products`):** search the effective catalog, add a family product, edit a global product (writes an override) or family product, "Reset to catalog default" for an overridden global, and delete a family product. Linked from the lists page.
|
||||
- **Override indicator:** "Edited" pill on overridden rows in both the catalog page and the typeahead dropdown.
|
||||
- **`ProductStoreSection` write path:** when an item is saved/checked with `(productId, sectionId)`, the mapping is remembered for `(family, store, product)`.
|
||||
- **Seed data:** ~50 hand-curated common-groceries items in `Data/Seed/products.json`, applied by `CatalogSeeder` on startup (idempotent on `Name`).
|
||||
|
||||
#### API
|
||||
- `/api/products?q=` — search effective catalog (global ∪ family additions, with overrides applied). Returns merged results without exposing whether a hit came from the global catalog vs. family.
|
||||
- `/api/products` POST — create a `FamilyProduct` (family scope).
|
||||
- `/api/products/{id}` PUT — write a `FamilyProductOverride` if the id refers to a global product, or update the `FamilyProduct` directly if family-owned.
|
||||
- All scoped by the authed user's `FamilyId`.
|
||||
#### Remaining
|
||||
- **Auto-assign section from product on add.** The write path remembers `(product, store) → section` and the backend has a helper that reads it back, but the add-item form doesn't call it on product pick yet. Described in detail in *Auto-assign section from product* further down.
|
||||
- **"Add '<text>' as a new product" affordance in the typeahead.** Today the typeahead only suggests existing catalog rows; an unmatched name becomes a pure-text item. Adding an explicit affordance to promote that name into a `FamilyProduct` from the same dropdown is still open.
|
||||
- **Seed expansion to ~2–3k curated items.** Current seed file has ~50 entries. Growth is a data exercise, not a code one — keep `products.json` the source of truth, keep the seeder idempotent on `Name`.
|
||||
- **Catalog ingestion tooling (future).** When the curated list starts feeling limiting, build re-runnable importers for public datasets so we don't grow by hand-typing.
|
||||
- Candidate sources: **USDA FoodData Central** (public domain — Branded Foods ~400k with UPCs, Foundation/SR Legacy generics) and **OpenFoodFacts** (ODbL share-alike + attribution — ~3M global products, barcodes, images; watch the share-alike obligation on derived datasets).
|
||||
- Tooling concerns: normalize and dedupe by name+brand+UPC; record `source` + `source-id` on each imported `Product` for re-sync/revert; per-source attribution metadata for license compliance; idempotent scripts kept out of the runtime startup path; a quality/popularity flag so imports don't drown the curated set in the typeahead.
|
||||
- **Promotion analytics (long-term).** Aggregate `FamilyProduct` creations across families to surface promotion candidates for the global catalog. Requires the multi-tenant model AND privacy guardrails: only emit aggregated, normalized name counts — never raw family data. Manual editorial step to actually promote.
|
||||
|
||||
#### Seed data
|
||||
- **Decision (2026-05-06):** start with a hand-curated common-groceries list (~2–3k items). High-signal, fast to ship, no licensing entanglement, gives the typeahead something useful on day one.
|
||||
- Catalog seeding runs as a one-time data migration, not on every startup.
|
||||
- **Decision (2026-05-06):** recipes share the same product catalog. `RecipeIngredient` gets an optional `ProductId` — typeahead during entry, with **free-form text entry always available as a fallback** (for "salt to taste", "1 onion", or anything not in the catalog). This unblocks the "add recipe to grocery list" flow described below.
|
||||
|
||||
#### Future: catalog ingestion tooling
|
||||
- Build tooling to ingest from public-domain / open-source catalog sources so the curated list can grow without manual data entry. In scope when the curated list starts to feel limiting.
|
||||
- Candidate sources to support:
|
||||
- **USDA FoodData Central** (public domain) — Branded Foods (~400k with UPCs) and Foundation/SR Legacy (generic items). Bulk CSV/JSON downloads available.
|
||||
- **OpenFoodFacts** (ODbL — share-alike, attribution) — ~3M global products, barcodes, images. Watch out for the share-alike obligation on derived datasets.
|
||||
- Any future open dataset that surfaces.
|
||||
- Tooling concerns:
|
||||
- Normalization pipeline: dedupe by name + brand + UPC, map to our `Product` schema, drop nutrition fields we don't use (or keep behind a flag).
|
||||
- Provenance: record source + source-id on each imported `Product` so we can re-sync or revert.
|
||||
- License compliance: keep per-source attribution metadata; surface in an /about or /credits page if any source requires it.
|
||||
- Re-runnable: idempotent import scripts (no duplicates on re-run); separate from runtime startup.
|
||||
- Curation workflow: imported items shouldn't drown the curated set in the typeahead — likely a quality/popularity flag controls default surfacing.
|
||||
|
||||
#### Promotion analytics (long-term)
|
||||
- Aggregate `FamilyProduct` creations across families: count distinct families using a given normalized name, with a threshold (e.g. ≥N families with ≥M lists each) before flagging for editorial review.
|
||||
- This requires the multi-tenant model above, AND privacy considerations: only emit aggregated, normalized name counts — never raw family data.
|
||||
- Manual editorial step to actually promote → keeps the global catalog quality high.
|
||||
|
||||
#### Open questions
|
||||
- Free-form entry on a list — does the user pick from a typeahead (preferred) or can they bypass the catalog entirely with a one-off text item? Recommend typeahead with "Add '<text>' as a new product" affordance.
|
||||
- When a global product is overridden, is the override applied automatically (transparent) or shown as "Edited by your family" in the UI?
|
||||
- Should recipes use the same product catalog, or stay free-form? Big consistency win if they share, but recipe ingredients tend to be more abstract ("flour") than shopping items ("King Arthur AP Flour 5lb").
|
||||
- How does this interact with the existing `Store.Name` uniqueness — moot if we add `FamilyId` to everything.
|
||||
#### Decisions on record
|
||||
- **Overrides are visibly marked, not transparent.** A small "Edited" pill in any product surface tells the user this row differs from the catalog default.
|
||||
- **Recipes share the catalog with shopping lists** (decided 2026-05-06). `RecipeIngredient` carries an optional `ProductId`; free-form text remains for "salt to taste" / "1 onion" cases.
|
||||
- **Reset granularity is whole-override.** A single "Reset to catalog default" button removes the entire `FamilyProductOverride` row. Per-field reset was considered and rejected as over-engineering for v1.
|
||||
- **Family-product delete is unconditional.** FKs from `ShoppingListItem`/`RecipeIngredient` are `SET NULL`, `ProductStoreSection` cascades — so deleting a family product is safe; linked rows keep their text and lose the link.
|
||||
|
||||
## Shopping list items
|
||||
|
||||
@@ -167,15 +143,11 @@ When a user picks a product from the typeahead on the shopping list add form, pr
|
||||
|
||||
This is the "per-store ingredient memory" item above, stated from the user's perspective: choosing "Spaghetti" should already know it belongs in Pasta/Dry Goods at this store.
|
||||
|
||||
**Data model:** `ProductStoreSection` is already sketched in the product catalog section above — `(FamilyId, ProductId | FamilyProductId, StoreId, StoreSectionId)`. That table is the source of truth.
|
||||
**State of play:** `ProductStoreSection` is shipped — table, indexes, and the write path that records `(family, store, product) → section` whenever an item with a product link is saved with a chosen section. A backend helper in `ShoppingListEndpoints` already reads the effective section back. **The missing piece is the frontend:** `onItemProductChange` in `lists/[id]/+page.svelte` doesn't call the read path on product pick yet, so the section dropdown still defaults to "Uncategorized" until the user sets it.
|
||||
|
||||
**Write path:** when a user manually changes the section on a list item whose product is linked, offer to remember that choice (or auto-remember silently after N uses). Write to `ProductStoreSection`.
|
||||
**To finish:** expose the lookup via an endpoint (`GET /api/products/{id}/section?storeId=...` or roll it into the typeahead response when a `storeId` is supplied), call it on `onItemProductChange`, and pre-select the returned section. Same treatment for the recipe → list copy path so adding a recipe to a list lands ingredients in the right sections.
|
||||
|
||||
**Read path:** on product selection in the add-item form, query the effective section for `(productId, storeId)` and pre-select it in the section dropdown. User can still override.
|
||||
|
||||
**Dependencies:** requires the product catalog entity to be in place (`ProductId` on `ShoppingListItem`, product typeahead already wired). The `ProductStoreSection` table and its endpoints are net-new work.
|
||||
|
||||
**Scope note:** the section pre-fill should be scoped to the family's own memory (`ProductStoreSection` rows they've created), not a global default — different families organize differently.
|
||||
**Scope note:** the section pre-fill is family-scoped memory (`ProductStoreSection` rows they've created), not a global default — different families organize differently.
|
||||
|
||||
## Recipes
|
||||
|
||||
@@ -194,12 +166,6 @@ Replace the single free-form instructions textarea with an ordered list of discr
|
||||
- Keep `Instructions` as a migration fallback column, or do a single-step cutover migration?
|
||||
- Should steps support rich text (bold ingredient names, timers) or stay plain text for v1?
|
||||
|
||||
## Lists
|
||||
|
||||
### Block create-list flow when no stores exist
|
||||
- A shopping list requires a `Store` (see `ListSummary.store` and the `newStoreId` state in `lists/+page.svelte`), so the create-list flow shouldn't be available until at least one store exists.
|
||||
- Behavior: if user tries to open the create-list UI (or hits the create-list page directly via URL) with zero stores, surface a `toast.warning()` (or modal) that says "You need to create a store first" with a CTA linking to `/stores`. Don't render the empty/broken create form.
|
||||
|
||||
## Stores
|
||||
|
||||
### Store types / categorization
|
||||
|
||||
@@ -153,6 +153,48 @@ public static class ProductEndpoints
|
||||
return Results.Ok(ToDto(product));
|
||||
});
|
||||
|
||||
// Remove a family-owned product. ShoppingListItem / RecipeIngredient
|
||||
// FKs are configured ON DELETE SET NULL, and ProductStoreSection rows
|
||||
// cascade — so deletion is safe; previously-linked rows just lose the
|
||||
// product link and continue to render their free-form Name.
|
||||
group.MapDelete("/family/{id:int}", async (int id, YesChefDb db, HttpContext http) =>
|
||||
{
|
||||
var familyId = http.User.GetFamilyId();
|
||||
var product = await db.FamilyProducts.FirstOrDefaultAsync(p => p.Id == id && p.FamilyId == familyId);
|
||||
if (product is null) return Results.NotFound();
|
||||
db.FamilyProducts.Remove(product);
|
||||
await db.SaveChangesAsync();
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
// Reset a family's view of a global product to the catalog default by
|
||||
// deleting any FamilyProductOverride row. Returns the now-unmodified
|
||||
// effective DTO so the client can swap state without a refetch. No-op
|
||||
// (still 200) when there's nothing to reset — keeps the UI idempotent.
|
||||
group.MapDelete("/global/{id:int}/override", async (int id, YesChefDb db, HttpContext http) =>
|
||||
{
|
||||
var familyId = http.User.GetFamilyId();
|
||||
var product = await db.Products.AsNoTracking().FirstOrDefaultAsync(p => p.Id == id);
|
||||
if (product is null) return Results.NotFound();
|
||||
|
||||
var ovr = await db.FamilyProductOverrides
|
||||
.FirstOrDefaultAsync(o => o.FamilyId == familyId && o.ProductId == id);
|
||||
if (ovr is not null)
|
||||
{
|
||||
db.FamilyProductOverrides.Remove(ovr);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return Results.Ok(new ProductDto(
|
||||
product.Id,
|
||||
ProductKind.Global,
|
||||
product.Name,
|
||||
product.Brand,
|
||||
product.Notes,
|
||||
IsOverridden: false,
|
||||
product.AllowedUnitCategories));
|
||||
});
|
||||
|
||||
// Update a global product for this family by upserting an override.
|
||||
// Each non-null field becomes the override; null fields fall back to
|
||||
// the global value. To "reset" a field to the global, send null —
|
||||
|
||||
@@ -183,6 +183,12 @@
|
||||
}}
|
||||
>
|
||||
<span>{suggestion.name}</span>
|
||||
{#if suggestion.isOverridden}
|
||||
<span
|
||||
class="ml-2 rounded-full bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-800"
|
||||
title="Edited by your family">Edited</span
|
||||
>
|
||||
{/if}
|
||||
{#if suggestion.brand}
|
||||
<span class="ml-2 text-xs text-gray-400">{suggestion.brand}</span>
|
||||
{/if}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api';
|
||||
import { toast } from '$lib/toast.svelte';
|
||||
import { startConnection, stopConnection } from '$lib/signalr';
|
||||
import type { HubConnection } from '@microsoft/signalr';
|
||||
|
||||
@@ -64,6 +66,16 @@
|
||||
await stopConnection();
|
||||
});
|
||||
|
||||
function toggleCreate() {
|
||||
if (!showCreate && stores.length === 0) {
|
||||
toast.warning('You need to create a store first', {
|
||||
action: { label: 'Add a store', onClick: () => goto('/stores') }
|
||||
});
|
||||
return;
|
||||
}
|
||||
showCreate = !showCreate;
|
||||
}
|
||||
|
||||
async function createList() {
|
||||
if (!newName.trim() || !newStoreId) return;
|
||||
await api<{ id: number }>('/api/lists', {
|
||||
@@ -79,7 +91,7 @@
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="text-2xl font-bold">Shopping Lists</h2>
|
||||
<button
|
||||
onclick={() => (showCreate = !showCreate)}
|
||||
onclick={toggleCreate}
|
||||
class="rounded-full bg-primary px-4 py-2 text-sm font-semibold text-white"
|
||||
>
|
||||
{showCreate ? 'Cancel' : '+ New list'}
|
||||
@@ -153,10 +165,14 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-6 border-t border-gray-100 pt-4">
|
||||
<div class="mt-6 space-y-3 border-t border-gray-100 pt-4">
|
||||
<a href="/stores" class="flex items-center justify-between text-sm text-gray-500">
|
||||
<span>Manage stores</span>
|
||||
<span>›</span>
|
||||
</a>
|
||||
<a href="/products" class="flex items-center justify-between text-sm text-gray-500">
|
||||
<span>Manage products</span>
|
||||
<span>›</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { toast } from '$lib/toast.svelte';
|
||||
import { UnitCategoryFlag, type ProductSuggestion } from '$lib/ProductTypeahead.svelte';
|
||||
|
||||
let products = $state<ProductSuggestion[]>([]);
|
||||
let query = $state('');
|
||||
let loading = $state(true);
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
// Increment per request; only the most recent response wins. Same pattern as the typeahead.
|
||||
let requestSeq = 0;
|
||||
|
||||
// Form modal — used for both add (editingProduct === null) and edit.
|
||||
let formOpen = $state(false);
|
||||
let editingProduct = $state<ProductSuggestion | null>(null);
|
||||
let formName = $state('');
|
||||
let formBrand = $state('');
|
||||
let formNotes = $state('');
|
||||
// Bitfield matching the backend UnitCategoryFlags. 0 = "any unit allowed".
|
||||
let formCategories = $state(0);
|
||||
let saving = $state(false);
|
||||
|
||||
// Confirmation modals — rendered after the form closes so we only ever show one dialog at a time.
|
||||
let pendingResetGlobal = $state<ProductSuggestion | null>(null);
|
||||
let pendingDeleteFamily = $state<ProductSuggestion | null>(null);
|
||||
|
||||
const categoryOptions = [
|
||||
{ flag: UnitCategoryFlag.Count, label: 'Count' },
|
||||
{ flag: UnitCategoryFlag.Weight, label: 'Weight' },
|
||||
{ flag: UnitCategoryFlag.Volume, label: 'Volume' },
|
||||
{ flag: UnitCategoryFlag.Packaging, label: 'Packaging' }
|
||||
];
|
||||
|
||||
onMount(() => {
|
||||
void search('');
|
||||
});
|
||||
|
||||
async function search(q: string) {
|
||||
const seq = ++requestSeq;
|
||||
loading = true;
|
||||
try {
|
||||
const results = await api<ProductSuggestion[]>(
|
||||
`/api/products?q=${encodeURIComponent(q)}`
|
||||
);
|
||||
if (seq !== requestSeq) return;
|
||||
products = results;
|
||||
} catch (e) {
|
||||
if (seq !== requestSeq) return;
|
||||
toast.error(e instanceof Error ? e.message : 'Failed to load products');
|
||||
} finally {
|
||||
if (seq === requestSeq) loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onQueryInput() {
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => void search(query.trim()), 200);
|
||||
}
|
||||
|
||||
function startAdd() {
|
||||
editingProduct = null;
|
||||
formName = '';
|
||||
formBrand = '';
|
||||
formNotes = '';
|
||||
formCategories = 0;
|
||||
formOpen = true;
|
||||
}
|
||||
|
||||
function startEdit(p: ProductSuggestion) {
|
||||
editingProduct = p;
|
||||
formName = p.name;
|
||||
formBrand = p.brand ?? '';
|
||||
formNotes = p.notes ?? '';
|
||||
formCategories = p.allowedUnitCategories;
|
||||
formOpen = true;
|
||||
}
|
||||
|
||||
function toggleCategory(flag: number) {
|
||||
formCategories ^= flag;
|
||||
}
|
||||
|
||||
function closeForm() {
|
||||
formOpen = false;
|
||||
editingProduct = null;
|
||||
}
|
||||
|
||||
async function saveForm() {
|
||||
const name = formName.trim();
|
||||
if (!name) return;
|
||||
saving = true;
|
||||
try {
|
||||
const body = {
|
||||
name,
|
||||
brand: formBrand.trim() || null,
|
||||
notes: formNotes.trim() || null,
|
||||
allowedUnitCategories: formCategories
|
||||
};
|
||||
if (editingProduct === null) {
|
||||
await api('/api/products', { method: 'POST', body: JSON.stringify(body) });
|
||||
} else if (editingProduct.kind === 'Family') {
|
||||
await api(`/api/products/family/${editingProduct.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
} else {
|
||||
await api(`/api/products/global/${editingProduct.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
}
|
||||
closeForm();
|
||||
await search(query.trim());
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Failed to save product');
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function requestResetGlobal() {
|
||||
if (editingProduct?.kind !== 'Global') return;
|
||||
pendingResetGlobal = editingProduct;
|
||||
closeForm();
|
||||
}
|
||||
|
||||
function requestDeleteFamily() {
|
||||
if (editingProduct?.kind !== 'Family') return;
|
||||
pendingDeleteFamily = editingProduct;
|
||||
closeForm();
|
||||
}
|
||||
|
||||
async function confirmResetGlobal() {
|
||||
if (!pendingResetGlobal) return;
|
||||
const id = pendingResetGlobal.id;
|
||||
pendingResetGlobal = null;
|
||||
try {
|
||||
await api(`/api/products/global/${id}/override`, { method: 'DELETE' });
|
||||
await search(query.trim());
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Failed to reset product');
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeleteFamily() {
|
||||
if (!pendingDeleteFamily) return;
|
||||
const id = pendingDeleteFamily.id;
|
||||
pendingDeleteFamily = null;
|
||||
try {
|
||||
await api(`/api/products/family/${id}`, { method: 'DELETE' });
|
||||
await search(query.trim());
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Failed to delete product');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div class="mb-4">
|
||||
<a href="/lists" class="text-sm text-gray-500">← Back to lists</a>
|
||||
<h2 class="mt-1 text-2xl font-bold">Products</h2>
|
||||
<p class="text-sm text-gray-500">Search the catalog, add your own, or edit how a catalog item shows up for your family.</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={query}
|
||||
oninput={onQueryInput}
|
||||
placeholder="Search products"
|
||||
aria-label="Search products"
|
||||
class="field flex-1 py-2.5"
|
||||
/>
|
||||
<button type="button" onclick={startAdd} class="btn-primary py-2.5">Add</button>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<p class="py-8 text-center text-gray-400">Loading...</p>
|
||||
{:else if products.length === 0}
|
||||
<p class="py-12 text-center text-gray-400">
|
||||
{query.trim() ? 'No products match' : 'No products in catalog yet'}
|
||||
</p>
|
||||
{:else}
|
||||
<ul class="space-y-2">
|
||||
{#each products as product (product.kind + product.id)}
|
||||
<li class="flex items-center gap-3 rounded-lg bg-white px-4 py-3 shadow-sm">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="truncate font-medium">{product.name}</span>
|
||||
{#if product.isOverridden}
|
||||
<span
|
||||
class="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800"
|
||||
title="Edited by your family">Edited</span
|
||||
>
|
||||
{/if}
|
||||
{#if product.kind === 'Family'}
|
||||
<span
|
||||
class="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600"
|
||||
title="Family-only product (not in the global catalog)">Yours</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{#if product.brand}
|
||||
<div class="text-xs text-gray-400">{product.brand}</div>
|
||||
{/if}
|
||||
</div>
|
||||
<button onclick={() => startEdit(product)} class="text-sm text-gray-400">Edit</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if formOpen}
|
||||
<div
|
||||
class="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="product-form-title"
|
||||
>
|
||||
<div class="w-full max-w-sm rounded-lg bg-white p-5 shadow-xl">
|
||||
<h3 id="product-form-title" class="mb-1 text-lg font-semibold">
|
||||
{editingProduct === null
|
||||
? 'Add product'
|
||||
: editingProduct.kind === 'Family'
|
||||
? 'Edit product'
|
||||
: 'Edit catalog product'}
|
||||
</h3>
|
||||
{#if editingProduct?.kind === 'Global'}
|
||||
<p class="mb-3 text-xs text-gray-500">
|
||||
Changes save as a family-only override. Other families still see the catalog defaults.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void saveForm();
|
||||
}}
|
||||
class="space-y-3"
|
||||
>
|
||||
<label class="block">
|
||||
<span class="text-xs font-medium text-gray-700">Name</span>
|
||||
<input type="text" bind:value={formName} required class="field mt-1 w-full" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-xs font-medium text-gray-700"
|
||||
>Brand <span class="text-gray-400">(optional)</span></span
|
||||
>
|
||||
<input type="text" bind:value={formBrand} class="field mt-1 w-full" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-xs font-medium text-gray-700"
|
||||
>Notes <span class="text-gray-400">(optional)</span></span
|
||||
>
|
||||
<textarea bind:value={formNotes} rows="2" class="field mt-1 w-full"></textarea>
|
||||
</label>
|
||||
<fieldset>
|
||||
<legend class="text-xs font-medium text-gray-700">Allowed units</legend>
|
||||
<p class="mt-0.5 text-xs text-gray-400">Leave all unchecked to allow any unit.</p>
|
||||
<div class="mt-2 grid grid-cols-2 gap-1.5">
|
||||
{#each categoryOptions as opt}
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(formCategories & opt.flag) !== 0}
|
||||
onchange={() => toggleCategory(opt.flag)}
|
||||
/>
|
||||
{opt.label}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<button type="button" onclick={closeForm} class="btn-secondary">Cancel</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving || !formName.trim()}
|
||||
class="btn-primary"
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if editingProduct?.kind === 'Global' && editingProduct.isOverridden}
|
||||
<div class="mt-2 border-t border-gray-100 pt-2 text-right">
|
||||
<button type="button" onclick={requestResetGlobal} class="btn-danger-link">
|
||||
Reset to catalog default
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if editingProduct?.kind === 'Family'}
|
||||
<div class="mt-2 border-t border-gray-100 pt-2 text-right">
|
||||
<button type="button" onclick={requestDeleteFamily} class="btn-danger-link">
|
||||
Delete product
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if pendingResetGlobal}
|
||||
<div
|
||||
class="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="reset-product-title"
|
||||
>
|
||||
<div class="w-full max-w-sm rounded-lg bg-white p-5 shadow-xl">
|
||||
<h3 id="reset-product-title" class="mb-2 text-lg font-semibold">Reset to catalog default?</h3>
|
||||
<p class="mb-5 text-sm text-gray-600">
|
||||
<span class="font-medium">{pendingResetGlobal.name}</span> will go back to its catalog
|
||||
values for your family. Your edits will be discarded.
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (pendingResetGlobal = null)}
|
||||
class="btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" onclick={confirmResetGlobal} class="btn-primary">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if pendingDeleteFamily}
|
||||
<div
|
||||
class="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="delete-product-title"
|
||||
>
|
||||
<div class="w-full max-w-sm rounded-lg bg-white p-5 shadow-xl">
|
||||
<h3 id="delete-product-title" class="mb-2 text-lg font-semibold">Delete product?</h3>
|
||||
<p class="mb-5 text-sm text-gray-600">
|
||||
Delete <span class="font-medium">{pendingDeleteFamily.name}</span>? Any list items or
|
||||
ingredients linked to it will keep their text but lose the link.
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (pendingDeleteFamily = null)}
|
||||
class="btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={confirmDeleteFamily}
|
||||
class="rounded-lg bg-danger px-4 py-2 text-sm font-semibold text-white"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user