Adds `collectives.default_list_title` (nullable text) plus a new `collective_list_titles (collective_id, title)` table — admins curate via two SECURITY DEFINER RPCs (`add_list_title` / `remove_list_title`, P0002 on non-admin, idempotent on conflict). Members read; direct writes are all-deny RLS, matching the item_frequency pattern. Table is added to the `supabase_realtime` publication with REPLICA IDENTITY FULL so the manage UI sees admin edits live across browsers. Numbering rules (#N suffix) stay client-side intentionally — the DB is agnostic about the format so future "YYYY-MM" or other prefixes don't require a schema migration. The two-clients-race-on-#6 case is accepted (documented in migration header); a unique constraint would block deliberate duplicates. Wires `default_list_title` through every Collective construction site: loadUserCollectives in root layout, onboarding, CreateCollectiveModal, features.ts realtime subscriber, and the manage-collective patcher. The hand-curated `database.ts` gets the new column, table, and two RPC signatures. pgTAP `019_list_title_catalog.sql` (13 assertions): schema invariants on the column + table, PK shape, RLS posture, two-policies count, RPC existence + SECURITY DEFINER flag, realtime publication membership, and the default-NULL behaviour. Role-gate denial is covered by the upcoming list-title-flow integration suite (postgres bypasses auth.uid inside pgTAP, same caveat as Fase 15's 018_item_frequency_weight.sql). Tests: 191 pgTAP (was 178; +13). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
190 lines
6.4 KiB
Svelte
190 lines
6.4 KiB
Svelte
<script lang="ts">
|
|
import { goto } from '$app/navigation';
|
|
import { getSupabase } from '$lib/supabase';
|
|
import { currentCollective, userCollectives } from '$lib/stores/collective';
|
|
import * as m from '$lib/paraglide/messages';
|
|
|
|
type Tab = 'create' | 'join';
|
|
|
|
let activeTab = $state<Tab>('create');
|
|
|
|
// Create collective form
|
|
let collectiveName = $state('');
|
|
let collectiveEmoji = $state('🏠');
|
|
let creating = $state(false);
|
|
let createError = $state<string | null>(null);
|
|
|
|
const EMOJI_OPTIONS = [
|
|
'🏠', '🏡', '🏢', '🏣', '🏤', '🏥', '🏦', '🏨',
|
|
'🏩', '🏪', '🏫', '🏬', '🏭', '🏯', '🏰', '🗼',
|
|
'🌆', '🌇', '🌃', '🌉', '🏙️', '⛺', '🌴', '🌵'
|
|
];
|
|
|
|
// Join via invite link
|
|
let inviteLink = $state('');
|
|
let joining = $state(false);
|
|
let joinError = $state<string | null>(null);
|
|
|
|
async function createCollective() {
|
|
if (!collectiveName.trim()) return;
|
|
creating = true;
|
|
createError = null;
|
|
|
|
const supabase = getSupabase();
|
|
|
|
// Atomic create: the RPC inserts both the collective and the creator's
|
|
// admin membership in one transaction (SECURITY DEFINER, bypasses RLS).
|
|
// Doing this as two separate REST calls fails because the SELECT policy
|
|
// on collectives and the INSERT policy on collective_members both gate
|
|
// on membership the creator doesn't have yet.
|
|
const { data, error } = await supabase.rpc('create_collective', {
|
|
p_name: collectiveName.trim(),
|
|
p_emoji: collectiveEmoji
|
|
});
|
|
|
|
if (error || !data) {
|
|
createError = error?.message ?? 'Failed to create collective.';
|
|
creating = false;
|
|
return;
|
|
}
|
|
|
|
const collective = {
|
|
...(data as { id: string; name: string; emoji: string; created_at: string }),
|
|
// New collective starts with no flags — every section ON by default.
|
|
feature_flags: {} as import('@colectivo/types').FeatureFlags,
|
|
// Fase 18: no default list title until an admin sets one.
|
|
default_list_title: null as string | null
|
|
};
|
|
|
|
userCollectives.update((list) => [...list, collective]);
|
|
currentCollective.set(collective);
|
|
localStorage.setItem('activeCollectiveId', collective.id);
|
|
goto('/lists');
|
|
}
|
|
|
|
async function joinViaLink() {
|
|
joinError = null;
|
|
const tokenMatch = inviteLink.trim().match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i);
|
|
if (!tokenMatch) {
|
|
joinError = m.invitation_invalid_link();
|
|
return;
|
|
}
|
|
goto(`/invitation/${tokenMatch[1]}`);
|
|
}
|
|
</script>
|
|
|
|
<div class="flex min-h-screen items-center justify-center bg-background px-4">
|
|
<div class="w-full max-w-md">
|
|
<h1 class="mb-2 text-2xl font-bold text-slate-900 dark:text-slate-50">
|
|
{m.onboarding_title()}
|
|
</h1>
|
|
<p class="mb-8 text-sm text-text-secondary">{m.onboarding_subtitle()}</p>
|
|
|
|
<!-- Tab switcher -->
|
|
<div class="mb-6 flex rounded-lg border border-slate-200 p-1 dark:border-slate-700">
|
|
<button
|
|
data-testid="onboarding-create-tab"
|
|
class="flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors
|
|
{activeTab === 'create'
|
|
? 'bg-slate-900 text-white dark:bg-slate-50 dark:text-slate-900'
|
|
: 'text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-50'}"
|
|
onclick={() => (activeTab = 'create')}
|
|
>
|
|
{m.onboarding_create_tab()}
|
|
</button>
|
|
<button
|
|
data-testid="onboarding-join-tab"
|
|
class="flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors
|
|
{activeTab === 'join'
|
|
? 'bg-slate-900 text-white dark:bg-slate-50 dark:text-slate-900'
|
|
: 'text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-50'}"
|
|
onclick={() => (activeTab = 'join')}
|
|
>
|
|
{m.onboarding_join_tab()}
|
|
</button>
|
|
</div>
|
|
|
|
{#if activeTab === 'create'}
|
|
<form onsubmit={(e) => { e.preventDefault(); createCollective(); }} class="space-y-4">
|
|
<div>
|
|
<label for="collective-name" class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">
|
|
{m.onboarding_collective_name()}
|
|
</label>
|
|
<input
|
|
id="collective-name"
|
|
data-testid="collective-name-input"
|
|
type="text"
|
|
bind:value={collectiveName}
|
|
placeholder={m.onboarding_collective_name_placeholder()}
|
|
maxlength="60"
|
|
class="w-full rounded-lg border border-slate-300 bg-surface px-3 py-2 text-sm
|
|
focus:border-slate-500 focus:outline-none dark:border-slate-600 dark:bg-slate-800"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<p class="mb-2 text-sm font-medium text-slate-700 dark:text-slate-300">
|
|
{m.onboarding_collective_emoji()}
|
|
</p>
|
|
<div class="grid grid-cols-8 gap-1">
|
|
{#each EMOJI_OPTIONS as e}
|
|
<button
|
|
type="button"
|
|
class="flex h-9 w-9 items-center justify-center rounded-md text-xl transition-colors
|
|
{collectiveEmoji === e
|
|
? 'bg-slate-900 dark:bg-slate-100'
|
|
: 'hover:bg-slate-100 dark:hover:bg-slate-700'}"
|
|
onclick={() => (collectiveEmoji = e)}
|
|
aria-label={e}
|
|
>{e}</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
|
|
{#if createError}
|
|
<p class="text-sm text-red-600">{createError}</p>
|
|
{/if}
|
|
|
|
<button
|
|
data-testid="collective-submit"
|
|
type="submit"
|
|
disabled={!collectiveName.trim() || creating}
|
|
class="w-full rounded-lg bg-slate-900 px-4 py-2.5 text-sm font-semibold text-white
|
|
transition-opacity hover:opacity-90 disabled:opacity-50 dark:bg-slate-50 dark:text-slate-900"
|
|
>
|
|
{creating ? m.loading() : m.onboarding_create_btn()}
|
|
</button>
|
|
</form>
|
|
{:else}
|
|
<form onsubmit={(e) => { e.preventDefault(); joinViaLink(); }} class="space-y-4">
|
|
<div>
|
|
<label for="invite-link" class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">
|
|
{m.onboarding_invite_link_label()}
|
|
</label>
|
|
<input
|
|
id="invite-link"
|
|
type="text"
|
|
bind:value={inviteLink}
|
|
placeholder={m.onboarding_invite_link_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 dark:border-slate-600 dark:bg-slate-800"
|
|
/>
|
|
</div>
|
|
|
|
{#if joinError}
|
|
<p class="text-sm text-red-600">{joinError}</p>
|
|
{/if}
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={!inviteLink.trim() || joining}
|
|
class="w-full rounded-lg bg-slate-900 px-4 py-2.5 text-sm font-semibold text-white
|
|
transition-opacity hover:opacity-90 disabled:opacity-50 dark:bg-slate-50 dark:text-slate-900"
|
|
>
|
|
{joining ? m.loading() : m.onboarding_join_btn()}
|
|
</button>
|
|
</form>
|
|
{/if}
|
|
</div>
|
|
</div>
|