From 92e5789837efd365fc8d1f286ee7d28d0c3f9c31 Mon Sep 17 00:00:00 2001 From: Ivan Mercedes Date: Tue, 7 Jul 2026 17:29:45 -0400 Subject: [PATCH] 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 --- messages/en.json | 181 ++++++++++++++++++ src/app/(dashboard)/broadcasts/[id]/page.tsx | 102 +++++----- src/app/(dashboard)/broadcasts/new/page.tsx | 26 +-- src/app/(dashboard)/broadcasts/page.tsx | 35 ++-- .../broadcasts/step1-choose-template.tsx | 16 +- .../broadcasts/step2-select-audience.tsx | 109 +++++------ .../broadcasts/step3-personalize.tsx | 53 +++-- .../broadcasts/step4-schedule-send.tsx | 36 ++-- .../pipelines/pipeline-analytics.tsx | 1 + .../pipelines/pipeline-settings.tsx | 2 + src/lib/broadcast-status.ts | 22 +-- 11 files changed, 386 insertions(+), 197 deletions(-) diff --git a/messages/en.json b/messages/en.json index 85fee41..77497cd 100644 --- a/messages/en.json +++ b/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" + } + } } } diff --git a/src/app/(dashboard)/broadcasts/[id]/page.tsx b/src/app/(dashboard)/broadcasts/[id]/page.tsx index 7449d51..4dd4241 100644 --- a/src/app/(dashboard)/broadcasts/[id]/page.tsx +++ b/src/app/(dashboard)/broadcasts/[id]/page.tsx @@ -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(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 (
-

{error ?? 'Broadcast not found'}

+

