mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +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.",
|
||||
"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,
|
||||
getRecipientStatus,
|
||||
} from '@/lib/broadcast-status';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
@ -144,6 +145,8 @@ function downloadBlob(filename: string, content: string) {
|
||||
export default function BroadcastDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const t = useTranslations('Broadcasts.detail');
|
||||
const tStatus = useTranslations('Broadcasts.status');
|
||||
const broadcastId = params.id as string;
|
||||
|
||||
const [broadcast, setBroadcast] = useState<Broadcast | null>(null);
|
||||
@ -179,7 +182,7 @@ export default function BroadcastDetailPage() {
|
||||
if (recsError) throw recsError;
|
||||
setRecipients(recs ?? []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load broadcast');
|
||||
setError(err instanceof Error ? err.message : t('notFound'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -199,14 +202,13 @@ export default function BroadcastDetailPage() {
|
||||
function handleExport() {
|
||||
if (!broadcast) return;
|
||||
const header = [
|
||||
'Contact',
|
||||
'Phone',
|
||||
'Status',
|
||||
'Sent At',
|
||||
'Delivered At',
|
||||
'Read At',
|
||||
'Replied At',
|
||||
'Error',
|
||||
t('table.contact'),
|
||||
t('table.phone'),
|
||||
t('table.status'),
|
||||
t('table.sent'),
|
||||
t('table.delivered'),
|
||||
t('table.read'),
|
||||
t('table.error'),
|
||||
];
|
||||
const rows = recipients.map((r) => [
|
||||
r.contact?.name ?? '',
|
||||
@ -215,7 +217,6 @@ export default function BroadcastDetailPage() {
|
||||
r.sent_at ?? '',
|
||||
r.delivered_at ?? '',
|
||||
r.read_at ?? '',
|
||||
r.replied_at ?? '',
|
||||
r.error_message ?? '',
|
||||
]);
|
||||
const csv = toCsv([header, ...rows]);
|
||||
@ -236,10 +237,10 @@ export default function BroadcastDetailPage() {
|
||||
.eq('id', broadcastId);
|
||||
setDeleting(false);
|
||||
if (delErr) {
|
||||
toast.error(`Failed to delete: ${delErr.message}`);
|
||||
toast.error(t('toastFailedDelete', { error: delErr.message }));
|
||||
return;
|
||||
}
|
||||
toast.success('Broadcast deleted');
|
||||
toast.success(t('toastDeleted'));
|
||||
router.push('/broadcasts');
|
||||
}
|
||||
|
||||
@ -254,9 +255,9 @@ export default function BroadcastDetailPage() {
|
||||
if (error || !broadcast) {
|
||||
return (
|
||||
<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')}>
|
||||
Back to Broadcasts
|
||||
{t('backToBroadcasts')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@ -265,10 +266,10 @@ export default function BroadcastDetailPage() {
|
||||
const status = getBroadcastStatus(broadcast.status);
|
||||
|
||||
const funnelSteps: FunnelStep[] = [
|
||||
{ label: 'Sent', value: broadcast.sent_count, color: 'bg-primary' },
|
||||
{ label: 'Delivered', value: broadcast.delivered_count, color: 'bg-teal-500' },
|
||||
{ label: 'Read', value: broadcast.read_count, color: 'bg-blue-500' },
|
||||
{ label: 'Replied', value: broadcast.replied_count, color: 'bg-indigo-500' },
|
||||
{ label: t('stats.sent'), value: broadcast.sent_count, color: 'bg-primary' },
|
||||
{ label: t('stats.delivered'), value: broadcast.delivered_count, color: 'bg-teal-500' },
|
||||
{ label: t('stats.read'), value: broadcast.read_count, color: 'bg-blue-500' },
|
||||
{ label: t('stats.replied'), value: broadcast.replied_count, color: 'bg-indigo-500' },
|
||||
];
|
||||
|
||||
return (
|
||||
@ -290,14 +291,14 @@ export default function BroadcastDetailPage() {
|
||||
<span
|
||||
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>
|
||||
</div>
|
||||
<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>
|
||||
Created {new Date(broadcast.created_at).toLocaleDateString()}
|
||||
{t('createdAt', { date: new Date(broadcast.created_at).toLocaleDateString() })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -309,7 +310,7 @@ export default function BroadcastDetailPage() {
|
||||
funnel inconsistent. */}
|
||||
{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">
|
||||
<span className="text-red-300">Delete this broadcast?</span>
|
||||
<span className="text-red-300">{t('deletePrompt')}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@ -317,7 +318,7 @@ export default function BroadcastDetailPage() {
|
||||
disabled={deleting}
|
||||
className="h-7 border-border bg-transparent text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
Cancel
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@ -325,7 +326,7 @@ export default function BroadcastDetailPage() {
|
||||
disabled={deleting}
|
||||
className="h-7 bg-red-600 text-white hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
{deleting ? 'Deleting…' : 'Confirm'}
|
||||
{deleting ? t('deleting') : t('confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
@ -336,13 +337,13 @@ export default function BroadcastDetailPage() {
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
title={
|
||||
broadcast.status === 'sending'
|
||||
? 'Cannot delete while a broadcast is actively sending'
|
||||
: 'Delete this broadcast'
|
||||
? t('cannotDeleteSending')
|
||||
: t('deleteHover')
|
||||
}
|
||||
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" />
|
||||
Delete
|
||||
{t('delete')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@ -350,42 +351,42 @@ export default function BroadcastDetailPage() {
|
||||
{/* 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">
|
||||
<StatCard
|
||||
label="Total Recipients"
|
||||
label={t('stats.totalRecipients')}
|
||||
value={broadcast.total_recipients}
|
||||
total={broadcast.total_recipients}
|
||||
icon={<Users className="h-4 w-4" />}
|
||||
color="bg-muted text-muted-foreground"
|
||||
/>
|
||||
<StatCard
|
||||
label="Sent"
|
||||
label={t('stats.sent')}
|
||||
value={broadcast.sent_count}
|
||||
total={broadcast.total_recipients}
|
||||
icon={<Send className="h-4 w-4" />}
|
||||
color="bg-primary/10 text-primary"
|
||||
/>
|
||||
<StatCard
|
||||
label="Delivered"
|
||||
label={t('stats.delivered')}
|
||||
value={broadcast.delivered_count}
|
||||
total={broadcast.total_recipients}
|
||||
icon={<CheckCheck className="h-4 w-4" />}
|
||||
color="bg-teal-500/10 text-teal-400"
|
||||
/>
|
||||
<StatCard
|
||||
label="Read"
|
||||
label={t('stats.read')}
|
||||
value={broadcast.read_count}
|
||||
total={broadcast.total_recipients}
|
||||
icon={<Eye className="h-4 w-4" />}
|
||||
color="bg-blue-500/10 text-blue-400"
|
||||
/>
|
||||
<StatCard
|
||||
label="Replied"
|
||||
label={t('stats.replied')}
|
||||
value={broadcast.replied_count}
|
||||
total={broadcast.total_recipients}
|
||||
icon={<MessageCircle className="h-4 w-4" />}
|
||||
color="bg-indigo-500/10 text-indigo-400"
|
||||
/>
|
||||
<StatCard
|
||||
label="Failed"
|
||||
label={t('stats.failed')}
|
||||
value={broadcast.failed_count}
|
||||
total={broadcast.total_recipients}
|
||||
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="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">
|
||||
Recipients ({filteredRecipients.length}
|
||||
{statusFilter !== 'all' ? ` of ${recipients.length}` : ''})
|
||||
{statusFilter !== 'all'
|
||||
? t('recipientsHeader', { filtered: filteredRecipients.length, total: recipients.length })
|
||||
: t('recipientsHeaderAll', { total: recipients.length })}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<DropdownMenu>
|
||||
@ -415,8 +417,8 @@ export default function BroadcastDetailPage() {
|
||||
>
|
||||
<Filter className="h-3.5 w-3.5" />
|
||||
{statusFilter === 'all'
|
||||
? 'All statuses'
|
||||
: getRecipientStatus(statusFilter).label}
|
||||
? t('allStatuses')
|
||||
: tStatus(getRecipientStatus(statusFilter).label)}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="border-border bg-popover">
|
||||
@ -426,7 +428,7 @@ export default function BroadcastDetailPage() {
|
||||
statusFilter === 'all' ? 'text-primary' : 'text-popover-foreground'
|
||||
}
|
||||
>
|
||||
All statuses
|
||||
{t('allStatuses')}
|
||||
</DropdownMenuItem>
|
||||
{RECIPIENT_STATUSES.map((s) => (
|
||||
<DropdownMenuItem
|
||||
@ -438,7 +440,7 @@ export default function BroadcastDetailPage() {
|
||||
: 'text-popover-foreground'
|
||||
}
|
||||
>
|
||||
{getRecipientStatus(s).label}
|
||||
{tStatus(getRecipientStatus(s).label)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
@ -452,7 +454,7 @@ export default function BroadcastDetailPage() {
|
||||
className="border-border text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
Export CSV
|
||||
{t('exportCsv')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -461,8 +463,8 @@ export default function BroadcastDetailPage() {
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{recipients.length === 0
|
||||
? 'No recipients found.'
|
||||
: 'No recipients match this filter.'}
|
||||
? t('noRecipients')
|
||||
: t('noRecipientsFilter')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@ -470,13 +472,13 @@ export default function BroadcastDetailPage() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-border hover:bg-transparent">
|
||||
<TableHead className="text-muted-foreground">Contact</TableHead>
|
||||
<TableHead className="text-muted-foreground">Phone</TableHead>
|
||||
<TableHead className="text-muted-foreground">Status</TableHead>
|
||||
<TableHead className="text-muted-foreground">Sent</TableHead>
|
||||
<TableHead className="text-muted-foreground">Delivered</TableHead>
|
||||
<TableHead className="text-muted-foreground">Read</TableHead>
|
||||
<TableHead className="text-muted-foreground">Error</TableHead>
|
||||
<TableHead className="text-muted-foreground">{t('table.contact')}</TableHead>
|
||||
<TableHead className="text-muted-foreground">{t('table.phone')}</TableHead>
|
||||
<TableHead className="text-muted-foreground">{t('table.status')}</TableHead>
|
||||
<TableHead className="text-muted-foreground">{t('table.sent')}</TableHead>
|
||||
<TableHead className="text-muted-foreground">{t('table.delivered')}</TableHead>
|
||||
<TableHead className="text-muted-foreground">{t('table.read')}</TableHead>
|
||||
<TableHead className="text-muted-foreground">{t('table.error')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@ -494,7 +496,7 @@ export default function BroadcastDetailPage() {
|
||||
<span
|
||||
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>
|
||||
</TableCell>
|
||||
<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 { useBroadcastSending } from '@/hooks/use-broadcast-sending';
|
||||
import { Check } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
const steps = [
|
||||
{ label: 'Template', key: 'template' },
|
||||
{ label: 'Audience', key: 'audience' },
|
||||
{ label: 'Personalize', key: 'personalize' },
|
||||
{ label: 'Send', key: 'send' },
|
||||
{ label: 'template', key: 'template' },
|
||||
{ label: 'audience', key: 'audience' },
|
||||
{ label: 'personalize', key: 'personalize' },
|
||||
{ label: 'send', key: 'send' },
|
||||
] as const;
|
||||
|
||||
export default function NewBroadcastPage() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations('Broadcasts.new');
|
||||
const { accountId } = useAuth();
|
||||
const { createAndSendBroadcast, isProcessing, progress } = useBroadcastSending();
|
||||
|
||||
@ -82,7 +84,7 @@ export default function NewBroadcastPage() {
|
||||
*/
|
||||
async function handleSaveDraft() {
|
||||
if (!template || !name.trim()) {
|
||||
toast.error('Give the broadcast a name before saving a draft.');
|
||||
toast.error(t('toastGiveName'));
|
||||
return;
|
||||
}
|
||||
const supabase = createClient();
|
||||
@ -91,11 +93,11 @@ export default function NewBroadcastPage() {
|
||||
} = await supabase.auth.getSession();
|
||||
const user = session?.user;
|
||||
if (!user) {
|
||||
toast.error('Not signed in.');
|
||||
toast.error(t('toastNotSignedIn'));
|
||||
return;
|
||||
}
|
||||
if (!accountId) {
|
||||
toast.error('Your profile is not linked to an account.');
|
||||
toast.error(t('toastNotLinked'));
|
||||
return;
|
||||
}
|
||||
|
||||
@ -120,10 +122,10 @@ export default function NewBroadcastPage() {
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(`Failed to save draft: ${error.message}`);
|
||||
toast.error(t('toastFailedDraft', { error: error.message }));
|
||||
return;
|
||||
}
|
||||
toast.success('Draft saved');
|
||||
toast.success(t('toastDraftSaved'));
|
||||
router.push('/broadcasts');
|
||||
}
|
||||
|
||||
@ -131,9 +133,9 @@ export default function NewBroadcastPage() {
|
||||
<div className="mx-auto max-w-3xl space-y-8">
|
||||
{/* Header */}
|
||||
<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">
|
||||
Create and send a broadcast message to your contacts.
|
||||
{t('subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -162,7 +164,7 @@ export default function NewBroadcastPage() {
|
||||
isActive ? 'text-foreground' : isCompleted ? 'text-primary' : 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{step.label}
|
||||
{t(`steps.${step.label}`)}
|
||||
</span>
|
||||
</div>
|
||||
{index < steps.length - 1 && (
|
||||
|
||||
@ -17,6 +17,7 @@ import { Radio, Plus, Loader2 } from 'lucide-react';
|
||||
import { useCan } from '@/hooks/use-can';
|
||||
import { GatedButton } from '@/components/ui/gated-button';
|
||||
import { getBroadcastStatus } from '@/lib/broadcast-status';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
/**
|
||||
* Poll cadence while any broadcast is sending. Kept modest so we don't
|
||||
@ -58,6 +59,8 @@ function RateCell({
|
||||
|
||||
export default function BroadcastsPage() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations('Broadcasts.page');
|
||||
const tStatus = useTranslations('Broadcasts.status');
|
||||
const canCreate = useCan('send-messages');
|
||||
const [broadcasts, setBroadcasts] = useState<Broadcast[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@ -77,7 +80,7 @@ export default function BroadcastsPage() {
|
||||
if (fetchError) throw fetchError;
|
||||
setBroadcasts(data ?? []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load broadcasts');
|
||||
setError(err instanceof Error ? err.message : t('errorLoad'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -141,7 +144,7 @@ export default function BroadcastsPage() {
|
||||
<div className="flex h-64 flex-col items-center justify-center gap-2">
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
<Button variant="outline" onClick={() => window.location.reload()}>
|
||||
Retry
|
||||
{t('retry')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@ -179,9 +182,9 @@ export default function BroadcastsPage() {
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<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">
|
||||
Send bulk messages to your contacts using approved templates.
|
||||
{t('subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<GatedButton
|
||||
@ -191,16 +194,16 @@ export default function BroadcastsPage() {
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
New Broadcast
|
||||
{t('newBroadcast')}
|
||||
</GatedButton>
|
||||
</div>
|
||||
|
||||
{broadcasts.length === 0 ? (
|
||||
<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" />
|
||||
<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">
|
||||
Create your first broadcast to reach your contacts at scale.
|
||||
{t('createFirst')}
|
||||
</p>
|
||||
<GatedButton
|
||||
canAct={canCreate}
|
||||
@ -209,7 +212,7 @@ export default function BroadcastsPage() {
|
||||
className="mt-4 bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
New Broadcast
|
||||
{t('newBroadcast')}
|
||||
</GatedButton>
|
||||
</div>
|
||||
) : (
|
||||
@ -217,15 +220,15 @@ export default function BroadcastsPage() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-border hover:bg-transparent">
|
||||
<TableHead className="text-muted-foreground">Name</TableHead>
|
||||
<TableHead className="hidden text-muted-foreground md:table-cell">Template</TableHead>
|
||||
<TableHead className="text-muted-foreground">{t('table.name')}</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">
|
||||
Recipients
|
||||
{t('table.recipients')}
|
||||
</TableHead>
|
||||
<TableHead className="hidden text-muted-foreground lg:table-cell">Delivery</TableHead>
|
||||
<TableHead className="hidden text-muted-foreground lg:table-cell">Read</TableHead>
|
||||
<TableHead className="text-muted-foreground">Status</TableHead>
|
||||
<TableHead className="hidden text-muted-foreground sm:table-cell">Date</TableHead>
|
||||
<TableHead className="hidden text-muted-foreground lg:table-cell">{t('table.delivery')}</TableHead>
|
||||
<TableHead className="hidden text-muted-foreground lg:table-cell">{t('table.read')}</TableHead>
|
||||
<TableHead className="text-muted-foreground">{t('table.status')}</TableHead>
|
||||
<TableHead className="hidden text-muted-foreground sm:table-cell">{t('table.date')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<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>
|
||||
)}
|
||||
{status.label}
|
||||
{tStatus(status.label)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="hidden text-muted-foreground sm:table-cell">
|
||||
|
||||
@ -5,6 +5,7 @@ import { createClient } from '@/lib/supabase/client';
|
||||
import { MessageTemplate } from '@/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Loader2, FileText, ArrowRight } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
const categoryColors: Record<string, string> = {
|
||||
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) {
|
||||
const t = useTranslations('Broadcasts.wizard');
|
||||
const [templates, setTemplates] = useState<MessageTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@ -40,7 +42,7 @@ export function Step1ChooseTemplate({ selectedTemplate, onSelect, onNext, onBack
|
||||
if (fetchError) throw fetchError;
|
||||
setTemplates(data ?? []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load templates');
|
||||
setError(err instanceof Error ? err.message : t('chooseTemplate.errorLoad'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -68,17 +70,17 @@ export function Step1ChooseTemplate({ selectedTemplate, onSelect, onNext, onBack
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<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">
|
||||
Select an approved message template for your broadcast.
|
||||
{t('chooseTemplate.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{templates.length === 0 ? (
|
||||
<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" />
|
||||
<p className="text-sm text-muted-foreground">No templates available.</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Create a template in Settings first.</p>
|
||||
<p className="text-sm text-muted-foreground">{t('chooseTemplate.noTemplates')}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t('chooseTemplate.createFirst')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<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">
|
||||
<Button variant="outline" onClick={onBack} className="border-border text-muted-foreground">
|
||||
Back
|
||||
{t('back')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onNext}
|
||||
disabled={!selectedTemplate}
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
{t('next')}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { CustomField, Tag } from '@/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@ -14,6 +14,7 @@ import {
|
||||
ArrowLeft,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
type AudienceType = 'all' | 'tags' | 'custom_field' | 'csv';
|
||||
type CustomFieldOperator = 'is' | 'is_not' | 'contains';
|
||||
@ -39,50 +40,51 @@ interface Step2Props {
|
||||
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({
|
||||
audience,
|
||||
onUpdate,
|
||||
onNext,
|
||||
onBack,
|
||||
}: 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 [customFields, setCustomFields] = useState<CustomField[]>([]);
|
||||
const [loadingTags, setLoadingTags] = useState(false);
|
||||
@ -249,14 +251,14 @@ export function Step2SelectAudience({
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<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">
|
||||
Choose who will receive this broadcast.
|
||||
{t('selectAudience.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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 Icon = option.icon;
|
||||
return (
|
||||
@ -305,12 +307,12 @@ export function Step2SelectAudience({
|
||||
|
||||
{audience.type === 'tags' && (
|
||||
<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 ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin text-primary" />
|
||||
) : tags.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No tags found. Create tags in Settings.
|
||||
{t('selectAudience.noTagsFound')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@ -341,12 +343,12 @@ export function Step2SelectAudience({
|
||||
|
||||
{audience.type === 'custom_field' && (
|
||||
<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 ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin text-primary" />
|
||||
) : customFields.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No custom fields defined. Create one in Settings → Custom Fields.
|
||||
{t('selectAudience.errorLoadFields')}
|
||||
</p>
|
||||
) : (
|
||||
<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 })}
|
||||
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) => (
|
||||
<option key={f.id} value={f.id}>
|
||||
{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"
|
||||
>
|
||||
{OPERATOR_OPTIONS.map((op) => (
|
||||
{OPERATOR_OPTIONS.map((op: { value: CustomFieldOperator; label: string }) => (
|
||||
<option key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</option>
|
||||
@ -381,7 +383,7 @@ export function Step2SelectAudience({
|
||||
type="text"
|
||||
value={audience.customField?.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"
|
||||
/>
|
||||
</div>
|
||||
@ -394,12 +396,11 @@ export function Step2SelectAudience({
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<X className="h-4 w-4 text-red-400" />
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Exclude contacts with these tags
|
||||
{t('selectAudience.excludeTags')}
|
||||
</p>
|
||||
<span className="text-xs text-muted-foreground">(optional)</span>
|
||||
</div>
|
||||
{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">
|
||||
{tags.map((tag) => {
|
||||
@ -456,14 +457,14 @@ export function Step2SelectAudience({
|
||||
className="border-border text-muted-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back
|
||||
{t('back')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onNext}
|
||||
disabled={!isValid}
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
{t('next')}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -13,6 +13,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { ArrowLeft, ArrowRight, Eye, ImageIcon, Loader2 } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
type VariableType = 'static' | 'field' | 'custom_field';
|
||||
|
||||
@ -49,10 +50,9 @@ function isValidHttpUrl(value: string): boolean {
|
||||
}
|
||||
|
||||
const contactFields = [
|
||||
{ value: 'name', label: 'Contact Name' },
|
||||
{ value: 'phone', label: 'Phone Number' },
|
||||
{ value: 'email', label: 'Email Address' },
|
||||
{ value: 'company', label: 'Company' },
|
||||
{ value: 'name', labelKey: 'name' },
|
||||
{ value: 'phone', labelKey: 'phone' },
|
||||
{ value: 'email', labelKey: 'email' },
|
||||
];
|
||||
|
||||
const SAMPLE_CONTACT: Contact = {
|
||||
@ -76,6 +76,7 @@ export function Step3Personalize({
|
||||
onNext,
|
||||
onBack,
|
||||
}: Step3Props) {
|
||||
const t = useTranslations('Broadcasts.wizard');
|
||||
const [customFields, setCustomFields] = useState<CustomField[]>([]);
|
||||
const [loadingFields, setLoadingFields] = useState(true);
|
||||
const [firstContact, setFirstContact] = useState<Contact | null>(null);
|
||||
@ -230,15 +231,14 @@ export function Step3Personalize({
|
||||
|
||||
const previewLabel = firstContact
|
||||
? firstContact.name || firstContact.phone
|
||||
: 'sample data';
|
||||
: t('personalize.previewSample');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<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">
|
||||
Map template variables to contact fields, custom fields, or static
|
||||
values.
|
||||
{t('personalize.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -246,30 +246,23 @@ export function Step3Personalize({
|
||||
<div className="rounded-xl border border-border bg-card/50 p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<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">
|
||||
{mediaHeaderType}
|
||||
</span>
|
||||
</div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-muted-foreground">
|
||||
Media URL
|
||||
{t('personalize.imageUrl')}
|
||||
</label>
|
||||
<Input
|
||||
type="url"
|
||||
value={headerMediaUrl}
|
||||
onChange={(e) => onHeaderMediaUrlChange(e.target.value)}
|
||||
placeholder={`https://example.com/header.${
|
||||
mediaHeaderType === 'image'
|
||||
? 'jpg'
|
||||
: mediaHeaderType === 'video'
|
||||
? 'mp4'
|
||||
: 'pdf'
|
||||
}`}
|
||||
placeholder={t('personalize.imageUrlPlaceholder')}
|
||||
className="border-border bg-muted text-foreground placeholder:text-muted-foreground"
|
||||
/>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
Public URL of the {mediaHeaderType} sent as the message header.
|
||||
Used for every recipient in this broadcast.
|
||||
{t('personalize.headerImageDesc')}
|
||||
</p>
|
||||
{mediaHeaderType === 'image' &&
|
||||
headerMediaError === null &&
|
||||
@ -294,7 +287,7 @@ export function Step3Personalize({
|
||||
{placeholders.length === 0 && !mediaHeaderType ? (
|
||||
<div className="rounded-xl border border-border bg-card/50 p-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This template has no variables to personalize.
|
||||
{t('personalize.noPreview')}
|
||||
</p>
|
||||
</div>
|
||||
) : placeholders.length === 0 ? null : (
|
||||
@ -317,7 +310,7 @@ export function Step3Personalize({
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-muted-foreground">
|
||||
Mapping Type
|
||||
{t('personalize.type')}
|
||||
</label>
|
||||
<Select
|
||||
value={mapping.type}
|
||||
@ -332,10 +325,10 @@ export function Step3Personalize({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="border-border bg-popover">
|
||||
<SelectItem value="static">Static Value</SelectItem>
|
||||
<SelectItem value="field">Contact Field</SelectItem>
|
||||
<SelectItem value="static">{t('personalize.typeStatic')}</SelectItem>
|
||||
<SelectItem value="field">{t('personalize.typeContact')}</SelectItem>
|
||||
<SelectItem value="custom_field">
|
||||
Custom Field
|
||||
{t('personalize.typeCustom')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@ -343,7 +336,7 @@ export function Step3Personalize({
|
||||
|
||||
<div>
|
||||
<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>
|
||||
{mapping.type === 'static' ? (
|
||||
<Input
|
||||
@ -362,12 +355,12 @@ export function Step3Personalize({
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full border-border bg-muted text-foreground">
|
||||
<SelectValue placeholder="Select field..." />
|
||||
<SelectValue placeholder={t('personalize.selectContactField')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="border-border bg-popover">
|
||||
{contactFields.map((field) => (
|
||||
<SelectItem key={field.value} value={field.value}>
|
||||
{field.label}
|
||||
{t(`personalize.fieldMap.${field.labelKey}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@ -412,7 +405,7 @@ export function Step3Personalize({
|
||||
<div className="rounded-xl border border-border bg-card/50 p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<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>
|
||||
{loadingPreview && (
|
||||
<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"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back
|
||||
{t('back')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onNext}
|
||||
disabled={unmappedKeys.length > 0 || headerMediaError !== null}
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
{t('next')}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -15,6 +15,7 @@ import {
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { ArrowLeft, Send, Loader2, Users, Save } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
interface AudienceConfig {
|
||||
type: string;
|
||||
@ -45,6 +46,7 @@ export function Step4ScheduleSend({
|
||||
isProcessing,
|
||||
progress,
|
||||
}: Step4Props) {
|
||||
const t = useTranslations('Broadcasts.wizard');
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
const [estimatedReach, setEstimatedReach] = useState<number>(0);
|
||||
const [loadingReach, setLoadingReach] = useState(true);
|
||||
@ -83,43 +85,43 @@ export function Step4ScheduleSend({
|
||||
|
||||
const audienceLabel =
|
||||
audience.type === 'all'
|
||||
? 'All Contacts'
|
||||
? t('scheduleSend.audienceAll')
|
||||
: audience.type === 'tags'
|
||||
? `Tags (${audience.tagIds?.length ?? 0} selected)`
|
||||
? t('scheduleSend.audienceTags')
|
||||
: audience.type === 'csv'
|
||||
? 'CSV Upload'
|
||||
: 'Custom';
|
||||
? t('scheduleSend.audienceCsv')
|
||||
: t('scheduleSend.audienceField');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<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">
|
||||
Name your broadcast, review the details, and send.
|
||||
{t('scheduleSend.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Broadcast Name */}
|
||||
<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
|
||||
value={name}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Summary Card */}
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
<div>
|
||||
@ -148,7 +150,7 @@ export function Step4ScheduleSend({
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
<span className="text-xs font-medium text-primary">{progress}%</span>
|
||||
</div>
|
||||
@ -169,7 +171,7 @@ export function Step4ScheduleSend({
|
||||
className="border-border text-muted-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back
|
||||
{t('back')}
|
||||
</Button>
|
||||
|
||||
<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"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
Save as Draft
|
||||
{t('scheduleSend.saveDraft')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@ -195,7 +197,7 @@ export function Step4ScheduleSend({
|
||||
}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
Send Broadcast
|
||||
{t('scheduleSend.sendNow')}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="border-border bg-popover sm:max-w-md">
|
||||
<DialogHeader>
|
||||
@ -214,7 +216,7 @@ export function Step4ScheduleSend({
|
||||
onClick={() => setShowConfirm(false)}
|
||||
className="border-border text-muted-foreground"
|
||||
>
|
||||
Cancel
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
@ -224,7 +226,7 @@ export function Step4ScheduleSend({
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
Confirm & Send
|
||||
{t('scheduleSend.sendNow')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@ -154,6 +154,7 @@ function Metric({
|
||||
label: string;
|
||||
value: string;
|
||||
tooltip: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
t: any;
|
||||
}) {
|
||||
return (
|
||||
|
||||
@ -378,6 +378,7 @@ function SortableStageRow({
|
||||
onColorChange: (v: string) => void;
|
||||
onRemove: () => void;
|
||||
colors: string[];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
t: any;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
||||
@ -431,6 +432,7 @@ function ColorSwatch({
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
colors: string[];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
t: any;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@ -25,51 +25,51 @@ export interface StatusDisplay {
|
||||
|
||||
export const broadcastStatusConfig: Record<BroadcastStatus, StatusDisplay> = {
|
||||
draft: {
|
||||
label: "Draft",
|
||||
label: "draft",
|
||||
classes: "bg-slate-500/10 text-muted-foreground border-slate-500/20",
|
||||
},
|
||||
scheduled: {
|
||||
label: "Scheduled",
|
||||
label: "scheduled",
|
||||
classes: "bg-blue-500/10 text-blue-400 border-blue-500/20",
|
||||
},
|
||||
sending: {
|
||||
label: "Sending",
|
||||
label: "sending",
|
||||
classes: "bg-yellow-500/10 text-yellow-400 border-yellow-500/20",
|
||||
pulse: true,
|
||||
},
|
||||
sent: {
|
||||
label: "Sent",
|
||||
label: "sent",
|
||||
classes: "bg-primary/10 text-primary border-primary/20",
|
||||
},
|
||||
failed: {
|
||||
label: "Failed",
|
||||
label: "failed",
|
||||
classes: "bg-red-500/10 text-red-400 border-red-500/20",
|
||||
},
|
||||
};
|
||||
|
||||
export const recipientStatusConfig: Record<RecipientStatus, StatusDisplay> = {
|
||||
pending: {
|
||||
label: "Pending",
|
||||
label: "pending",
|
||||
classes: "bg-slate-500/10 text-muted-foreground border-slate-500/20",
|
||||
},
|
||||
sent: {
|
||||
label: "Sent",
|
||||
label: "sent",
|
||||
classes: "bg-blue-500/10 text-blue-400 border-blue-500/20",
|
||||
},
|
||||
delivered: {
|
||||
label: "Delivered",
|
||||
label: "delivered",
|
||||
classes: "bg-primary/10 text-primary border-primary/20",
|
||||
},
|
||||
read: {
|
||||
label: "Read",
|
||||
label: "read",
|
||||
classes: "bg-primary/10 text-primary border-primary/20",
|
||||
},
|
||||
replied: {
|
||||
label: "Replied",
|
||||
label: "replied",
|
||||
classes: "bg-purple-500/10 text-purple-400 border-purple-500/20",
|
||||
},
|
||||
failed: {
|
||||
label: "Failed",
|
||||
label: "failed",
|
||||
classes: "bg-red-500/10 text-red-400 border-red-500/20",
|
||||
},
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user