feat(events): bring list (wer bringt was?) — Phase 2

Add an "eventItems" mini-collection attached to each social event so
hosts can track what each guest is bringing, and so public visitors
on the share-link page can claim an item without an account.

Local-first side
- New eventItems table (Dexie v11), module config update for sync.
- LocalEventItem type + EventItem domain type, useEventItems query.
- eventItemsStore: addItem / updateItem / toggleDone / assign /
  deleteItem. Every mutation pushes the full list to the server
  snapshot via eventsStore.syncItems if the event is published.
- BringListEditor component on the host DetailView with assign-to-
  guest dropdown, quantity, and done-checkbox.
- eventsStore.syncItems + a syncItems call in publishEvent so the
  public page sees pre-existing items as soon as the event ships.

Server side
- New event_items_published table (FK cascade from events_published
  so unpublishing wipes the bring list along with the snapshot).
- Host endpoints PUT/GET /events/:eventId/items: full-replace upsert
  that preserves any existing claimed_by_name across host edits, max
  100 items, ownership check.
- Public POST /rsvp/:token/items/:itemId/claim: name-only claim, 1×
  per item (first write wins), shares the per-token hourly rate
  bucket with RSVP submissions to keep the abuse surface uniform.
- GET /rsvp/:token now also returns the bring list (sorted) so the
  public page renders in a single round-trip.

Public RSVP page
- Renders the bring list with claim buttons; clicking prompts for a
  name and POSTs the claim, then optimistically updates the UI.
- New bring-list i18n keys for all five locales (de/en/it/fr/es).

Tests
- 15 new server tests covering host PUT/GET (insert / update / prune /
  ownership / claimed-name preservation / cascade), GET /rsvp item
  exposure, and POST /claim (success / double-claim / cross-token /
  cancelled / validation). 50 server tests total, all green.
- E2E spec scoped to .guest-editor where the new BringListEditor
  introduced a duplicate "Hinzufügen" button label.
This commit is contained in:
Till JS 2026-04-07 19:31:39 +02:00
parent af92720a62
commit 6a60e22a31
19 changed files with 1296 additions and 16 deletions

View file

