mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
feat(i18n): localize Broadcasts module and wizard
- Add 'Broadcasts' namespace to en.json for all messaging flows - Localize broadcast list and detail views (stats, recipient tables, funnel) - Localize 4-step Broadcast creation wizard (Choose Template, Select Audience, Personalize, Schedule) - Refactor status configuration to use translation keys instead of hardcoded labels - Pass full typechecking across all wizard components
This commit is contained in:
parent
8b1d75456f
commit
92e5789837
181
messages/en.json
181
messages/en.json
@ -558,5 +558,186 @@
|
|||||||
"lostThisMonthTooltip": "Deals marked as Lost since the first day of the current month.",
|
"lostThisMonthTooltip": "Deals marked as Lost since the first day of the current month.",
|
||||||
"howCalculated": "How {label} is calculated"
|
"howCalculated": "How {label} is calculated"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Broadcasts": {
|
||||||
|
"page": {
|
||||||
|
"title": "Broadcasts",
|
||||||
|
"subtitle": "Send bulk messages to your contacts using approved templates.",
|
||||||
|
"newBroadcast": "New Broadcast",
|
||||||
|
"noBroadcastsYet": "No broadcasts yet",
|
||||||
|
"createFirst": "Create your first broadcast to reach your contacts at scale.",
|
||||||
|
"table": {
|
||||||
|
"name": "Name",
|
||||||
|
"template": "Template",
|
||||||
|
"recipients": "Recipients",
|
||||||
|
"delivery": "Delivery",
|
||||||
|
"read": "Read",
|
||||||
|
"status": "Status",
|
||||||
|
"date": "Date"
|
||||||
|
},
|
||||||
|
"errorLoad": "Failed to load broadcasts",
|
||||||
|
"retry": "Retry"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"draft": "Draft",
|
||||||
|
"scheduled": "Scheduled",
|
||||||
|
"sending": "Sending",
|
||||||
|
"sent": "Sent",
|
||||||
|
"failed": "Failed",
|
||||||
|
"pending": "Pending",
|
||||||
|
"delivered": "Delivered",
|
||||||
|
"read": "Read",
|
||||||
|
"replied": "Replied",
|
||||||
|
"unknown": "Unknown"
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"backToBroadcasts": "Back to Broadcasts",
|
||||||
|
"notFound": "Broadcast not found",
|
||||||
|
"template": "Template: {name}",
|
||||||
|
"createdAt": "Created {date}",
|
||||||
|
"deletePrompt": "Delete this broadcast?",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"deleting": "Deleting…",
|
||||||
|
"delete": "Delete",
|
||||||
|
"cannotDeleteSending": "Cannot delete while a broadcast is actively sending",
|
||||||
|
"deleteHover": "Delete this broadcast",
|
||||||
|
"funnel": "Funnel",
|
||||||
|
"recipientsHeader": "Recipients ({filtered} of {total})",
|
||||||
|
"recipientsHeaderAll": "Recipients ({total})",
|
||||||
|
"allStatuses": "All statuses",
|
||||||
|
"exportCsv": "Export CSV",
|
||||||
|
"noRecipients": "No recipients found.",
|
||||||
|
"noRecipientsFilter": "No recipients match this filter.",
|
||||||
|
"table": {
|
||||||
|
"contact": "Contact",
|
||||||
|
"phone": "Phone",
|
||||||
|
"status": "Status",
|
||||||
|
"sent": "Sent",
|
||||||
|
"delivered": "Delivered",
|
||||||
|
"read": "Read",
|
||||||
|
"error": "Error"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"totalRecipients": "Total Recipients",
|
||||||
|
"sent": "Sent",
|
||||||
|
"delivered": "Delivered",
|
||||||
|
"read": "Read",
|
||||||
|
"replied": "Replied",
|
||||||
|
"failed": "Failed"
|
||||||
|
},
|
||||||
|
"toastFailedDelete": "Failed to delete: {error}",
|
||||||
|
"toastDeleted": "Broadcast deleted"
|
||||||
|
},
|
||||||
|
"new": {
|
||||||
|
"title": "New Broadcast",
|
||||||
|
"subtitle": "Create and send a broadcast message to your contacts.",
|
||||||
|
"steps": {
|
||||||
|
"template": "Template",
|
||||||
|
"audience": "Audience",
|
||||||
|
"personalize": "Personalize",
|
||||||
|
"send": "Send"
|
||||||
|
},
|
||||||
|
"toastGiveName": "Give the broadcast a name before saving a draft.",
|
||||||
|
"toastNotSignedIn": "Not signed in.",
|
||||||
|
"toastNotLinked": "Your profile is not linked to an account.",
|
||||||
|
"toastFailedDraft": "Failed to save draft: {error}",
|
||||||
|
"toastDraftSaved": "Draft saved"
|
||||||
|
},
|
||||||
|
"wizard": {
|
||||||
|
"next": "Next",
|
||||||
|
"back": "Back",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"chooseTemplate": {
|
||||||
|
"title": "Choose a Template",
|
||||||
|
"subtitle": "Select an approved message template for your broadcast.",
|
||||||
|
"noTemplates": "No templates available.",
|
||||||
|
"createFirst": "Create a template in Settings first.",
|
||||||
|
"errorLoad": "Failed to load templates"
|
||||||
|
},
|
||||||
|
"selectAudience": {
|
||||||
|
"title": "Select Audience",
|
||||||
|
"subtitle": "Choose who will receive this broadcast.",
|
||||||
|
"method": {
|
||||||
|
"all": "All Contacts",
|
||||||
|
"tags": "Filter by Tags",
|
||||||
|
"customField": "Custom Field",
|
||||||
|
"csv": "Upload CSV"
|
||||||
|
},
|
||||||
|
"allDesc": "Send to all {count} contacts in your database.",
|
||||||
|
"allDescLoading": "Send to all contacts in your database.",
|
||||||
|
"tagDesc": "Send to contacts matching specific tags.",
|
||||||
|
"customFieldDesc": "Send to contacts matching a custom field rule.",
|
||||||
|
"csvDesc": "Upload a CSV with phone numbers (and optional names).",
|
||||||
|
"selectTags": "Select Tags",
|
||||||
|
"searchTags": "Search tags...",
|
||||||
|
"noTagsFound": "No tags found.",
|
||||||
|
"excludeTags": "Exclude Tags (Optional)",
|
||||||
|
"searchExcludeTags": "Search tags to exclude...",
|
||||||
|
"field": "Field",
|
||||||
|
"selectField": "Select a field...",
|
||||||
|
"operator": "Operator",
|
||||||
|
"operatorIs": "Is",
|
||||||
|
"operatorIsNot": "Is not",
|
||||||
|
"operatorContains": "Contains",
|
||||||
|
"value": "Value",
|
||||||
|
"valuePlaceholder": "Value to match",
|
||||||
|
"uploadCsv": "Upload CSV File",
|
||||||
|
"csvFormatDesc": "Must contain a 'phone' column (E.164 format). 'name' column is optional.",
|
||||||
|
"csvContactsFound": "{count} valid contact(s) found in CSV.",
|
||||||
|
"errorLoadTags": "Failed to load tags",
|
||||||
|
"errorLoadFields": "Failed to load custom fields",
|
||||||
|
"errorCsvParse": "Could not parse CSV file.",
|
||||||
|
"errorCsvMissingPhone": "CSV must have a 'phone' column."
|
||||||
|
},
|
||||||
|
"personalize": {
|
||||||
|
"title": "Personalize Variables",
|
||||||
|
"subtitle": "Map your template variables to contact fields or provide static values.",
|
||||||
|
"headerImage": "Header Image",
|
||||||
|
"headerImageDesc": "This template requires a header image.",
|
||||||
|
"imageUrl": "Image URL",
|
||||||
|
"imageUrlPlaceholder": "https://example.com/image.jpg",
|
||||||
|
"variables": "Variables",
|
||||||
|
"varDesc": "Set values for {{name}}",
|
||||||
|
"type": "Type",
|
||||||
|
"typeStatic": "Static Value",
|
||||||
|
"typeContact": "Contact Field",
|
||||||
|
"typeCustom": "Custom Field",
|
||||||
|
"staticValue": "Static Value",
|
||||||
|
"contactField": "Contact Field",
|
||||||
|
"selectContactField": "Select field...",
|
||||||
|
"customField": "Custom Field",
|
||||||
|
"selectCustomField": "Select custom field...",
|
||||||
|
"preview": "Preview",
|
||||||
|
"previewSample": "Showing preview with sample data.",
|
||||||
|
"noPreview": "Preview not available.",
|
||||||
|
"fieldMap": {
|
||||||
|
"name": "Name",
|
||||||
|
"phone": "Phone",
|
||||||
|
"email": "Email"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scheduleSend": {
|
||||||
|
"title": "Review & Send",
|
||||||
|
"subtitle": "Give your broadcast a name and confirm sending.",
|
||||||
|
"broadcastName": "Broadcast Name",
|
||||||
|
"broadcastNamePlaceholder": "e.g., Summer Promo",
|
||||||
|
"summary": "Summary",
|
||||||
|
"template": "Template:",
|
||||||
|
"audience": "Audience:",
|
||||||
|
"audienceAll": "All contacts",
|
||||||
|
"audienceTags": "Filtered by tags",
|
||||||
|
"audienceField": "Filtered by custom field",
|
||||||
|
"audienceCsv": "CSV upload",
|
||||||
|
"variables": "Variables:",
|
||||||
|
"variablesMapped": "{count} mapped",
|
||||||
|
"saveDraft": "Save as Draft",
|
||||||
|
"sendNow": "Send Broadcast",
|
||||||
|
"sending": "Sending...",
|
||||||
|
"preparing": "Preparing...",
|
||||||
|
"progressDesc": "Processing {current} of {total} recipients...",
|
||||||
|
"toastFailedPreview": "Failed to load preview data"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,6 +38,7 @@ import {
|
|||||||
getBroadcastStatus,
|
getBroadcastStatus,
|
||||||
getRecipientStatus,
|
getRecipientStatus,
|
||||||
} from '@/lib/broadcast-status';
|
} from '@/lib/broadcast-status';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
interface StatCardProps {
|
interface StatCardProps {
|
||||||
label: string;
|
label: string;
|
||||||
@ -144,6 +145,8 @@ function downloadBlob(filename: string, content: string) {
|
|||||||
export default function BroadcastDetailPage() {
|
export default function BroadcastDetailPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const t = useTranslations('Broadcasts.detail');
|
||||||
|
const tStatus = useTranslations('Broadcasts.status');
|
||||||
const broadcastId = params.id as string;
|
const broadcastId = params.id as string;
|
||||||
|
|
||||||
const [broadcast, setBroadcast] = useState<Broadcast | null>(null);
|
const [broadcast, setBroadcast] = useState<Broadcast | null>(null);
|
||||||
@ -179,7 +182,7 @@ export default function BroadcastDetailPage() {
|
|||||||
if (recsError) throw recsError;
|
if (recsError) throw recsError;
|
||||||
setRecipients(recs ?? []);
|
setRecipients(recs ?? []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load broadcast');
|
setError(err instanceof Error ? err.message : t('notFound'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -199,14 +202,13 @@ export default function BroadcastDetailPage() {
|
|||||||
function handleExport() {
|
function handleExport() {
|
||||||
if (!broadcast) return;
|
if (!broadcast) return;
|
||||||
const header = [
|
const header = [
|
||||||
'Contact',
|
t('table.contact'),
|
||||||
'Phone',
|
t('table.phone'),
|
||||||
'Status',
|
t('table.status'),
|
||||||
'Sent At',
|
t('table.sent'),
|
||||||
'Delivered At',
|
t('table.delivered'),
|
||||||
'Read At',
|
t('table.read'),
|
||||||
'Replied At',
|
t('table.error'),
|
||||||
'Error',
|
|
||||||
];
|
];
|
||||||
const rows = recipients.map((r) => [
|
const rows = recipients.map((r) => [
|
||||||
r.contact?.name ?? '',
|
r.contact?.name ?? '',
|
||||||
@ -215,7 +217,6 @@ export default function BroadcastDetailPage() {
|
|||||||
r.sent_at ?? '',
|
r.sent_at ?? '',
|
||||||
r.delivered_at ?? '',
|
r.delivered_at ?? '',
|
||||||
r.read_at ?? '',
|
r.read_at ?? '',
|
||||||
r.replied_at ?? '',
|
|
||||||
r.error_message ?? '',
|
r.error_message ?? '',
|
||||||
]);
|
]);
|
||||||
const csv = toCsv([header, ...rows]);
|
const csv = toCsv([header, ...rows]);
|
||||||
@ -236,10 +237,10 @@ export default function BroadcastDetailPage() {
|
|||||||
.eq('id', broadcastId);
|
.eq('id', broadcastId);
|
||||||
setDeleting(false);
|
setDeleting(false);
|
||||||
if (delErr) {
|
if (delErr) {
|
||||||
toast.error(`Failed to delete: ${delErr.message}`);
|
toast.error(t('toastFailedDelete', { error: delErr.message }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toast.success('Broadcast deleted');
|
toast.success(t('toastDeleted'));
|
||||||
router.push('/broadcasts');
|
router.push('/broadcasts');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -254,9 +255,9 @@ export default function BroadcastDetailPage() {
|
|||||||
if (error || !broadcast) {
|
if (error || !broadcast) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-64 flex-col items-center justify-center gap-2">
|
<div className="flex h-64 flex-col items-center justify-center gap-2">
|
||||||
<p className="text-sm text-red-400">{error ?? 'Broadcast not found'}</p>
|
<p className="text-sm text-red-400">{error ?? t('notFound')}</p>
|
||||||
<Button variant="outline" onClick={() => router.push('/broadcasts')}>
|
<Button variant="outline" onClick={() => router.push('/broadcasts')}>
|
||||||
Back to Broadcasts
|
{t('backToBroadcasts')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -265,10 +266,10 @@ export default function BroadcastDetailPage() {
|
|||||||
const status = getBroadcastStatus(broadcast.status);
|
const status = getBroadcastStatus(broadcast.status);
|
||||||
|
|
||||||
const funnelSteps: FunnelStep[] = [
|
const funnelSteps: FunnelStep[] = [
|
||||||
{ label: 'Sent', value: broadcast.sent_count, color: 'bg-primary' },
|
{ label: t('stats.sent'), value: broadcast.sent_count, color: 'bg-primary' },
|
||||||
{ label: 'Delivered', value: broadcast.delivered_count, color: 'bg-teal-500' },
|
{ label: t('stats.delivered'), value: broadcast.delivered_count, color: 'bg-teal-500' },
|
||||||
{ label: 'Read', value: broadcast.read_count, color: 'bg-blue-500' },
|
{ label: t('stats.read'), value: broadcast.read_count, color: 'bg-blue-500' },
|
||||||
{ label: 'Replied', value: broadcast.replied_count, color: 'bg-indigo-500' },
|
{ label: t('stats.replied'), value: broadcast.replied_count, color: 'bg-indigo-500' },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -290,14 +291,14 @@ export default function BroadcastDetailPage() {
|
|||||||
<span
|
<span
|
||||||
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${status.classes}`}
|
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${status.classes}`}
|
||||||
>
|
>
|
||||||
{status.label}
|
{tStatus(status.label)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 flex items-center gap-3 text-sm text-muted-foreground">
|
<div className="mt-1 flex items-center gap-3 text-sm text-muted-foreground">
|
||||||
<span>Template: {broadcast.template_name}</span>
|
<span>{t('template', { name: broadcast.template_name })}</span>
|
||||||
<span>-</span>
|
<span>-</span>
|
||||||
<span>
|
<span>
|
||||||
Created {new Date(broadcast.created_at).toLocaleDateString()}
|
{t('createdAt', { date: new Date(broadcast.created_at).toLocaleDateString() })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -309,7 +310,7 @@ export default function BroadcastDetailPage() {
|
|||||||
funnel inconsistent. */}
|
funnel inconsistent. */}
|
||||||
{confirmDelete ? (
|
{confirmDelete ? (
|
||||||
<div className="flex items-center gap-2 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-1.5 text-sm">
|
<div className="flex items-center gap-2 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-1.5 text-sm">
|
||||||
<span className="text-red-300">Delete this broadcast?</span>
|
<span className="text-red-300">{t('deletePrompt')}</span>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@ -317,7 +318,7 @@ export default function BroadcastDetailPage() {
|
|||||||
disabled={deleting}
|
disabled={deleting}
|
||||||
className="h-7 border-border bg-transparent text-muted-foreground hover:bg-muted"
|
className="h-7 border-border bg-transparent text-muted-foreground hover:bg-muted"
|
||||||
>
|
>
|
||||||
Cancel
|
{t('cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@ -325,7 +326,7 @@ export default function BroadcastDetailPage() {
|
|||||||
disabled={deleting}
|
disabled={deleting}
|
||||||
className="h-7 bg-red-600 text-white hover:bg-red-700 disabled:opacity-50"
|
className="h-7 bg-red-600 text-white hover:bg-red-700 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{deleting ? 'Deleting…' : 'Confirm'}
|
{deleting ? t('deleting') : t('confirm')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -336,13 +337,13 @@ export default function BroadcastDetailPage() {
|
|||||||
onClick={() => setConfirmDelete(true)}
|
onClick={() => setConfirmDelete(true)}
|
||||||
title={
|
title={
|
||||||
broadcast.status === 'sending'
|
broadcast.status === 'sending'
|
||||||
? 'Cannot delete while a broadcast is actively sending'
|
? t('cannotDeleteSending')
|
||||||
: 'Delete this broadcast'
|
: t('deleteHover')
|
||||||
}
|
}
|
||||||
className="border-red-500/30 bg-transparent text-red-400 hover:bg-red-500/10 disabled:opacity-40"
|
className="border-red-500/30 bg-transparent text-red-400 hover:bg-red-500/10 disabled:opacity-40"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
Delete
|
{t('delete')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -350,42 +351,42 @@ export default function BroadcastDetailPage() {
|
|||||||
{/* Stats — 6 cards: Total / Sent / Delivered / Read / Replied / Failed */}
|
{/* Stats — 6 cards: Total / Sent / Delivered / Read / Replied / Failed */}
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Total Recipients"
|
label={t('stats.totalRecipients')}
|
||||||
value={broadcast.total_recipients}
|
value={broadcast.total_recipients}
|
||||||
total={broadcast.total_recipients}
|
total={broadcast.total_recipients}
|
||||||
icon={<Users className="h-4 w-4" />}
|
icon={<Users className="h-4 w-4" />}
|
||||||
color="bg-muted text-muted-foreground"
|
color="bg-muted text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Sent"
|
label={t('stats.sent')}
|
||||||
value={broadcast.sent_count}
|
value={broadcast.sent_count}
|
||||||
total={broadcast.total_recipients}
|
total={broadcast.total_recipients}
|
||||||
icon={<Send className="h-4 w-4" />}
|
icon={<Send className="h-4 w-4" />}
|
||||||
color="bg-primary/10 text-primary"
|
color="bg-primary/10 text-primary"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Delivered"
|
label={t('stats.delivered')}
|
||||||
value={broadcast.delivered_count}
|
value={broadcast.delivered_count}
|
||||||
total={broadcast.total_recipients}
|
total={broadcast.total_recipients}
|
||||||
icon={<CheckCheck className="h-4 w-4" />}
|
icon={<CheckCheck className="h-4 w-4" />}
|
||||||
color="bg-teal-500/10 text-teal-400"
|
color="bg-teal-500/10 text-teal-400"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Read"
|
label={t('stats.read')}
|
||||||
value={broadcast.read_count}
|
value={broadcast.read_count}
|
||||||
total={broadcast.total_recipients}
|
total={broadcast.total_recipients}
|
||||||
icon={<Eye className="h-4 w-4" />}
|
icon={<Eye className="h-4 w-4" />}
|
||||||
color="bg-blue-500/10 text-blue-400"
|
color="bg-blue-500/10 text-blue-400"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Replied"
|
label={t('stats.replied')}
|
||||||
value={broadcast.replied_count}
|
value={broadcast.replied_count}
|
||||||
total={broadcast.total_recipients}
|
total={broadcast.total_recipients}
|
||||||
icon={<MessageCircle className="h-4 w-4" />}
|
icon={<MessageCircle className="h-4 w-4" />}
|
||||||
color="bg-indigo-500/10 text-indigo-400"
|
color="bg-indigo-500/10 text-indigo-400"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Failed"
|
label={t('stats.failed')}
|
||||||
value={broadcast.failed_count}
|
value={broadcast.failed_count}
|
||||||
total={broadcast.total_recipients}
|
total={broadcast.total_recipients}
|
||||||
icon={<AlertCircle className="h-4 w-4" />}
|
icon={<AlertCircle className="h-4 w-4" />}
|
||||||
@ -399,8 +400,9 @@ export default function BroadcastDetailPage() {
|
|||||||
<div className="rounded-xl border border-border bg-card">
|
<div className="rounded-xl border border-border bg-card">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border px-4 py-3">
|
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border px-4 py-3">
|
||||||
<h2 className="text-sm font-medium text-foreground">
|
<h2 className="text-sm font-medium text-foreground">
|
||||||
Recipients ({filteredRecipients.length}
|
{statusFilter !== 'all'
|
||||||
{statusFilter !== 'all' ? ` of ${recipients.length}` : ''})
|
? t('recipientsHeader', { filtered: filteredRecipients.length, total: recipients.length })
|
||||||
|
: t('recipientsHeaderAll', { total: recipients.length })}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@ -415,8 +417,8 @@ export default function BroadcastDetailPage() {
|
|||||||
>
|
>
|
||||||
<Filter className="h-3.5 w-3.5" />
|
<Filter className="h-3.5 w-3.5" />
|
||||||
{statusFilter === 'all'
|
{statusFilter === 'all'
|
||||||
? 'All statuses'
|
? t('allStatuses')
|
||||||
: getRecipientStatus(statusFilter).label}
|
: tStatus(getRecipientStatus(statusFilter).label)}
|
||||||
<ChevronDown className="h-3 w-3" />
|
<ChevronDown className="h-3 w-3" />
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent className="border-border bg-popover">
|
<DropdownMenuContent className="border-border bg-popover">
|
||||||
@ -426,7 +428,7 @@ export default function BroadcastDetailPage() {
|
|||||||
statusFilter === 'all' ? 'text-primary' : 'text-popover-foreground'
|
statusFilter === 'all' ? 'text-primary' : 'text-popover-foreground'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
All statuses
|
{t('allStatuses')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{RECIPIENT_STATUSES.map((s) => (
|
{RECIPIENT_STATUSES.map((s) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@ -438,7 +440,7 @@ export default function BroadcastDetailPage() {
|
|||||||
: 'text-popover-foreground'
|
: 'text-popover-foreground'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{getRecipientStatus(s).label}
|
{tStatus(getRecipientStatus(s).label)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
@ -452,7 +454,7 @@ export default function BroadcastDetailPage() {
|
|||||||
className="border-border text-muted-foreground hover:bg-muted"
|
className="border-border text-muted-foreground hover:bg-muted"
|
||||||
>
|
>
|
||||||
<Download className="h-3.5 w-3.5" />
|
<Download className="h-3.5 w-3.5" />
|
||||||
Export CSV
|
{t('exportCsv')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -461,8 +463,8 @@ export default function BroadcastDetailPage() {
|
|||||||
<div className="flex h-32 items-center justify-center">
|
<div className="flex h-32 items-center justify-center">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{recipients.length === 0
|
{recipients.length === 0
|
||||||
? 'No recipients found.'
|
? t('noRecipients')
|
||||||
: 'No recipients match this filter.'}
|
: t('noRecipientsFilter')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -470,13 +472,13 @@ export default function BroadcastDetailPage() {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="border-border hover:bg-transparent">
|
<TableRow className="border-border hover:bg-transparent">
|
||||||
<TableHead className="text-muted-foreground">Contact</TableHead>
|
<TableHead className="text-muted-foreground">{t('table.contact')}</TableHead>
|
||||||
<TableHead className="text-muted-foreground">Phone</TableHead>
|
<TableHead className="text-muted-foreground">{t('table.phone')}</TableHead>
|
||||||
<TableHead className="text-muted-foreground">Status</TableHead>
|
<TableHead className="text-muted-foreground">{t('table.status')}</TableHead>
|
||||||
<TableHead className="text-muted-foreground">Sent</TableHead>
|
<TableHead className="text-muted-foreground">{t('table.sent')}</TableHead>
|
||||||
<TableHead className="text-muted-foreground">Delivered</TableHead>
|
<TableHead className="text-muted-foreground">{t('table.delivered')}</TableHead>
|
||||||
<TableHead className="text-muted-foreground">Read</TableHead>
|
<TableHead className="text-muted-foreground">{t('table.read')}</TableHead>
|
||||||
<TableHead className="text-muted-foreground">Error</TableHead>
|
<TableHead className="text-muted-foreground">{t('table.error')}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@ -494,7 +496,7 @@ export default function BroadcastDetailPage() {
|
|||||||
<span
|
<span
|
||||||
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${rStatus.classes}`}
|
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${rStatus.classes}`}
|
||||||
>
|
>
|
||||||
{rStatus.label}
|
{tStatus(rStatus.label)}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-muted-foreground">
|
<TableCell className="text-muted-foreground">
|
||||||
|
|||||||
@ -12,16 +12,18 @@ import { Step3Personalize } from '@/components/broadcasts/step3-personalize';
|
|||||||
import { Step4ScheduleSend } from '@/components/broadcasts/step4-schedule-send';
|
import { Step4ScheduleSend } from '@/components/broadcasts/step4-schedule-send';
|
||||||
import { useBroadcastSending } from '@/hooks/use-broadcast-sending';
|
import { useBroadcastSending } from '@/hooks/use-broadcast-sending';
|
||||||
import { Check } from 'lucide-react';
|
import { Check } from 'lucide-react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
const steps = [
|
const steps = [
|
||||||
{ label: 'Template', key: 'template' },
|
{ label: 'template', key: 'template' },
|
||||||
{ label: 'Audience', key: 'audience' },
|
{ label: 'audience', key: 'audience' },
|
||||||
{ label: 'Personalize', key: 'personalize' },
|
{ label: 'personalize', key: 'personalize' },
|
||||||
{ label: 'Send', key: 'send' },
|
{ label: 'send', key: 'send' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export default function NewBroadcastPage() {
|
export default function NewBroadcastPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const t = useTranslations('Broadcasts.new');
|
||||||
const { accountId } = useAuth();
|
const { accountId } = useAuth();
|
||||||
const { createAndSendBroadcast, isProcessing, progress } = useBroadcastSending();
|
const { createAndSendBroadcast, isProcessing, progress } = useBroadcastSending();
|
||||||
|
|
||||||
@ -82,7 +84,7 @@ export default function NewBroadcastPage() {
|
|||||||
*/
|
*/
|
||||||
async function handleSaveDraft() {
|
async function handleSaveDraft() {
|
||||||
if (!template || !name.trim()) {
|
if (!template || !name.trim()) {
|
||||||
toast.error('Give the broadcast a name before saving a draft.');
|
toast.error(t('toastGiveName'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
@ -91,11 +93,11 @@ export default function NewBroadcastPage() {
|
|||||||
} = await supabase.auth.getSession();
|
} = await supabase.auth.getSession();
|
||||||
const user = session?.user;
|
const user = session?.user;
|
||||||
if (!user) {
|
if (!user) {
|
||||||
toast.error('Not signed in.');
|
toast.error(t('toastNotSignedIn'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!accountId) {
|
if (!accountId) {
|
||||||
toast.error('Your profile is not linked to an account.');
|
toast.error(t('toastNotLinked'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -120,10 +122,10 @@ export default function NewBroadcastPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
toast.error(`Failed to save draft: ${error.message}`);
|
toast.error(t('toastFailedDraft', { error: error.message }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toast.success('Draft saved');
|
toast.success(t('toastDraftSaved'));
|
||||||
router.push('/broadcasts');
|
router.push('/broadcasts');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -131,9 +133,9 @@ export default function NewBroadcastPage() {
|
|||||||
<div className="mx-auto max-w-3xl space-y-8">
|
<div className="mx-auto max-w-3xl space-y-8">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-foreground">New Broadcast</h1>
|
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
Create and send a broadcast message to your contacts.
|
{t('subtitle')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -162,7 +164,7 @@ export default function NewBroadcastPage() {
|
|||||||
isActive ? 'text-foreground' : isCompleted ? 'text-primary' : 'text-muted-foreground'
|
isActive ? 'text-foreground' : isCompleted ? 'text-primary' : 'text-muted-foreground'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{step.label}
|
{t(`steps.${step.label}`)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{index < steps.length - 1 && (
|
{index < steps.length - 1 && (
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import { Radio, Plus, Loader2 } from 'lucide-react';
|
|||||||
import { useCan } from '@/hooks/use-can';
|
import { useCan } from '@/hooks/use-can';
|
||||||
import { GatedButton } from '@/components/ui/gated-button';
|
import { GatedButton } from '@/components/ui/gated-button';
|
||||||
import { getBroadcastStatus } from '@/lib/broadcast-status';
|
import { getBroadcastStatus } from '@/lib/broadcast-status';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Poll cadence while any broadcast is sending. Kept modest so we don't
|
* Poll cadence while any broadcast is sending. Kept modest so we don't
|
||||||
@ -58,6 +59,8 @@ function RateCell({
|
|||||||
|
|
||||||
export default function BroadcastsPage() {
|
export default function BroadcastsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const t = useTranslations('Broadcasts.page');
|
||||||
|
const tStatus = useTranslations('Broadcasts.status');
|
||||||
const canCreate = useCan('send-messages');
|
const canCreate = useCan('send-messages');
|
||||||
const [broadcasts, setBroadcasts] = useState<Broadcast[]>([]);
|
const [broadcasts, setBroadcasts] = useState<Broadcast[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@ -77,7 +80,7 @@ export default function BroadcastsPage() {
|
|||||||
if (fetchError) throw fetchError;
|
if (fetchError) throw fetchError;
|
||||||
setBroadcasts(data ?? []);
|
setBroadcasts(data ?? []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load broadcasts');
|
setError(err instanceof Error ? err.message : t('errorLoad'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -141,7 +144,7 @@ export default function BroadcastsPage() {
|
|||||||
<div className="flex h-64 flex-col items-center justify-center gap-2">
|
<div className="flex h-64 flex-col items-center justify-center gap-2">
|
||||||
<p className="text-sm text-red-400">{error}</p>
|
<p className="text-sm text-red-400">{error}</p>
|
||||||
<Button variant="outline" onClick={() => window.location.reload()}>
|
<Button variant="outline" onClick={() => window.location.reload()}>
|
||||||
Retry
|
{t('retry')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -179,9 +182,9 @@ export default function BroadcastsPage() {
|
|||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-foreground">Broadcasts</h1>
|
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
Send bulk messages to your contacts using approved templates.
|
{t('subtitle')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<GatedButton
|
<GatedButton
|
||||||
@ -191,16 +194,16 @@ export default function BroadcastsPage() {
|
|||||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
New Broadcast
|
{t('newBroadcast')}
|
||||||
</GatedButton>
|
</GatedButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{broadcasts.length === 0 ? (
|
{broadcasts.length === 0 ? (
|
||||||
<div className="flex h-64 flex-col items-center justify-center rounded-xl border border-border bg-card">
|
<div className="flex h-64 flex-col items-center justify-center rounded-xl border border-border bg-card">
|
||||||
<Radio className="mb-3 h-10 w-10 text-muted-foreground" />
|
<Radio className="mb-3 h-10 w-10 text-muted-foreground" />
|
||||||
<p className="text-sm font-medium text-foreground">No broadcasts yet</p>
|
<p className="text-sm font-medium text-foreground">{t('noBroadcastsYet')}</p>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
Create your first broadcast to reach your contacts at scale.
|
{t('createFirst')}
|
||||||
</p>
|
</p>
|
||||||
<GatedButton
|
<GatedButton
|
||||||
canAct={canCreate}
|
canAct={canCreate}
|
||||||
@ -209,7 +212,7 @@ export default function BroadcastsPage() {
|
|||||||
className="mt-4 bg-primary text-primary-foreground hover:bg-primary/90"
|
className="mt-4 bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
New Broadcast
|
{t('newBroadcast')}
|
||||||
</GatedButton>
|
</GatedButton>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -217,15 +220,15 @@ export default function BroadcastsPage() {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="border-border hover:bg-transparent">
|
<TableRow className="border-border hover:bg-transparent">
|
||||||
<TableHead className="text-muted-foreground">Name</TableHead>
|
<TableHead className="text-muted-foreground">{t('table.name')}</TableHead>
|
||||||
<TableHead className="hidden text-muted-foreground md:table-cell">Template</TableHead>
|
<TableHead className="hidden text-muted-foreground md:table-cell">{t('table.template')}</TableHead>
|
||||||
<TableHead className="hidden text-right text-muted-foreground sm:table-cell">
|
<TableHead className="hidden text-right text-muted-foreground sm:table-cell">
|
||||||
Recipients
|
{t('table.recipients')}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="hidden text-muted-foreground lg:table-cell">Delivery</TableHead>
|
<TableHead className="hidden text-muted-foreground lg:table-cell">{t('table.delivery')}</TableHead>
|
||||||
<TableHead className="hidden text-muted-foreground lg:table-cell">Read</TableHead>
|
<TableHead className="hidden text-muted-foreground lg:table-cell">{t('table.read')}</TableHead>
|
||||||
<TableHead className="text-muted-foreground">Status</TableHead>
|
<TableHead className="text-muted-foreground">{t('table.status')}</TableHead>
|
||||||
<TableHead className="hidden text-muted-foreground sm:table-cell">Date</TableHead>
|
<TableHead className="hidden text-muted-foreground sm:table-cell">{t('table.date')}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@ -270,7 +273,7 @@ export default function BroadcastsPage() {
|
|||||||
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-yellow-400" />
|
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-yellow-400" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{status.label}
|
{tStatus(status.label)}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="hidden text-muted-foreground sm:table-cell">
|
<TableCell className="hidden text-muted-foreground sm:table-cell">
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { createClient } from '@/lib/supabase/client';
|
|||||||
import { MessageTemplate } from '@/types';
|
import { MessageTemplate } from '@/types';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Loader2, FileText, ArrowRight } from 'lucide-react';
|
import { Loader2, FileText, ArrowRight } from 'lucide-react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
const categoryColors: Record<string, string> = {
|
const categoryColors: Record<string, string> = {
|
||||||
Marketing: 'bg-purple-500/10 text-purple-400 border-purple-500/20',
|
Marketing: 'bg-purple-500/10 text-purple-400 border-purple-500/20',
|
||||||
@ -20,6 +21,7 @@ interface Step1Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Step1ChooseTemplate({ selectedTemplate, onSelect, onNext, onBack }: Step1Props) {
|
export function Step1ChooseTemplate({ selectedTemplate, onSelect, onNext, onBack }: Step1Props) {
|
||||||
|
const t = useTranslations('Broadcasts.wizard');
|
||||||
const [templates, setTemplates] = useState<MessageTemplate[]>([]);
|
const [templates, setTemplates] = useState<MessageTemplate[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@ -40,7 +42,7 @@ export function Step1ChooseTemplate({ selectedTemplate, onSelect, onNext, onBack
|
|||||||
if (fetchError) throw fetchError;
|
if (fetchError) throw fetchError;
|
||||||
setTemplates(data ?? []);
|
setTemplates(data ?? []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load templates');
|
setError(err instanceof Error ? err.message : t('chooseTemplate.errorLoad'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -68,17 +70,17 @@ export function Step1ChooseTemplate({ selectedTemplate, onSelect, onNext, onBack
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-foreground">Choose a Template</h2>
|
<h2 className="text-lg font-semibold text-foreground">{t('chooseTemplate.title')}</h2>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
Select an approved message template for your broadcast.
|
{t('chooseTemplate.subtitle')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{templates.length === 0 ? (
|
{templates.length === 0 ? (
|
||||||
<div className="flex h-48 flex-col items-center justify-center rounded-xl border border-border bg-card/50">
|
<div className="flex h-48 flex-col items-center justify-center rounded-xl border border-border bg-card/50">
|
||||||
<FileText className="mb-2 h-8 w-8 text-muted-foreground" />
|
<FileText className="mb-2 h-8 w-8 text-muted-foreground" />
|
||||||
<p className="text-sm text-muted-foreground">No templates available.</p>
|
<p className="text-sm text-muted-foreground">{t('chooseTemplate.noTemplates')}</p>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">Create a template in Settings first.</p>
|
<p className="mt-1 text-xs text-muted-foreground">{t('chooseTemplate.createFirst')}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
@ -119,14 +121,14 @@ export function Step1ChooseTemplate({ selectedTemplate, onSelect, onNext, onBack
|
|||||||
|
|
||||||
<div className="flex items-center justify-between border-t border-border pt-4">
|
<div className="flex items-center justify-between border-t border-border pt-4">
|
||||||
<Button variant="outline" onClick={onBack} className="border-border text-muted-foreground">
|
<Button variant="outline" onClick={onBack} className="border-border text-muted-foreground">
|
||||||
Back
|
{t('back')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={onNext}
|
onClick={onNext}
|
||||||
disabled={!selectedTemplate}
|
disabled={!selectedTemplate}
|
||||||
className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Next
|
{t('next')}
|
||||||
<ArrowRight className="h-4 w-4" />
|
<ArrowRight className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||||
import { createClient } from '@/lib/supabase/client';
|
import { createClient } from '@/lib/supabase/client';
|
||||||
import { CustomField, Tag } from '@/types';
|
import { CustomField, Tag } from '@/types';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@ -14,6 +14,7 @@ import {
|
|||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
type AudienceType = 'all' | 'tags' | 'custom_field' | 'csv';
|
type AudienceType = 'all' | 'tags' | 'custom_field' | 'csv';
|
||||||
type CustomFieldOperator = 'is' | 'is_not' | 'contains';
|
type CustomFieldOperator = 'is' | 'is_not' | 'contains';
|
||||||
@ -39,50 +40,51 @@ interface Step2Props {
|
|||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const audienceOptions: {
|
|
||||||
type: AudienceType;
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
icon: typeof Users;
|
|
||||||
}[] = [
|
|
||||||
{
|
|
||||||
type: 'all',
|
|
||||||
label: 'All Contacts',
|
|
||||||
description: 'Send to every contact in your database',
|
|
||||||
icon: Users,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'tags',
|
|
||||||
label: 'Filter by Tags',
|
|
||||||
description: 'Target contacts with specific tags',
|
|
||||||
icon: Tags,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'custom_field',
|
|
||||||
label: 'Custom Field',
|
|
||||||
description: 'Filter by a custom field value',
|
|
||||||
icon: Filter,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'csv',
|
|
||||||
label: 'Upload CSV',
|
|
||||||
description: 'Upload a list of phone numbers',
|
|
||||||
icon: Upload,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const OPERATOR_OPTIONS: { value: CustomFieldOperator; label: string }[] = [
|
|
||||||
{ value: 'is', label: 'is' },
|
|
||||||
{ value: 'is_not', label: 'is not' },
|
|
||||||
{ value: 'contains', label: 'contains' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function Step2SelectAudience({
|
export function Step2SelectAudience({
|
||||||
audience,
|
audience,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onNext,
|
onNext,
|
||||||
onBack,
|
onBack,
|
||||||
}: Step2Props) {
|
}: Step2Props) {
|
||||||
|
const t = useTranslations('Broadcasts.wizard');
|
||||||
|
|
||||||
|
const OPERATOR_OPTIONS = useMemo<{ value: CustomFieldOperator; label: string }[]>(() => [
|
||||||
|
{ value: 'is', label: t('selectAudience.operatorIs') },
|
||||||
|
{ value: 'is_not', label: t('selectAudience.operatorIsNot') },
|
||||||
|
{ value: 'contains', label: t('selectAudience.operatorContains') },
|
||||||
|
], [t]);
|
||||||
|
|
||||||
|
const audienceOptions = useMemo<{
|
||||||
|
type: AudienceType;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
icon: typeof Users;
|
||||||
|
}[]>(() => [
|
||||||
|
{
|
||||||
|
type: 'all',
|
||||||
|
label: t('selectAudience.method.all'),
|
||||||
|
description: t('selectAudience.allDescLoading'),
|
||||||
|
icon: Users,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'tags',
|
||||||
|
label: t('selectAudience.method.tags'),
|
||||||
|
description: t('selectAudience.tagDesc'),
|
||||||
|
icon: Tags,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'custom_field',
|
||||||
|
label: t('selectAudience.method.customField'),
|
||||||
|
description: t('selectAudience.customFieldDesc'),
|
||||||
|
icon: Filter,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'csv',
|
||||||
|
label: t('selectAudience.method.csv'),
|
||||||
|
description: t('selectAudience.csvDesc'),
|
||||||
|
icon: Upload,
|
||||||
|
},
|
||||||
|
], [t]);
|
||||||
const [tags, setTags] = useState<Tag[]>([]);
|
const [tags, setTags] = useState<Tag[]>([]);
|
||||||
const [customFields, setCustomFields] = useState<CustomField[]>([]);
|
const [customFields, setCustomFields] = useState<CustomField[]>([]);
|
||||||
const [loadingTags, setLoadingTags] = useState(false);
|
const [loadingTags, setLoadingTags] = useState(false);
|
||||||
@ -249,14 +251,14 @@ export function Step2SelectAudience({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-foreground">Select Audience</h2>
|
<h2 className="text-lg font-semibold text-foreground">{t('selectAudience.title')}</h2>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
Choose who will receive this broadcast.
|
{t('selectAudience.subtitle')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
{audienceOptions.map((option) => {
|
{audienceOptions.map((option: { type: AudienceType; label: string; description: string; icon: typeof Users }) => {
|
||||||
const isSelected = audience.type === option.type;
|
const isSelected = audience.type === option.type;
|
||||||
const Icon = option.icon;
|
const Icon = option.icon;
|
||||||
return (
|
return (
|
||||||
@ -305,12 +307,12 @@ export function Step2SelectAudience({
|
|||||||
|
|
||||||
{audience.type === 'tags' && (
|
{audience.type === 'tags' && (
|
||||||
<div className="rounded-xl border border-border bg-card/50 p-4">
|
<div className="rounded-xl border border-border bg-card/50 p-4">
|
||||||
<p className="mb-3 text-sm font-medium text-foreground">Select Tags</p>
|
<p className="mb-3 text-sm font-medium text-foreground">{t('selectAudience.selectTags')}</p>
|
||||||
{loadingTags ? (
|
{loadingTags ? (
|
||||||
<Loader2 className="h-5 w-5 animate-spin text-primary" />
|
<Loader2 className="h-5 w-5 animate-spin text-primary" />
|
||||||
) : tags.length === 0 ? (
|
) : tags.length === 0 ? (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
No tags found. Create tags in Settings.
|
{t('selectAudience.noTagsFound')}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@ -341,12 +343,12 @@ export function Step2SelectAudience({
|
|||||||
|
|
||||||
{audience.type === 'custom_field' && (
|
{audience.type === 'custom_field' && (
|
||||||
<div className="space-y-3 rounded-xl border border-border bg-card/50 p-4">
|
<div className="space-y-3 rounded-xl border border-border bg-card/50 p-4">
|
||||||
<p className="text-sm font-medium text-foreground">Custom Field Filter</p>
|
<p className="text-sm font-medium text-foreground">{t('selectAudience.method.customField')}</p>
|
||||||
{loadingFields ? (
|
{loadingFields ? (
|
||||||
<Loader2 className="h-5 w-5 animate-spin text-primary" />
|
<Loader2 className="h-5 w-5 animate-spin text-primary" />
|
||||||
) : customFields.length === 0 ? (
|
) : customFields.length === 0 ? (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
No custom fields defined. Create one in Settings → Custom Fields.
|
{t('selectAudience.errorLoadFields')}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_140px_minmax(0,1fr)]">
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_140px_minmax(0,1fr)]">
|
||||||
@ -355,7 +357,7 @@ export function Step2SelectAudience({
|
|||||||
onChange={(e) => updateCustomField({ fieldId: e.target.value })}
|
onChange={(e) => updateCustomField({ fieldId: e.target.value })}
|
||||||
className="h-9 rounded-lg border border-border bg-muted px-2.5 text-sm text-foreground outline-none focus:border-primary focus:ring-1 focus:ring-primary"
|
className="h-9 rounded-lg border border-border bg-muted px-2.5 text-sm text-foreground outline-none focus:border-primary focus:ring-1 focus:ring-primary"
|
||||||
>
|
>
|
||||||
<option value="">Select field…</option>
|
<option value="">{t('selectAudience.selectField')}</option>
|
||||||
{customFields.map((f) => (
|
{customFields.map((f) => (
|
||||||
<option key={f.id} value={f.id}>
|
<option key={f.id} value={f.id}>
|
||||||
{f.field_name}
|
{f.field_name}
|
||||||
@ -371,7 +373,7 @@ export function Step2SelectAudience({
|
|||||||
}
|
}
|
||||||
className="h-9 rounded-lg border border-border bg-muted px-2.5 text-sm text-foreground outline-none focus:border-primary focus:ring-1 focus:ring-primary"
|
className="h-9 rounded-lg border border-border bg-muted px-2.5 text-sm text-foreground outline-none focus:border-primary focus:ring-1 focus:ring-primary"
|
||||||
>
|
>
|
||||||
{OPERATOR_OPTIONS.map((op) => (
|
{OPERATOR_OPTIONS.map((op: { value: CustomFieldOperator; label: string }) => (
|
||||||
<option key={op.value} value={op.value}>
|
<option key={op.value} value={op.value}>
|
||||||
{op.label}
|
{op.label}
|
||||||
</option>
|
</option>
|
||||||
@ -381,7 +383,7 @@ export function Step2SelectAudience({
|
|||||||
type="text"
|
type="text"
|
||||||
value={audience.customField?.value ?? ''}
|
value={audience.customField?.value ?? ''}
|
||||||
onChange={(e) => updateCustomField({ value: e.target.value })}
|
onChange={(e) => updateCustomField({ value: e.target.value })}
|
||||||
placeholder="Value"
|
placeholder={t('selectAudience.valuePlaceholder')}
|
||||||
className="h-9 rounded-lg border border-border bg-muted px-2.5 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary"
|
className="h-9 rounded-lg border border-border bg-muted px-2.5 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -394,12 +396,11 @@ export function Step2SelectAudience({
|
|||||||
<div className="mb-3 flex items-center gap-2">
|
<div className="mb-3 flex items-center gap-2">
|
||||||
<X className="h-4 w-4 text-red-400" />
|
<X className="h-4 w-4 text-red-400" />
|
||||||
<p className="text-sm font-medium text-foreground">
|
<p className="text-sm font-medium text-foreground">
|
||||||
Exclude contacts with these tags
|
{t('selectAudience.excludeTags')}
|
||||||
</p>
|
</p>
|
||||||
<span className="text-xs text-muted-foreground">(optional)</span>
|
|
||||||
</div>
|
</div>
|
||||||
{tags.length === 0 ? (
|
{tags.length === 0 ? (
|
||||||
<p className="text-xs text-muted-foreground">No tags available.</p>
|
<p className="text-xs text-muted-foreground">{t('selectAudience.noTagsFound')}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{tags.map((tag) => {
|
{tags.map((tag) => {
|
||||||
@ -456,14 +457,14 @@ export function Step2SelectAudience({
|
|||||||
className="border-border text-muted-foreground"
|
className="border-border text-muted-foreground"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
Back
|
{t('back')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={onNext}
|
onClick={onNext}
|
||||||
disabled={!isValid}
|
disabled={!isValid}
|
||||||
className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Next
|
{t('next')}
|
||||||
<ArrowRight className="h-4 w-4" />
|
<ArrowRight className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { ArrowLeft, ArrowRight, Eye, ImageIcon, Loader2 } from 'lucide-react';
|
import { ArrowLeft, ArrowRight, Eye, ImageIcon, Loader2 } from 'lucide-react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
type VariableType = 'static' | 'field' | 'custom_field';
|
type VariableType = 'static' | 'field' | 'custom_field';
|
||||||
|
|
||||||
@ -49,10 +50,9 @@ function isValidHttpUrl(value: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const contactFields = [
|
const contactFields = [
|
||||||
{ value: 'name', label: 'Contact Name' },
|
{ value: 'name', labelKey: 'name' },
|
||||||
{ value: 'phone', label: 'Phone Number' },
|
{ value: 'phone', labelKey: 'phone' },
|
||||||
{ value: 'email', label: 'Email Address' },
|
{ value: 'email', labelKey: 'email' },
|
||||||
{ value: 'company', label: 'Company' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const SAMPLE_CONTACT: Contact = {
|
const SAMPLE_CONTACT: Contact = {
|
||||||
@ -76,6 +76,7 @@ export function Step3Personalize({
|
|||||||
onNext,
|
onNext,
|
||||||
onBack,
|
onBack,
|
||||||
}: Step3Props) {
|
}: Step3Props) {
|
||||||
|
const t = useTranslations('Broadcasts.wizard');
|
||||||
const [customFields, setCustomFields] = useState<CustomField[]>([]);
|
const [customFields, setCustomFields] = useState<CustomField[]>([]);
|
||||||
const [loadingFields, setLoadingFields] = useState(true);
|
const [loadingFields, setLoadingFields] = useState(true);
|
||||||
const [firstContact, setFirstContact] = useState<Contact | null>(null);
|
const [firstContact, setFirstContact] = useState<Contact | null>(null);
|
||||||
@ -230,15 +231,14 @@ export function Step3Personalize({
|
|||||||
|
|
||||||
const previewLabel = firstContact
|
const previewLabel = firstContact
|
||||||
? firstContact.name || firstContact.phone
|
? firstContact.name || firstContact.phone
|
||||||
: 'sample data';
|
: t('personalize.previewSample');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-foreground">Personalize Message</h2>
|
<h2 className="text-lg font-semibold text-foreground">{t('personalize.title')}</h2>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
Map template variables to contact fields, custom fields, or static
|
{t('personalize.subtitle')}
|
||||||
values.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -246,30 +246,23 @@ export function Step3Personalize({
|
|||||||
<div className="rounded-xl border border-border bg-card/50 p-4">
|
<div className="rounded-xl border border-border bg-card/50 p-4">
|
||||||
<div className="mb-3 flex items-center gap-2">
|
<div className="mb-3 flex items-center gap-2">
|
||||||
<ImageIcon className="h-4 w-4 text-primary" />
|
<ImageIcon className="h-4 w-4 text-primary" />
|
||||||
<p className="text-sm font-medium text-foreground">Header media</p>
|
<p className="text-sm font-medium text-foreground">{t('personalize.headerImage')}</p>
|
||||||
<span className="inline-flex items-center rounded-md bg-primary/10 px-2 py-0.5 text-xs font-medium uppercase text-primary">
|
<span className="inline-flex items-center rounded-md bg-primary/10 px-2 py-0.5 text-xs font-medium uppercase text-primary">
|
||||||
{mediaHeaderType}
|
{mediaHeaderType}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<label className="mb-1.5 block text-xs font-medium text-muted-foreground">
|
<label className="mb-1.5 block text-xs font-medium text-muted-foreground">
|
||||||
Media URL
|
{t('personalize.imageUrl')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
type="url"
|
type="url"
|
||||||
value={headerMediaUrl}
|
value={headerMediaUrl}
|
||||||
onChange={(e) => onHeaderMediaUrlChange(e.target.value)}
|
onChange={(e) => onHeaderMediaUrlChange(e.target.value)}
|
||||||
placeholder={`https://example.com/header.${
|
placeholder={t('personalize.imageUrlPlaceholder')}
|
||||||
mediaHeaderType === 'image'
|
|
||||||
? 'jpg'
|
|
||||||
: mediaHeaderType === 'video'
|
|
||||||
? 'mp4'
|
|
||||||
: 'pdf'
|
|
||||||
}`}
|
|
||||||
className="border-border bg-muted text-foreground placeholder:text-muted-foreground"
|
className="border-border bg-muted text-foreground placeholder:text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||||
Public URL of the {mediaHeaderType} sent as the message header.
|
{t('personalize.headerImageDesc')}
|
||||||
Used for every recipient in this broadcast.
|
|
||||||
</p>
|
</p>
|
||||||
{mediaHeaderType === 'image' &&
|
{mediaHeaderType === 'image' &&
|
||||||
headerMediaError === null &&
|
headerMediaError === null &&
|
||||||
@ -294,7 +287,7 @@ export function Step3Personalize({
|
|||||||
{placeholders.length === 0 && !mediaHeaderType ? (
|
{placeholders.length === 0 && !mediaHeaderType ? (
|
||||||
<div className="rounded-xl border border-border bg-card/50 p-6 text-center">
|
<div className="rounded-xl border border-border bg-card/50 p-6 text-center">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
This template has no variables to personalize.
|
{t('personalize.noPreview')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : placeholders.length === 0 ? null : (
|
) : placeholders.length === 0 ? null : (
|
||||||
@ -317,7 +310,7 @@ export function Step3Personalize({
|
|||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-xs font-medium text-muted-foreground">
|
<label className="mb-1.5 block text-xs font-medium text-muted-foreground">
|
||||||
Mapping Type
|
{t('personalize.type')}
|
||||||
</label>
|
</label>
|
||||||
<Select
|
<Select
|
||||||
value={mapping.type}
|
value={mapping.type}
|
||||||
@ -332,10 +325,10 @@ export function Step3Personalize({
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="border-border bg-popover">
|
<SelectContent className="border-border bg-popover">
|
||||||
<SelectItem value="static">Static Value</SelectItem>
|
<SelectItem value="static">{t('personalize.typeStatic')}</SelectItem>
|
||||||
<SelectItem value="field">Contact Field</SelectItem>
|
<SelectItem value="field">{t('personalize.typeContact')}</SelectItem>
|
||||||
<SelectItem value="custom_field">
|
<SelectItem value="custom_field">
|
||||||
Custom Field
|
{t('personalize.typeCustom')}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@ -343,7 +336,7 @@ export function Step3Personalize({
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-xs font-medium text-muted-foreground">
|
<label className="mb-1.5 block text-xs font-medium text-muted-foreground">
|
||||||
{mapping.type === 'static' ? 'Value' : 'Field'}
|
{mapping.type === 'static' ? t('personalize.staticValue') : t('personalize.contactField')}
|
||||||
</label>
|
</label>
|
||||||
{mapping.type === 'static' ? (
|
{mapping.type === 'static' ? (
|
||||||
<Input
|
<Input
|
||||||
@ -362,12 +355,12 @@ export function Step3Personalize({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full border-border bg-muted text-foreground">
|
<SelectTrigger className="w-full border-border bg-muted text-foreground">
|
||||||
<SelectValue placeholder="Select field..." />
|
<SelectValue placeholder={t('personalize.selectContactField')} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="border-border bg-popover">
|
<SelectContent className="border-border bg-popover">
|
||||||
{contactFields.map((field) => (
|
{contactFields.map((field) => (
|
||||||
<SelectItem key={field.value} value={field.value}>
|
<SelectItem key={field.value} value={field.value}>
|
||||||
{field.label}
|
{t(`personalize.fieldMap.${field.labelKey}`)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@ -412,7 +405,7 @@ export function Step3Personalize({
|
|||||||
<div className="rounded-xl border border-border bg-card/50 p-4">
|
<div className="rounded-xl border border-border bg-card/50 p-4">
|
||||||
<div className="mb-3 flex items-center gap-2">
|
<div className="mb-3 flex items-center gap-2">
|
||||||
<Eye className="h-4 w-4 text-primary" />
|
<Eye className="h-4 w-4 text-primary" />
|
||||||
<p className="text-sm font-medium text-foreground">Live Preview</p>
|
<p className="text-sm font-medium text-foreground">{t('personalize.preview')}</p>
|
||||||
<span className="text-xs text-muted-foreground">({previewLabel})</span>
|
<span className="text-xs text-muted-foreground">({previewLabel})</span>
|
||||||
{loadingPreview && (
|
{loadingPreview && (
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-primary" />
|
<Loader2 className="h-3.5 w-3.5 animate-spin text-primary" />
|
||||||
@ -444,14 +437,14 @@ export function Step3Personalize({
|
|||||||
className="border-border text-muted-foreground"
|
className="border-border text-muted-foreground"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
Back
|
{t('back')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={onNext}
|
onClick={onNext}
|
||||||
disabled={unmappedKeys.length > 0 || headerMediaError !== null}
|
disabled={unmappedKeys.length > 0 || headerMediaError !== null}
|
||||||
className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Next
|
{t('next')}
|
||||||
<ArrowRight className="h-4 w-4" />
|
<ArrowRight className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import {
|
|||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { ArrowLeft, Send, Loader2, Users, Save } from 'lucide-react';
|
import { ArrowLeft, Send, Loader2, Users, Save } from 'lucide-react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
interface AudienceConfig {
|
interface AudienceConfig {
|
||||||
type: string;
|
type: string;
|
||||||
@ -45,6 +46,7 @@ export function Step4ScheduleSend({
|
|||||||
isProcessing,
|
isProcessing,
|
||||||
progress,
|
progress,
|
||||||
}: Step4Props) {
|
}: Step4Props) {
|
||||||
|
const t = useTranslations('Broadcasts.wizard');
|
||||||
const [showConfirm, setShowConfirm] = useState(false);
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
const [estimatedReach, setEstimatedReach] = useState<number>(0);
|
const [estimatedReach, setEstimatedReach] = useState<number>(0);
|
||||||
const [loadingReach, setLoadingReach] = useState(true);
|
const [loadingReach, setLoadingReach] = useState(true);
|
||||||
@ -83,43 +85,43 @@ export function Step4ScheduleSend({
|
|||||||
|
|
||||||
const audienceLabel =
|
const audienceLabel =
|
||||||
audience.type === 'all'
|
audience.type === 'all'
|
||||||
? 'All Contacts'
|
? t('scheduleSend.audienceAll')
|
||||||
: audience.type === 'tags'
|
: audience.type === 'tags'
|
||||||
? `Tags (${audience.tagIds?.length ?? 0} selected)`
|
? t('scheduleSend.audienceTags')
|
||||||
: audience.type === 'csv'
|
: audience.type === 'csv'
|
||||||
? 'CSV Upload'
|
? t('scheduleSend.audienceCsv')
|
||||||
: 'Custom';
|
: t('scheduleSend.audienceField');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-foreground">Review & Send</h2>
|
<h2 className="text-lg font-semibold text-foreground">{t('scheduleSend.title')}</h2>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
Name your broadcast, review the details, and send.
|
{t('scheduleSend.subtitle')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Broadcast Name */}
|
{/* Broadcast Name */}
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-sm font-medium text-foreground">Broadcast Name</label>
|
<label className="mb-1.5 block text-sm font-medium text-foreground">{t('scheduleSend.broadcastName')}</label>
|
||||||
<Input
|
<Input
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => onNameChange(e.target.value)}
|
onChange={(e) => onNameChange(e.target.value)}
|
||||||
placeholder="e.g. Summer Sale Announcement"
|
placeholder={t('scheduleSend.broadcastNamePlaceholder')}
|
||||||
className="border-border bg-muted text-foreground placeholder:text-muted-foreground"
|
className="border-border bg-muted text-foreground placeholder:text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary Card */}
|
{/* Summary Card */}
|
||||||
<div className="rounded-xl border border-border bg-card/50 p-4 space-y-3">
|
<div className="rounded-xl border border-border bg-card/50 p-4 space-y-3">
|
||||||
<p className="text-sm font-medium text-foreground">Summary</p>
|
<p className="text-sm font-medium text-foreground">{t('scheduleSend.summary')}</p>
|
||||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">Template</p>
|
<p className="text-xs text-muted-foreground">{t('scheduleSend.template')}</p>
|
||||||
<p className="text-foreground">{template.name}</p>
|
<p className="text-foreground">{template.name}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">Audience</p>
|
<p className="text-xs text-muted-foreground">{t('scheduleSend.audience')}</p>
|
||||||
<p className="text-foreground">{audienceLabel}</p>
|
<p className="text-foreground">{audienceLabel}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -148,7 +150,7 @@ export function Step4ScheduleSend({
|
|||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||||
<p className="text-sm font-medium text-foreground">Sending broadcast...</p>
|
<p className="text-sm font-medium text-foreground">{t('scheduleSend.sending')}</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs font-medium text-primary">{progress}%</span>
|
<span className="text-xs font-medium text-primary">{progress}%</span>
|
||||||
</div>
|
</div>
|
||||||
@ -169,7 +171,7 @@ export function Step4ScheduleSend({
|
|||||||
className="border-border text-muted-foreground"
|
className="border-border text-muted-foreground"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
Back
|
{t('back')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@ -181,7 +183,7 @@ export function Step4ScheduleSend({
|
|||||||
className="border-border text-muted-foreground hover:bg-muted disabled:opacity-50"
|
className="border-border text-muted-foreground hover:bg-muted disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<Save className="h-4 w-4" />
|
<Save className="h-4 w-4" />
|
||||||
Save as Draft
|
{t('scheduleSend.saveDraft')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -195,7 +197,7 @@ export function Step4ScheduleSend({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Send className="h-4 w-4" />
|
<Send className="h-4 w-4" />
|
||||||
Send Broadcast
|
{t('scheduleSend.sendNow')}
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="border-border bg-popover sm:max-w-md">
|
<DialogContent className="border-border bg-popover sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
@ -214,7 +216,7 @@ export function Step4ScheduleSend({
|
|||||||
onClick={() => setShowConfirm(false)}
|
onClick={() => setShowConfirm(false)}
|
||||||
className="border-border text-muted-foreground"
|
className="border-border text-muted-foreground"
|
||||||
>
|
>
|
||||||
Cancel
|
{t('cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@ -224,7 +226,7 @@ export function Step4ScheduleSend({
|
|||||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
>
|
>
|
||||||
<Send className="h-4 w-4" />
|
<Send className="h-4 w-4" />
|
||||||
Confirm & Send
|
{t('scheduleSend.sendNow')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@ -154,6 +154,7 @@ function Metric({
|
|||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
tooltip: string;
|
tooltip: string;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
t: any;
|
t: any;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -378,6 +378,7 @@ function SortableStageRow({
|
|||||||
onColorChange: (v: string) => void;
|
onColorChange: (v: string) => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
colors: string[];
|
colors: string[];
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
t: any;
|
t: any;
|
||||||
}) {
|
}) {
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
||||||
@ -431,6 +432,7 @@ function ColorSwatch({
|
|||||||
value: string;
|
value: string;
|
||||||
onChange: (v: string) => void;
|
onChange: (v: string) => void;
|
||||||
colors: string[];
|
colors: string[];
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
t: any;
|
t: any;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|||||||
@ -25,51 +25,51 @@ export interface StatusDisplay {
|
|||||||
|
|
||||||
export const broadcastStatusConfig: Record<BroadcastStatus, StatusDisplay> = {
|
export const broadcastStatusConfig: Record<BroadcastStatus, StatusDisplay> = {
|
||||||
draft: {
|
draft: {
|
||||||
label: "Draft",
|
label: "draft",
|
||||||
classes: "bg-slate-500/10 text-muted-foreground border-slate-500/20",
|
classes: "bg-slate-500/10 text-muted-foreground border-slate-500/20",
|
||||||
},
|
},
|
||||||
scheduled: {
|
scheduled: {
|
||||||
label: "Scheduled",
|
label: "scheduled",
|
||||||
classes: "bg-blue-500/10 text-blue-400 border-blue-500/20",
|
classes: "bg-blue-500/10 text-blue-400 border-blue-500/20",
|
||||||
},
|
},
|
||||||
sending: {
|
sending: {
|
||||||
label: "Sending",
|
label: "sending",
|
||||||
classes: "bg-yellow-500/10 text-yellow-400 border-yellow-500/20",
|
classes: "bg-yellow-500/10 text-yellow-400 border-yellow-500/20",
|
||||||
pulse: true,
|
pulse: true,
|
||||||
},
|
},
|
||||||
sent: {
|
sent: {
|
||||||
label: "Sent",
|
label: "sent",
|
||||||
classes: "bg-primary/10 text-primary border-primary/20",
|
classes: "bg-primary/10 text-primary border-primary/20",
|
||||||
},
|
},
|
||||||
failed: {
|
failed: {
|
||||||
label: "Failed",
|
label: "failed",
|
||||||
classes: "bg-red-500/10 text-red-400 border-red-500/20",
|
classes: "bg-red-500/10 text-red-400 border-red-500/20",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const recipientStatusConfig: Record<RecipientStatus, StatusDisplay> = {
|
export const recipientStatusConfig: Record<RecipientStatus, StatusDisplay> = {
|
||||||
pending: {
|
pending: {
|
||||||
label: "Pending",
|
label: "pending",
|
||||||
classes: "bg-slate-500/10 text-muted-foreground border-slate-500/20",
|
classes: "bg-slate-500/10 text-muted-foreground border-slate-500/20",
|
||||||
},
|
},
|
||||||
sent: {
|
sent: {
|
||||||
label: "Sent",
|
label: "sent",
|
||||||
classes: "bg-blue-500/10 text-blue-400 border-blue-500/20",
|
classes: "bg-blue-500/10 text-blue-400 border-blue-500/20",
|
||||||
},
|
},
|
||||||
delivered: {
|
delivered: {
|
||||||
label: "Delivered",
|
label: "delivered",
|
||||||
classes: "bg-primary/10 text-primary border-primary/20",
|
classes: "bg-primary/10 text-primary border-primary/20",
|
||||||
},
|
},
|
||||||
read: {
|
read: {
|
||||||
label: "Read",
|
label: "read",
|
||||||
classes: "bg-primary/10 text-primary border-primary/20",
|
classes: "bg-primary/10 text-primary border-primary/20",
|
||||||
},
|
},
|
||||||
replied: {
|
replied: {
|
||||||
label: "Replied",
|
label: "replied",
|
||||||
classes: "bg-purple-500/10 text-purple-400 border-purple-500/20",
|
classes: "bg-purple-500/10 text-purple-400 border-purple-500/20",
|
||||||
},
|
},
|
||||||
failed: {
|
failed: {
|
||||||
label: "Failed",
|
label: "failed",
|
||||||
classes: "bg-red-500/10 text-red-400 border-red-500/20",
|
classes: "bg-red-500/10 text-red-400 border-red-500/20",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user