Files
collective-lists/apps/web/src/routes/onboarding/+page.svelte
Oier Bravo Urtasun bd9a0a6d3f feat(fase-18): migration 027 — list title catalog + per-collective default
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>
2026-05-19 02:03:41 +02:00

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>