mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
feat(broadcast): M1 skeleton — module registration + empty ListView
New 1:N email-campaign module (newsletters / announcements). M1 scope: - types (LocalCampaign / LocalBroadcastTemplate / LocalBroadcastSettings), constants (STATUS_LABELS, BROADCAST_SETTINGS_ID, rate-limit hints) - collections.ts: Dexie table refs, no guest seed (a demo campaign that might accidentally hit real SMTP felt wrong) - module.config registered in module-registry - Dexie v32 wired in (already in tree from a parallel Spaces commit picking it up via lint-staged — matches what the module expects) - encryption registry entries for all three tables (type-safe via entry<T>), content + audience always encrypted because the recipient graph is a leakable business secret - app entry (requiredTier: alpha) + megaphone gradient icon (indigo→cyan, sits between mail and invoices in the comm family) - route /broadcasts mounts ListView with empty-state placeholder Status machine defined: draft → scheduled → sending → sent, with cancelled as the off-ramp from draft/scheduled. No CRUD yet — that's M2. Plan: docs/plans/broadcast-module.md. Next: M2 AudienceBuilder + Tiptap editor. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
79a6da3e2e
commit
1f392c1ea6
11 changed files with 500 additions and 0 deletions
|
|
@ -83,6 +83,11 @@ import type {
|
|||
LocalInvoiceClient,
|
||||
LocalInvoiceSettings,
|
||||
} from '../../modules/invoices/types';
|
||||
import type {
|
||||
LocalCampaign,
|
||||
LocalBroadcastTemplate,
|
||||
LocalBroadcastSettings,
|
||||
} from '../../modules/broadcast/types';
|
||||
|
||||
export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
|
||||
// ─── Chat ────────────────────────────────────────────────
|
||||
|
|
@ -619,6 +624,42 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
|
|||
'notes',
|
||||
]),
|
||||
|
||||
// ─── Broadcast ─────────────────────────────────────────
|
||||
// Newsletter / campaign content. Every field the user typed — subject,
|
||||
// body JSON/HTML, audience filter values, sender profile — is sensitive
|
||||
// *until sent*. Post-send the content itself becomes semi-public (it
|
||||
// went out to N recipients), but pre-send the draft is confidential
|
||||
// marketing copy. Audience definitions always stay sensitive — the
|
||||
// recipient graph is a leakable business secret (who's on the list).
|
||||
//
|
||||
// Plaintext (intentional):
|
||||
// - status, scheduledAt, sentAt, templateId, serverJobId: structural,
|
||||
// indexed for the "drafts vs scheduled vs sent" filter.
|
||||
// - stats: cached counters mirrored from the server tracking tables;
|
||||
// not sensitive by design (they describe aggregate behaviour).
|
||||
// - isBuiltIn on templates: controls whether the user can delete the
|
||||
// row; structural, no privacy value.
|
||||
// - dnsCheck on settings: public DNS state, not user-typed content.
|
||||
broadcastCampaigns: entry<LocalCampaign>([
|
||||
'name',
|
||||
'subject',
|
||||
'preheader',
|
||||
'fromName',
|
||||
'fromEmail',
|
||||
'replyTo',
|
||||
'content',
|
||||
'audience',
|
||||
]),
|
||||
broadcastTemplates: entry<LocalBroadcastTemplate>(['name', 'description', 'subject', 'content']),
|
||||
broadcastSettings: entry<LocalBroadcastSettings>([
|
||||
'defaultFromName',
|
||||
'defaultFromEmail',
|
||||
'defaultReplyTo',
|
||||
'defaultFooter',
|
||||
'legalAddress',
|
||||
'unsubscribeLandingCopy',
|
||||
]),
|
||||
|
||||
// Singleton sender profile. The user's legal address + IBAN live here
|
||||
// and are the most sensitive fields in the module (appear on every PDF
|
||||
// the user issues). logoMediaId / accentColor / number sequence state
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ import { quizModuleConfig } from '$lib/modules/quiz/module.config';
|
|||
import { profileModuleConfig } from '$lib/modules/profile/module.config';
|
||||
import { libraryModuleConfig } from '$lib/modules/library/module.config';
|
||||
import { invoicesModuleConfig } from '$lib/modules/invoices/module.config';
|
||||
import { broadcastModuleConfig } from '$lib/modules/broadcast/module.config';
|
||||
import { wetterModuleConfig } from '$lib/modules/wetter/module.config';
|
||||
import { aiModuleConfig } from '$lib/data/ai/module.config';
|
||||
|
||||
|
|
@ -157,6 +158,7 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
|
|||
profileModuleConfig,
|
||||
libraryModuleConfig,
|
||||
invoicesModuleConfig,
|
||||
broadcastModuleConfig,
|
||||
wetterModuleConfig,
|
||||
aiModuleConfig,
|
||||
];
|
||||
|
|
|
|||
173
apps/mana/apps/web/src/lib/modules/broadcast/ListView.svelte
Normal file
173
apps/mana/apps/web/src/lib/modules/broadcast/ListView.svelte
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
<!--
|
||||
Broadcast — ListView (M1 skeleton)
|
||||
Empty state + "+ Neue Kampagne"-button placeholder. Real list, filters,
|
||||
and stats cards land in M2/M7. Plan: docs/plans/broadcast-module.md.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import { scopedForModule } from '$lib/data/scope';
|
||||
import { STATUS_LABELS, STATUS_COLORS } from './constants';
|
||||
import type { LocalCampaign } from './types';
|
||||
|
||||
const campaigns$ = useLiveQueryWithDefault(async () => {
|
||||
const rows = await scopedForModule<LocalCampaign, string>(
|
||||
'broadcast',
|
||||
'broadcastCampaigns'
|
||||
).toArray();
|
||||
const visible = rows.filter((r) => !r.deletedAt);
|
||||
return (await decryptRecords('broadcastCampaigns', visible)) as LocalCampaign[];
|
||||
}, [] as LocalCampaign[]);
|
||||
const campaigns = $derived(campaigns$.value ?? []);
|
||||
</script>
|
||||
|
||||
<div class="broadcast-shell">
|
||||
<header class="head">
|
||||
<div>
|
||||
<h1>Broadcasts</h1>
|
||||
<p class="subtitle">Newsletter und Kampagnen an deine Kontakte</p>
|
||||
</div>
|
||||
<button class="btn-primary" type="button" disabled title="M2">+ Neue Kampagne</button>
|
||||
</header>
|
||||
|
||||
{#if campaigns.length === 0}
|
||||
<div class="empty">
|
||||
<div class="empty-icon">📣</div>
|
||||
<h2>Noch keine Kampagnen</h2>
|
||||
<p>
|
||||
Verschicke deinen ersten Newsletter — mit Rich-Text-Editor, Tracking und DSGVO-konformem
|
||||
Abmelden.
|
||||
</p>
|
||||
<p class="note">M1 Skelett — Compose-Flow folgt in M2.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="list">
|
||||
{#each campaigns as campaign (campaign.id)}
|
||||
<li class="row">
|
||||
<span class="subject">{campaign.subject}</span>
|
||||
<span class="recipient-count">
|
||||
{campaign.audience?.estimatedCount ?? 0} Empfänger
|
||||
</span>
|
||||
<span class="status" style="--dot: {STATUS_COLORS[campaign.status]}">
|
||||
<span class="dot"></span>
|
||||
{STATUS_LABELS[campaign.status].de}
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.broadcast-shell {
|
||||
padding: 1.5rem;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.head h1 {
|
||||
margin: 0;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0.25rem 0 0;
|
||||
color: var(--color-text-muted, #64748b);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 0;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 4rem 1rem;
|
||||
color: var(--color-text-muted, #64748b);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.empty h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text, #0f172a);
|
||||
}
|
||||
|
||||
.empty p {
|
||||
margin: 0.25rem 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin-top: 1rem !important;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 8rem;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--color-surface, #fff);
|
||||
border: 1px solid var(--color-border, #e2e8f0);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.subject {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.recipient-count {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted, #64748b);
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.status .dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--dot);
|
||||
}
|
||||
</style>
|
||||
16
apps/mana/apps/web/src/lib/modules/broadcast/collections.ts
Normal file
16
apps/mana/apps/web/src/lib/modules/broadcast/collections.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
/**
|
||||
* Broadcast module — Dexie accessors.
|
||||
*
|
||||
* No guest seed: shipping a demo campaign feels wrong for a module that
|
||||
* will eventually hit real SMTP. The empty state is the onboarding.
|
||||
*/
|
||||
|
||||
import { db } from '$lib/data/database';
|
||||
import type { LocalCampaign, LocalBroadcastTemplate, LocalBroadcastSettings } from './types';
|
||||
|
||||
export const campaignTable = db.table<LocalCampaign>('broadcastCampaigns');
|
||||
export const templateTable = db.table<LocalBroadcastTemplate>('broadcastTemplates');
|
||||
export const settingsTable = db.table<LocalBroadcastSettings>('broadcastSettings');
|
||||
|
||||
/** Explicitly empty so module-registry loaders still get a valid export. */
|
||||
export const BROADCAST_GUEST_SEED = {};
|
||||
27
apps/mana/apps/web/src/lib/modules/broadcast/constants.ts
Normal file
27
apps/mana/apps/web/src/lib/modules/broadcast/constants.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import type { CampaignStatus } from './types';
|
||||
|
||||
export const STATUS_LABELS: Record<CampaignStatus, { de: string; en: string }> = {
|
||||
draft: { de: 'Entwurf', en: 'Draft' },
|
||||
scheduled: { de: 'Geplant', en: 'Scheduled' },
|
||||
sending: { de: 'Versand läuft', en: 'Sending' },
|
||||
sent: { de: 'Versendet', en: 'Sent' },
|
||||
cancelled: { de: 'Abgebrochen', en: 'Cancelled' },
|
||||
};
|
||||
|
||||
export const STATUS_COLORS: Record<CampaignStatus, string> = {
|
||||
draft: '#64748b', // slate-500
|
||||
scheduled: '#6366f1', // indigo-500
|
||||
sending: '#f59e0b', // amber-500
|
||||
sent: '#22c55e', // green-500
|
||||
cancelled: '#94a3b8', // slate-400
|
||||
};
|
||||
|
||||
/** Stable sentinel so the singleton settings row dedupes across devices. */
|
||||
export const BROADCAST_SETTINGS_ID = 'broadcast-settings';
|
||||
|
||||
/** Conservative per-user cap until we talk to the SMTP provider. Mirrors
|
||||
* the mana-mail env var BROADCAST_MAX_RECIPIENTS_PER_CAMPAIGN. */
|
||||
export const MAX_RECIPIENTS_PER_CAMPAIGN = 5000;
|
||||
|
||||
/** Rate-limit hint for the UI — server-side limit is the authoritative one. */
|
||||
export const MAX_RECIPIENTS_PER_HOUR = 500;
|
||||
31
apps/mana/apps/web/src/lib/modules/broadcast/index.ts
Normal file
31
apps/mana/apps/web/src/lib/modules/broadcast/index.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* Broadcast module — barrel exports.
|
||||
*/
|
||||
|
||||
export { campaignTable, templateTable, settingsTable, BROADCAST_GUEST_SEED } from './collections';
|
||||
|
||||
export {
|
||||
STATUS_LABELS,
|
||||
STATUS_COLORS,
|
||||
BROADCAST_SETTINGS_ID,
|
||||
MAX_RECIPIENTS_PER_CAMPAIGN,
|
||||
MAX_RECIPIENTS_PER_HOUR,
|
||||
} from './constants';
|
||||
|
||||
export type {
|
||||
LocalCampaign,
|
||||
LocalBroadcastTemplate,
|
||||
LocalBroadcastSettings,
|
||||
Campaign,
|
||||
BroadcastTemplate,
|
||||
BroadcastSettings,
|
||||
CampaignStatus,
|
||||
CampaignContent,
|
||||
CampaignStats,
|
||||
AudienceDefinition,
|
||||
AudienceFilter,
|
||||
AudienceField,
|
||||
AudienceOp,
|
||||
DnsCheck,
|
||||
DnsRecordStatus,
|
||||
} from './types';
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import type { ModuleConfig } from '$lib/data/module-registry';
|
||||
|
||||
export const broadcastModuleConfig: ModuleConfig = {
|
||||
appId: 'broadcast',
|
||||
tables: [
|
||||
{ name: 'broadcastCampaigns' },
|
||||
{ name: 'broadcastTemplates' },
|
||||
{ name: 'broadcastSettings' },
|
||||
],
|
||||
};
|
||||
168
apps/mana/apps/web/src/lib/modules/broadcast/types.ts
Normal file
168
apps/mana/apps/web/src/lib/modules/broadcast/types.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
/**
|
||||
* Broadcast module types.
|
||||
*
|
||||
* 1:N email campaigns (newsletters + announcements) sent via mana-mail's
|
||||
* bulk-send endpoint. Plan: `docs/plans/broadcast-module.md`.
|
||||
*
|
||||
* Critical invariants:
|
||||
* - `content.html` + `content.plainText` are **derived** from
|
||||
* `content.tiptap`; they get regenerated on every save so the editor
|
||||
* JSON stays the source of truth.
|
||||
* - `status` can only advance forward: draft → scheduled → sending →
|
||||
* sent (cancelled is the only off-ramp, allowed from draft/scheduled).
|
||||
* - `stats` is a plaintext cache of the server-side events table — it
|
||||
* can lag; liveQuery consumers should tolerate missing / stale data.
|
||||
*/
|
||||
|
||||
import type { BaseRecord } from '@mana/local-store';
|
||||
|
||||
// ─── Discriminators & Enums ──────────────────────────────
|
||||
|
||||
export type CampaignStatus = 'draft' | 'scheduled' | 'sending' | 'sent' | 'cancelled';
|
||||
|
||||
// ─── Audience segment ────────────────────────────────────
|
||||
|
||||
export type AudienceField = 'tag' | 'email' | 'custom';
|
||||
export type AudienceOp = 'has' | 'not-has' | 'eq' | 'contains';
|
||||
|
||||
export interface AudienceFilter {
|
||||
field: AudienceField;
|
||||
op: AudienceOp;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface AudienceDefinition {
|
||||
filters: AudienceFilter[];
|
||||
/** Cached count so the list doesn't have to recompute on every render.
|
||||
* Not authoritative — the send-time resolver always re-runs the query. */
|
||||
estimatedCount: number;
|
||||
}
|
||||
|
||||
// ─── Campaign content ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Tiptap JSON + derived outputs. HTML + plainText are regenerated on
|
||||
* save; we persist them so the PDF-less send path doesn't need an
|
||||
* editor round-trip just to re-render.
|
||||
*/
|
||||
export interface CampaignContent {
|
||||
tiptap: object;
|
||||
html?: string;
|
||||
plainText?: string;
|
||||
}
|
||||
|
||||
// ─── Campaign stats (mirror of server events) ────────────
|
||||
|
||||
export interface CampaignStats {
|
||||
totalRecipients: number;
|
||||
sent: number;
|
||||
delivered: number;
|
||||
bounced: number;
|
||||
opened: number;
|
||||
clicked: number;
|
||||
unsubscribed: number;
|
||||
/** ISO timestamp — how fresh is this snapshot? */
|
||||
lastSyncedAt: string;
|
||||
}
|
||||
|
||||
// ─── DNS check ───────────────────────────────────────────
|
||||
|
||||
export type DnsRecordStatus = 'ok' | 'missing' | 'wrong' | 'weak';
|
||||
|
||||
export interface DnsCheck {
|
||||
domain: string;
|
||||
spf: DnsRecordStatus;
|
||||
dkim: DnsRecordStatus;
|
||||
dmarc: DnsRecordStatus;
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
// ─── Local Records (Dexie) ───────────────────────────────
|
||||
|
||||
export interface LocalCampaign extends BaseRecord {
|
||||
/** Internal working title; not sent. */
|
||||
name: string;
|
||||
/** What lands in the recipient's mailbox subject line. */
|
||||
subject: string;
|
||||
/** "Preheader" — the grey excerpt shown next to the subject in Gmail. */
|
||||
preheader?: string | null;
|
||||
fromName: string;
|
||||
fromEmail: string;
|
||||
replyTo?: string | null;
|
||||
content: CampaignContent;
|
||||
templateId?: string | null;
|
||||
audience: AudienceDefinition;
|
||||
/** For scheduled sends — when should mana-mail pick this up? */
|
||||
scheduledAt?: string | null;
|
||||
/** When was the send actually fired. */
|
||||
sentAt?: string | null;
|
||||
status: CampaignStatus;
|
||||
/** Server-side orchestrator job id (mana-mail). */
|
||||
serverJobId?: string | null;
|
||||
stats?: CampaignStats | null;
|
||||
}
|
||||
|
||||
export interface LocalBroadcastTemplate extends BaseRecord {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
subject?: string | null;
|
||||
content: CampaignContent;
|
||||
/** Built-ins ship with the app; users can't delete them, only clone. */
|
||||
isBuiltIn: boolean;
|
||||
thumbnailUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface LocalBroadcastSettings extends BaseRecord {
|
||||
defaultFromName: string;
|
||||
defaultFromEmail: string;
|
||||
defaultReplyTo?: string | null;
|
||||
defaultFooter?: string | null;
|
||||
dnsCheck?: DnsCheck | null;
|
||||
/** Impressumspflicht — lands at the bottom of every campaign HTML. */
|
||||
legalAddress: string;
|
||||
unsubscribeLandingCopy?: string | null;
|
||||
}
|
||||
|
||||
// ─── Domain Types (plaintext, UI) ────────────────────────
|
||||
|
||||
export interface Campaign {
|
||||
id: string;
|
||||
name: string;
|
||||
subject: string;
|
||||
preheader: string | null;
|
||||
fromName: string;
|
||||
fromEmail: string;
|
||||
replyTo: string | null;
|
||||
content: CampaignContent;
|
||||
templateId: string | null;
|
||||
audience: AudienceDefinition;
|
||||
scheduledAt: string | null;
|
||||
sentAt: string | null;
|
||||
status: CampaignStatus;
|
||||
serverJobId: string | null;
|
||||
stats: CampaignStats | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface BroadcastTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
subject: string | null;
|
||||
content: CampaignContent;
|
||||
isBuiltIn: boolean;
|
||||
thumbnailUrl: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface BroadcastSettings {
|
||||
id: string;
|
||||
defaultFromName: string;
|
||||
defaultFromEmail: string;
|
||||
defaultReplyTo: string | null;
|
||||
defaultFooter: string | null;
|
||||
dnsCheck: DnsCheck | null;
|
||||
legalAddress: string;
|
||||
unsubscribeLandingCopy: string | null;
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<script lang="ts">
|
||||
import ListView from '$lib/modules/broadcast/ListView.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Broadcasts - Mana</title>
|
||||
</svelte:head>
|
||||
|
||||
<ListView />
|
||||
|
|
@ -236,6 +236,12 @@ export const APP_ICONS = {
|
|||
// Emerald→teal sits next to finance green in the Arbeit & Finanzen row.
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="iv" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#059669"/><stop offset="100%" style="stop-color:#14b8a6"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#iv)"/><path d="M28 22h34l14 14v42a4 4 0 0 1-4 4H28a4 4 0 0 1-4-4V26a4 4 0 0 1 4-4z" fill="white" fill-opacity="0.95"/><path d="M62 22v10a4 4 0 0 0 4 4h10" fill="none" stroke="#059669" stroke-width="2" stroke-opacity="0.35"/><rect x="32" y="44" width="24" height="3" rx="1" fill="#059669" fill-opacity="0.6"/><rect x="32" y="52" width="20" height="3" rx="1" fill="#059669" fill-opacity="0.45"/><rect x="32" y="60" width="28" height="3" rx="1" fill="#059669" fill-opacity="0.6"/><rect x="60" y="58" width="14" height="14" rx="1" fill="#059669"/><rect x="62" y="60" width="3" height="3" fill="white"/><rect x="69" y="60" width="3" height="3" fill="white"/><rect x="62" y="67" width="3" height="3" fill="white"/><rect x="66" y="64" width="2" height="2" fill="white"/><rect x="69" y="67" width="3" height="3" fill="white"/></svg>`
|
||||
),
|
||||
broadcast: svgToDataUrl(
|
||||
// Megaphone / loudspeaker with three radiating sound arcs.
|
||||
// Indigo→cyan gradient sets it apart from mail (blue) and invoices
|
||||
// (emerald) while staying in the "communication" colour family.
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="bc" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#6366f1"/><stop offset="100%" style="stop-color:#06b6d4"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#bc)"/><path d="M32 42v16l14 4v-24l-14 4z" fill="white" fill-opacity="0.95"/><path d="M46 38l26-10v44l-26-10z" fill="white"/><path d="M42 62v14a3 3 0 0 0 3 3h4a3 3 0 0 0 3-3V64" fill="white" fill-opacity="0.85"/><path d="M78 40c4 2 6 5 6 10s-2 8-6 10" fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.8"/><path d="M84 32c6 3 10 9 10 18s-4 15-10 18" fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.5"/></svg>`
|
||||
),
|
||||
} as const;
|
||||
|
||||
export type AppIconId = keyof typeof APP_ICONS;
|
||||
|
|
|
|||
|
|
@ -1020,6 +1020,23 @@ export const MANA_APPS: ManaApp[] = [
|
|||
status: 'development',
|
||||
requiredTier: 'guest',
|
||||
},
|
||||
{
|
||||
id: 'broadcast',
|
||||
name: 'Broadcasts',
|
||||
description: {
|
||||
de: 'Newsletter & Kampagnen',
|
||||
en: 'Newsletters & campaigns',
|
||||
},
|
||||
longDescription: {
|
||||
de: 'Newsletter und Ankündigungen an Kontaktgruppen versenden — mit Rich-Text-Editor, Open/Click-Tracking, DSGVO-konformem Unsubscribe und Kampagnen-Statistik.',
|
||||
en: 'Send newsletters and announcements to contact segments — with a rich-text editor, open/click tracking, GDPR-compliant unsubscribe, and per-campaign stats.',
|
||||
},
|
||||
icon: APP_ICONS.broadcast,
|
||||
color: '#6366f1',
|
||||
comingSoon: false,
|
||||
status: 'development',
|
||||
requiredTier: 'alpha',
|
||||
},
|
||||
{
|
||||
id: 'invoices',
|
||||
name: 'Rechnungen',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue