Feature: SEPA-Export, Push-Notifications, Onboarding + vollständige UI

- Phosphor Icons (Icon.svelte, svg-Registry)
- Schema-Abgleich: alle Felder zwischen PB-Migrations und types.ts konsistent
- Stripe entfernt, SEPA pain.008 XML-Export implementiert (sepa.ts)
- Beiträge: vollständiges CRUD + SEPA-Einzug-Sheet mit Vorschau
- Termine: vollständiges CRUD (upcoming/vergangen, datetime-local)
- Mitglieder: Formulare um alle Felder erweitert (Adresse, SEPA-Mandat, Notizen)
- Nachrichten: Brevo E-Mail via PocketBase-Hook, UI mit Gruppen-Filter
- Push-Notifications: VAPID, Custom Service Worker (injectManifest),
  Subscribe/Send API-Routen, automatische Subscription nach Login
- Onboarding: 3-Schritt-Flow für neue Vereine, Guard im App-Layout
- Makefile: .env wird vollständig zur DS übertragen
This commit is contained in:
rene 2026-05-20 13:01:11 +02:00
parent c2c4dfd518
commit 77c6f513b5
32 changed files with 3012 additions and 399 deletions

View file

@ -1,27 +1,328 @@
<script lang="ts">
import { pb } from '$lib/pb';
import { onMount } from 'svelte';
import type { Nachricht, Gruppe } from '$lib/types';
let nachrichten = $state<Nachricht[]>([]);
let gruppen = $state<Gruppe[]>([]);
let loading = $state(true);
// Formular
let showForm = $state(false);
let fBetreff = $state('');
let fText = $state('');
let fGruppeIds = $state<string[]>([]);
let sending = $state(false);
let sendError = $state('');
let sendSuccess = $state('');
onMount(async () => {
[nachrichten, gruppen] = await Promise.all([
pb.collection('nachrichten').getFullList<Nachricht>({ sort: '-gesendet_am' }),
pb.collection('gruppen').getFullList<Gruppe>({ sort: 'name' }),
]);
loading = false;
});
function toggleGruppe(id: string) {
fGruppeIds = fGruppeIds.includes(id)
? fGruppeIds.filter((g) => g !== id)
: [...fGruppeIds, id];
}
function neueNachricht() {
fBetreff = ''; fText = ''; fGruppeIds = [];
sendError = ''; sendSuccess = ''; showForm = true;
}
async function senden() {
if (!fBetreff.trim() || !fText.trim()) {
sendError = 'Betreff und Nachricht sind Pflichtfelder.';
return;
}
sendError = ''; sending = true;
try {
const verein_id = pb.authStore.record?.verein_id as string;
const autor_id = pb.authStore.record?.id as string;
const record = await pb.collection('nachrichten').create<Nachricht>({
verein_id,
autor_id,
betreff: fBetreff.trim(),
text: fText.trim(),
gruppe_ids: fGruppeIds,
gesendet_am: new Date().toISOString(),
});
nachrichten = [record, ...nachrichten];
showForm = false;
// Push-Benachrichtigung an alle abonnierten Geräte im Verein
fetch('/api/push/senden', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: pb.authStore.token,
},
body: JSON.stringify({
titel: fBetreff.trim(),
body: fText.trim().slice(0, 120),
url: '/nachrichten',
}),
}).catch(() => {});
sendSuccess = 'Nachricht wurde gespeichert und per E-Mail versendet.';
} catch (e: unknown) {
sendError = e instanceof Error ? e.message : 'Fehler beim Senden.';
} finally {
sending = false;
}
}
function formatDatum(iso: string | undefined): string {
if (!iso) return '';
return new Date(iso).toLocaleDateString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
});
}
const gruppenLabel = (ids: string[]) =>
ids.length === 0
? 'Alle Mitglieder'
: ids.map((id) => gruppen.find((g) => g.id === id)?.name ?? id).join(', ');
</script>
<svelte:head><title>Nachrichten — vereins.haus</title></svelte:head>
<div class="page-header">
<div class="top">
<h1>Nachrichten</h1>
<button class="btn-primary">+ Nachricht</button>
<button class="btn-primary" onclick={neueNachricht}>+ Nachricht</button>
</div>
<p class="placeholder">Nachrichten & Push-Benachrichtigungen — in Entwicklung</p>
{#if sendSuccess}
<div class="success">{sendSuccess}</div>
{/if}
{#if loading}
<p class="hint">Laden…</p>
{:else if nachrichten.length === 0}
<p class="hint">Noch keine Nachrichten versendet.</p>
{:else}
<ul class="liste">
{#each nachrichten as n (n.id)}
<li class="karte">
<div class="karte-header">
<span class="karte-betreff">{n.betreff}</span>
<span class="karte-datum">{formatDatum(n.gesendet_am)}</span>
</div>
<span class="karte-meta">{gruppenLabel(n.gruppe_ids ?? [])}</span>
{#if n.text}
<p class="karte-vorschau">{n.text.slice(0, 120)}{n.text.length > 120 ? '…' : ''}</p>
{/if}
</li>
{/each}
</ul>
{/if}
<!-- Nachricht verfassen -->
{#if showForm}
<div class="overlay" role="dialog" aria-modal="true">
<div class="sheet">
<h2>Neue Nachricht</h2>
<form onsubmit={(e) => { e.preventDefault(); senden(); }}>
<div class="field">
<label for="fbetreff">Betreff *</label>
<input id="fbetreff" type="text" bind:value={fBetreff} placeholder="Betreff" required />
</div>
<div class="field">
<label for="ftext">Nachricht *</label>
<textarea id="ftext" bind:value={fText} rows="6" placeholder="Text der Nachricht…" required></textarea>
</div>
{#if gruppen.length > 0}
<div class="field">
<span class="field-label">Empfänger</span>
<div class="checkboxes">
<label class="check-label alle" class:active={fGruppeIds.length === 0}>
<input
type="checkbox"
checked={fGruppeIds.length === 0}
onchange={() => (fGruppeIds = [])}
/>
Alle Mitglieder
</label>
{#each gruppen as g (g.id)}
<label class="check-label" class:active={fGruppeIds.includes(g.id)}>
<input
type="checkbox"
checked={fGruppeIds.includes(g.id)}
onchange={() => toggleGruppe(g.id)}
/>
{g.name}
</label>
{/each}
</div>
</div>
{/if}
<p class="versand-info">
Die Nachricht wird an alle aktiven Mitglieder
{fGruppeIds.length > 0 ? 'der gewählten Gruppen' : ''}
mit hinterlegter E-Mail-Adresse gesendet.
</p>
{#if sendError}
<p class="error">{sendError}</p>
{/if}
<div class="actions">
<button type="button" class="btn-ghost" onclick={() => (showForm = false)}>
Abbrechen
</button>
<button type="submit" class="btn-primary" disabled={sending}>
{sending ? 'Senden…' : 'Senden'}
</button>
</div>
</form>
</div>
</div>
{/if}
<style>
.page-header {
.top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.25rem;
}
h1 { font-size: 1.4rem; font-weight: 700; color: #1e293b; }
.btn-primary {
background: #1e40af; color: #fff; border: none;
border-radius: 8px; padding: 0.5rem 1rem;
font-size: 0.9rem; font-weight: 600;
.success {
background: #dcfce7;
border: 1px solid #86efac;
border-radius: 8px;
padding: 0.75rem 1rem;
font-size: 0.875rem;
color: #166534;
margin-bottom: 1rem;
}
.hint { color: #94a3b8; font-size: 0.95rem; text-align: center; margin-top: 3rem; }
.liste { list-style: none; padding: 0; display: flex; flex-direction: column; gap: 0.5rem; }
.karte {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 0.9rem 1rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.karte-header {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 0.5rem;
}
.karte-betreff { font-weight: 600; font-size: 0.95rem; color: #1e293b; }
.karte-datum { font-size: 0.75rem; color: #94a3b8; flex-shrink: 0; }
.karte-meta { font-size: 0.78rem; color: #64748b; }
.karte-vorschau { font-size: 0.85rem; color: #475569; margin: 0; }
/* Sheet */
.overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.4);
display: flex;
align-items: flex-end;
justify-content: center;
z-index: 100;
padding: 1rem;
padding-bottom: calc(1rem + env(safe-area-inset-bottom));
}
.sheet {
background: #fff;
border-radius: 16px;
padding: 1.5rem;
width: 100%;
max-width: 480px;
max-height: 92dvh;
overflow-y: auto;
}
h2 { font-size: 1.1rem; font-weight: 700; color: #1e293b; margin-bottom: 1rem; }
.field { display: flex; flex-direction: column; gap: 0.3rem; margin-bottom: 0.9rem; }
label, .field-label { font-size: 0.875rem; font-weight: 500; color: #475569; }
input, textarea {
padding: 0.65rem 0.85rem;
border: 1.5px solid #e2e8f0;
border-radius: 8px;
font-size: 1rem;
background: #fff;
width: 100%;
box-sizing: border-box;
transition: border-color 0.15s;
font-family: inherit;
resize: vertical;
}
input:focus, textarea:focus { outline: none; border-color: #1e40af; }
.checkboxes { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.25rem; }
.check-label {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.7rem;
border: 1.5px solid #e2e8f0;
border-radius: 20px;
font-size: 0.82rem;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.check-label.active { border-color: #1e40af; background: #e0e7ff; color: #1e40af; }
.check-label input { display: none; }
.versand-info {
font-size: 0.8rem;
color: #94a3b8;
margin-bottom: 0.75rem;
}
.error { color: #dc2626; font-size: 0.875rem; margin-bottom: 0.75rem; }
.actions { display: flex; gap: 0.75rem; margin-top: 0.5rem; }
.btn-primary {
flex: 1;
padding: 0.75rem;
background: #1e40af;
color: #fff;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
.btn-primary:hover:not(:disabled) { background: #1d3a9e; }
.btn-primary:disabled { opacity: 0.55; cursor: not-allowed; }
.btn-ghost {
padding: 0.75rem 1rem;
background: none;
border: 1.5px solid #e2e8f0;
border-radius: 8px;
font-size: 1rem;
color: #64748b;
cursor: pointer;
}
.placeholder { color: #94a3b8; font-size: 0.95rem; }
</style>