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": { "ModeToggle": {
"switchMode": "Switch to {mode} mode" "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 { ResponseTimeChart } from '@/components/dashboard/response-time-chart'
import { ActivityFeed } from '@/components/dashboard/activity-feed' import { ActivityFeed } from '@/components/dashboard/activity-feed'
import { useTranslations } from 'next-intl'
type RangeDays = 7 | 30 | 90 type RangeDays = 7 | 30 | 90
export default function DashboardPage() { export default function DashboardPage() {
const t = useTranslations('Dashboard.page')
const { defaultCurrency } = useAuth() const { defaultCurrency } = useAuth()
const [metrics, setMetrics] = useState<MetricsBundle | null>(null) const [metrics, setMetrics] = useState<MetricsBundle | null>(null)
const [metricsLoading, setMetricsLoading] = useState(true) const [metricsLoading, setMetricsLoading] = useState(true)
@ -122,9 +125,9 @@ export default function DashboardPage() {
<div className="space-y-5"> <div className="space-y-5">
{/* Header */} {/* Header */}
<div> <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"> <p className="mt-1 text-sm text-muted-foreground">
Live analytics across conversations, contacts, deals, broadcasts, and automations. {t('description')}
</p> </p>
</div> </div>
@ -135,16 +138,20 @@ export default function DashboardPage() {
) : ( ) : (
<> <>
<MetricCard <MetricCard
title="Active Conversations" title={t('activeConversations')}
value={metrics.activeConversations.current.toLocaleString()} value={metrics.activeConversations.current.toLocaleString()}
icon={MessageSquare} icon={MessageSquare}
delta={{ delta={{
sign: metrics.activeConversations.previous, 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 <MetricCard
title="New Contacts Today" title={t('newContactsToday')}
value={metrics.newContactsToday.current.toLocaleString()} value={metrics.newContactsToday.current.toLocaleString()}
icon={UserPlus} icon={UserPlus}
delta={{ delta={{
@ -152,18 +159,19 @@ export default function DashboardPage() {
metrics.newContactsToday.current - metrics.newContactsToday.previous, metrics.newContactsToday.current - metrics.newContactsToday.previous,
label: deltaLabel( label: deltaLabel(
metrics.newContactsToday.current - metrics.newContactsToday.previous, metrics.newContactsToday.current - metrics.newContactsToday.previous,
'vs yesterday', t('vsYesterday'),
t('noChange', { suffix: t('vsYesterday') })
), ),
}} }}
/> />
<MetricCard <MetricCard
title="Open Deals Value" title={t('openDealsValue')}
value={formatCurrency(metrics.openDealsValue, defaultCurrency)} value={formatCurrency(metrics.openDealsValue, defaultCurrency)}
icon={DollarSign} icon={DollarSign}
subtitle={`${metrics.openDealsCount} open deal${metrics.openDealsCount === 1 ? '' : 's'}`} subtitle={t('openDeals', { count: metrics.openDealsCount })}
/> />
<MetricCard <MetricCard
title="Messages Sent Today" title={t('messagesSentToday')}
value={metrics.messagesSentToday.current.toLocaleString()} value={metrics.messagesSentToday.current.toLocaleString()}
icon={Send} icon={Send}
delta={{ delta={{
@ -171,7 +179,8 @@ export default function DashboardPage() {
metrics.messagesSentToday.current - metrics.messagesSentToday.previous, metrics.messagesSentToday.current - metrics.messagesSentToday.previous,
label: deltaLabel( label: deltaLabel(
metrics.messagesSentToday.current - metrics.messagesSentToday.previous, 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 { function deltaLabel(delta: number, suffix: string, noChangeLabel: string): string {
if (delta === 0) return `No change ${suffix}` if (delta === 0) return noChangeLabel
const sign = delta > 0 ? '+' : '' const sign = delta > 0 ? '+' : ''
return `${sign}${delta.toLocaleString()} ${suffix}` 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' }, automation: { icon: Zap, badge: 'bg-rose-500/10 text-rose-400' },
} }
import { useTranslations } from 'next-intl'
export function ActivityFeed({ items, loading }: ActivityFeedProps) { export function ActivityFeed({ items, loading }: ActivityFeedProps) {
const t = useTranslations('Dashboard.activityFeed')
// Start at 5 — a quick scan of the most recent events without // Start at 5 — a quick scan of the most recent events without
// dominating vertical real estate. User expands explicitly via the // dominating vertical real estate. User expands explicitly via the
// footer control when they want deeper history. // footer control when they want deeper history.
@ -56,12 +59,12 @@ export function ActivityFeed({ items, loading }: ActivityFeedProps) {
return ( return (
<section className="rounded-xl border border-border bg-card"> <section className="rounded-xl border border-border bg-card">
<header className="flex items-center justify-between border-b border-border px-5 py-4"> <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 <Link
href="/inbox" href="/inbox"
className="text-xs font-medium text-primary hover:text-primary/80" className="text-xs font-medium text-primary hover:text-primary/80"
> >
View all → {t('viewAll')}
</Link> </Link>
</header> </header>
@ -75,8 +78,8 @@ export function ActivityFeed({ items, loading }: ActivityFeedProps) {
<div className="p-5"> <div className="p-5">
<EmptyState <EmptyState
icon={Inbox} icon={Inbox}
title="No activity yet" title={t('noActivity')}
hint="Activity from messages, deals, broadcasts, and automations will appear here." hint={t('noActivityHint')}
/> />
</div> </div>
) : ( ) : (
@ -103,7 +106,7 @@ export function ActivityFeed({ items, loading }: ActivityFeedProps) {
{it.text} {it.text}
</span> </span>
<span className="flex-shrink-0 text-xs text-muted-foreground tabular-nums"> <span className="flex-shrink-0 text-xs text-muted-foreground tabular-nums">
{relativeTime(it.at)} {relativeTime(it.at, t)}
</span> </span>
</div> </div>
) )
@ -122,11 +125,10 @@ export function ActivityFeed({ items, loading }: ActivityFeedProps) {
</ul> </ul>
<footer className="flex items-center justify-between border-t border-border px-5 py-3 text-xs"> <footer className="flex items-center justify-between border-t border-border px-5 py-3 text-xs">
<span className="text-muted-foreground tabular-nums"> <span className="text-muted-foreground tabular-nums">
Showing {visible.length} of {totalLoaded} {t('showingOf', { visible: visible.length, totalLoaded, plus: totalLoaded === 50 ? '+' : '' })}
{totalLoaded === 50 ? '+' : ''}
</span> </span>
<div className="flex items-center gap-1"> <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) => { {PAGE_SIZES.map((size, i) => {
const disabled = !isSizeUseful(size, i) const disabled = !isSizeUseful(size, i)
return ( 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() const then = new Date(iso).getTime()
if (Number.isNaN(then)) return '' if (Number.isNaN(then)) return ''
const diffSec = Math.round((Date.now() - then) / 1000) const diffSec = Math.round((Date.now() - then) / 1000)
if (diffSec < 60) return `${Math.max(1, diffSec)}s ago` if (diffSec < 60) return t('timeS', { sec: Math.max(1, diffSec) })
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago` if (diffSec < 3600) return t('timeM', { min: Math.floor(diffSec / 60) })
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago` if (diffSec < 86400) return t('timeH', { hr: Math.floor(diffSec / 3600) })
if (diffSec < 2_592_000) return `${Math.floor(diffSec / 86400)}d ago` if (diffSec < 2_592_000) return t('timeD', { day: Math.floor(diffSec / 86400) })
return new Date(iso).toLocaleDateString() return new Date(iso).toLocaleDateString()
} }

View File

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

View File

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

View File

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

View File

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

View File

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