mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-10 00:04:27 +00:00
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.
This commit is contained in:
@@ -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 <StatsShareCard> 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<HTMLDivElement>(null);
|
||||
const { exporting, exportPng } = useExportCard();
|
||||
const { renderPng, downloadPng } = useExportCard();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [aspect, setAspect] = useState<ShareAspect>("1:1");
|
||||
const [url, setUrl] = useState<string | null>(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<Blob> 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 (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => exportPng(ref.current, filename)}
|
||||
disabled={exporting}
|
||||
className="h-7 px-2.5 rounded-md border border-slate-200 hover:border-slate-300 text-slate-700 hover:text-slate-900 bg-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors disabled:opacity-60"
|
||||
onClick={() => setOpen(true)}
|
||||
className="h-7 px-2.5 rounded-md border border-slate-200 hover:border-slate-300 text-slate-700 hover:text-slate-900 bg-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors"
|
||||
>
|
||||
{exporting ? (
|
||||
<Loader2Icon className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<DownloadIcon className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{exporting ? "Rendering…" : label}
|
||||
<ImageIcon className="w-3.5 h-3.5" />
|
||||
{label}
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[120] flex items-center justify-center bg-slate-900/30 backdrop-blur-[2px] px-4"
|
||||
onMouseDown={() => setOpen(false)}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.97, y: 8 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.97, y: 8 }}
|
||||
transition={{ duration: 0.18, ease: "easeOut" }}
|
||||
onMouseDown={(e) => 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"
|
||||
>
|
||||
<div className="h-12 px-4 border-b border-slate-200 flex items-center gap-2 shrink-0">
|
||||
<span className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">
|
||||
Share image
|
||||
</span>
|
||||
<div className="h-4 w-px bg-slate-200" />
|
||||
<span className="text-[12.5px] text-slate-600 truncate">
|
||||
Preview before download
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label="Close"
|
||||
className="ml-auto size-7 rounded-md text-slate-400 hover:text-slate-900 hover:bg-slate-100 inline-flex items-center justify-center transition-colors"
|
||||
>
|
||||
<XIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* aspect preset selector (house theme segmented control) */}
|
||||
<div className="px-4 pt-3 flex items-center gap-2">
|
||||
<span className="text-[11px] text-slate-500 font-medium">Aspect</span>
|
||||
<div className="inline-flex items-center gap-1 rounded-md bg-slate-100 p-0.5">
|
||||
{ASPECTS.map((a) => {
|
||||
const active = a.value === aspect;
|
||||
return (
|
||||
<button
|
||||
key={a.value}
|
||||
type="button"
|
||||
onClick={() => setAspect(a.value)}
|
||||
aria-pressed={active}
|
||||
className={`h-7 px-3 rounded text-[12px] font-medium transition-colors ${
|
||||
active
|
||||
? "bg-sky-600 text-white shadow-sm"
|
||||
: "text-slate-600 hover:text-slate-900"
|
||||
}`}
|
||||
>
|
||||
{a.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-4 bg-slate-50/40 flex justify-center">
|
||||
<div
|
||||
className="relative max-w-full rounded-md border border-slate-200 bg-white overflow-hidden"
|
||||
style={{ aspectRatio: current.ratio, height: "min(460px, 58vh)" }}
|
||||
>
|
||||
{url ? (
|
||||
<img
|
||||
src={url}
|
||||
alt="Analytics share preview"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 text-slate-400">
|
||||
<Loader2Icon className="w-5 h-5 animate-spin" />
|
||||
<span className="text-[11.5px]">Rendering…</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 border-t border-slate-200 flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
disabled={!url}
|
||||
className="h-8 px-3 rounded-md border border-slate-200 hover:border-slate-300 bg-white text-slate-700 hover:text-slate-900 text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{copied ? (
|
||||
<CheckIcon className="w-3.5 h-3.5 text-emerald-600" />
|
||||
) : (
|
||||
<CopyIcon className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
url && downloadPng(url, withPresetSuffix(filename, current.suffix))
|
||||
}
|
||||
disabled={!url}
|
||||
className="h-8 px-3 rounded-md bg-slate-900 hover:bg-slate-800 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<DownloadIcon className="w-3.5 h-3.5" />
|
||||
Download PNG
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Off-viewport but laid out, so html-to-image captures real pixels. */}
|
||||
<div aria-hidden className="fixed pointer-events-none -z-10" style={{ left: -99999, top: 0 }}>
|
||||
<StatsShareCard ref={ref} data={data} />
|
||||
<StatsShareCard ref={ref} data={data} aspect={aspect} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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 <linearGradient>.
|
||||
|
||||
export interface ShareMetric {
|
||||
label: string;
|
||||
@@ -20,63 +29,260 @@ export interface ShareCardData {
|
||||
daily: ChartPoint[]; // primary "sent" series
|
||||
}
|
||||
|
||||
const StatsShareCard = React.forwardRef<HTMLDivElement, { data: ShareCardData }>(
|
||||
function StatsShareCard({ data }, ref) {
|
||||
export type ShareAspect = "1:1" | "3:2" | "16:9";
|
||||
|
||||
const DIMENSIONS: Record<ShareAspect, { width: number; height: number }> = {
|
||||
"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 (
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
{/* additive light wash */}
|
||||
<div className="absolute inset-0" style={{ background: SKY_BREATHE, opacity: 0.6 }} />
|
||||
{/* lift the bottom band */}
|
||||
<div className="absolute inset-x-0 bottom-0" style={{ height: "55%", background: HAZE_BOTTOM }} />
|
||||
{CLOUDS.map((c, i) => (
|
||||
<img
|
||||
key={i}
|
||||
src={`/backdrops/cloud-${c.v}.webp`}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
style={{ position: "absolute", height: "auto", pointerEvents: "none", userSelect: "none", ...c.style }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ metric, valueSize }: { metric: ShareMetric; valueSize: number }) {
|
||||
return (
|
||||
<div className="px-7 first:pl-0 last:pr-0">
|
||||
<div className="text-[14px] font-medium uppercase tracking-[0.14em] text-slate-400">{metric.label}</div>
|
||||
<div className="mt-2.5 font-mono leading-none tabular-nums text-slate-900" style={{ fontSize: valueSize }}>
|
||||
{metric.value}
|
||||
</div>
|
||||
{metric.sub && <div className="mt-2 text-[14px] text-slate-400">{metric.sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<div className="relative flex-1 min-h-0">
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
preserveAspectRatio="none"
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={{ display: "block" }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="shareArea" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#0ea5e9" stopOpacity="0.28" />
|
||||
<stop offset="100%" stopColor="#0ea5e9" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{hasData && <path d={area} fill="url(#shareArea)" />}
|
||||
{hasData && (
|
||||
<path
|
||||
d={line}
|
||||
fill="none"
|
||||
stroke="#0284c7"
|
||||
strokeWidth={3}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
)}
|
||||
{/* baseline — always present */}
|
||||
<line
|
||||
x1={padX}
|
||||
y1={baseY}
|
||||
x2={W - padX}
|
||||
y2={baseY}
|
||||
stroke="#cbd5e1"
|
||||
strokeWidth={2}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</svg>
|
||||
{!hasData && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-[16px] text-slate-300">No sends in this window yet</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 flex justify-between font-mono text-[12px] tabular-nums text-slate-400">
|
||||
<span>{hasData ? shortDate(points[0].label) : ""}</span>
|
||||
<span>{hasData ? shortDate(points[points.length - 1].label) : ""}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const StatsShareCard = React.forwardRef<HTMLDivElement, { data: ShareCardData; aspect?: ShareAspect }>(
|
||||
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 (
|
||||
<div
|
||||
ref={ref}
|
||||
style={{ width: 1080, height: 1080 }}
|
||||
className="flex flex-col bg-white text-slate-900 p-[72px]"
|
||||
style={{ width, height, background: SKY_BASE, position: "relative", overflow: "hidden", borderRadius: 24 }}
|
||||
>
|
||||
{/* header */}
|
||||
<div className="flex items-center">
|
||||
<Logo className="w-12 h-12 text-sky-600" />
|
||||
<span className="ml-4 text-[34px] font-semibold tracking-tight">Warmbly</span>
|
||||
{data.subtitle && (
|
||||
<span className="ml-auto text-[20px] font-medium uppercase tracking-[0.18em] text-slate-400">
|
||||
{data.subtitle}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<SkyBackdrop />
|
||||
|
||||
<h1 className="mt-14 text-[52px] font-semibold leading-tight tracking-tight">
|
||||
{data.title}
|
||||
</h1>
|
||||
|
||||
{/* metric grid */}
|
||||
<div className="mt-12 grid grid-cols-2 gap-x-12 gap-y-10">
|
||||
{metrics.map((m) => (
|
||||
<div key={m.label} className="border-l-2 border-sky-500 pl-6">
|
||||
<div className="text-[18px] font-medium uppercase tracking-[0.14em] text-slate-400">
|
||||
{m.label}
|
||||
</div>
|
||||
<div className="mt-3 font-mono text-[64px] leading-none tabular-nums text-slate-900">
|
||||
{m.value}
|
||||
</div>
|
||||
{m.sub && <div className="mt-3 text-[18px] text-slate-400">{m.sub}</div>}
|
||||
<div className="relative z-10 flex flex-col h-full" style={{ padding: pad }}>
|
||||
{/* logo on the sky */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="inline-flex items-center gap-3.5" style={LOGO_LIFT}>
|
||||
<Logo className={`${logoClass} text-white`} />
|
||||
<span
|
||||
className="text-white font-extrabold tracking-tight"
|
||||
style={{ fontFamily: "var(--font-display)", fontSize: wordmarkSize }}
|
||||
>
|
||||
Warmbly
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* chart snapshot */}
|
||||
<div className="mt-auto">
|
||||
<div className="text-[18px] font-medium uppercase tracking-[0.14em] text-slate-400 mb-4">
|
||||
Sends over time
|
||||
<span className="font-mono text-[15px] tabular-nums text-white/85" style={onSky}>
|
||||
{todayLabel()}
|
||||
</span>
|
||||
</div>
|
||||
<DailyBars points={data.daily} height={220} emptyLabel="No sends in this window" />
|
||||
</div>
|
||||
|
||||
{/* footer */}
|
||||
<div className="mt-12 flex items-center justify-between text-[18px] text-slate-400">
|
||||
<span className="font-medium text-slate-500">warmbly.com</span>
|
||||
<span className="font-mono tabular-nums">{todayLabel()}</span>
|
||||
{/* single white panel */}
|
||||
<div style={PANEL} className="mt-6 flex-1 flex flex-col min-h-0" >
|
||||
<div className="flex flex-col h-full" style={{ padding: landscape ? 44 : 40 }}>
|
||||
{/* title */}
|
||||
{data.subtitle && (
|
||||
<div className="text-[14px] font-semibold uppercase tracking-[0.16em] text-sky-600">
|
||||
{data.subtitle}
|
||||
</div>
|
||||
)}
|
||||
<h1
|
||||
className="mt-2 font-semibold leading-tight tracking-tight text-slate-900 line-clamp-2 break-words"
|
||||
style={{ fontSize: titleSize }}
|
||||
>
|
||||
{data.title}
|
||||
</h1>
|
||||
|
||||
{/* divided metric row */}
|
||||
<div
|
||||
className="mt-8 grid divide-x divide-slate-200"
|
||||
style={{ gridTemplateColumns: `repeat(${metrics.length || 1}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{metrics.map((m) => (
|
||||
<Stat key={m.label} metric={m} valueSize={valueSize} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 h-px bg-slate-200/70" />
|
||||
|
||||
{/* long area chart */}
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<span className="text-[14px] font-medium uppercase tracking-[0.14em] text-slate-400">
|
||||
Sends over time
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 flex-1 min-h-0 flex flex-col">
|
||||
<ShareAreaChart points={data.daily} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* footer on the sky */}
|
||||
<div className="mt-5 flex items-center justify-between text-[16px]">
|
||||
<span className="text-white font-semibold" style={onSky}>
|
||||
warmbly.com
|
||||
</span>
|
||||
<span className="text-white/80" style={onSky}>
|
||||
Cold email, warmed up.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
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" });
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ export function DailyBars({
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="relative flex-1 flex items-end h-full"
|
||||
className="relative flex-1 flex items-end justify-center h-full"
|
||||
onMouseEnter={() => 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 && (
|
||||
<div className="absolute -top-7 left-1/2 -translate-x-1/2 z-10 whitespace-nowrap rounded bg-slate-900 px-1.5 py-0.5 text-[10px] font-medium text-white shadow-sm">
|
||||
|
||||
@@ -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<string | null> => {
|
||||
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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user