feat(i18n): translate dashboard overview components

- Added `Dashboard` namespace to English dictionary.

- Translated metric cards, quick actions, and activity feed.

- Localized charts (conversations, pipeline donut, response time) and empty states.
This commit is contained in:
Ivan Mercedes 2026-07-07 14:25:19 -04:00
parent 738b0dde1f
commit afa136245b
8 changed files with 155 additions and 59 deletions

View File

@ -59,5 +59,70 @@
},
"ModeToggle": {
"switchMode": "Switch to {mode} mode"
},
"Dashboard": {
"page": {
"title": "Dashboard",
"description": "Live analytics across conversations, contacts, deals, broadcasts, and automations.",
"activeConversations": "Active Conversations",
"newContactsToday": "New Contacts Today",
"openDealsValue": "Open Deals Value",
"messagesSentToday": "Messages Sent Today",
"newTodayVsYesterday": "new today vs yesterday",
"vsYesterday": "vs yesterday",
"openDeals": "{count} open {count, plural, =1 {deal} other {deals}}",
"noChange": "No change {suffix}"
},
"quickActions": {
"newContact": "New Contact",
"newDeal": "New Deal",
"newBroadcast": "New Broadcast",
"newAutomation": "New Automation"
},
"activityFeed": {
"title": "Recent Activity",
"viewAll": "View all →",
"noActivity": "No activity yet",
"noActivityHint": "Activity from messages, deals, broadcasts, and automations will appear here.",
"showingOf": "Showing {visible} of {totalLoaded}{plus}",
"show": "Show",
"timeS": "{sec}s ago",
"timeM": "{min}m ago",
"timeH": "{hr}h ago",
"timeD": "{day}d ago"
},
"conversationsChart": {
"title": "Conversations Over Time",
"description": "Daily message volume by direction",
"days": "{count} days",
"noActivity": "No message activity in this range",
"noActivityHint": "Send or receive messages to start populating this chart.",
"incoming": "Incoming",
"outgoing": "Outgoing",
"tooltipIncoming": "{count} incoming",
"tooltipOutgoing": "{count} outgoing",
"ariaLabel": "Conversations per day"
},
"pipelineDonut": {
"title": "Pipeline Value",
"description": "Open deals by stage",
"noOpenDeals": "No open deals yet",
"noOpenDealsHint": "Create deals in Pipelines to see stage breakdowns here.",
"dealCount": "{count} {count, plural, =1 {deal} other {deals}}",
"total": "Total",
"ariaLabel": "Pipeline value by stage"
},
"responseTimeChart": {
"title": "Average First Response Time",
"description": "Minutes to reply to a customer's first unreplied message, by weekday",
"target": "target {minutes}m",
"thisWeek": "This week:",
"lastWeek": "Last week:",
"noReplies": "No replies recorded yet",
"noRepliesHint": "This chart fills in as you reply to customer messages."
},
"emptyState": {
"title": "Not enough data yet"
}
}
}

View File

@ -34,9 +34,12 @@ import { PipelineDonut } from '@/components/dashboard/pipeline-donut'
import { ResponseTimeChart } from '@/components/dashboard/response-time-chart'
import { ActivityFeed } from '@/components/dashboard/activity-feed'
import { useTranslations } from 'next-intl'
type RangeDays = 7 | 30 | 90
export default function DashboardPage() {
const t = useTranslations('Dashboard.page')
const { defaultCurrency } = useAuth()
const [metrics, setMetrics] = useState<MetricsBundle | null>(null)
const [metricsLoading, setMetricsLoading] = useState(true)
@ -122,9 +125,9 @@ export default function DashboardPage() {
<div className="space-y-5">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-foreground">Dashboard</h1>
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1>
<p className="mt-1 text-sm text-muted-foreground">
Live analytics across conversations, contacts, deals, broadcasts, and automations.
{t('description')}
</p>
</div>
@ -135,16 +138,20 @@ export default function DashboardPage() {
) : (
<>
<MetricCard
title="Active Conversations"
title={t('activeConversations')}
value={metrics.activeConversations.current.toLocaleString()}
icon={MessageSquare}
delta={{
sign: metrics.activeConversations.previous,
label: deltaLabel(metrics.activeConversations.previous, 'new today vs yesterday'),
label: deltaLabel(
metrics.activeConversations.previous,
t('newTodayVsYesterday'),
t('noChange', { suffix: t('newTodayVsYesterday') })
),
}}
/>
<MetricCard
title="New Contacts Today"
title={t('newContactsToday')}
value={metrics.newContactsToday.current.toLocaleString()}
icon={UserPlus}
delta={{
@ -152,18 +159,19 @@ export default function DashboardPage() {
metrics.newContactsToday.current - metrics.newContactsToday.previous,
label: deltaLabel(
metrics.newContactsToday.current - metrics.newContactsToday.previous,
'vs yesterday',
t('vsYesterday'),
t('noChange', { suffix: t('vsYesterday') })
),
}}
/>
<MetricCard
title="Open Deals Value"
title={t('openDealsValue')}
value={formatCurrency(metrics.openDealsValue, defaultCurrency)}
icon={DollarSign}
subtitle={`${metrics.openDealsCount} open deal${metrics.openDealsCount === 1 ? '' : 's'}`}
subtitle={t('openDeals', { count: metrics.openDealsCount })}
/>
<MetricCard
title="Messages Sent Today"
title={t('messagesSentToday')}
value={metrics.messagesSentToday.current.toLocaleString()}
icon={Send}
delta={{
@ -171,7 +179,8 @@ export default function DashboardPage() {
metrics.messagesSentToday.current - metrics.messagesSentToday.previous,
label: deltaLabel(
metrics.messagesSentToday.current - metrics.messagesSentToday.previous,
'vs yesterday',
t('vsYesterday'),
t('noChange', { suffix: t('vsYesterday') })
),
}}
/>
@ -218,8 +227,8 @@ export default function DashboardPage() {
// ------------------------------------------------------------
function deltaLabel(delta: number, suffix: string): string {
if (delta === 0) return `No change ${suffix}`
function deltaLabel(delta: number, suffix: string, noChangeLabel: string): string {
if (delta === 0) return noChangeLabel
const sign = delta > 0 ? '+' : ''
return `${sign}${delta.toLocaleString()} ${suffix}`
}

View File

@ -38,7 +38,10 @@ const KIND_THEME: Record<ActivityKind, KindTheme> = {
automation: { icon: Zap, badge: 'bg-rose-500/10 text-rose-400' },
}
import { useTranslations } from 'next-intl'
export function ActivityFeed({ items, loading }: ActivityFeedProps) {
const t = useTranslations('Dashboard.activityFeed')
// Start at 5 — a quick scan of the most recent events without
// dominating vertical real estate. User expands explicitly via the
// footer control when they want deeper history.
@ -56,12 +59,12 @@ export function ActivityFeed({ items, loading }: ActivityFeedProps) {
return (
<section className="rounded-xl border border-border bg-card">
<header className="flex items-center justify-between border-b border-border px-5 py-4">
<h2 className="text-sm font-semibold text-foreground">Recent Activity</h2>
<h2 className="text-sm font-semibold text-foreground">{t('title')}</h2>
<Link
href="/inbox"
className="text-xs font-medium text-primary hover:text-primary/80"
>
View all →
{t('viewAll')}
</Link>
</header>
@ -75,8 +78,8 @@ export function ActivityFeed({ items, loading }: ActivityFeedProps) {
<div className="p-5">
<EmptyState
icon={Inbox}
title="No activity yet"
hint="Activity from messages, deals, broadcasts, and automations will appear here."
title={t('noActivity')}
hint={t('noActivityHint')}
/>
</div>
) : (
@ -103,7 +106,7 @@ export function ActivityFeed({ items, loading }: ActivityFeedProps) {
{it.text}
</span>
<span className="flex-shrink-0 text-xs text-muted-foreground tabular-nums">
{relativeTime(it.at)}
{relativeTime(it.at, t)}
</span>
</div>
)
@ -122,11 +125,10 @@ export function ActivityFeed({ items, loading }: ActivityFeedProps) {
</ul>
<footer className="flex items-center justify-between border-t border-border px-5 py-3 text-xs">
<span className="text-muted-foreground tabular-nums">
Showing {visible.length} of {totalLoaded}
{totalLoaded === 50 ? '+' : ''}
{t('showingOf', { visible: visible.length, totalLoaded, plus: totalLoaded === 50 ? '+' : '' })}
</span>
<div className="flex items-center gap-1">
<span className="mr-1 text-muted-foreground">Show</span>
<span className="mr-1 text-muted-foreground">{t('show')}</span>
{PAGE_SIZES.map((size, i) => {
const disabled = !isSizeUseful(size, i)
return (
@ -155,13 +157,13 @@ export function ActivityFeed({ items, loading }: ActivityFeedProps) {
)
}
function relativeTime(iso: string): string {
function relativeTime(iso: string, t: ReturnType<typeof useTranslations>): string {
const then = new Date(iso).getTime()
if (Number.isNaN(then)) return ''
const diffSec = Math.round((Date.now() - then) / 1000)
if (diffSec < 60) return `${Math.max(1, diffSec)}s ago`
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`
if (diffSec < 2_592_000) return `${Math.floor(diffSec / 86400)}d ago`
if (diffSec < 60) return t('timeS', { sec: Math.max(1, diffSec) })
if (diffSec < 3600) return t('timeM', { min: Math.floor(diffSec / 60) })
if (diffSec < 86400) return t('timeH', { hr: Math.floor(diffSec / 3600) })
if (diffSec < 2_592_000) return t('timeD', { day: Math.floor(diffSec / 86400) })
return new Date(iso).toLocaleDateString()
}

View File

@ -27,7 +27,10 @@ const VB_W = 760
const VB_H = 240
const PADDING = { top: 16, right: 16, bottom: 28, left: 40 }
import { useTranslations } from 'next-intl'
export function ConversationsChart({ series, loading, range, onRangeChange }: ConversationsChartProps) {
const t = useTranslations('Dashboard.conversationsChart')
const data = series[range]
// Memoise the max so per-day hover math doesn't recompute it.
@ -49,8 +52,8 @@ export function ConversationsChart({ series, loading, range, onRangeChange }: Co
<section className="flex h-full flex-col rounded-xl border border-border bg-card">
<header className="flex items-center justify-between border-b border-border px-5 py-4">
<div>
<h2 className="text-sm font-semibold text-foreground">Conversations Over Time</h2>
<p className="mt-0.5 text-xs text-muted-foreground">Daily message volume by direction</p>
<h2 className="text-sm font-semibold text-foreground">{t('title')}</h2>
<p className="mt-0.5 text-xs text-muted-foreground">{t('description')}</p>
</div>
<div className="flex items-center gap-1 rounded-lg bg-muted/60 p-1">
{[7, 30, 90].map((r) => (
@ -65,7 +68,7 @@ export function ConversationsChart({ series, loading, range, onRangeChange }: Co
: 'text-muted-foreground hover:text-foreground',
)}
>
{r} days
{t('days', { count: r })}
</button>
))}
</div>
@ -77,17 +80,17 @@ export function ConversationsChart({ series, loading, range, onRangeChange }: Co
) : data.every((p) => p.incoming === 0 && p.outgoing === 0) ? (
<EmptyState
icon={MessageSquare}
title="No message activity in this range"
hint="Send or receive messages to start populating this chart."
title={t('noActivity')}
hint={t('noActivityHint')}
/>
) : (
<LineSvg data={data} maxY={maxY} ticks={niceTicks} />
<LineSvg data={data} maxY={maxY} ticks={niceTicks} t={t} />
)}
</div>
<footer className="flex items-center gap-4 border-t border-border px-5 py-3 text-xs text-muted-foreground">
<LegendDot color="#3b82f6" label="Incoming" />
<LegendDot color="#7c3aed" label="Outgoing" />
<LegendDot color="#3b82f6" label={t('incoming')} />
<LegendDot color="#7c3aed" label={t('outgoing')} />
</footer>
</section>
)
@ -101,10 +104,12 @@ function LineSvg({
data,
maxY,
ticks,
t
}: {
data: ConversationsSeriesPoint[]
maxY: number
ticks: number[]
t: ReturnType<typeof useTranslations>
}) {
// Hover state: both the snapped index AND the tooltip's pixel
// offset inside the wrapper div. They're stored together so the
@ -195,7 +200,7 @@ function LineSvg({
viewBox={`0 0 ${VB_W} ${VB_H}`}
className="h-[240px] w-full"
role="img"
aria-label="Conversations per day"
aria-label={t('ariaLabel')}
>
{/* Y-axis gridlines + labels */}
{ticks.map((t) => {
@ -287,11 +292,11 @@ function LineSvg({
<div className="mt-1 flex flex-col gap-0.5">
<span className="flex items-center gap-1.5 text-blue-300">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-blue-500" />
{hovered.incoming} incoming
{t('tooltipIncoming', { count: hovered.incoming })}
</span>
<span className="flex items-center gap-1.5 text-primary">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-primary" />
{hovered.outgoing} outgoing
{t('tooltipOutgoing', { count: hovered.outgoing })}
</span>
</div>
</div>

View File

@ -2,6 +2,8 @@ import { BarChart3 } from 'lucide-react'
import type { ComponentType } from 'react'
import { cn } from '@/lib/utils'
import { useTranslations } from 'next-intl'
/**
* Shared empty-state panel for charts that can't render meaningfully
* without a minimum amount of data. Kept minimal and uniform so the
@ -9,7 +11,7 @@ import { cn } from '@/lib/utils'
* different widget.
*/
export function EmptyState({
title = 'Not enough data yet',
title,
hint,
icon: Icon = BarChart3,
className,
@ -19,6 +21,9 @@ export function EmptyState({
icon?: ComponentType<{ className?: string }>
className?: string
}) {
const t = useTranslations('Dashboard.emptyState')
const defaultTitle = t('title')
return (
<div
className={cn(
@ -29,7 +34,7 @@ export function EmptyState({
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Icon className="h-5 w-5" />
</div>
<p className="text-sm font-medium text-muted-foreground">{title}</p>
<p className="text-sm font-medium text-muted-foreground">{title || defaultTitle}</p>
{hint && <p className="max-w-xs text-xs text-muted-foreground">{hint}</p>}
</div>
)

View File

@ -13,13 +13,16 @@ interface PipelineDonutProps {
currency: string
}
import { useTranslations } from 'next-intl'
export function PipelineDonut({ data, loading, currency }: PipelineDonutProps) {
const t = useTranslations('Dashboard.pipelineDonut')
return (
<section className="flex h-full flex-col rounded-xl border border-border bg-card">
<header className="border-b border-border px-5 py-4">
<h2 className="text-sm font-semibold text-foreground">Pipeline Value</h2>
<h2 className="text-sm font-semibold text-foreground">{t('title')}</h2>
<p className="mt-0.5 text-xs text-muted-foreground">
Open deals by stage
{t('description')}
</p>
</header>
@ -29,8 +32,8 @@ export function PipelineDonut({ data, loading, currency }: PipelineDonutProps) {
) : data.stages.length === 0 ? (
<EmptyState
icon={GitBranch}
title="No open deals yet"
hint="Create deals in Pipelines to see stage breakdowns here."
title={t('noOpenDeals')}
hint={t('noOpenDealsHint')}
/>
) : (
<>
@ -45,7 +48,7 @@ export function PipelineDonut({ data, loading, currency }: PipelineDonutProps) {
/>
<span className="flex-1 truncate text-muted-foreground">{s.name}</span>
<span className="text-muted-foreground tabular-nums">
{s.dealCount} deal{s.dealCount === 1 ? '' : 's'}
{t('dealCount', { count: s.dealCount })}
</span>
<span className="w-20 text-right text-muted-foreground tabular-nums">
{formatCurrencyShort(s.totalValue, currency)}
@ -67,6 +70,7 @@ export function PipelineDonut({ data, loading, currency }: PipelineDonutProps) {
// them for a cleaner look.
// ------------------------------------------------------------
function Donut({ data, currency }: { data: PipelineDonutData; currency: string }) {
const t = useTranslations('Dashboard.pipelineDonut')
const size = 200
const r = 80
const ringWidth = 18
@ -96,7 +100,7 @@ function Donut({ data, currency }: { data: PipelineDonutData; currency: string }
return (
<div className="flex items-center justify-center">
<svg viewBox={`0 0 ${size} ${size}`} className="h-48 w-48" role="img" aria-label="Pipeline value by stage">
<svg viewBox={`0 0 ${size} ${size}`} className="h-48 w-48" role="img" aria-label={t('ariaLabel')}>
{/* background ring */}
<circle cx={cx} cy={cy} r={r} fill="none" stroke="var(--muted)" strokeWidth={ringWidth} />
{segments.map((seg) => (
@ -116,7 +120,7 @@ function Donut({ data, currency }: { data: PipelineDonutData; currency: string }
textAnchor="middle"
className="fill-muted-foreground text-[11px]"
>
Total
{t('total')}
</text>
<text
x={cx}

View File

@ -4,25 +4,29 @@ import Link from 'next/link'
import { UserPlus, Briefcase, Radio, Zap } from 'lucide-react'
import type { ComponentType } from 'react'
import { useTranslations } from 'next-intl'
// Quick-action shortcuts. Each navigates to the page that owns the
// relevant "create" flow. We deliberately don't try to auto-open any
// modal on the target page — that'd require touching those pages,
// which is out of scope here.
interface Action {
label: string
labelKey: string
href: string
icon: ComponentType<{ className?: string }>
tint: string
}
const ACTIONS: Action[] = [
{ label: 'New Contact', href: '/contacts', icon: UserPlus, tint: 'text-primary' },
{ label: 'New Deal', href: '/pipelines', icon: Briefcase, tint: 'text-blue-400' },
{ label: 'New Broadcast', href: '/broadcasts/new', icon: Radio, tint: 'text-amber-400' },
{ label: 'New Automation', href: '/automations/new', icon: Zap, tint: 'text-primary' },
{ labelKey: 'newContact', href: '/contacts', icon: UserPlus, tint: 'text-primary' },
{ labelKey: 'newDeal', href: '/pipelines', icon: Briefcase, tint: 'text-blue-400' },
{ labelKey: 'newBroadcast', href: '/broadcasts/new', icon: Radio, tint: 'text-amber-400' },
{ labelKey: 'newAutomation', href: '/automations/new', icon: Zap, tint: 'text-primary' },
]
export function QuickActions() {
const t = useTranslations('Dashboard.quickActions')
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{ACTIONS.map((a) => {
@ -36,7 +40,7 @@ export function QuickActions() {
<div className={`flex h-9 w-9 items-center justify-center rounded-lg bg-muted ${a.tint}`}>
<Icon className="h-4 w-4" />
</div>
<span className="text-sm font-medium text-foreground">{a.label}</span>
<span className="text-sm font-medium text-foreground">{t(a.labelKey as string)}</span>
</Link>
)
})}

View File

@ -19,6 +19,8 @@ interface ResponseTimeChartProps {
thresholdMinutes?: number
}
import { useTranslations } from 'next-intl'
// Single category, single colour — the data is "average minutes
// per weekday". Tremor expects categories as the second tuple in
// the row object, so we shape the buckets into
@ -30,6 +32,7 @@ export function ResponseTimeChart({
loading,
thresholdMinutes = 5,
}: ResponseTimeChartProps) {
const t = useTranslations('Dashboard.responseTimeChart')
const hasData = data?.buckets.some((b) => b.avgMinutes != null) ?? false
// Map buckets → Tremor rows. Null `avgMinutes` (no samples)
@ -48,29 +51,28 @@ export function ResponseTimeChart({
<header className="flex items-center justify-between gap-3 border-b border-border px-5 py-4">
<div>
<h2 className="text-sm font-semibold text-foreground">
Average First Response Time
{t('title')}
</h2>
<p className="mt-0.5 text-xs text-muted-foreground">
Minutes to reply to a customer&apos;s first unreplied message, by
weekday
{t('description')}
</p>
</div>
<div className="flex items-center gap-3 text-right text-xs">
{thresholdMinutes > 0 && (
<span className="rounded-full border border-rose-500/40 bg-rose-500/10 px-2 py-0.5 font-medium text-rose-300 tabular-nums">
target {thresholdMinutes}m
{t('target', { minutes: thresholdMinutes })}
</span>
)}
{data && (data.thisWeekAvg != null || data.lastWeekAvg != null) && (
<div>
<div className="text-muted-foreground">
This week:{' '}
{t('thisWeek')}{' '}
<span className="font-medium text-foreground tabular-nums">
{fmt(data.thisWeekAvg)}
</span>
</div>
<div className="text-muted-foreground">
Last week:{' '}
{t('lastWeek')}{' '}
<span className="tabular-nums">{fmt(data.lastWeekAvg)}</span>
</div>
</div>
@ -84,8 +86,8 @@ export function ResponseTimeChart({
) : !hasData ? (
<EmptyState
icon={Clock}
title="No replies recorded yet"
hint="This chart fills in as you reply to customer messages."
title={t('noReplies')}
hint={t('noRepliesHint')}
/>
) : (
<BarChart