@ -49,13 +49,12 @@ test.describe('Events module — local flow', () => {
await expect(page.getByRole('heading', { name: title })).toBeVisible();
await expect(page.getByText('Café am See')).toBeVisible();
// 4. Add a guest
await page.getByPlaceholder('Name', { exact: false }).first().fill('Tante Erika');
await page
.getByPlaceholder(/E-Mail/i)
.first()
.fill('erika@example.com');
await page.getByRole('button', { name: 'Hinzufügen' }).click();
// 4. Add a guest — scope to the guest editor since the bring-list
// editor below it uses the same "Hinzufügen" button text.
const guestEditor = page.locator('.guest-editor');
await guestEditor.getByPlaceholder('Name', { exact: false }).fill('Tante Erika');
await guestEditor.getByPlaceholder(/E-Mail/i).fill('erika@example.com');
await guestEditor.getByRole('button', { name: 'Hinzufügen' }).click();
// Guest row should appear
await expect(page.getByText('Tante Erika')).toBeVisible();
@ -68,10 +67,10 @@ test.describe('Events module — local flow', () => {
// Summary should reflect 1 yes / 1 attending (no plus-ones)
await expect(page.locator('.rsvp-summary .badge.yes .count')).toHaveText('1');
// Set 2 plus-ones
await page.locator('input[type="number"]').first().fill('2');
// Blur to commit (onchange)
await page.locator('input[type="number"]').first().blur();
// Set 2 plus-ones (scoped to the guest editor — bring list also has a number input)
const plusOnes = guestEditor.locator('input[type="number"]').first();
await plusOnes.fill('2');
await plusOnes.blur();
// totalAttending should now be 3 (1 + 2 plus-ones)
await expect(page.locator('.rsvp-summary .total strong')).toHaveText('3');

View file

@ -512,6 +512,16 @@ db.version(10).stores({
'++id, createdAt, appId, collection, recordId, op, [appId+createdAt], [collection+recordId], userId',
});
// ─── Version 11: Events bring-list (eventItems) ───────────────
// Adds the "wer bringt was?" table attached to social events.
// `assignedGuestId` points at a local guest the host picked manually;
// `claimedByName` is set by a public RSVP visitor who reserved the
// item from the share-link page.
db.version(11).stores({
eventItems: 'id, eventId, assignedGuestId, done, order, [eventId+order], [eventId+done]',
});
// ─── Sync Routing ──────────────────────────────────────────
// SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE,
// toSyncName() and fromSyncName() are now derived from per-module

View file

@ -48,6 +48,23 @@ async function fetchWithAuth<T>(path: string, init: RequestInit = {}): Promise<T
return res.json() as Promise<T>;
}
export interface PublishedItemSnapshot {
id: string;
label: string;
quantity?: number | null;
order: number;
done?: boolean;
}
export interface PublishedItemRecord {
id: string;
label: string;
quantity: number | null;
sortOrder: number;
done: boolean;
claimedByName: string | null;
}
export const eventsApi = {
async publish(input: PublishedSnapshotInput): Promise<{ token: string; isNew: boolean }> {
return fetchWithAuth('/api/v1/events/publish', {
@ -73,4 +90,18 @@ export const eventsApi = {
async getRsvps(eventId: string): Promise<{ token: string; rsvps: PublicRsvpRecord[] }> {
return fetchWithAuth(`/api/v1/events/${eventId}/rsvps`);
},
async syncItems(
eventId: string,
items: PublishedItemSnapshot[]
): Promise<{ ok: true; count: number }> {
return fetchWithAuth(`/api/v1/events/${eventId}/items`, {
method: 'PUT',
body: JSON.stringify({ items }),
});
},
async getItems(eventId: string): Promise<{ items: PublishedItemRecord[] }> {
return fetchWithAuth(`/api/v1/events/${eventId}/items`);
},
};

View file

@ -3,8 +3,14 @@
*/
import { db } from '$lib/data/database';
import type { LocalSocialEvent, LocalEventGuest, LocalEventInvitation } from './types';
import type {
LocalSocialEvent,
LocalEventGuest,
LocalEventInvitation,
LocalEventItem,
} from './types';
export const socialEventTable = db.table<LocalSocialEvent>('socialEvents');
export const eventGuestTable = db.table<LocalEventGuest>('eventGuests');
export const eventInvitationTable = db.table<LocalEventInvitation>('eventInvitations');
export const eventItemTable = db.table<LocalEventItem>('eventItems');

View file

@ -0,0 +1,232 @@
<script lang="ts">
import { useEventItems, useEventGuests } from '../queries';
import { eventItemsStore } from '../stores/items.svelte';
import { eventsStore } from '../stores/events.svelte';
import type { EventItem } from '../types';
interface Props {
eventId: string;
}
let { eventId }: Props = $props();
const items = useEventItems(() => eventId);
const guests = useEventGuests(() => eventId);
let newLabel = $state('');
let newQuantity = $state<number | undefined>(undefined);
const guestNameById = $derived(new Map((guests.value ?? []).map((g) => [g.id, g.name])));
function assigneeLabel(item: EventItem): string | null {
if (item.assignedGuestId) {
return guestNameById.get(item.assignedGuestId) ?? '?';
}
if (item.claimedByName) {
return `${item.claimedByName} (via Link)`;
}
return null;
}
async function handleAdd(e: SubmitEvent) {
e.preventDefault();
const label = newLabel.trim();
if (!label) return;
await eventItemsStore.addItem({
eventId,
label,
quantity: newQuantity ?? null,
});
// Snapshot pushed afterwards so the public bring list reflects the
// addition immediately for already-published events.
void eventsStore.syncSnapshotIfPublished(eventId);
newLabel = '';
newQuantity = undefined;
}
</script>
<div class="bring-editor">
<form class="add-row" onsubmit={handleAdd}>
<input
type="text"
bind:value={newLabel}
placeholder="z. B. Salat, Wein, Lautsprecher"
class="input label-input"
required
/>
<input
type="number"
min="1"
max="999"
placeholder="Anzahl"
bind:value={newQuantity}
class="input qty-input"
/>
<button type="submit" class="add-btn">Hinzufügen</button>
</form>
<ul class="item-list">
{#each items.value ?? [] as item (item.id)}
{@const assignee = assigneeLabel(item)}
<li class="item-row" class:done={item.done}>
<label class="check">
<input
type="checkbox"
checked={item.done}
onchange={(e) => eventItemsStore.toggleDone(item.id, e.currentTarget.checked)}
/>
</label>
<div class="info">
<div class="label-row">
<span class="label">{item.label}</span>
{#if item.quantity}
<span class="qty">×{item.quantity}</span>
{/if}
</div>
{#if assignee}
<div class="assignee">{assignee}</div>
{/if}
</div>
<select
class="assign-select"
value={item.assignedGuestId ?? ''}
onchange={(e) => eventItemsStore.assign(item.id, e.currentTarget.value || null)}
>
<option value="">— Niemand —</option>
{#each guests.value ?? [] as g (g.id)}
<option value={g.id}>{g.name}</option>
{/each}
</select>
<button
class="remove-btn"
onclick={() => eventItemsStore.deleteItem(item.id)}
title="Entfernen"
>
×
</button>
</li>
{/each}
{#if (items.value ?? []).length === 0}
<li class="empty">Noch nichts auf der Liste.</li>
{/if}
</ul>
</div>
<style>
.bring-editor {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.add-row {
display: flex;
gap: 0.5rem;
}
.input {
padding: 0.5rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-background));
font-size: 0.875rem;
color: hsl(var(--color-foreground));
}
.label-input {
flex: 1;
}
.qty-input {
width: 5rem;
}
.add-btn {
padding: 0.5rem 0.875rem;
border: none;
border-radius: 0.5rem;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
}
.item-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.item-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-card));
}
.item-row.done .label {
text-decoration: line-through;
color: hsl(var(--color-muted-foreground));
}
.check input {
width: 1rem;
height: 1rem;
accent-color: hsl(var(--color-primary));
}
.info {
flex: 1;
min-width: 0;
}
.label-row {
display: flex;
gap: 0.5rem;
align-items: baseline;
}
.label {
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--color-foreground));
}
.qty {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.assignee {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
margin-top: 0.125rem;
}
.assign-select {
max-width: 9rem;
padding: 0.25rem 0.375rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
background: hsl(var(--color-background));
font-size: 0.75rem;
color: hsl(var(--color-foreground));
}
.remove-btn {
padding: 0.125rem 0.5rem;
border: none;
background: transparent;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
font-size: 1.25rem;
line-height: 1;
border-radius: 0.25rem;
}
.remove-btn:hover {
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
}
.empty {
padding: 1rem;
text-align: center;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
</style>

View file

@ -3,4 +3,5 @@ export * from './collections';
export * from './queries';
export { eventsStore } from './stores/events.svelte';
export { eventGuestsStore } from './stores/guests.svelte';
export { eventItemsStore } from './stores/items.svelte';
export { drainTombstones, recordTombstone } from './tombstones';

View file

@ -0,0 +1,12 @@
import type { ModuleConfig } from '$lib/data/module-registry';
export const eventsModuleConfig: ModuleConfig = {
appId: 'events',
tables: [
// `socialEvents` is renamed in unified DB to avoid collision with calendar.events.
{ name: 'socialEvents', syncName: 'events' },
{ name: 'eventGuests' },
{ name: 'eventInvitations' },
{ name: 'eventItems' },
],
};

View file

@ -11,8 +11,10 @@ import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
import type {
LocalSocialEvent,
LocalEventGuest,
LocalEventItem,
SocialEvent,
EventGuest,
EventItem,
RsvpSummary,
} from './types';
@ -42,6 +44,23 @@ export function toSocialEvent(local: LocalSocialEvent, block: LocalTimeBlock | n
};
}
export function toEventItem(local: LocalEventItem): EventItem {
const now = new Date().toISOString();
return {
id: local.id,
eventId: local.eventId,
label: local.label,
quantity: local.quantity ?? null,
order: local.order ?? 0,
done: local.done ?? false,
assignedGuestId: local.assignedGuestId ?? null,
claimedByName: local.claimedByName ?? null,
claimedAt: local.claimedAt ?? null,
createdAt: local.createdAt ?? now,
updatedAt: local.updatedAt ?? now,
};
}
export function toEventGuest(local: LocalEventGuest): EventGuest {
const now = new Date().toISOString();
return {
@ -148,6 +167,23 @@ export function useEventGuests(eventId: () => string) {
}, [] as EventGuest[]);
}
/** Bring-list items for a single event, sorted by order. */
export function useEventItems(eventId: () => string) {
return useLiveQueryWithDefault(async () => {
const id = eventId();
if (!id) return [];
const items = await db
.table<LocalEventItem>('eventItems')
.where('eventId')
.equals(id)
.toArray();
return items
.filter((i) => !i.deletedAt)
.map(toEventItem)
.sort((a, b) => a.order - b.order);
}, [] as EventItem[]);
}
// ─── Pure Helpers ──────────────────────────────────────────
export function summarizeRsvps(guests: EventGuest[]): RsvpSummary {

View file

@ -8,7 +8,7 @@
import { db } from '$lib/data/database';
import { createBlock, updateBlock, deleteBlock } from '$lib/data/time-blocks/service';
import { timeBlockTable } from '$lib/data/time-blocks/collections';
import type { LocalSocialEvent, EventStatus } from '../types';
import type { LocalSocialEvent, LocalEventItem, EventStatus } from '../types';
import { eventsApi } from '../api';
import { recordTombstone } from '../tombstones';
@ -192,6 +192,9 @@ export const eventsStore = {
status: 'published' satisfies EventStatus,
updatedAt: new Date().toISOString(),
});
// Push any pre-existing bring-list items right away so the
// public page shows them on first open.
await this.syncItems(id);
return { success: true as const, token };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to publish event';
@ -229,6 +232,7 @@ export const eventsStore = {
/**
* Push the latest local state of a published event to the server snapshot.
* Called after an updateEvent() if the event is currently published.
* Also pushes the bring-list items so the public page stays in sync.
*/
async syncSnapshotIfPublished(id: string) {
try {
@ -249,8 +253,39 @@ export const eventsStore = {
color: event.color ?? null,
capacity: event.capacity ?? null,
});
// Items are independent of the snapshot fields above but the
// host always wants them in sync after any edit.
await this.syncItems(id);
} catch (e) {
console.warn('Snapshot sync failed:', e);
}
},
/**
* Push the local bring list to the server snapshot. Safe to call
* for unpublished events it just no-ops.
*/
async syncItems(id: string) {
try {
const event = await db.table<LocalSocialEvent>('socialEvents').get(id);
if (!event || !event.isPublished) return;
const items = await db
.table<LocalEventItem>('eventItems')
.where('eventId')
.equals(id)
.toArray();
const payload = items
.filter((i) => !i.deletedAt)
.map((i) => ({
id: i.id,
label: i.label,
quantity: i.quantity ?? null,
order: i.order ?? 0,
done: i.done ?? false,
}));
await eventsApi.syncItems(id, payload);
} catch (e) {
console.warn('Item sync failed:', e);
}
},
};

View file

@ -0,0 +1,115 @@
/**
* Bring-list items store mutation-only service for the "wer bringt was?"
* list attached to an event.
*/
import { db } from '$lib/data/database';
import type { LocalEventItem } from '../types';
import { eventsStore } from './events.svelte';
let error = $state<string | null>(null);
async function nextOrder(eventId: string): Promise<number> {
const existing = await db
.table<LocalEventItem>('eventItems')
.where('eventId')
.equals(eventId)
.toArray();
const max = existing.filter((i) => !i.deletedAt).reduce((m, i) => Math.max(m, i.order ?? 0), -1);
return max + 1;
}
export const eventItemsStore = {
get error() {
return error;
},
async addItem(input: {
eventId: string;
label: string;
quantity?: number | null;
assignedGuestId?: string | null;
}) {
error = null;
try {
const id = crypto.randomUUID();
const order = await nextOrder(input.eventId);
const newItem: LocalEventItem = {
id,
eventId: input.eventId,
label: input.label.trim(),
quantity: input.quantity ?? null,
order,
done: false,
assignedGuestId: input.assignedGuestId ?? null,
claimedByName: null,
claimedAt: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
await db.table<LocalEventItem>('eventItems').add(newItem);
void eventsStore.syncItems(input.eventId);
return { success: true as const, id };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to add item';
return { success: false as const, error };
}
},
async updateItem(
id: string,
input: Partial<{
label: string;
quantity: number | null;
done: boolean;
assignedGuestId: string | null;
claimedByName: string | null;
claimedAt: string | null;
}>
) {
error = null;
try {
await db.table('eventItems').update(id, {
...input,
updatedAt: new Date().toISOString(),
});
// Push the updated bring list to the server. We need the
// parent eventId, so re-read the row first.
const item = await db.table<LocalEventItem>('eventItems').get(id);
if (item) void eventsStore.syncItems(item.eventId);
return { success: true as const };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update item';
return { success: false as const, error };
}
},
async toggleDone(id: string, done: boolean) {
return this.updateItem(id, { done });
},
async assign(id: string, guestId: string | null) {
return this.updateItem(id, {
assignedGuestId: guestId,
// Clearing the public-claim when the host takes over the assignment
// avoids double-counting in the host's view.
...(guestId ? { claimedByName: null, claimedAt: null } : {}),
});
},
async deleteItem(id: string) {
error = null;
try {
const item = await db.table<LocalEventItem>('eventItems').get(id);
await db.table('eventItems').update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
if (item) void eventsStore.syncItems(item.eventId);
return { success: true as const };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete item';
return { success: false as const, error };
}
},
};

View file

@ -52,6 +52,19 @@ export interface LocalEventInvitation extends BaseRecord {
token: string;
}
export interface LocalEventItem extends BaseRecord {
eventId: string;
label: string;
quantity?: number | null;
order: number;
done: boolean;
// Either a local guest the host assigned…
assignedGuestId?: string | null;
// …or a public visitor who claimed it via the share link.
claimedByName?: string | null;
claimedAt?: string | null;
}
// ─── Domain (UI-facing) ────────────────────────────────────
export interface SocialEvent {
@ -97,3 +110,17 @@ export interface RsvpSummary {
pending: number;
totalAttending: number; // yes + plusOnes
}
export interface EventItem {
id: string;
eventId: string;
label: string;
quantity: number | null;
order: number;
done: boolean;
assignedGuestId: string | null;
claimedByName: string | null;
claimedAt: string | null;
createdAt: string;
updatedAt: string;
}

View file

@ -4,6 +4,7 @@
import GuestListEditor from '../components/GuestListEditor.svelte';
import RsvpSummaryView from '../components/RsvpSummary.svelte';
import PublicRsvpList from '../components/PublicRsvpList.svelte';
import BringListEditor from '../components/BringListEditor.svelte';
interface Props {
eventId: string;
@ -166,6 +167,11 @@
<GuestListEditor eventId={event.id} />
</section>
<section class="section">
<h2>Bring-Liste</h2>
<BringListEditor eventId={event.id} />
</section>
{#if event.isPublished}
<section class="section">
<PublicRsvpList eventId={event.id} isPublished={event.isPublished} />

View file

@ -48,6 +48,15 @@ interface RsvpSummary {
totalAttending: number;
}
interface BringItem {
id: string;
label: string;
quantity: number | null;
sortOrder: number;
done: boolean;
claimedByName: string | null;
}
export const load: PageServerLoad = async ({ params, fetch, request }) => {
const token = params.token;
if (!token) throw error(404, 'Not found');
@ -78,8 +87,9 @@ export const load: PageServerLoad = async ({ params, fetch, request }) => {
event: EventSnapshot;
summary: RsvpSummary | null;
cancelled?: boolean;
items?: BringItem[];
};
return { token, ...data, eventsUrl: EVENTS_URL, lang };
return { token, ...data, items: data.items ?? [], eventsUrl: EVENTS_URL, lang };
} catch (e) {
if (e && typeof e === 'object' && 'status' in e) throw e;
throw error(500, errorMsg);

View file

@ -15,6 +15,48 @@
let submitted = $state(false);
let errorMessage = $state<string | null>(null);
// Local mirror of items so claims can update the UI without a page
// reload. SSR data is the initial source of truth.
let items = $state(data.items);
let claimingItemId = $state<string | null>(null);
let claimError = $state<string | null>(null);
async function claimItem(itemId: string) {
if (claimingItemId) return;
const claimerName = window.prompt(t.claimNamePrompt, name)?.trim();
if (!claimerName) return;
claimingItemId = itemId;
claimError = null;
try {
const res = await fetch(
`${getManaEventsUrl()}/api/v1/rsvp/${data.token}/items/${itemId}/claim`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: claimerName }),
}
);
if (!res.ok) {
if (res.status === 400) {
claimError = t.claimAlreadyTaken;
} else {
const err = await res.json().catch(() => ({ message: 'Fehler' }));
throw new Error(err.message || `HTTP ${res.status}`);
}
return;
}
// Optimistically reflect the claim locally
items = items.map((it) => (it.id === itemId ? { ...it, claimedByName: claimerName } : it));
// Pre-fill the RSVP name field for convenience
if (!name) name = claimerName;
} catch (e) {
claimError = e instanceof Error ? e.message : t.genericError;
} finally {
claimingItemId = null;
}
}
const startDate = $derived(new Date(data.event.startAt));
const dateLabel = $derived(
startDate.toLocaleDateString(t.dateLocale, {
@ -108,6 +150,40 @@
<p class="description">{data.event.description}</p>
{/if}
{#if items.length > 0 && !data.cancelled}
<section class="bring-list">
<h2>{t.bringListHeading}</h2>
<ul>
{#each items as item (item.id)}
<li class="bring-item" class:claimed={!!item.claimedByName}>
<div class="bring-info">
<span class="bring-label">{item.label}</span>
{#if item.quantity}
<span class="bring-qty">×{item.quantity}</span>
{/if}
{#if item.claimedByName}
<span class="bring-claimed">{t.claimedBy(item.claimedByName)}</span>
{/if}
</div>
{#if !item.claimedByName}
<button
type="button"
class="claim-btn"
disabled={claimingItemId === item.id}
onclick={() => claimItem(item.id)}
>
{claimingItemId === item.id ? '…' : t.claimButton}
</button>
{/if}
</li>
{/each}
</ul>
{#if claimError}
<p class="claim-error">{claimError}</p>
{/if}
</section>
{/if}
{#if data.cancelled}
<div class="cancelled">{t.cancelledNotice}</div>
{:else if submitted}
@ -275,6 +351,89 @@
white-space: pre-wrap;
line-height: 1.5;
}
.bring-list {
margin: 0 0 1.5rem;
padding: 1rem;
background: #fef9fb;
border: 1px solid #fce4eb;
border-radius: 0.625rem;
}
.bring-list h2 {
margin: 0 0 0.625rem;
font-size: 0.8125rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #be123c;
}
.bring-list ul {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.bring-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: white;
border: 1px solid #fce4eb;
border-radius: 0.5rem;
}
.bring-item.claimed {
background: #f9fafb;
border-color: #e5e7eb;
}
.bring-info {
flex: 1;
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
align-items: baseline;
font-size: 0.875rem;
}
.bring-label {
font-weight: 500;
color: #111827;
}
.bring-item.claimed .bring-label {
color: #6b7280;
}
.bring-qty {
font-size: 0.75rem;
color: #6b7280;
}
.bring-claimed {
font-size: 0.75rem;
color: #16a34a;
font-style: italic;
}
.claim-btn {
padding: 0.375rem 0.75rem;
border: 1px solid #f43f5e;
border-radius: 0.375rem;
background: white;
color: #be123c;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
}
.claim-btn:hover:not(:disabled) {
background: #fff1f3;
}
.claim-btn:disabled {
opacity: 0.5;
cursor: wait;
}
.claim-error {
margin: 0.5rem 0 0;
font-size: 0.75rem;
color: #dc2626;
}
.cancelled {
padding: 1rem;
border-radius: 0.5rem;

View file

@ -35,6 +35,12 @@ interface Strings {
genericError: string;
poweredBy: string;
dateLocale: string;
bringListHeading: string;
bringListEmpty: string;
claimButton: string;
claimedBy: (name: string) => string;
claimNamePrompt: string;
claimAlreadyTaken: string;
}
const DICTS: Record<Lang, Strings> = {
@ -69,6 +75,12 @@ const DICTS: Record<Lang, Strings> = {
genericError: 'Konnte nicht senden',
poweredBy: 'Powered by',
dateLocale: 'de-DE',
bringListHeading: 'Wer bringt was?',
bringListEmpty: 'Noch nichts auf der Liste.',
claimButton: 'Übernehmen',
claimedBy: (name) => `${name} bringt das mit`,
claimNamePrompt: 'Wie heißt du? Wir reservieren das Item für dich:',
claimAlreadyTaken: 'Schon vergeben — bitte Seite neu laden.',
},
en: {
rsvpTitle: 'Please RSVP',
@ -100,6 +112,12 @@ const DICTS: Record<Lang, Strings> = {
genericError: 'Could not send',
poweredBy: 'Powered by',
dateLocale: 'en-US',
bringListHeading: 'Who brings what?',
bringListEmpty: 'Nothing on the list yet.',
claimButton: 'Claim',
claimedBy: (name) => `${name} is bringing this`,
claimNamePrompt: "What's your name? We'll reserve the item for you:",
claimAlreadyTaken: 'Already taken — please reload the page.',
},
it: {
rsvpTitle: 'Conferma la tua presenza',
@ -132,6 +150,12 @@ const DICTS: Record<Lang, Strings> = {
genericError: 'Impossibile inviare',
poweredBy: 'Powered by',
dateLocale: 'it-IT',
bringListHeading: 'Chi porta cosa?',
bringListEmpty: 'Nulla nella lista per ora.',
claimButton: 'Riserva',
claimedBy: (name) => `${name} lo porta`,
claimNamePrompt: 'Come ti chiami? Riserveremo loggetto per te:',
claimAlreadyTaken: 'Già preso — ricarica la pagina.',
},
fr: {
rsvpTitle: 'Confirmez votre présence',
@ -163,6 +187,12 @@ const DICTS: Record<Lang, Strings> = {
genericError: 'Impossible denvoyer',
poweredBy: 'Propulsé par',
dateLocale: 'fr-FR',
bringListHeading: 'Qui apporte quoi ?',
bringListEmpty: 'Rien sur la liste pour linstant.',
claimButton: 'Je men charge',
claimedBy: (name) => `${name} lapporte`,
claimNamePrompt: 'Quel est ton nom ? Nous te réservons lélément :',
claimAlreadyTaken: 'Déjà pris — recharge la page.',
},
es: {
rsvpTitle: 'Confirma tu asistencia',
@ -195,6 +225,12 @@ const DICTS: Record<Lang, Strings> = {
genericError: 'No se pudo enviar',
poweredBy: 'Powered by',
dateLocale: 'es-ES',
bringListHeading: '¿Quién trae qué?',
bringListEmpty: 'Nada en la lista todavía.',
claimButton: 'Lo traigo yo',
claimedBy: (name) => `${name} lo trae`,
claimNamePrompt: '¿Cómo te llamas? Te reservamos el artículo:',
claimAlreadyTaken: 'Ya lo cogió alguien — recarga la página.',
},
};