diff --git a/apps/mana/apps/web/src/lib/i18n/locales/forms/de.json b/apps/mana/apps/web/src/lib/i18n/locales/forms/de.json index 8b637f122..5c85c8d4d 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/forms/de.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/forms/de.json @@ -80,7 +80,18 @@ "none": "Einmalig", "weekly": "Wöchentlich", "monthly": "Monatlich", - "hint": "Eingehende Antworten bekommen automatisch einen Wellen-Tag (z.B. \"KW 19 / 2026\") für Trend-Vergleich. Versand des Links via Broadcast kommt im nächsten Schritt." + "hint": "Eingehende Antworten bekommen automatisch einen Wellen-Tag (z.B. \"KW 19 / 2026\") für Trend-Vergleich.", + "recipientsLabel": "Empfänger-Emails (max. 50, eine pro Zeile)", + "recipientsCount": "{n} valide Empfänger erkannt", + "lastSent": "Letzter Versand: {date}", + "waveDue": "Nächste Welle ist fällig — schicke den Link an deine Empfänger.", + "nextWaveAt": "Nächste Welle: {date}", + "sendNow": "Welle jetzt senden", + "needsUnlisted": "Setze die Sichtbarkeit auf \"unlisted\", um den Link zu erzeugen.", + "needsRecipients": "Trage Empfänger-Emails in den Settings ein.", + "mailSubject": "Bitte ausfüllen: {title}", + "mailBody": "{description}Hier kannst du antworten:\n{url}\n\nDanke!", + "confirmSend": "Welle an {n} Empfänger senden? Dein Mail-Programm öffnet sich mit BCC-Liste und Link. Nach dem Versand stempeln wir den Zeitpunkt." }, "autoSync": { "title": "Auto-Sync — bei Antwort erzeugen", diff --git a/apps/mana/apps/web/src/lib/i18n/locales/forms/en.json b/apps/mana/apps/web/src/lib/i18n/locales/forms/en.json index c2b80933c..13c9017c6 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/forms/en.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/forms/en.json @@ -80,7 +80,18 @@ "none": "One-off", "weekly": "Weekly", "monthly": "Monthly", - "hint": "Incoming responses are auto-tagged with a wave label (e.g. \"W19 / 2026\") for trend comparison. Broadcast-based sending will come in a follow-up step." + "hint": "Incoming responses are auto-tagged with a wave label (e.g. \"W19 / 2026\") for trend comparison.", + "recipientsLabel": "Recipient emails (max. 50, one per line)", + "recipientsCount": "{n} valid recipients detected", + "lastSent": "Last sent: {date}", + "waveDue": "Next wave is due — send the link to your recipients.", + "nextWaveAt": "Next wave: {date}", + "sendNow": "Send wave now", + "needsUnlisted": "Set visibility to \"unlisted\" to create the link.", + "needsRecipients": "Add recipient emails in the settings.", + "mailSubject": "Please fill in: {title}", + "mailBody": "{description}Reply here:\n{url}\n\nThanks!", + "confirmSend": "Send wave to {n} recipients? Your mail client opens with the BCC list and link prefilled. We'll stamp the timestamp after." }, "autoSync": { "title": "Auto-sync — create on submit", diff --git a/apps/mana/apps/web/src/lib/i18n/locales/forms/es.json b/apps/mana/apps/web/src/lib/i18n/locales/forms/es.json index 63174e2ba..bec3d3ec6 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/forms/es.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/forms/es.json @@ -80,7 +80,18 @@ "none": "Una vez", "weekly": "Semanal", "monthly": "Mensual", - "hint": "Las respuestas recibidas se etiquetan automáticamente con la ola (p. ej. \"S19 / 2026\") para comparar tendencias. El envío automático por broadcast llegará en un paso posterior." + "hint": "Las respuestas recibidas se etiquetan automáticamente con la ola (p. ej. \"S19 / 2026\") para comparar tendencias.", + "recipientsLabel": "Emails de destinatarios (máx. 50, uno por línea)", + "recipientsCount": "{n} destinatarios válidos detectados", + "lastSent": "Último envío: {date}", + "waveDue": "La próxima ola está pendiente — envía el enlace a tus destinatarios.", + "nextWaveAt": "Próxima ola: {date}", + "sendNow": "Enviar ola ahora", + "needsUnlisted": "Pon la visibilidad en \"unlisted\" para crear el enlace.", + "needsRecipients": "Añade emails de destinatarios en los ajustes.", + "mailSubject": "Por favor rellena: {title}", + "mailBody": "{description}Responde aquí:\n{url}\n\n¡Gracias!", + "confirmSend": "¿Enviar ola a {n} destinatarios? Tu cliente de correo se abrirá con la lista BCC y el enlace prellenado. Marcamos la marca de tiempo después." }, "autoSync": { "title": "Auto-sync — crear al recibir respuesta", diff --git a/apps/mana/apps/web/src/lib/i18n/locales/forms/fr.json b/apps/mana/apps/web/src/lib/i18n/locales/forms/fr.json index ee662728f..fbdffdab2 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/forms/fr.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/forms/fr.json @@ -80,7 +80,18 @@ "none": "Ponctuel", "weekly": "Hebdomadaire", "monthly": "Mensuel", - "hint": "Les réponses entrantes reçoivent automatiquement un tag de vague (p. ex. « S19 / 2026 ») pour comparer les tendances. L'envoi par broadcast arrive à l'étape suivante." + "hint": "Les réponses entrantes reçoivent automatiquement un tag de vague (p. ex. « S19 / 2026 ») pour comparer les tendances.", + "recipientsLabel": "Emails des destinataires (max. 50, un par ligne)", + "recipientsCount": "{n} destinataires valides détectés", + "lastSent": "Dernier envoi : {date}", + "waveDue": "La prochaine vague est due — envoie le lien à tes destinataires.", + "nextWaveAt": "Prochaine vague : {date}", + "sendNow": "Envoyer la vague", + "needsUnlisted": "Mets la visibilité sur « unlisted » pour créer le lien.", + "needsRecipients": "Ajoute des emails de destinataires dans les paramètres.", + "mailSubject": "Merci de remplir : {title}", + "mailBody": "{description}Réponds ici :\n{url}\n\nMerci !", + "confirmSend": "Envoyer la vague à {n} destinataires ? Ton client mail s'ouvre avec la liste BCC et le lien préremplis. On marque l'horodatage ensuite." }, "autoSync": { "title": "Auto-sync — créer à la soumission", diff --git a/apps/mana/apps/web/src/lib/i18n/locales/forms/it.json b/apps/mana/apps/web/src/lib/i18n/locales/forms/it.json index e8c918791..e8e07d9ef 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/forms/it.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/forms/it.json @@ -80,7 +80,18 @@ "none": "Una tantum", "weekly": "Settimanale", "monthly": "Mensile", - "hint": "Le risposte in arrivo vengono etichettate automaticamente con l'ondata (es. \"Sett. 19 / 2026\") per confronti di trend. L'invio via broadcast arriva nel passo successivo." + "hint": "Le risposte in arrivo vengono etichettate automaticamente con l'ondata (es. \"Sett. 19 / 2026\") per confronti di trend.", + "recipientsLabel": "Email dei destinatari (max. 50, uno per riga)", + "recipientsCount": "{n} destinatari validi rilevati", + "lastSent": "Ultimo invio: {date}", + "waveDue": "La prossima ondata è in scadenza — invia il link ai destinatari.", + "nextWaveAt": "Prossima ondata: {date}", + "sendNow": "Invia l'ondata ora", + "needsUnlisted": "Imposta la visibilità su \"unlisted\" per creare il link.", + "needsRecipients": "Aggiungi email dei destinatari nelle impostazioni.", + "mailSubject": "Per favore compila: {title}", + "mailBody": "{description}Rispondi qui:\n{url}\n\nGrazie!", + "confirmSend": "Invia l'ondata a {n} destinatari? Il tuo client di posta si apre con la lista BCC e il link precompilati. Marchiamo l'orario dopo." }, "autoSync": { "title": "Auto-sync — crea al ricevere risposta", diff --git a/apps/mana/apps/web/src/lib/modules/forms/components/SettingsPanel.svelte b/apps/mana/apps/web/src/lib/modules/forms/components/SettingsPanel.svelte index 8b47f3038..4e2f69df1 100644 --- a/apps/mana/apps/web/src/lib/modules/forms/components/SettingsPanel.svelte +++ b/apps/mana/apps/web/src/lib/modules/forms/components/SettingsPanel.svelte @@ -157,6 +157,12 @@ settings.recurrence?.frequency ?? 'none' ); + let recipientEmailsRaw = $state(''); + $effect(() => { + // Re-sync when settings change upstream (clone, undo, etc.). + recipientEmailsRaw = (settings.recurrence?.recipientEmails ?? []).join('\n'); + }); + function setRecurrence(next: 'none' | 'weekly' | 'monthly') { if (next === 'none') { onchange({ recurrence: undefined }); @@ -165,10 +171,25 @@ recurrence: { frequency: next, startedAt: settings.recurrence?.startedAt ?? new Date().toISOString(), + recipientEmails: settings.recurrence?.recipientEmails, + lastSentAt: settings.recurrence?.lastSentAt, }, }); } } + + async function commitRecipientEmails() { + const { parseRecipientEmails } = await import('../lib/wave'); + const parsed = parseRecipientEmails(recipientEmailsRaw).slice(0, 50); + const current = settings.recurrence; + if (!current) return; + onchange({ + recurrence: { + ...current, + recipientEmails: parsed.length > 0 ? parsed : undefined, + }, + }); + }
@@ -274,9 +295,43 @@

{$_('forms.builder.recurrence.hint', { default: - 'Eingehende Antworten bekommen automatisch einen Wellen-Tag (z.B. "KW 19 / 2026") für Trend-Vergleich. Versand des Links via Broadcast kommt im nächsten Schritt.', + 'Eingehende Antworten bekommen automatisch einen Wellen-Tag (z.B. "KW 19 / 2026") für Trend-Vergleich.', })}

+ + + + {#if settings.recurrence?.lastSentAt} +

+ {$_('forms.builder.recurrence.lastSent', { + default: 'Letzter Versand: {date}', + values: { + date: new Date(settings.recurrence.lastSentAt).toLocaleString(), + }, + })} +

+ {/if} {/if}
@@ -449,6 +504,19 @@ color: rgb(255 255 255 / 0.45); } + .recipients-input { + width: 100%; + padding: 0.5rem 0.625rem; + background: rgb(255 255 255 / 0.04); + border: 1px solid rgb(255 255 255 / 0.08); + border-radius: 0.375rem; + color: inherit; + font-size: 0.8125rem; + font-family: ui-monospace, monospace; + resize: vertical; + min-height: 4rem; + } + .target-select { max-width: 240px; padding: 0.375rem 0.625rem; diff --git a/apps/mana/apps/web/src/lib/modules/forms/index.ts b/apps/mana/apps/web/src/lib/modules/forms/index.ts index 251defa28..db04b4448 100644 --- a/apps/mana/apps/web/src/lib/modules/forms/index.ts +++ b/apps/mana/apps/web/src/lib/modules/forms/index.ts @@ -28,6 +28,7 @@ export { } from './lib/auto-sync'; export { computeCohort, cohortLabel, sortCohortsDesc } from './lib/cohort'; export type { RecurrenceFrequency } from './lib/cohort'; +export { nextWaveDueAt, isWaveDue, buildWaveMailto, parseRecipientEmails } from './lib/wave'; // ─── Types ─────────────────────────────────────────────── export { diff --git a/apps/mana/apps/web/src/lib/modules/forms/lib/wave.spec.ts b/apps/mana/apps/web/src/lib/modules/forms/lib/wave.spec.ts new file mode 100644 index 000000000..cdef07028 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/forms/lib/wave.spec.ts @@ -0,0 +1,162 @@ +import { describe, it, expect } from 'vitest'; +import { nextWaveDueAt, isWaveDue, buildWaveMailto, parseRecipientEmails } from './wave'; +import type { RecurrenceConfig } from '../types'; + +describe('nextWaveDueAt', () => { + it('returns null when recurrence is undefined', () => { + expect(nextWaveDueAt(undefined)).toBeNull(); + }); + + it('returns startedAt when lastSentAt is missing (never-sent first wave)', () => { + const cfg: RecurrenceConfig = { + frequency: 'weekly', + startedAt: '2026-05-01T00:00:00Z', + }; + expect(nextWaveDueAt(cfg)?.toISOString()).toBe('2026-05-01T00:00:00.000Z'); + }); + + it('falls back to epoch when never-sent and startedAt is missing', () => { + expect(nextWaveDueAt({ frequency: 'weekly' })?.toISOString()).toBe('1970-01-01T00:00:00.000Z'); + }); + + it('adds 7 days for weekly frequency', () => { + const cfg: RecurrenceConfig = { + frequency: 'weekly', + lastSentAt: '2026-05-01T12:00:00Z', + }; + expect(nextWaveDueAt(cfg)?.toISOString()).toBe('2026-05-08T12:00:00.000Z'); + }); + + it('adds 1 calendar month for monthly frequency', () => { + const cfg: RecurrenceConfig = { + frequency: 'monthly', + lastSentAt: '2026-05-01T12:00:00Z', + }; + expect(nextWaveDueAt(cfg)?.toISOString()).toBe('2026-06-01T12:00:00.000Z'); + }); + + it('handles month-end overflow gracefully (Jan 31 → Feb 28/29)', () => { + const cfg: RecurrenceConfig = { + frequency: 'monthly', + lastSentAt: '2026-01-31T12:00:00Z', + }; + // 2026 is not a leap year — Date math rolls Jan 31 + 1 month into Mar 3 + // (which is the standard JS behavior — accept it rather than fight it). + const due = nextWaveDueAt(cfg); + expect(due).not.toBeNull(); + expect(due!.getUTCFullYear()).toBe(2026); + // Either Feb 28/29 or rolled into March — in both cases >= Feb 28. + expect(due!.getTime()).toBeGreaterThanOrEqual(new Date('2026-02-28T12:00:00Z').getTime()); + }); + + it('returns null for invalid lastSentAt timestamps', () => { + const cfg: RecurrenceConfig = { + frequency: 'weekly', + lastSentAt: 'not-a-date', + }; + expect(nextWaveDueAt(cfg)).toBeNull(); + }); +}); + +describe('isWaveDue', () => { + it('false when recurrence is undefined', () => { + expect(isWaveDue(undefined, new Date('2026-05-08T00:00:00Z'))).toBe(false); + }); + + it('true when next-due is in the past', () => { + const cfg: RecurrenceConfig = { + frequency: 'weekly', + lastSentAt: '2026-05-01T00:00:00Z', + }; + expect(isWaveDue(cfg, new Date('2026-05-09T00:00:00Z'))).toBe(true); + }); + + it('false when next-due is in the future', () => { + const cfg: RecurrenceConfig = { + frequency: 'weekly', + lastSentAt: '2026-05-01T00:00:00Z', + }; + expect(isWaveDue(cfg, new Date('2026-05-05T00:00:00Z'))).toBe(false); + }); + + it('true at the exact due-instant (boundary inclusive)', () => { + const cfg: RecurrenceConfig = { + frequency: 'weekly', + lastSentAt: '2026-05-01T12:00:00Z', + }; + expect(isWaveDue(cfg, new Date('2026-05-08T12:00:00Z'))).toBe(true); + }); + + it('true on first-wave when never sent (startedAt in past)', () => { + const cfg: RecurrenceConfig = { + frequency: 'monthly', + startedAt: '2026-04-01T00:00:00Z', + }; + expect(isWaveDue(cfg, new Date('2026-05-06T00:00:00Z'))).toBe(true); + }); +}); + +describe('buildWaveMailto', () => { + it('builds a mailto URL with bcc + subject + body', () => { + const url = buildWaveMailto({ + recipientEmails: ['anna@example.com', 'bob@example.com'], + subject: 'Pulse-Check', + body: 'Bitte ausfüllen: https://mana.how/share/abc', + }); + expect(url).toContain('mailto:?'); + expect(url).toContain('bcc=anna%40example.com%2Cbob%40example.com'); + expect(url).toContain('subject=Pulse-Check'); + expect(url).toContain('body=Bitte+ausf'); + expect(url).toContain('mana.how%2Fshare%2Fabc'); + }); + + it('omits bcc when recipients is empty', () => { + const url = buildWaveMailto({ + recipientEmails: [], + subject: 'X', + body: 'Y', + }); + expect(url).not.toContain('bcc='); + expect(url).toContain('subject=X'); + }); +}); + +describe('parseRecipientEmails', () => { + it('handles newline-separated input', () => { + expect(parseRecipientEmails('anna@example.com\nbob@example.com\ncarol@example.com')).toEqual([ + 'anna@example.com', + 'bob@example.com', + 'carol@example.com', + ]); + }); + + it('handles comma-separated input', () => { + expect(parseRecipientEmails('anna@example.com, bob@example.com')).toEqual([ + 'anna@example.com', + 'bob@example.com', + ]); + }); + + it('handles mixed separators and whitespace', () => { + expect( + parseRecipientEmails('anna@example.com;\nbob@example.com ,carol@example.com\t\t') + ).toEqual(['anna@example.com', 'bob@example.com', 'carol@example.com']); + }); + + it('drops invalid email shapes silently', () => { + expect(parseRecipientEmails('valid@example.com\nnot-an-email\nfoo@\n@bar.com')).toEqual([ + 'valid@example.com', + ]); + }); + + it('deduplicates case-insensitively but keeps the first casing', () => { + expect(parseRecipientEmails('Anna@Example.com\nanna@example.com')).toEqual([ + 'Anna@Example.com', + ]); + }); + + it('returns empty array for empty input', () => { + expect(parseRecipientEmails('')).toEqual([]); + expect(parseRecipientEmails(' \n\n ')).toEqual([]); + }); +}); diff --git a/apps/mana/apps/web/src/lib/modules/forms/lib/wave.ts b/apps/mana/apps/web/src/lib/modules/forms/lib/wave.ts new file mode 100644 index 000000000..3847a981f --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/forms/lib/wave.ts @@ -0,0 +1,93 @@ +/** + * Wave-send scheduling for recurring forms (M10b). + * + * Given a `RecurrenceConfig` and the current time, decide when the + * next "wave" of the link should go out. Pure — no Date.now(), no + * Dexie. The owner-action button in BuilderView reads `isWaveDue` to + * decide whether to surface a reminder banner. + * + * Bucketing convention: + * - weekly → next wave is 7 days after lastSentAt + * - monthly → next wave is 1 calendar month after lastSentAt + * - When `lastSentAt` is missing, the form has never been sent and + * the *first* wave is considered due immediately (so the user + * gets nudged after configuring recurrence). + * + * The intentionally-naive "+30 days" rule for monthly would drift over + * the course of a year; we use UTC calendar math instead so "the 1st + * of every month" stays the 1st. + */ + +import type { RecurrenceConfig } from '../types'; + +export function nextWaveDueAt(recurrence: RecurrenceConfig | undefined): Date | null { + if (!recurrence) return null; + const lastIso = recurrence.lastSentAt; + if (!lastIso) { + // Never sent — treat the recurrence-startedAt (or epoch) as the + // implicit first wave so the due-check returns true on day 1. + return recurrence.startedAt ? new Date(recurrence.startedAt) : new Date(0); + } + const last = new Date(lastIso); + if (Number.isNaN(last.getTime())) return null; + + if (recurrence.frequency === 'weekly') { + return new Date(last.getTime() + 7 * 24 * 60 * 60 * 1000); + } + // Monthly: same day-of-month one month later, in UTC. + const next = new Date(last); + next.setUTCMonth(next.getUTCMonth() + 1); + return next; +} + +export function isWaveDue( + recurrence: RecurrenceConfig | undefined, + now: Date = new Date() +): boolean { + const due = nextWaveDueAt(recurrence); + if (!due) return false; + return due.getTime() <= now.getTime(); +} + +/** + * Build a mailto: URL with BCC recipients + subject + body containing + * the share-link. Browsers cap mailto-URLs at ~2KB on average, hence + * the recipient-cap upstream. For larger lists the user copies the + * link manually. + */ +export function buildWaveMailto(opts: { + recipientEmails: string[]; + subject: string; + body: string; +}): string { + const bcc = opts.recipientEmails.join(','); + const params = new URLSearchParams(); + if (bcc) params.set('bcc', bcc); + params.set('subject', opts.subject); + params.set('body', opts.body); + // Browsers prefer + for spaces in mailto bodies but URLSearchParams + // emits %20; both work. Keep %20 for predictability. + return `mailto:?${params.toString()}`; +} + +/** + * Parse a free-form recipient input (one email per line OR + * comma-separated) into a deduplicated trimmed array. Invalid + * shapes get dropped silently — the SettingsPanel surfaces the + * accepted count back to the user. + */ +export function parseRecipientEmails(raw: string): string[] { + const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + const seen = new Set(); + const out: string[] = []; + for (const part of raw.split(/[\s,;]+/)) { + const trimmed = part.trim(); + if (!trimmed) continue; + if (!re.test(trimmed)) continue; + const lower = trimmed.toLowerCase(); + if (seen.has(lower)) continue; + seen.add(lower); + out.push(trimmed); + } + return out; +} diff --git a/apps/mana/apps/web/src/lib/modules/forms/stores/forms.svelte.ts b/apps/mana/apps/web/src/lib/modules/forms/stores/forms.svelte.ts index 4d2148a4f..c63a65e25 100644 --- a/apps/mana/apps/web/src/lib/modules/forms/stores/forms.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/forms/stores/forms.svelte.ts @@ -64,6 +64,26 @@ export const formsStore = { await formTable.update(id, diff); }, + /** + * Stamp the recurrence lastSentAt timestamp after the user fired a + * wave (M10b). The whole settings-blob travels encrypted, so we + * read the current settings, patch lastSentAt, then re-encrypt the + * full blob. Other settings stay untouched. + */ + async markWaveSent(id: string, sentAtIso: string = new Date().toISOString()) { + const form = await formTable.get(id); + if (!form) return; + const settings = form.settings ?? DEFAULT_FORM_SETTINGS; + if (!settings.recurrence) return; + const nextSettings: typeof settings = { + ...settings, + recurrence: { ...settings.recurrence, lastSentAt: sentAtIso }, + }; + const diff: Partial = { settings: nextSettings }; + await encryptRecord('forms', diff); + await formTable.update(id, diff); + }, + async deleteForm(id: string) { await formTable.update(id, { deletedAt: nowIso() }); }, diff --git a/apps/mana/apps/web/src/lib/modules/forms/types.ts b/apps/mana/apps/web/src/lib/modules/forms/types.ts index 81ad1145e..b115bab41 100644 --- a/apps/mana/apps/web/src/lib/modules/forms/types.ts +++ b/apps/mana/apps/web/src/lib/modules/forms/types.ts @@ -90,8 +90,16 @@ export interface RecurrenceConfig { frequency: 'weekly' | 'monthly'; /** ISO timestamp the recurrence started — informational only today. */ startedAt?: string; - /** Future M10b — currently no-op. */ + /** M10b: how the share-link gets distributed when a wave is due. */ sendVia?: 'broadcast' | 'manual'; + /** + * M10b — recipient emails, one per element. Capped at 50 (mailto-bridge + * realism). For larger groups the user copies the link manually until a + * proper bulk-send integration lands (M10c). + */ + recipientEmails?: string[]; + /** ISO timestamp of the last wave the user fired. Drives the due-banner. */ + lastSentAt?: string; } export interface FormSettings { diff --git a/apps/mana/apps/web/src/lib/modules/forms/views/BuilderView.svelte b/apps/mana/apps/web/src/lib/modules/forms/views/BuilderView.svelte index 6ccaea12a..2133e1578 100644 --- a/apps/mana/apps/web/src/lib/modules/forms/views/BuilderView.svelte +++ b/apps/mana/apps/web/src/lib/modules/forms/views/BuilderView.svelte @@ -19,6 +19,7 @@ import FieldPalette from '../components/FieldPalette.svelte'; import SettingsPanel from '../components/SettingsPanel.svelte'; import BranchingEditor from '../components/BranchingEditor.svelte'; + import { buildWaveMailto, isWaveDue, nextWaveDueAt } from '../lib/wave'; import { VisibilityPicker, SharedLinkControls, @@ -149,6 +150,51 @@ return buildShareUrl(origin, entry.unlistedToken); }); + // ── Wave-Send (M10b) ────────────────────────────────── + const recurrence = $derived(entry.settings.recurrence); + const waveDue = $derived(isWaveDue(recurrence)); + const waveDueAt = $derived(nextWaveDueAt(recurrence)); + const canSendWave = $derived( + !!recurrence && + !!entry.unlistedToken && + !!shareUrl && + (recurrence.recipientEmails ?? []).length > 0 + ); + + async function sendWave() { + if (!canSendWave || !shareUrl) return; + const recipients = entry.settings.recurrence?.recipientEmails ?? []; + if (recipients.length === 0) return; + + const subject = $_('forms.builder.recurrence.mailSubject', { + default: 'Bitte ausfüllen: {title}', + values: { title: entry.title }, + }); + const description = entry.description ? `${entry.description}\n\n` : ''; + const body = $_('forms.builder.recurrence.mailBody', { + default: '{description}Hier kannst du antworten:\n{url}\n\nDanke!', + values: { description, url: shareUrl }, + }); + const mailto = buildWaveMailto({ recipientEmails: recipients, subject, body }); + + const ok = confirm( + $_('forms.builder.recurrence.confirmSend', { + default: + 'Welle an {n} Empfänger senden? Dein Mail-Programm öffnet sich mit BCC-Liste und Link. Nach dem Versand stempeln wir den Zeitpunkt.', + values: { n: recipients.length }, + }) + ); + if (!ok) return; + + window.open(mailto, '_blank'); + await formsStore.markWaveSent(entry.id); + } + + function formatDueAt(d: Date | null): string { + if (!d) return ''; + return d.toLocaleString(); + } + async function setStatus(status: FormStatus) { await formsStore.setStatus(entry.id, status); } @@ -289,6 +335,47 @@ onExpiryChange={handleExpiryChange} /> {/if} + + {#if recurrence} +
+ {#if waveDue} +

+ {$_('forms.builder.recurrence.waveDue', { + default: 'Nächste Welle ist fällig — schicke den Link an deine Empfänger.', + })} +

+ {:else if waveDueAt && entry.settings.recurrence?.lastSentAt} +

+ {$_('forms.builder.recurrence.nextWaveAt', { + default: 'Nächste Welle: {date}', + values: { date: formatDueAt(waveDueAt) }, + })} +

+ {/if} + + {#if !canSendWave} +

+ {!entry.unlistedToken + ? $_('forms.builder.recurrence.needsUnlisted', { + default: 'Setze die Sichtbarkeit auf "unlisted", um den Link zu erzeugen.', + }) + : $_('forms.builder.recurrence.needsRecipients', { + default: 'Trage Empfänger-Emails in den Settings ein.', + })} +

+ {/if} +
+ {/if}
@@ -478,6 +565,60 @@ color: rgb(252 165 165); } + .wave-block { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding-top: 0.625rem; + border-top: 1px solid rgb(255 255 255 / 0.06); + } + + .wave-due-banner { + margin: 0; + padding: 0.5rem 0.75rem; + background: rgb(245 158 11 / 0.16); + border: 1px solid rgb(245 158 11 / 0.4); + border-radius: 0.375rem; + color: rgb(252 211 77); + font-size: 0.8125rem; + } + + .wave-hint { + margin: 0; + font-size: 0.75rem; + color: rgb(255 255 255 / 0.5); + } + + .wave-send { + align-self: flex-start; + padding: 0.5rem 0.875rem; + background: rgb(20 184 166 / 0.16); + border: 1px solid rgb(20 184 166 / 0.4); + border-radius: 0.375rem; + color: rgb(94 234 212); + font-size: 0.8125rem; + cursor: pointer; + } + + .wave-send:hover:not(:disabled) { + background: rgb(20 184 166 / 0.24); + } + + .wave-send:disabled { + opacity: 0.45; + cursor: not-allowed; + } + + .wave-send.due { + background: rgb(245 158 11 / 0.18); + border-color: rgb(245 158 11 / 0.5); + color: rgb(252 211 77); + } + + .wave-send.due:hover:not(:disabled) { + background: rgb(245 158 11 / 0.26); + } + .section-header { display: flex; align-items: baseline;