From cdb2eaa229d14bfa7cb051b40a1bced80d0b7918 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 4 Jun 2026 18:57:08 +0200 Subject: [PATCH] feat: add analytics share preview Add preview sizing, copy-to-clipboard, and improved PNG rendering for analytics share cards. Refresh the exported share card visuals and keep chart bars constrained for compact layouts. --- .../app/analytics/AnalyticsShareButton.tsx | 218 ++++++++++++- .../app/analytics/StatsShareCard.tsx | 300 +++++++++++++++--- web/src/components/ui/charts.tsx | 7 +- web/src/hooks/useExportCard.ts | 47 ++- 4 files changed, 499 insertions(+), 73 deletions(-) diff --git a/web/src/components/app/analytics/AnalyticsShareButton.tsx b/web/src/components/app/analytics/AnalyticsShareButton.tsx index eab5299e..e7d2402c 100644 --- a/web/src/components/app/analytics/AnalyticsShareButton.tsx +++ b/web/src/components/app/analytics/AnalyticsShareButton.tsx @@ -1,11 +1,30 @@ -import { useRef } from "react"; -import { DownloadIcon, Loader2Icon } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { CheckIcon, CopyIcon, DownloadIcon, ImageIcon, Loader2Icon, XIcon } from "lucide-react"; +import toast from "react-hot-toast"; import useExportCard from "@/hooks/useExportCard"; -import StatsShareCard, { type ShareCardData } from "./StatsShareCard"; +import StatsShareCard, { type ShareAspect, type ShareCardData } from "./StatsShareCard"; // Drops a "Share image" button next to any analytics surface. It keeps a // branded mounted off-viewport (real layout, so the capture -// isn't blank) and rasterizes it to a downloadable PNG on click. +// isn't blank) and, on click, opens a preview modal that rasterizes the card to +// a PNG. The user picks an aspect preset (1:1 / 3:2 / 16:9) and previews it +// before choosing to download or copy. + +const ASPECTS: { value: ShareAspect; label: string; ratio: string; suffix: string }[] = [ + { value: "1:1", label: "1:1", ratio: "1 / 1", suffix: "1x1" }, + { value: "3:2", label: "3:2", ratio: "3 / 2", suffix: "3x2" }, + { value: "16:9", label: "16:9", ratio: "16 / 9", suffix: "16x9" }, +]; + +// Apply a preset suffix before the ".png" extension (e.g. "warmbly-x.png" -> +// "warmbly-x-16x9.png"). Falls back to appending if there's no .png. +function withPresetSuffix(filename: string, suffix: string): string { + const i = filename.toLowerCase().lastIndexOf(".png"); + if (i === -1) return `${filename}-${suffix}.png`; + return `${filename.slice(0, i)}-${suffix}${filename.slice(i)}`; +} + export default function AnalyticsShareButton({ data, filename, @@ -16,27 +35,196 @@ export default function AnalyticsShareButton({ label?: string; }) { const ref = useRef(null); - const { exporting, exportPng } = useExportCard(); + const { renderPng, downloadPng } = useExportCard(); + const [open, setOpen] = useState(false); + const [aspect, setAspect] = useState("1:1"); + const [url, setUrl] = useState(null); + const [copied, setCopied] = useState(false); + + // Render the PNG when the preview opens AND whenever the aspect changes; + // reset when it closes. The capture is heavy, so defer a frame to let the + // off-viewport card relayout for the new aspect first. Square stays crisp at + // pixelRatio 3; wider presets drop to 2 so the (much larger) capture is + // snappy. + useEffect(() => { + if (!open) { + setUrl(null); + setCopied(false); + return; + } + let alive = true; + setUrl(null); // show the spinner while re-capturing for the new aspect + // Double rAF: the off-viewport card re-lays-out for the new aspect on + // the next frame; capturing in a second frame guarantees we rasterize + // the committed-and-painted new size, not the previous one. + let raf2 = 0; + const raf1 = requestAnimationFrame(() => { + raf2 = requestAnimationFrame(async () => { + const out = await renderPng(ref.current, { pixelRatio: aspect === "1:1" ? 3 : 2 }); + if (alive) setUrl(out); + }); + }); + return () => { + alive = false; + cancelAnimationFrame(raf1); + cancelAnimationFrame(raf2); + }; + }, [open, aspect, renderPng]); + + // Esc closes the preview. + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpen(false); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open]); + + async function copy() { + if (!url) return; + try { + // Pass a Promise to ClipboardItem rather than awaiting the blob + // first: browsers (especially Safari) require clipboard.write() to run + // within the click's user-gesture, and an `await fetch(...)` beforehand + // forfeits it → NotAllowedError. The Promise form keeps the gesture. + const item = new ClipboardItem({ "image/png": fetch(url).then((r) => r.blob()) }); + await navigator.clipboard.write([item]); + setCopied(true); + toast.success("Image copied to clipboard"); + setTimeout(() => setCopied(false), 1500); + } catch { + toast.error("Couldn't copy — download instead"); + } + } + + const current = ASPECTS.find((a) => a.value === aspect) ?? ASPECTS[0]; return ( <> + + {open && ( + setOpen(false)} + > + e.stopPropagation()} + className="w-full max-w-[min(94vw,860px)] rounded-lg bg-white border border-slate-200 shadow-[0_24px_48px_-12px_rgba(15,23,42,0.18),0_8px_16px_-8px_rgba(15,23,42,0.1)] overflow-hidden" + > +
+ + Share image + +
+ + Preview before download + + +
+ + {/* aspect preset selector (house theme segmented control) */} +
+ Aspect +
+ {ASPECTS.map((a) => { + const active = a.value === aspect; + return ( + + ); + })} +
+
+ +
+
+ {url ? ( + Analytics share preview + ) : ( +
+ + Rendering… +
+ )} +
+
+ +
+ + +
+ + + )} + + {/* Off-viewport but laid out, so html-to-image captures real pixels. */}
- +
); diff --git a/web/src/components/app/analytics/StatsShareCard.tsx b/web/src/components/app/analytics/StatsShareCard.tsx index 60037bed..1c9957c0 100644 --- a/web/src/components/app/analytics/StatsShareCard.tsx +++ b/web/src/components/app/analytics/StatsShareCard.tsx @@ -1,11 +1,20 @@ import React from "react"; import { Logo } from "@/components/svg"; -import { DailyBars, type ChartPoint } from "@/components/ui/charts"; +import { type ChartPoint } from "@/components/ui/charts"; -// A fixed-size, branded analytics card meant to be rasterized to a shareable -// PNG (see useExportCard). It mirrors the live dashboard's slate/sky language -// so the export looks like Warmbly, not a generic screenshot. Rendered -// off-viewport by AnalyticsShareButton. +// A branded analytics card rasterized to a shareable PNG (see useExportCard). +// Design: the marketing-site hero sky (deep-blue .sky-base + the real cloud +// WebP images), the Warmbly logo white on the sky, and ONE clean white panel +// floating on it holding the title, a divided metric row, and a long +// Instantly-style area chart. One soft shadow — no scattered heavy-shadow +// boxes. +// +// Capture-safe for html-to-image (SVG foreignObject): +// - sky / glow / haze are pure CSS gradients set inline. +// - clouds are SAME-ORIGIN WebP images (web/public/backdrops, from the site) +// so html-to-image inlines them without tainting the canvas. +// - NO CSS filter: blur() (unreliable in capture) and NO mix-blend-mode. +// - the chart is an inline SVG with an internal . export interface ShareMetric { label: string; @@ -20,63 +29,260 @@ export interface ShareCardData { daily: ChartPoint[]; // primary "sent" series } -const StatsShareCard = React.forwardRef( - function StatsShareCard({ data }, ref) { +export type ShareAspect = "1:1" | "3:2" | "16:9"; + +const DIMENSIONS: Record = { + "1:1": { width: 1080, height: 1080 }, + "3:2": { width: 1620, height: 1080 }, + "16:9": { width: 1920, height: 1080 }, +}; + +// Marketing-site hero sky palette, but SYMMETRIC for a standalone card: a soft +// top-center radial in the bright sky-300 → sky-400 band (never the hero's +// sky-800 navy). Centered at 50%, so neither side is darker than the other — +// no "dark on the right". Bright at top, gently deeper toward the bottom. +const SKY_BASE = + "radial-gradient(ellipse 125% 105% at 50% -12%," + + " #9fe0fb 0%, #74cef7 30%, #4ec1f6 58%, #2fb6f2 82%, #18abed 100%)"; + +// Additive light wash (the .sky-breathe layer, baked static) — centered to match. +const SKY_BREATHE = + "radial-gradient(ellipse 125% 105% at 50% -12%," + + " rgba(224,242,254,0.85) 0%, rgba(125,211,252,0.38) 24%, rgba(56,189,248,0.14) 46%, transparent 64%)"; + +// Lifts the exposed bottom band toward light sky so it stays airy. +const HAZE_BOTTOM = + "linear-gradient(to top, rgba(186,230,253,0.40) 0%, rgba(125,211,252,0.16) 42%, transparent 100%)"; + +// NO dark shadow — any dark-toned shadow on the blue sky pools into a visible +// "shadow band" along the card edge. Instead the panel lifts with a faint WHITE +// halo (lighter than the sky), so it separates without darkening anything. +const PANEL: React.CSSProperties = { + background: "#ffffff", + borderRadius: 26, + border: "1px solid rgba(255,255,255,0.85)", + boxShadow: "0 16px 50px -18px rgba(255,255,255,0.55), 0 2px 12px -6px rgba(255,255,255,0.45)", +}; + +const LOGO_LIFT: React.CSSProperties = { + filter: "drop-shadow(0 3px 10px rgba(8,47,73,0.40)) drop-shadow(0 1px 2px rgba(8,47,73,0.30))", +}; +const onSky: React.CSSProperties = { textShadow: "0 1px 4px rgba(8,47,73,0.45)" }; + +// Real WebP clouds in the hero's arrangement (big top-left + top-right framing +// the logo, softer puffs drifting through the lower sky band). Same-origin so +// html-to-image inlines them without tainting the canvas. +const CLOUDS: { v: number; style: React.CSSProperties }[] = [ + { v: 5, style: { top: "-5%", left: "-5%", width: "44%", opacity: 0.95 } }, + { v: 2, style: { top: "2%", right: "-4%", width: "40%", opacity: 0.9 } }, + { v: 3, style: { bottom: "-3%", left: "12%", width: "28%", opacity: 0.62 } }, + { v: 4, style: { bottom: "3%", right: "8%", width: "26%", opacity: 0.58 } }, +]; + +function SkyBackdrop() { + return ( +
+ {/* additive light wash */} +
+ {/* lift the bottom band */} +
+ {CLOUDS.map((c, i) => ( + + ))} +
+ ); +} + +function Stat({ metric, valueSize }: { metric: ShareMetric; valueSize: number }) { + return ( +
+
{metric.label}
+
+ {metric.value} +
+ {metric.sub &&
{metric.sub}
} +
+ ); +} + +// Long area chart — smooth sky line + gradient fill, ALWAYS with a bottom +// baseline (so it reads as a chart even with no data). Fills its flex parent. +function ShareAreaChart({ points }: { points: ChartPoint[] }) { + const vals = points.map((p) => p.value || 0); + const hasData = points.length > 0 && vals.reduce((a, b) => a + b, 0) > 0; + + const W = 1000; + const H = 320; + const padX = 4; + const padTop = 14; + const baseY = H - 6; + const max = Math.max(1, ...vals); + const w = W - padX * 2; + const h = baseY - padTop; + const step = vals.length > 1 ? w / (vals.length - 1) : 0; + const pts = vals.map((v, i) => [padX + i * step, baseY - (v / max) * h] as const); + const line = pts.map(([x, y], i) => `${i === 0 ? "M" : "L"} ${x.toFixed(1)} ${y.toFixed(1)}`).join(" "); + const lastX = padX + Math.max(0, vals.length - 1) * step; + const area = `${line} L ${lastX.toFixed(1)} ${baseY} L ${padX} ${baseY} Z`; + + return ( +
+
+ + + + + + + + {hasData && } + {hasData && ( + + )} + {/* baseline — always present */} + + + {!hasData && ( +
+ No sends in this window yet +
+ )} +
+
+ {hasData ? shortDate(points[0].label) : ""} + {hasData ? shortDate(points[points.length - 1].label) : ""} +
+
+ ); +} + +const StatsShareCard = React.forwardRef( + function StatsShareCard({ data, aspect = "1:1" }, ref) { + const { width, height } = DIMENSIONS[aspect]; const metrics = data.metrics.slice(0, 4); + const landscape = aspect !== "1:1"; + + const pad = aspect === "16:9" ? 64 : aspect === "3:2" ? 56 : 48; + const logoClass = landscape ? "w-[60px] h-[60px]" : "w-[54px] h-[54px]"; + const wordmarkSize = landscape ? 40 : 36; + const titleSize = aspect === "16:9" ? 50 : aspect === "3:2" ? 46 : 40; + const valueSize = landscape ? 50 : 46; + return (
- {/* header */} -
- - Warmbly - {data.subtitle && ( - - {data.subtitle} - - )} -
+ -

- {data.title} -

- - {/* metric grid */} -
- {metrics.map((m) => ( -
-
- {m.label} -
-
- {m.value} -
- {m.sub &&
{m.sub}
} +
+ {/* logo on the sky */} +
+
+ + + Warmbly +
- ))} -
- - {/* chart snapshot */} -
-
- Sends over time + + {todayLabel()} +
- -
- {/* footer */} -
- warmbly.com - {todayLabel()} + {/* single white panel */} +
+
+ {/* title */} + {data.subtitle && ( +
+ {data.subtitle} +
+ )} +

+ {data.title} +

+ + {/* divided metric row */} +
+ {metrics.map((m) => ( + + ))} +
+ +
+ + {/* long area chart */} +
+ + Sends over time + +
+
+ +
+
+
+ + {/* footer on the sky */} +
+ + warmbly.com + + + Cold email, warmed up. + +
); }, ); +function shortDate(iso: string): string { + try { + return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric" }); + } catch { + return iso; + } +} + function todayLabel(): string { return new Date().toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }); } diff --git a/web/src/components/ui/charts.tsx b/web/src/components/ui/charts.tsx index e695c8c3..c19ec2e2 100644 --- a/web/src/components/ui/charts.tsx +++ b/web/src/components/ui/charts.tsx @@ -93,7 +93,7 @@ export function DailyBars({ return (
setHover(i)} onMouseLeave={() => setHover(null)} > @@ -103,7 +103,10 @@ export function DailyBars({ barClass, hover === null || hover === i ? "opacity-100" : "opacity-50", )} - style={{ height: h }} + // Cap + center each bar so a sparse series (1–2 points) renders + // as a normal bar, not a full-width block. Dense series have + // cells narrower than the cap, so this is a no-op for them. + style={{ height: h, maxWidth: 64 }} /> {hover === i && (
diff --git a/web/src/hooks/useExportCard.ts b/web/src/hooks/useExportCard.ts index 62ce7759..d4d13c29 100644 --- a/web/src/hooks/useExportCard.ts +++ b/web/src/hooks/useExportCard.ts @@ -15,9 +15,19 @@ import { downloadBlob } from "@/lib/api/client/app/contacts/exportContacts"; export default function useExportCard() { const [exporting, setExporting] = useState(false); - const exportPng = useCallback( - async (node: HTMLElement | null, filename: string) => { - if (!node || exporting) return; + // Rasterize an offscreen node to a PNG *data URL* (two-pass: prime the + // font/embed cache, then capture). Returns null on failure. Does NOT + // download — callers can preview the result first, then downloadPng it. + // + // pixelRatio defaults to 3 (crisp for the square card). Wide presets (16:9 + // at 1920px) get huge at 3x, so callers can pass a lower ratio (e.g. 2) to + // keep the capture snappy. + const renderPng = useCallback( + async ( + node: HTMLElement | null, + options?: { pixelRatio?: number }, + ): Promise => { + if (!node) return null; setExporting(true); try { try { @@ -25,17 +35,36 @@ export default function useExportCard() { } catch { /* fonts.ready is best-effort */ } - const opts = { pixelRatio: 3, backgroundColor: "#ffffff", cacheBust: true }; + const opts = { + pixelRatio: options?.pixelRatio ?? 3, + backgroundColor: "#ffffff", + cacheBust: true, + }; await toPng(node, opts); // prime font/embed cache - const dataUrl = await toPng(node, opts); // real capture - const blob = await (await fetch(dataUrl)).blob(); - downloadBlob(blob, filename); + return await toPng(node, opts); // real capture + } catch { + return null; } finally { setExporting(false); } }, - [exporting], + [], ); - return { exporting, exportPng }; + // Trigger a browser download of a PNG data URL (produced by renderPng). + const downloadPng = useCallback(async (dataUrl: string, filename: string) => { + const blob = await (await fetch(dataUrl)).blob(); + downloadBlob(blob, filename); + }, []); + + // Convenience: render + download in one shot (no preview step). + const exportPng = useCallback( + async (node: HTMLElement | null, filename: string, options?: { pixelRatio?: number }) => { + const url = await renderPng(node, options); + if (url) await downloadPng(url, filename); + }, + [renderPng, downloadPng], + ); + + return { exporting, renderPng, downloadPng, exportPng }; }