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:
parent
c2c4dfd518
commit
77c6f513b5
32 changed files with 3012 additions and 399 deletions
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue