237 lines
7.2 KiB
Svelte
237 lines
7.2 KiB
Svelte
|
|
<script lang="ts">
|
||
|
|
import { Send, Mail, Users, Shield, CheckCircle, XCircle, Clock, AlertTriangle } from 'lucide-svelte';
|
||
|
|
import { Input } from '$lib/components/ui/input';
|
||
|
|
import { Label } from '$lib/components/ui/label';
|
||
|
|
import { enhance } from '$app/forms';
|
||
|
|
import { invalidateAll } from '$app/navigation';
|
||
|
|
|
||
|
|
let { data, form } = $props();
|
||
|
|
const { broadcasts, recipientCounts } = data;
|
||
|
|
|
||
|
|
let isSending = $state(false);
|
||
|
|
let selectedTarget = $state('all');
|
||
|
|
let showConfirm = $state(false);
|
||
|
|
|
||
|
|
const targetOptions = [
|
||
|
|
{ value: 'all', label: 'All Members', count: recipientCounts.all },
|
||
|
|
{ value: 'active', label: 'Active Members Only', count: recipientCounts.active },
|
||
|
|
{ value: 'board', label: 'Board & Admins', count: recipientCounts.board },
|
||
|
|
{ value: 'admin', label: 'Admins Only', count: recipientCounts.admin }
|
||
|
|
];
|
||
|
|
|
||
|
|
const currentRecipientCount = $derived(
|
||
|
|
targetOptions.find(o => o.value === selectedTarget)?.count || 0
|
||
|
|
);
|
||
|
|
|
||
|
|
function getStatusBadge(status: string) {
|
||
|
|
switch (status) {
|
||
|
|
case 'completed':
|
||
|
|
return { color: 'text-green-600', bg: 'bg-green-50', label: 'Sent' };
|
||
|
|
case 'failed':
|
||
|
|
return { color: 'text-red-600', bg: 'bg-red-50', label: 'Failed' };
|
||
|
|
case 'sending':
|
||
|
|
return { color: 'text-blue-600', bg: 'bg-blue-50', label: 'Sending' };
|
||
|
|
default:
|
||
|
|
return { color: 'text-slate-500', bg: 'bg-slate-50', label: status };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatDate(dateStr: string): string {
|
||
|
|
return new Date(dateStr).toLocaleDateString('en-US', {
|
||
|
|
month: 'short',
|
||
|
|
day: 'numeric',
|
||
|
|
year: 'numeric',
|
||
|
|
hour: '2-digit',
|
||
|
|
minute: '2-digit'
|
||
|
|
});
|
||
|
|
}
|
||
|
|
</script>
|
||
|
|
|
||
|
|
<svelte:head>
|
||
|
|
<title>Bulk Email | Monaco USA</title>
|
||
|
|
</svelte:head>
|
||
|
|
|
||
|
|
<div class="space-y-6">
|
||
|
|
<div>
|
||
|
|
<h1 class="text-2xl font-bold text-slate-900">Bulk Email Broadcast</h1>
|
||
|
|
<p class="text-slate-500">Send announcements and newsletters to members</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{#if form?.error}
|
||
|
|
<div class="rounded-lg bg-red-50 border border-red-200 p-4 text-sm text-red-600">
|
||
|
|
<div class="flex items-center gap-2">
|
||
|
|
<AlertTriangle class="h-4 w-4" />
|
||
|
|
{form.error}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
{/if}
|
||
|
|
|
||
|
|
{#if form?.success}
|
||
|
|
<div class="rounded-lg bg-green-50 border border-green-200 p-4 text-sm text-green-600">
|
||
|
|
<div class="flex items-center gap-2">
|
||
|
|
<CheckCircle class="h-4 w-4" />
|
||
|
|
{form.success}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
{/if}
|
||
|
|
|
||
|
|
<!-- Compose Email -->
|
||
|
|
<div class="glass-card p-6">
|
||
|
|
<h2 class="mb-4 flex items-center gap-2 text-lg font-semibold text-slate-900">
|
||
|
|
<Mail class="h-5 w-5 text-monaco-600" />
|
||
|
|
Compose Broadcast
|
||
|
|
</h2>
|
||
|
|
|
||
|
|
<form
|
||
|
|
method="POST"
|
||
|
|
action="?/send"
|
||
|
|
use:enhance={() => {
|
||
|
|
isSending = true;
|
||
|
|
showConfirm = false;
|
||
|
|
return async ({ update }) => {
|
||
|
|
await invalidateAll();
|
||
|
|
await update();
|
||
|
|
isSending = false;
|
||
|
|
};
|
||
|
|
}}
|
||
|
|
class="space-y-4"
|
||
|
|
>
|
||
|
|
<!-- Recipients -->
|
||
|
|
<div>
|
||
|
|
<Label for="target">Recipients</Label>
|
||
|
|
<select
|
||
|
|
id="target"
|
||
|
|
name="target"
|
||
|
|
bind:value={selectedTarget}
|
||
|
|
class="mt-1 w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm"
|
||
|
|
>
|
||
|
|
{#each targetOptions as option}
|
||
|
|
<option value={option.value}>{option.label} ({option.count})</option>
|
||
|
|
{/each}
|
||
|
|
</select>
|
||
|
|
<p class="mt-1 text-xs text-slate-500">
|
||
|
|
This will send to <strong>{currentRecipientCount}</strong> recipients.
|
||
|
|
Email preferences will be respected (members can opt out of announcements).
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Subject -->
|
||
|
|
<div>
|
||
|
|
<Label for="subject">Subject *</Label>
|
||
|
|
<Input
|
||
|
|
type="text"
|
||
|
|
id="subject"
|
||
|
|
name="subject"
|
||
|
|
required
|
||
|
|
placeholder="e.g., Monaco USA Monthly Newsletter - February 2026"
|
||
|
|
class="mt-1"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Body -->
|
||
|
|
<div>
|
||
|
|
<Label for="body">Email Body *</Label>
|
||
|
|
<textarea
|
||
|
|
id="body"
|
||
|
|
name="body"
|
||
|
|
required
|
||
|
|
rows="10"
|
||
|
|
placeholder="Write your email content here. Use variables like first_name and last_name for personalization."
|
||
|
|
class="mt-1 w-full rounded-lg border border-slate-200 px-3 py-2 text-sm"
|
||
|
|
></textarea>
|
||
|
|
<p class="mt-1 text-xs text-slate-500">
|
||
|
|
Plain text with line breaks. Use {'{{first_name}}'} and {'{{last_name}}'} for personalization.
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Send button -->
|
||
|
|
<div class="flex items-center gap-4 border-t border-slate-200 pt-4">
|
||
|
|
{#if !showConfirm}
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onclick={() => (showConfirm = true)}
|
||
|
|
class="flex items-center gap-2 rounded-lg bg-monaco-600 px-6 py-2 text-sm font-medium text-white hover:bg-monaco-700"
|
||
|
|
>
|
||
|
|
<Send class="h-4 w-4" />
|
||
|
|
Send Broadcast
|
||
|
|
</button>
|
||
|
|
{:else}
|
||
|
|
<div class="flex items-center gap-3 rounded-lg bg-amber-50 border border-amber-200 px-4 py-3">
|
||
|
|
<AlertTriangle class="h-5 w-5 text-amber-600 shrink-0" />
|
||
|
|
<p class="text-sm text-amber-700">
|
||
|
|
Send to <strong>{currentRecipientCount}</strong> recipients? This cannot be undone.
|
||
|
|
</p>
|
||
|
|
<button
|
||
|
|
type="submit"
|
||
|
|
disabled={isSending}
|
||
|
|
class="shrink-0 rounded-lg bg-monaco-600 px-4 py-1.5 text-sm font-medium text-white hover:bg-monaco-700 disabled:opacity-50"
|
||
|
|
>
|
||
|
|
{isSending ? 'Sending...' : 'Confirm Send'}
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onclick={() => (showConfirm = false)}
|
||
|
|
class="shrink-0 text-sm text-slate-500 hover:text-slate-700"
|
||
|
|
>
|
||
|
|
Cancel
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
{/if}
|
||
|
|
</div>
|
||
|
|
</form>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Broadcast History -->
|
||
|
|
<div class="glass-card p-6">
|
||
|
|
<h2 class="mb-4 text-lg font-semibold text-slate-900">Broadcast History</h2>
|
||
|
|
|
||
|
|
{#if broadcasts.length === 0}
|
||
|
|
<div class="py-12 text-center">
|
||
|
|
<Mail class="mx-auto h-12 w-12 text-slate-300" />
|
||
|
|
<p class="mt-4 text-slate-500">No broadcasts sent yet.</p>
|
||
|
|
</div>
|
||
|
|
{:else}
|
||
|
|
<div class="overflow-x-auto">
|
||
|
|
<table class="w-full text-sm">
|
||
|
|
<thead>
|
||
|
|
<tr class="border-b border-slate-200 text-left">
|
||
|
|
<th class="pb-3 pr-4 font-medium text-slate-600">Subject</th>
|
||
|
|
<th class="pb-3 pr-4 font-medium text-slate-600">Status</th>
|
||
|
|
<th class="pb-3 pr-4 font-medium text-slate-600">Recipients</th>
|
||
|
|
<th class="pb-3 pr-4 font-medium text-slate-600">Sent By</th>
|
||
|
|
<th class="pb-3 font-medium text-slate-600">Date</th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody class="divide-y divide-slate-100">
|
||
|
|
{#each broadcasts as broadcast}
|
||
|
|
{@const statusBadge = getStatusBadge(broadcast.status)}
|
||
|
|
<tr class="hover:bg-slate-50">
|
||
|
|
<td class="py-3 pr-4 font-medium text-slate-900 max-w-xs truncate">
|
||
|
|
{broadcast.subject}
|
||
|
|
</td>
|
||
|
|
<td class="py-3 pr-4">
|
||
|
|
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {statusBadge.bg} {statusBadge.color}">
|
||
|
|
{statusBadge.label}
|
||
|
|
</span>
|
||
|
|
</td>
|
||
|
|
<td class="py-3 pr-4 text-slate-600">
|
||
|
|
{broadcast.sent_count}/{broadcast.total_recipients}
|
||
|
|
{#if broadcast.failed_count > 0}
|
||
|
|
<span class="text-red-500 text-xs">({broadcast.failed_count} failed)</span>
|
||
|
|
{/if}
|
||
|
|
</td>
|
||
|
|
<td class="py-3 pr-4 text-slate-500">
|
||
|
|
{broadcast.sent_by_name || 'Unknown'}
|
||
|
|
</td>
|
||
|
|
<td class="py-3 text-slate-500 whitespace-nowrap">
|
||
|
|
{broadcast.sent_at ? formatDate(broadcast.sent_at) : formatDate(broadcast.created_at)}
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
{/each}
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
{/if}
|
||
|
|
</div>
|
||
|
|
</div>
|