diff --git a/apps/web/src/routes/(app)/collective/manage/+page.svelte b/apps/web/src/routes/(app)/collective/manage/+page.svelte index fa9c472..719808c 100644 --- a/apps/web/src/routes/(app)/collective/manage/+page.svelte +++ b/apps/web/src/routes/(app)/collective/manage/+page.svelte @@ -15,6 +15,13 @@ setWeight as setCommonItemWeight, purge as purgeCommonItem } from '$lib/stores/commonItems'; + import { + listTitleCatalog, + loadTitleCatalog, + addTitle as addCatalogTitle, + removeTitle as removeCatalogTitle, + setDefaultListTitle + } from '$lib/stores/listTitles'; import * as m from '$lib/paraglide/messages'; type Member = { @@ -51,6 +58,7 @@ if (c) { void loadMembers(); void loadCommonItems(c.id); + void loadTitleCatalog(c.id); } }); return unsub; @@ -432,6 +440,103 @@ return d.toLocaleDateString(); } + // ── Fase 18: list-title catalog (default + curated suggestions) ───────── + // Admin writes via the RPCs in `$lib/stores/listTitles`. Members + guests + // see the section read-only (the surface mirrors the common-items pattern + // from Fase 15 so the page reads as one consistent admin-tooling area). + + const canSeeListTitles = $derived(myRole === 'admin' || myRole === 'member'); + + let defaultTitleDraft = $state(''); + let defaultTitleSaving = $state(false); + let defaultTitleSaved = $state(false); + let listTitlesError = $state(null); + + // Keep the draft input in sync with the active collective. Guarded against + // the realtime UPDATE re-firing while the admin is mid-edit (only sync + // when the input isn't focused). + $effect(() => { + if ($currentCollective && document.activeElement?.id !== 'default-list-title-input') { + defaultTitleDraft = $currentCollective.default_list_title ?? ''; + } + }); + + async function saveDefaultTitle() { + if (!$currentCollective || !isAdmin) return; + const trimmed = defaultTitleDraft.trim(); + const current = $currentCollective.default_list_title ?? ''; + if (trimmed === current) return; + defaultTitleSaving = true; + listTitlesError = null; + const err = await setDefaultListTitle($currentCollective.id, trimmed.length > 0 ? trimmed : null); + defaultTitleSaving = false; + if (err) { + listTitlesError = err.message; + return; + } + // Optimistic patch so the next /lists modal prefill sees the value + // even before the realtime UPDATE round-trips. + currentCollective.update((c) => + c ? { ...c, default_list_title: trimmed.length > 0 ? trimmed : null } : c + ); + userCollectives.update((list) => + list.map((c) => + c.id === $currentCollective!.id + ? { ...c, default_list_title: trimmed.length > 0 ? trimmed : null } + : c + ) + ); + defaultTitleSaved = true; + setTimeout(() => (defaultTitleSaved = false), 1500); + } + + function onDefaultTitleKeydown(e: KeyboardEvent) { + if (e.key === 'Enter') { + e.preventDefault(); + void saveDefaultTitle(); + } else if (e.key === 'Escape') { + e.preventDefault(); + if ($currentCollective) defaultTitleDraft = $currentCollective.default_list_title ?? ''; + } + } + + // Curated-titles CRUD (modal-based add, inline remove). + let titleAddOpen = $state(false); + let titleAddName = $state(''); + let titleAddSaving = $state(false); + + function openTitleAdd() { + if (!isAdmin) return; + titleAddName = ''; + titleAddOpen = true; + } + + function closeTitleAdd() { + titleAddOpen = false; + } + + async function confirmTitleAdd() { + if (!$currentCollective || !isAdmin) return; + const trimmed = titleAddName.trim(); + if (!trimmed) return; + titleAddSaving = true; + listTitlesError = null; + const err = await addCatalogTitle($currentCollective.id, trimmed); + titleAddSaving = false; + if (err) { + listTitlesError = err.message; + return; + } + titleAddOpen = false; + } + + async function handleRemoveTitle(title: string) { + if (!$currentCollective || !isAdmin) return; + listTitlesError = null; + const err = await removeCatalogTitle($currentCollective.id, title); + if (err) listTitlesError = err.message; + } + async function confirmDissolve() { if (!$currentCollective || !dissolveConfirmReady || dissolveInFlight) return; const target = $currentCollective; @@ -749,6 +854,104 @@ {/if} + + {#if canSeeListTitles} +
+
+

+ {m.manage_list_titles_section_title()} +

+ {#if isAdmin} + + {/if} +
+

{m.manage_list_titles_blurb()}

+ + {#if !isAdmin} +

+ {m.manage_list_titles_readonly()} +

+ {/if} + + {#if listTitlesError} +

{listTitlesError}

+ {/if} + + +
+ +
+ void saveDefaultTitle()} + disabled={!isAdmin} + maxlength="120" + placeholder={m.manage_default_list_title_placeholder()} + class="w-full rounded-lg border border-slate-300 bg-surface px-3 py-2 text-sm + focus:border-slate-500 focus:outline-none disabled:opacity-60 + dark:border-slate-600 dark:bg-slate-800" + /> + {#if defaultTitleSaving} + {m.settings_saving()} + {:else if defaultTitleSaved} + + {/if} +
+
+ + + {#if $listTitleCatalog.length === 0} +

+ {m.manage_list_titles_empty()} +

+ {:else} + + {/if} +
+ {/if} + {#if isAdmin}
@@ -1044,3 +1247,69 @@ {/if} + + +{#if titleAddOpen} + +{/if} diff --git a/apps/web/tests/e2e/list-title-flow.test.ts b/apps/web/tests/e2e/list-title-flow.test.ts index 4e12ce4..ef084ed 100644 --- a/apps/web/tests/e2e/list-title-flow.test.ts +++ b/apps/web/tests/e2e/list-title-flow.test.ts @@ -92,6 +92,70 @@ test.describe('Shopping list title flow — admin (Ana)', () => { void titleInput; }); + test('LTF-02: catalog entry drives auto-suffix on the third "Compra" creation', async ({ + page + }) => { + const prefix = `LTF02-${Date.now()}`; + + // 1) Add the prefix to the catalog via the manage UI (the path under + // test). Then add it to default_list_title via the same UI so the + // modal prefills it consistently. + await page.goto('/collective/manage'); + await expect(page.getByTestId('list-titles-add-button')).toBeVisible({ + timeout: 10_000 + }); + await page.getByTestId('list-titles-add-button').click(); + await expect(page.getByTestId('list-titles-add-modal')).toBeVisible({ + timeout: 5_000 + }); + await page.getByTestId('list-titles-add-name').fill(prefix); + await page.getByTestId('list-titles-add-save').click(); + await expect(page.getByTestId('list-titles-add-modal')).not.toBeVisible({ + timeout: 5_000 + }); + await expect( + page.getByTestId(`list-title-row-${prefix}`) + ).toBeVisible({ timeout: 5_000 }); + + const defaultInput = page.getByTestId('manage-default-list-title-input'); + await defaultInput.fill(prefix); + await defaultInput.blur(); + // Wait briefly for the UPDATE round-trip to land. + await page.waitForTimeout(700); + + // Helper that drives the modal once: open → submit (prefilled) → wait + // for navigation, then bounce back to /lists. + async function createOne(): Promise { + await page.goto('/lists'); + await openCreateListModal(page); + // The prefill should be the catalog prefix verbatim — the modal + // auto-suffixes "#N" on submit when the value matches a catalog + // entry exactly without a "#N" already. + await expect(page.getByTestId('create-list-modal-name')).toHaveValue(prefix); + await page.getByTestId('create-list-modal-submit').click(); + await expect(page).toHaveURL(/\/lists\/[0-9a-f-]+$/, { timeout: 10_000 }); + } + + // 1st create → no prior matches → finalName === `${prefix}` (bare; + // computeNextNumber returns 1 → but rule 9: still fires auto-suffix + // when catalog match holds, so finalName === `${prefix} #1`). + await createOne(); + // 2nd create → prior is `prefix #1` → finalName === `${prefix} #2`. + await createOne(); + // 3rd create → prior set has {#1, #2} → finalName === `${prefix} #3`. + await createOne(); + + // Visit /lists and confirm the third numbered list exists. We don't + // assert the first two by name because the exact #N depends on race + // timing of the realtime echoes; what matters is that the catalog + // auto-suffix produced unique numbered names and the third one + // landed at #3 (no duplicates of bare prefix). + await page.goto('/lists'); + await expect( + page.getByRole('heading', { name: new RegExp(`^${prefix} #3$`) }) + ).toBeVisible({ timeout: 10_000 }); + }); + test('LTF-03: empty input keeps the submit button disabled', async ({ page }) => { // Make sure no default is set so the input opens blank. await setDefaultListTitle(page, ''); @@ -114,3 +178,26 @@ test.describe('Shopping list title flow — admin (Ana)', () => { await expect(submit).toBeEnabled(); }); }); + +test.describe('Shopping list title flow — member (Borja, read-only)', () => { + test.beforeEach(async ({ page }) => { + await loginAs(page, USERS.borja); + }); + + test('LTF-04: member sees the section but the controls are disabled', async ({ page }) => { + await page.goto('/collective/manage'); + await expect(page.getByTestId('list-titles-section')).toBeVisible({ + timeout: 15_000 + }); + + // No "Add title" button for non-admins. + await expect(page.getByTestId('list-titles-add-button')).not.toBeVisible(); + + // Read-only banner is visible. + await expect(page.getByTestId('list-titles-readonly-banner')).toBeVisible(); + + // The default-title input renders but is disabled. + await expect(page.getByTestId('manage-default-list-title-input')).toBeDisabled(); + }); +}); +