{error ?? t('notFound')}

); @@ -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() { - {status.label} + {tStatus(status.label)}
- Template: {broadcast.template_name} + {t('template', { name: broadcast.template_name })} - - Created {new Date(broadcast.created_at).toLocaleDateString()} + {t('createdAt', { date: new Date(broadcast.created_at).toLocaleDateString() })}
@@ -309,7 +310,7 @@ export default function BroadcastDetailPage() { funnel inconsistent. */} {confirmDelete ? (
- Delete this broadcast? + {t('deletePrompt')}
) : ( @@ -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" > - Delete + {t('delete')} )} @@ -350,42 +351,42 @@ export default function BroadcastDetailPage() { {/* Stats — 6 cards: Total / Sent / Delivered / Read / Replied / Failed */}
} color="bg-muted text-muted-foreground" /> } color="bg-primary/10 text-primary" /> } color="bg-teal-500/10 text-teal-400" /> } color="bg-blue-500/10 text-blue-400" /> } color="bg-indigo-500/10 text-indigo-400" /> } @@ -399,8 +400,9 @@ export default function BroadcastDetailPage() {

- Recipients ({filteredRecipients.length} - {statusFilter !== 'all' ? ` of ${recipients.length}` : ''}) + {statusFilter !== 'all' + ? t('recipientsHeader', { filtered: filteredRecipients.length, total: recipients.length }) + : t('recipientsHeaderAll', { total: recipients.length })}

@@ -415,8 +417,8 @@ export default function BroadcastDetailPage() { > {statusFilter === 'all' - ? 'All statuses' - : getRecipientStatus(statusFilter).label} + ? t('allStatuses') + : tStatus(getRecipientStatus(statusFilter).label)} @@ -426,7 +428,7 @@ export default function BroadcastDetailPage() { statusFilter === 'all' ? 'text-primary' : 'text-popover-foreground' } > - All statuses + {t('allStatuses')} {RECIPIENT_STATUSES.map((s) => ( - {getRecipientStatus(s).label} + {tStatus(getRecipientStatus(s).label)} ))} @@ -452,7 +454,7 @@ export default function BroadcastDetailPage() { className="border-border text-muted-foreground hover:bg-muted" > - Export CSV + {t('exportCsv')}
@@ -461,8 +463,8 @@ export default function BroadcastDetailPage() {

{recipients.length === 0 - ? 'No recipients found.' - : 'No recipients match this filter.'} + ? t('noRecipients') + : t('noRecipientsFilter')}

) : ( @@ -470,13 +472,13 @@ export default function BroadcastDetailPage() { - Contact - Phone - Status - Sent - Delivered - Read - Error + {t('table.contact')} + {t('table.phone')} + {t('table.status')} + {t('table.sent')} + {t('table.delivered')} + {t('table.read')} + {t('table.error')} @@ -494,7 +496,7 @@ export default function BroadcastDetailPage() { - {rStatus.label} + {tStatus(rStatus.label)} diff --git a/src/app/(dashboard)/broadcasts/new/page.tsx b/src/app/(dashboard)/broadcasts/new/page.tsx index 2eba6a8..50d1af2 100644 --- a/src/app/(dashboard)/broadcasts/new/page.tsx +++ b/src/app/(dashboard)/broadcasts/new/page.tsx @@ -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() {
{/* Header */}
-

New Broadcast

+

{t('title')}

- Create and send a broadcast message to your contacts. + {t('subtitle')}

@@ -162,7 +164,7 @@ export default function NewBroadcastPage() { isActive ? 'text-foreground' : isCompleted ? 'text-primary' : 'text-muted-foreground' }`} > - {step.label} + {t(`steps.${step.label}`)}
{index < steps.length - 1 && ( diff --git a/src/app/(dashboard)/broadcasts/page.tsx b/src/app/(dashboard)/broadcasts/page.tsx index 986000e..ca89c81 100644 --- a/src/app/(dashboard)/broadcasts/page.tsx +++ b/src/app/(dashboard)/broadcasts/page.tsx @@ -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([]); 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() {

{error}

); @@ -179,9 +182,9 @@ export default function BroadcastsPage() {
-

Broadcasts

+

{t('title')}

- Send bulk messages to your contacts using approved templates. + {t('subtitle')}

- New Broadcast + {t('newBroadcast')}
{broadcasts.length === 0 ? (
-

No broadcasts yet

+

{t('noBroadcastsYet')}

- Create your first broadcast to reach your contacts at scale. + {t('createFirst')}

- New Broadcast + {t('newBroadcast')}
) : ( @@ -217,15 +220,15 @@ export default function BroadcastsPage() {
- Name - Template + {t('table.name')} + {t('table.template')} - Recipients + {t('table.recipients')} - Delivery - Read - Status - Date + {t('table.delivery')} + {t('table.read')} + {t('table.status')} + {t('table.date')} @@ -270,7 +273,7 @@ export default function BroadcastsPage() { )} - {status.label} + {tStatus(status.label)} diff --git a/src/components/broadcasts/step1-choose-template.tsx b/src/components/broadcasts/step1-choose-template.tsx index 89da077..5831b37 100644 --- a/src/components/broadcasts/step1-choose-template.tsx +++ b/src/components/broadcasts/step1-choose-template.tsx @@ -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 = { 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([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(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 (
-

Choose a Template

+

{t('chooseTemplate.title')}

- Select an approved message template for your broadcast. + {t('chooseTemplate.subtitle')}

{templates.length === 0 ? (
-

No templates available.

-

Create a template in Settings first.

+

{t('chooseTemplate.noTemplates')}

+

{t('chooseTemplate.createFirst')}

) : (
@@ -119,14 +121,14 @@ export function Step1ChooseTemplate({ selectedTemplate, onSelect, onNext, onBack
diff --git a/src/components/broadcasts/step2-select-audience.tsx b/src/components/broadcasts/step2-select-audience.tsx index fb927cb..b832903 100644 --- a/src/components/broadcasts/step2-select-audience.tsx +++ b/src/components/broadcasts/step2-select-audience.tsx @@ -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([]); const [customFields, setCustomFields] = useState([]); const [loadingTags, setLoadingTags] = useState(false); @@ -249,14 +251,14 @@ export function Step2SelectAudience({ return (
-

Select Audience

+

{t('selectAudience.title')}

- Choose who will receive this broadcast. + {t('selectAudience.subtitle')}

- {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' && (
-

Select Tags

+

{t('selectAudience.selectTags')}

{loadingTags ? ( ) : tags.length === 0 ? (

- No tags found. Create tags in Settings. + {t('selectAudience.noTagsFound')}

) : (
@@ -341,12 +343,12 @@ export function Step2SelectAudience({ {audience.type === 'custom_field' && (
-

Custom Field Filter

+

{t('selectAudience.method.customField')}

{loadingFields ? ( ) : customFields.length === 0 ? (

- No custom fields defined. Create one in Settings → Custom Fields. + {t('selectAudience.errorLoadFields')}

) : (
@@ -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" > - + {customFields.map((f) => ( @@ -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" />
@@ -394,12 +396,11 @@ export function Step2SelectAudience({

- Exclude contacts with these tags + {t('selectAudience.excludeTags')}

- (optional)
{tags.length === 0 ? ( -

No tags available.

+

{t('selectAudience.noTagsFound')}

) : (
{tags.map((tag) => { @@ -456,14 +457,14 @@ export function Step2SelectAudience({ className="border-border text-muted-foreground" > - Back + {t('back')}
diff --git a/src/components/broadcasts/step3-personalize.tsx b/src/components/broadcasts/step3-personalize.tsx index 8bbeddb..990aeac 100644 --- a/src/components/broadcasts/step3-personalize.tsx +++ b/src/components/broadcasts/step3-personalize.tsx @@ -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([]); const [loadingFields, setLoadingFields] = useState(true); const [firstContact, setFirstContact] = useState(null); @@ -230,15 +231,14 @@ export function Step3Personalize({ const previewLabel = firstContact ? firstContact.name || firstContact.phone - : 'sample data'; + : t('personalize.previewSample'); return (
-

Personalize Message

+

{t('personalize.title')}

- Map template variables to contact fields, custom fields, or static - values. + {t('personalize.subtitle')}

@@ -246,30 +246,23 @@ export function Step3Personalize({
-

Header media

+

{t('personalize.headerImage')}

{mediaHeaderType}
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" />

- Public URL of the {mediaHeaderType} sent as the message header. - Used for every recipient in this broadcast. + {t('personalize.headerImageDesc')}

{mediaHeaderType === 'image' && headerMediaError === null && @@ -294,7 +287,7 @@ export function Step3Personalize({ {placeholders.length === 0 && !mediaHeaderType ? (

- This template has no variables to personalize. + {t('personalize.noPreview')}

) : placeholders.length === 0 ? null : ( @@ -317,7 +310,7 @@ export function Step3Personalize({
@@ -343,7 +336,7 @@ export function Step3Personalize({
{mapping.type === 'static' ? ( - + {contactFields.map((field) => ( - {field.label} + {t(`personalize.fieldMap.${field.labelKey}`)} ))} @@ -412,7 +405,7 @@ export function Step3Personalize({
-

Live Preview

+

{t('personalize.preview')}

({previewLabel}) {loadingPreview && ( @@ -444,14 +437,14 @@ export function Step3Personalize({ className="border-border text-muted-foreground" > - Back + {t('back')}
diff --git a/src/components/broadcasts/step4-schedule-send.tsx b/src/components/broadcasts/step4-schedule-send.tsx index 476f5f3..c4f8e68 100644 --- a/src/components/broadcasts/step4-schedule-send.tsx +++ b/src/components/broadcasts/step4-schedule-send.tsx @@ -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(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 (
-

Review & Send

+

{t('scheduleSend.title')}

- Name your broadcast, review the details, and send. + {t('scheduleSend.subtitle')}

{/* Broadcast Name */}
- + 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" />
{/* Summary Card */}
-

Summary

+

{t('scheduleSend.summary')}

-

Template

+

{t('scheduleSend.template')}

{template.name}

-

Audience

+

{t('scheduleSend.audience')}

{audienceLabel}

@@ -148,7 +150,7 @@ export function Step4ScheduleSend({
-

Sending broadcast...

+

{t('scheduleSend.sending')}

{progress}%
@@ -169,7 +171,7 @@ export function Step4ScheduleSend({ className="border-border text-muted-foreground" > - Back + {t('back')}
@@ -181,7 +183,7 @@ export function Step4ScheduleSend({ className="border-border text-muted-foreground hover:bg-muted disabled:opacity-50" > - Save as Draft + {t('scheduleSend.saveDraft')} )} @@ -195,7 +197,7 @@ export function Step4ScheduleSend({ } > - Send Broadcast + {t('scheduleSend.sendNow')} @@ -214,7 +216,7 @@ export function Step4ScheduleSend({ onClick={() => setShowConfirm(false)} className="border-border text-muted-foreground" > - Cancel + {t('cancel')} diff --git a/src/components/pipelines/pipeline-analytics.tsx b/src/components/pipelines/pipeline-analytics.tsx index 73f2b69..8fa90fd 100644 --- a/src/components/pipelines/pipeline-analytics.tsx +++ b/src/components/pipelines/pipeline-analytics.tsx @@ -154,6 +154,7 @@ function Metric({ label: string; value: string; tooltip: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any t: any; }) { return ( diff --git a/src/components/pipelines/pipeline-settings.tsx b/src/components/pipelines/pipeline-settings.tsx index f52f298..0b34723 100644 --- a/src/components/pipelines/pipeline-settings.tsx +++ b/src/components/pipelines/pipeline-settings.tsx @@ -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); diff --git a/src/lib/broadcast-status.ts b/src/lib/broadcast-status.ts index f16b8fe..22cae03 100644 --- a/src/lib/broadcast-status.ts +++ b/src/lib/broadcast-status.ts @@ -25,51 +25,51 @@ export interface StatusDisplay { export const broadcastStatusConfig: Record = { 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 = { 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", }, };