wacrm/src/components/dashboard/conversations-chart.tsx
Arnas Donauskas 77dfa4f165
chore: switch accent palette to Hostinger violet, drop emerald green
The legal brief on third-party trademark use says: avoid the WhatsApp
teal/green palette and any visual treatment that could read as a
WhatsApp/Meta product. Emerald-500 was used as the primary brand colour
across hero, CTAs, sidebar, charts, badges, and the favicon — close
enough in hue to WhatsApp's signature green to muddy the line.

Swapping to Tailwind violet (which matches the Hostinger brand purple
#7c3aed) makes the visual identity unambiguously ours.

Scope:
- Mechanical Tailwind class swap: emerald-* → violet-* across src/.
- src/app/globals.css: --primary, --ring, --sidebar-primary, --sidebar-ring,
  --chart-1 oklch tokens swapped from emerald (oklch 0.696 0.17 162) to
  Hostinger-aligned violet (oklch 0.526 0.247 293).
- src/app/icon.tsx: favicon background #10b981 → #7c3aed.
- src/app/opengraph-image.tsx: badge bg, accent text, gradient.
- src/components/landing/hero.tsx + cta-banner.tsx: radial-gradient hex.
- src/components/landing/mock/analytics-mock.tsx: marketing chart line.
- src/components/dashboard/conversations-chart.tsx + response-time-chart.tsx:
  "Outgoing" series colour. Distinguishable from "Incoming" blue at a
  glance; we can revisit the chart pair later for stronger colourblind
  contrast (separate concern).
- README.md: license badge colour parameter.

Out of scope:
- src/components/settings/tag-manager.tsx still offers "Emerald" as one
  of the user-pickable contact-tag colours alongside Blue / Yellow / Red
  / etc. — that's a user data choice, not a brand mark.

Contrast check: white text on #7c3aed measures ~4.78:1 (WCAG AA passing
for normal body text). Violet-400 (#a78bfa) on slate-950 measures ~10:1
for accent text, well clear of AA.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 11:57:12 +03:00

342 lines
12 KiB
TypeScript

"use client"
import { useEffect, useMemo, useRef, useState } from 'react'
import { MessageSquare } from 'lucide-react'
import type { ConversationsSeriesPoint } from '@/lib/dashboard/types'
import { EmptyState } from './empty-state'
import { Skeleton } from './skeleton'
import { cn } from '@/lib/utils'
type RangeDays = 7 | 30 | 90
interface ConversationsChartProps {
/** Per-range data, so switching tabs never re-fetches. */
series: Record<RangeDays, ConversationsSeriesPoint[] | null>
loading: boolean
range: RangeDays
onRangeChange: (r: RangeDays) => void
}
// ------------------------------------------------------------
// Layout constants. The SVG renders into a fixed viewBox and scales
// via CSS (preserveAspectRatio default). Everything inside uses
// viewBox coordinates so the drawing math stays simple even as the
// container resizes.
// ------------------------------------------------------------
const VB_W = 760
const VB_H = 240
const PADDING = { top: 16, right: 16, bottom: 28, left: 40 }
export function ConversationsChart({ series, loading, range, onRangeChange }: ConversationsChartProps) {
const data = series[range]
// Memoise the max so per-day hover math doesn't recompute it.
const { maxY, niceTicks } = useMemo(() => {
const arr = data ?? []
const max = arr.reduce(
(m, p) => Math.max(m, p.incoming, p.outgoing),
0,
)
const ceil = niceCeil(max)
const ticks = [0, ceil / 4, ceil / 2, (3 * ceil) / 4, ceil].map((v) =>
Math.round(v),
)
// De-dupe when the series is flat 0.
return { maxY: ceil, niceTicks: Array.from(new Set(ticks)) }
}, [data])
return (
<section className="flex h-full flex-col rounded-xl border border-slate-800 bg-slate-900">
<header className="flex items-center justify-between border-b border-slate-800 px-5 py-4">
<div>
<h2 className="text-sm font-semibold text-white">Conversations Over Time</h2>
<p className="mt-0.5 text-xs text-slate-500">Daily message volume by direction</p>
</div>
<div className="flex items-center gap-1 rounded-lg bg-slate-800/60 p-1">
{[7, 30, 90].map((r) => (
<button
key={r}
type="button"
onClick={() => onRangeChange(r as RangeDays)}
className={cn(
'rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
range === r
? 'bg-slate-700 text-white'
: 'text-slate-400 hover:text-white',
)}
>
{r} days
</button>
))}
</div>
</header>
<div className="p-5">
{loading || !data ? (
<Skeleton className="h-[240px] w-full" />
) : 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."
/>
) : (
<LineSvg data={data} maxY={maxY} ticks={niceTicks} />
)}
</div>
<footer className="flex items-center gap-4 border-t border-slate-800 px-5 py-3 text-xs text-slate-400">
<LegendDot color="#3b82f6" label="Incoming" />
<LegendDot color="#7c3aed" label="Outgoing" />
</footer>
</section>
)
}
// ------------------------------------------------------------
// The actual SVG. Two polylines + per-day hit targets for hover.
// ------------------------------------------------------------
function LineSvg({
data,
maxY,
ticks,
}: {
data: ConversationsSeriesPoint[]
maxY: number
ticks: number[]
}) {
// Hover state: both the snapped index AND the tooltip's pixel
// offset inside the wrapper div. They're stored together so the
// tooltip positions against the chart's actual rendered pixels,
// not against a raw viewBox percentage. See the precision note on
// the onMove handler below.
const [hover, setHover] = useState<{ idx: number; tooltipLeftPx: number } | null>(null)
const svgRef = useRef<SVGSVGElement>(null)
const wrapRef = useRef<HTMLDivElement>(null)
const chartW = VB_W - PADDING.left - PADDING.right
const chartH = VB_H - PADDING.top - PADDING.bottom
// x step can be fractional for 90-day views; points are positioned
// at the center of each "slot" so the first and last points don't
// sit right on the axis.
const stepX = data.length > 1 ? chartW / (data.length - 1) : 0
const yFor = (v: number) =>
maxY === 0 ? PADDING.top + chartH : PADDING.top + chartH - (v / maxY) * chartH
const xFor = (i: number) => PADDING.left + i * stepX
const incomingPath = data.map((p, i) => `${i === 0 ? 'M' : 'L'}${xFor(i)},${yFor(p.incoming)}`).join(' ')
const outgoingPath = data.map((p, i) => `${i === 0 ? 'M' : 'L'}${xFor(i)},${yFor(p.outgoing)}`).join(' ')
// Mouse-move: use the SVG's current screen-CTM to map clientX
// back to viewBox coordinates. The previous rect-based math
// assumed the viewBox filled the SVG DOM box linearly, but
// `preserveAspectRatio="xMidYMid meet"` (the SVG default)
// letterboxes the content horizontally when the container is
// wider than the viewBox aspect — so hover snapped hundreds of
// pixels off on wide layouts. CTM-inverse correctly accounts for
// letterboxing, scaling, and any future transform changes.
useEffect(() => {
const svg = svgRef.current
const wrap = wrapRef.current
if (!svg || !wrap) return
const onMove = (e: MouseEvent) => {
const ctm = svg.getScreenCTM()
if (!ctm) return
const pt = svg.createSVGPoint()
pt.x = e.clientX
pt.y = e.clientY
const local = pt.matrixTransform(ctm.inverse())
const xVb = local.x
if (xVb < PADDING.left - 8 || xVb > VB_W - PADDING.right + 8) {
setHover(null)
return
}
const relative = xVb - PADDING.left
const idx = Math.max(
0,
Math.min(data.length - 1, Math.round(stepX === 0 ? 0 : relative / stepX)),
)
// Map the snapped data-point's viewBox x back to screen, then
// subtract the wrapper's left edge — that pixel offset is what
// the absolutely-positioned tooltip div consumes. `xFor` is
// inlined here so the effect deps stay stable (it's a closure
// that'd otherwise be a new reference every render).
const dataPointVbX = PADDING.left + idx * stepX
const dataPointPt = svg.createSVGPoint()
dataPointPt.x = dataPointVbX
dataPointPt.y = 0
const screen = dataPointPt.matrixTransform(ctm)
const wrapRect = wrap.getBoundingClientRect()
setHover({ idx, tooltipLeftPx: screen.x - wrapRect.left })
}
const onLeave = () => setHover(null)
svg.addEventListener('mousemove', onMove)
svg.addEventListener('mouseleave', onLeave)
return () => {
svg.removeEventListener('mousemove', onMove)
svg.removeEventListener('mouseleave', onLeave)
}
// xFor + yFor close over stepX, so stepX covers them.
}, [data, stepX])
const hovered = hover !== null ? data[hover.idx] : null
const hoverX = hover !== null ? xFor(hover.idx) : 0
// X-axis label strategy: show ~6 evenly-spaced labels regardless
// of range so the axis never looks crowded.
const labelStride = Math.max(1, Math.ceil(data.length / 6))
return (
<div ref={wrapRef} className="relative w-full">
<svg
ref={svgRef}
viewBox={`0 0 ${VB_W} ${VB_H}`}
className="h-[240px] w-full"
role="img"
aria-label="Conversations per day"
>
{/* Y-axis gridlines + labels */}
{ticks.map((t) => {
const y = yFor(t)
return (
<g key={t}>
<line
x1={PADDING.left}
x2={VB_W - PADDING.right}
y1={y}
y2={y}
stroke="rgb(30 41 59)"
strokeDasharray="3 3"
/>
<text
x={PADDING.left - 8}
y={y}
textAnchor="end"
dominantBaseline="middle"
className="fill-slate-500 text-[10px]"
>
{t}
</text>
</g>
)
})}
{/* X-axis labels */}
{data.map((p, i) =>
i % labelStride === 0 ? (
<text
key={p.day}
x={xFor(i)}
y={VB_H - 8}
textAnchor="middle"
className="fill-slate-500 text-[10px]"
>
{shortDayLabel(p.day)}
</text>
) : null,
)}
{/* Outgoing polyline (violet) */}
<path
d={outgoingPath}
fill="none"
stroke="#7c3aed"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Incoming polyline (blue) */}
<path
d={incomingPath}
fill="none"
stroke="#3b82f6"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Hover crosshair */}
{hover !== null && (
<g pointerEvents="none">
<line
x1={hoverX}
x2={hoverX}
y1={PADDING.top}
y2={PADDING.top + chartH}
stroke="rgb(71 85 105)"
strokeDasharray="3 3"
/>
<circle cx={hoverX} cy={yFor(data[hover.idx].incoming)} r={3.5} fill="#3b82f6" />
<circle cx={hoverX} cy={yFor(data[hover.idx].outgoing)} r={3.5} fill="#7c3aed" />
</g>
)}
</svg>
{/* Tooltip — absolute-positioned div so we get crisp text, not
SVG-rendered text. The left offset comes from the CTM-based
mapping so it lines up with the actual crosshair pixel, not a
letterboxed viewBox percentage. */}
{hovered && hover !== null && (
<div
className="pointer-events-none absolute top-0 z-10 -translate-x-1/2 rounded-md border border-slate-700 bg-slate-950 px-2.5 py-1.5 text-[11px] shadow-lg"
style={{ left: `${hover.tooltipLeftPx}px` }}
>
<div className="font-medium text-white">{longDayLabel(hovered.day)}</div>
<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
</span>
<span className="flex items-center gap-1.5 text-violet-300">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-violet-500" />
{hovered.outgoing} outgoing
</span>
</div>
</div>
)}
</div>
)
}
function LegendDot({ color, label }: { color: string; label: string }) {
return (
<span className="flex items-center gap-1.5">
<span className="inline-block h-1.5 w-1.5 rounded-full" style={{ background: color }} />
{label}
</span>
)
}
function shortDayLabel(key: string): string {
// key is YYYY-MM-DD; return "Apr 17"-style. Using Date with an
// appended time avoids timezone-shift surprises across midnight.
const [y, m, d] = key.split('-').map(Number)
const date = new Date(y, m - 1, d)
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
}
function longDayLabel(key: string): string {
const [y, m, d] = key.split('-').map(Number)
const date = new Date(y, m - 1, d)
return date.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })
}
/**
* Round `max` up to a "nice" number so Y-axis ticks feel natural
* (1, 2, 5, 10, 20, 50, …). Keeps the chart readable even when the
* series is small (max=3 becomes ceil=4, not 3).
*/
function niceCeil(max: number): number {
if (max <= 0) return 4
const pow = Math.pow(10, Math.floor(Math.log10(max)))
const normalised = max / pow
let nice: number
if (normalised <= 1) nice = 1
else if (normalised <= 2) nice = 2
else if (normalised <= 5) nice = 5
else nice = 10
return nice * pow
}