mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-21 09:56:43 +02:00
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:
parent
af92720a62
commit
6a60e22a31
19 changed files with 1296 additions and 16 deletions
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
12
apps/mana/apps/web/src/lib/modules/events/module.config.ts
Normal file
12
apps/mana/apps/web/src/lib/modules/events/module.config.ts
Normal 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' },
|
||||
],
|
||||
};
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
115
apps/mana/apps/web/src/lib/modules/events/stores/items.svelte.ts
Normal file
115
apps/mana/apps/web/src/lib/modules/events/stores/items.svelte.ts
Normal 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 };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 l’oggetto 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 d’envoyer',
|
||||
poweredBy: 'Propulsé par',
|
||||
dateLocale: 'fr-FR',
|
||||
bringListHeading: 'Qui apporte quoi ?',
|
||||
bringListEmpty: 'Rien sur la liste pour l’instant.',
|
||||
claimButton: 'Je m’en charge',
|
||||
claimedBy: (name) => `${name} l’apporte`,
|
||||
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.',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue