From 0f0e289e519660228577fe1a19286152c20381d1 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Thu, 25 Jun 2026 09:11:58 +0200 Subject: [PATCH 01/83] chore(deps): update support-widget to v0.10.0 (neutral bubble shadow) --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index a5ea008c..8d4e4d28 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "@ciphera-net/facet": "^0.2.0", "@ciphera-net/facet-tailwind": "^0.1.2", "@ciphera-net/facet-tokens": "^0.0.3", - "@ciphera-net/support-widget": "^0.9.0", + "@ciphera-net/support-widget": "^0.10.0", "@ducanh2912/next-pwa": "^10.2.9", "@phosphor-icons/react": "^2.1.10", "@radix-ui/react-dialog": "^1.1.15", @@ -2113,9 +2113,9 @@ "license": "MIT" }, "node_modules/@ciphera-net/support-widget": { - "version": "0.9.0", - "resolved": "https://npm.pkg.github.com/download/@ciphera-net/support-widget/0.9.0/af8b780aa8eefe3db7a58cc8a47c1cfa38118942", - "integrity": "sha512-skcF6rEBV+Y7rBWCbGaxmuZrg/+mULY4EkZZdQ7VjRSCOM+67j/5umvIHWBN9Vbz3SOEiThfG7Oa19PkQQGRrg==", + "version": "0.10.0", + "resolved": "https://npm.pkg.github.com/download/@ciphera-net/support-widget/0.10.0/b7ba2a287ee0c4b62810ab0337b3f3960c931e0e", + "integrity": "sha512-rOw8jH2gsF67opidv03+QsE03gLgOqkL+/v3NP9QtNrPBrpZZx+lkLT4mdsXOJCYF3UQx01CEgzNn9mWBIMxlg==", "dependencies": { "clsx": "^2.1.1" }, diff --git a/package.json b/package.json index 12a14303..60fd0b97 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "@ciphera-net/facet": "^0.2.0", "@ciphera-net/facet-tailwind": "^0.1.2", "@ciphera-net/facet-tokens": "^0.0.3", - "@ciphera-net/support-widget": "^0.9.0", + "@ciphera-net/support-widget": "^0.10.0", "@ducanh2912/next-pwa": "^10.2.9", "@phosphor-icons/react": "^2.1.10", "@radix-ui/react-dialog": "^1.1.15", From 0c3d0793c0254744930677e48d96bd8da167d222 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Fri, 3 Jul 2026 14:20:32 +0200 Subject: [PATCH 02/83] fix(middleware): exclude sw.js and workbox scripts from auth matcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The service worker script was matched by the auth middleware and 307-redirected to /login for visitors without a session, so every page logged "SecurityError: ServiceWorker script is behind a redirect" and PWA/offline never activated. Static service-worker assets now bypass the matcher like manifest.json already does. Present on production too — promoting staging picks up the fix there. --- middleware.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/middleware.ts b/middleware.ts index 0392db09..2924e5e6 100644 --- a/middleware.ts +++ b/middleware.ts @@ -64,8 +64,10 @@ export const config = { * Match all routes except: * - _next/static, _next/image (Next.js internals) * - favicon.ico, manifest.json, icons, images (static assets) + * - sw.js, workbox-*.js (service worker — a redirect here breaks registration + * with a SecurityError; must be reachable without auth on every page) * - api routes (handled by their own auth) */ - '/((?!_next/static|_next/image|favicon\\.ico|manifest\\.json|sitemap\\.xml|robots\\.txt|llms\\.txt|build-id\\.json|.*\\.png$|.*\\.svg$|.*\\.ico$|api/).*)', + '/((?!_next/static|_next/image|favicon\\.ico|manifest\\.json|sitemap\\.xml|robots\\.txt|llms\\.txt|build-id\\.json|sw\\.js$|workbox-.*\\.js$|.*\\.png$|.*\\.svg$|.*\\.ico$|api/).*)', ], } From dae7504b5d241ec57489f2febce242304c4cf428 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Fri, 3 Jul 2026 14:20:32 +0200 Subject: [PATCH 03/83] fix(marketing): correct Hobby plan pageview limit on features page Features page claimed 10,000 pageviews/month; the pricing page and plan data say 5k. Align the copy with the real plan limit. --- app/features/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/features/page.tsx b/app/features/page.tsx index c73ad001..694a4ded 100644 --- a/app/features/page.tsx +++ b/app/features/page.tsx @@ -410,7 +410,7 @@ export default function FeaturesPage() {

- Hobby plan includes 1 site and 10,000 pageviews/month + Hobby plan includes 1 site and 5,000 pageviews/month

From 89d0eb41a86001f57c7e064b26ab534dc88b5ddb Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Fri, 3 Jul 2026 14:20:32 +0200 Subject: [PATCH 04/83] fix(marketing): colour compare-table cells by sentiment, not raw boolean MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Cookie Banner Required: false" (Pulse's headline win) rendered as a red ✗ while GA's "true" got a green ✓ — the first row of the table visually awarded GA the win. Cells where the raw boolean inverts sentiment now use worded values with an explicit good/bad tone ("None" green / "Required" red). --- app/about/page.tsx | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/app/about/page.tsx b/app/about/page.tsx index 5945706f..f178d5c1 100644 --- a/app/about/page.tsx +++ b/app/about/page.tsx @@ -3,7 +3,12 @@ import { motion } from 'framer-motion' import { CheckCircleIcon, XIcon } from '@ciphera-net/facet' -function ComparisonTable({ title, competitors }: { title: string, competitors: { name: string, isPulse: boolean, features: Record }[] }) { +// * Boolean cells render ✓/✗ and assume true = good. Where the raw boolean would invert +// * sentiment (e.g. "Cookie Banner Required" — false is the win), use { text, good } so +// * the colour follows the meaning, not the boolean. +type FeatureValue = boolean | string | { text: string; good: boolean } + +function ComparisonTable({ title, competitors }: { title: string, competitors: { name: string, isPulse: boolean, features: Record }[] }) { const allFeatures = [ "Cookie Banner Required", "GDPR Compliant", @@ -36,7 +41,9 @@ function ComparisonTable({ title, competitors }: { title: string, competitors: { const val = comp.features[feature] return ( - {val === true ? ( + {typeof val === 'object' ? ( + {val.text} + ) : val === true ? ( ) : val === false ? ( @@ -103,7 +110,7 @@ export default function AboutPage() { name: "Pulse", isPulse: true, features: { - "Cookie Banner Required": false, + "Cookie Banner Required": { text: "None", good: true }, "GDPR Compliant": true, "Script Size": "< 1 KB", "Data Ownership": "Yours", @@ -115,7 +122,7 @@ export default function AboutPage() { name: "Google Analytics 4", isPulse: false, features: { - "Cookie Banner Required": true, + "Cookie Banner Required": { text: "Required", good: false }, "GDPR Compliant": "Complex", "Script Size": "45 KB+", "Data Ownership": "Google's", @@ -134,7 +141,7 @@ export default function AboutPage() { name: "Pulse", isPulse: true, features: { - "Cookie Banner Required": false, + "Cookie Banner Required": { text: "None", good: true }, "GDPR Compliant": true, "Script Size": "< 1 KB", "Data Ownership": "Yours", @@ -146,7 +153,7 @@ export default function AboutPage() { name: "Plausible", isPulse: false, features: { - "Cookie Banner Required": false, + "Cookie Banner Required": { text: "None", good: true }, "GDPR Compliant": true, "Script Size": "< 1 KB", "Data Ownership": "Yours", From 5bba788b2dd3b15aff2e2c47e40084cbfc840c46 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Fri, 3 Jul 2026 14:20:32 +0200 Subject: [PATCH 05/83] fix(marketing): sentence-case the integrations section headers The Popular and category-group headers rendered sentence-length UPPERCASE via CSS (e.g. "STATIC SITES & DOCUMENTATION"), which the label rule reserves for 1-2-word data labels. Drop the uppercase transform and wide tracking; the h2s keep their display font. --- app/integrations/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/integrations/page.tsx b/app/integrations/page.tsx index f2193ba3..c27d7726 100644 --- a/app/integrations/page.tsx +++ b/app/integrations/page.tsx @@ -235,7 +235,7 @@ export default function IntegrationsPage() { whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }} transition={{ duration: 0.4 }} - className="text-lg font-semibold text-neutral-400 mb-6 tracking-wide uppercase flex items-center gap-2" + className="text-lg font-semibold text-neutral-400 mb-6 flex items-center gap-2" > @@ -276,7 +276,7 @@ export default function IntegrationsPage() { whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }} transition={{ duration: 0.4 }} - className="text-lg font-semibold text-neutral-400 mb-6 tracking-wide uppercase" + className="text-lg font-semibold text-neutral-400 mb-6" > {group.label} From 688ef0391bc88f62cf92bac12eedc9b2b8a85ae9 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Fri, 3 Jul 2026 14:20:32 +0200 Subject: [PATCH 06/83] fix(dashboard): add missing Roles & Permissions breadcrumb label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TAB_META in useHomePageMeta covered every settings tab except roles, so /settings/organization/roles fell back to "Settings" and the topbar read "Settings › Settings". --- components/dashboard/DashboardShell.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/components/dashboard/DashboardShell.tsx b/components/dashboard/DashboardShell.tsx index 1db6bfaf..3e445644 100644 --- a/components/dashboard/DashboardShell.tsx +++ b/components/dashboard/DashboardShell.tsx @@ -17,7 +17,7 @@ import { cdnUrl } from '@/lib/cdn' import { CaretDown, CaretRight, SidebarSimple, Gauge as GaugeIcon, Plugs as PlugsIcon, Tag as TagIcon, Globe as GlobeIcon, GearSix, Target, Eye, ShieldCheck, Robot, MagnifyingGlass, ChartBar, - Buildings, UsersThree, CreditCard, Bell, ClockCounterClockwise, User, Lock, DeviceMobile, + Buildings, UsersThree, Key, CreditCard, Bell, ClockCounterClockwise, User, Lock, DeviceMobile, } from '@phosphor-icons/react' import { DURATION_FAST, EASE_APPLE } from '@/lib/motion' import { EmptyState } from '@/components/ui/EmptyState' @@ -83,6 +83,7 @@ function useHomePageMeta(): PageMeta { integrations: { label: 'Integrations', icon: PlugsIcon }, workspace: { label: 'General', icon: Buildings }, members: { label: 'Members', icon: UsersThree }, + roles: { label: 'Roles & Permissions', icon: Key }, billing: { label: 'Billing', icon: CreditCard }, notifications: { label: 'Notifications', icon: Bell }, audit: { label: 'Audit Log', icon: ClockCounterClockwise }, From dbff2a3df5138dc67c3ba3d36b14420bfde79787 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Fri, 3 Jul 2026 14:20:32 +0200 Subject: [PATCH 07/83] chore(ui): remove dead line-chart, funnel-chart and avatar components Zero importers across all import forms (static, dynamic, require); the marketing funnel mockup uses its own local copy. 2,042 LOC. --- components/ui/avatar.tsx | 67 --- components/ui/funnel-chart.tsx | 948 ----------------------------- components/ui/line-chart.tsx | 1027 -------------------------------- 3 files changed, 2042 deletions(-) delete mode 100644 components/ui/avatar.tsx delete mode 100644 components/ui/funnel-chart.tsx delete mode 100644 components/ui/line-chart.tsx diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx deleted file mode 100644 index 4eec28f5..00000000 --- a/components/ui/avatar.tsx +++ /dev/null @@ -1,67 +0,0 @@ -'use client'; - -import * as React from 'react'; -import { cn } from '@/lib/utils'; -import { cva, VariantProps } from 'class-variance-authority'; -import { Avatar as AvatarPrimitive } from 'radix-ui'; - -const avatarStatusVariants = cva('flex items-center rounded-full size-2 border-2 border-background', { - variants: { - variant: { - online: 'bg-green-600', - offline: 'bg-zinc-300', - busy: 'bg-yellow-600', - away: 'bg-blue-600', - }, - }, - defaultVariants: { - variant: 'online', - }, -}); - -function Avatar({ className, ...props }: React.ComponentProps) { - return ( - - ); -} - -function AvatarImage({ className, ...props }: React.ComponentProps) { - return ( -
- -
- ); -} - -function AvatarFallback({ className, ...props }: React.ComponentProps) { - return ( - - ); -} - -function AvatarIndicator({ className, ...props }: React.HTMLAttributes) { - return ( -
- ); -} - -function AvatarStatus({ - className, - variant, - ...props -}: React.HTMLAttributes & VariantProps) { - return
; -} - -export { Avatar, AvatarFallback, AvatarImage, AvatarIndicator, AvatarStatus, avatarStatusVariants }; diff --git a/components/ui/funnel-chart.tsx b/components/ui/funnel-chart.tsx deleted file mode 100644 index e4812188..00000000 --- a/components/ui/funnel-chart.tsx +++ /dev/null @@ -1,948 +0,0 @@ -"use client"; - -import { motion, useSpring, useTransform } from "framer-motion"; -import { - type CSSProperties, - type ReactNode, - useCallback, - useEffect, - useRef, - useState, -} from "react"; -import { type ClassValue, clsx } from "clsx"; -import { twMerge } from "tailwind-merge"; -import { SPRING, EASE_APPLE, DURATION_SLOW } from "@/lib/motion"; - -// ─── Utils ─────────────────────────────────────────────────────────────────── - -function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); -} - -// ─── PatternLines ──────────────────────────────────────────────────────────── - -export interface PatternLinesProps { - id: string; - width?: number; - height?: number; - stroke?: string; - strokeWidth?: number; - orientation?: ("diagonal" | "horizontal" | "vertical")[]; - background?: string; -} - -export function PatternLines({ - id, - width = 6, - height = 6, - stroke = "var(--chart-line-primary)", - strokeWidth = 1, - orientation = ["diagonal"], - background, -}: PatternLinesProps) { - const paths: string[] = []; - - for (const o of orientation) { - if (o === "diagonal") { - paths.push(`M0,${height}l${width},${-height}`); - paths.push(`M${-width / 4},${height / 4}l${width / 2},${-height / 2}`); - paths.push( - `M${(3 * width) / 4},${height + height / 4}l${width / 2},${-height / 2}` - ); - } else if (o === "horizontal") { - paths.push(`M0,${height / 2}l${width},0`); - } else if (o === "vertical") { - paths.push(`M${width / 2},0l0,${height}`); - } - } - - return ( - - {background && ( - - )} - - - ); -} - -PatternLines.displayName = "PatternLines"; - -// ─── Types ─────────────────────────────────────────────────────────────────── - -export interface FunnelGradientStop { - offset: string | number; - color: string; -} - -export interface FunnelStage { - label: string; - value: number; - displayValue?: string; - color?: string; - gradient?: FunnelGradientStop[]; -} - -export interface FunnelChartProps { - data: FunnelStage[]; - orientation?: "horizontal" | "vertical"; - color?: string; - layers?: number; - className?: string; - style?: CSSProperties; - showPercentage?: boolean; - showValues?: boolean; - showLabels?: boolean; - hoveredIndex?: number | null; - onHoverChange?: (index: number | null) => void; - formatPercentage?: (pct: number) => string; - formatValue?: (value: number) => string; - staggerDelay?: number; - gap?: number; - renderPattern?: (id: string, color: string) => ReactNode; - edges?: "curved" | "straight"; - labelLayout?: "spread" | "grouped"; - labelOrientation?: "vertical" | "horizontal"; - labelAlign?: "center" | "start" | "end"; - grid?: - | boolean - | { - bands?: boolean; - bandColor?: string; - lines?: boolean; - lineColor?: string; - lineOpacity?: number; - lineWidth?: number; - }; -} - -// ─── Defaults ──────────────────────────────────────────────────────────────── - -const fmtPct = (p: number) => `${Math.round(p)}%`; -const fmtVal = (v: number) => v.toLocaleString("en-US"); - -const springConfig = { stiffness: 120, damping: 20, mass: 1 }; -const hoverSpring = { stiffness: 300, damping: 24 }; - -// ─── SVG Helpers ───────────────────────────────────────────────────────────── - -function hSegmentPath( - normStart: number, - normEnd: number, - segW: number, - H: number, - layerScale: number, - straight = false -) { - const my = H / 2; - const h0 = normStart * H * 0.3 * layerScale; - const h1 = normEnd * H * 0.3 * layerScale; - - if (straight) { - return `M 0 ${my - h0} L ${segW} ${my - h1} L ${segW} ${my + h1} L 0 ${my + h0} Z`; - } - - const cx = segW * 0.55; - const top = `M 0 ${my - h0} C ${cx} ${my - h0}, ${segW - cx} ${my - h1}, ${segW} ${my - h1}`; - const bot = `L ${segW} ${my + h1} C ${segW - cx} ${my + h1}, ${cx} ${my + h0}, 0 ${my + h0}`; - return `${top} ${bot} Z`; -} - -function vSegmentPath( - normStart: number, - normEnd: number, - segH: number, - W: number, - layerScale: number, - straight = false -) { - const mx = W / 2; - const w0 = normStart * W * 0.44 * layerScale; - const w1 = normEnd * W * 0.44 * layerScale; - - if (straight) { - return `M ${mx - w0} 0 L ${mx - w1} ${segH} L ${mx + w1} ${segH} L ${mx + w0} 0 Z`; - } - - const cy = segH * 0.55; - const left = `M ${mx - w0} 0 C ${mx - w0} ${cy}, ${mx - w1} ${segH - cy}, ${mx - w1} ${segH}`; - const right = `L ${mx + w1} ${segH} C ${mx + w1} ${segH - cy}, ${mx + w0} ${cy}, ${mx + w0} 0`; - return `${left} ${right} Z`; -} - -// ─── Animated Ring ─────────────────────────────────────────────────────────── - -function HRing({ - d, - color, - fill, - opacity, - hovered, - ringIndex, - totalRings, -}: { - d: string; - color: string; - fill?: string; - opacity: number; - hovered: boolean; - ringIndex: number; - totalRings: number; -}) { - const extraScale = 1 + (ringIndex / Math.max(totalRings - 1, 1)) * 0.12; - const ringSpring = { - stiffness: 300 - ringIndex * 60, - damping: 24 - ringIndex * 3, - }; - const scaleY = useSpring(1, ringSpring); - const [hasMounted, setHasMounted] = useState(false); - - useEffect(() => { - scaleY.set(hovered ? extraScale : 1); - }, [hovered, scaleY, extraScale]); - - useEffect(() => { setHasMounted(true); }, []); - - return ( - - ); -} - -function VRing({ - d, - color, - fill, - opacity, - hovered, - ringIndex, - totalRings, -}: { - d: string; - color: string; - fill?: string; - opacity: number; - hovered: boolean; - ringIndex: number; - totalRings: number; -}) { - const extraScale = 1 + (ringIndex / Math.max(totalRings - 1, 1)) * 0.12; - const ringSpring = { - stiffness: 300 - ringIndex * 60, - damping: 24 - ringIndex * 3, - }; - const scaleX = useSpring(1, ringSpring); - const [hasMounted, setHasMounted] = useState(false); - - useEffect(() => { - scaleX.set(hovered ? extraScale : 1); - }, [hovered, scaleX, extraScale]); - - useEffect(() => { setHasMounted(true); }, []); - - return ( - - ); -} - -// ─── Animated Segments ─────────────────────────────────────────────────────── - -function HSegment({ - index, - normStart, - normEnd, - segW, - fullH, - color, - layers, - staggerDelay, - hovered, - dimmed, - renderPattern, - straight, - gradientStops, -}: { - index: number; - normStart: number; - normEnd: number; - segW: number; - fullH: number; - color: string; - layers: number; - staggerDelay: number; - hovered: boolean; - dimmed: boolean; - renderPattern?: (id: string, color: string) => ReactNode; - straight: boolean; - gradientStops?: FunnelGradientStop[]; -}) { - const patternId = `funnel-h-pattern-${index}`; - const gradientId = `funnel-h-grad-${index}`; - const growProgress = useSpring(0, springConfig); - const entranceScaleX = useTransform(growProgress, [0, 1], [0, 1]); - const entranceScaleY = useTransform(growProgress, [0, 1], [0, 1]); - const dimOpacity = useSpring(1, hoverSpring); - - useEffect(() => { - dimOpacity.set(dimmed ? 0.4 : 1); - }, [dimmed, dimOpacity]); - - useEffect(() => { - const timeout = setTimeout( - () => growProgress.set(1), - index * staggerDelay * 1000 - ); - return () => clearTimeout(timeout); - }, [growProgress, index, staggerDelay]); - - const rings = Array.from({ length: layers }, (_, l) => { - const scale = 1 - (l / layers) * 0.35; - const opacity = 0.18 + (l / (layers - 1 || 1)) * 0.65; - return { - d: hSegmentPath(normStart, normEnd, segW, fullH, scale, straight), - opacity, - }; - }); - - return ( - - - - - - ); -} - -function VSegment({ - index, - normStart, - normEnd, - segH, - fullW, - color, - layers, - staggerDelay, - hovered, - dimmed, - renderPattern, - straight, - gradientStops, -}: { - index: number; - normStart: number; - normEnd: number; - segH: number; - fullW: number; - color: string; - layers: number; - staggerDelay: number; - hovered: boolean; - dimmed: boolean; - renderPattern?: (id: string, color: string) => ReactNode; - straight: boolean; - gradientStops?: FunnelGradientStop[]; -}) { - const patternId = `funnel-v-pattern-${index}`; - const gradientId = `funnel-v-grad-${index}`; - const growProgress = useSpring(0, springConfig); - const entranceScaleY = useTransform(growProgress, [0, 1], [0, 1]); - const entranceScaleX = useTransform(growProgress, [0, 1], [0, 1]); - const dimOpacity = useSpring(1, hoverSpring); - - useEffect(() => { - dimOpacity.set(dimmed ? 0.4 : 1); - }, [dimmed, dimOpacity]); - - useEffect(() => { - const timeout = setTimeout( - () => growProgress.set(1), - index * staggerDelay * 1000 - ); - return () => clearTimeout(timeout); - }, [growProgress, index, staggerDelay]); - - const rings = Array.from({ length: layers }, (_, l) => { - const scale = 1 - (l / layers) * 0.35; - const opacity = 0.18 + (l / (layers - 1 || 1)) * 0.65; - return { - d: vSegmentPath(normStart, normEnd, segH, fullW, scale, straight), - opacity, - }; - }); - - return ( - - - - - - ); -} - -// ─── Label Overlay ─────────────────────────────────────────────────────────── - -function SegmentLabel({ - stage, - pct, - isHorizontal, - showValues, - showPercentage, - showLabels, - formatPercentage, - formatValue, - index, - staggerDelay, - layout = "spread", - orientation, - align = "center", -}: { - stage: FunnelStage; - pct: number; - isHorizontal: boolean; - showValues: boolean; - showPercentage: boolean; - showLabels: boolean; - formatPercentage: (p: number) => string; - formatValue: (v: number) => string; - index: number; - staggerDelay: number; - layout?: "spread" | "grouped"; - orientation?: "vertical" | "horizontal"; - align?: "center" | "start" | "end"; -}) { - const display = stage.displayValue ?? formatValue(stage.value); - - const valueEl = showValues && ( - - {display} - - ); - const pctEl = showPercentage && ( - - {formatPercentage(pct)} - - ); - const labelEl = showLabels && ( - - {stage.label} - - ); - - if (layout === "spread") { - return ( - - {isHorizontal ? ( - <> -
- {valueEl} -
-
- {pctEl} -
-
- {labelEl} -
- - ) : ( - <> -
- {valueEl} -
-
- {pctEl} -
-
- {labelEl} -
- - )} -
- ); - } - - // Grouped layout - const resolvedOrientation = - orientation ?? (isHorizontal ? "vertical" : "horizontal"); - const isVerticalStack = resolvedOrientation === "vertical"; - - const justifyMap = { - start: "justify-start", - center: "justify-center", - end: "justify-end", - } as const; - const itemsMap = { - start: "items-start", - center: "items-center", - end: "items-end", - } as const; - - return ( - -
- {valueEl} - {pctEl} - {labelEl} -
-
- ); -} - -// ─── FunnelChart ───────────────────────────────────────────────────────────── - -export function FunnelChart({ - data, - orientation = "horizontal", - color = "var(--chart-1)", - layers = 3, - className, - style, - showPercentage = true, - showValues = true, - showLabels = true, - hoveredIndex: hoveredIndexProp, - onHoverChange, - formatPercentage = fmtPct, - formatValue = fmtVal, - staggerDelay = 0.12, - gap = 4, - renderPattern, - edges = "curved", - labelLayout = "spread", - labelOrientation, - labelAlign = "center", - grid: gridProp = false, -}: FunnelChartProps) { - const ref = useRef(null); - const [sz, setSz] = useState({ w: 0, h: 0 }); - const [internalHoveredIndex, setInternalHoveredIndex] = useState< - number | null - >(null); - - const isControlled = hoveredIndexProp !== undefined; - const hoveredIndex = isControlled ? hoveredIndexProp : internalHoveredIndex; - const setHoveredIndex = useCallback( - (index: number | null) => { - if (isControlled) { - onHoverChange?.(index); - } else { - setInternalHoveredIndex(index); - } - }, - [isControlled, onHoverChange] - ); - - const measure = useCallback(() => { - if (!ref.current) return; - const { width: w, height: h } = ref.current.getBoundingClientRect(); - if (w > 0 && h > 0) setSz({ w, h }); - }, []); - - useEffect(() => { - measure(); - const ro = new ResizeObserver(measure); - if (ref.current) ro.observe(ref.current); - return () => ro.disconnect(); - }, [measure]); - - if (!data.length) return null; - - const first = data[0]; - if (!first) return null; - - const max = first.value; - const n = data.length; - const norms = data.map((d) => d.value / max); - const horiz = orientation === "horizontal"; - const { w: W, h: H } = sz; - - const totalGap = gap * (n - 1); - const segW = (W - (horiz ? totalGap : 0)) / n; - const segH = (H - (horiz ? 0 : totalGap)) / n; - - // Grid config - const gridEnabled = gridProp !== false; - const gridCfg = typeof gridProp === "object" ? gridProp : {}; - const showBands = gridEnabled && (gridCfg.bands ?? true); - const bandColor = gridCfg.bandColor ?? "var(--color-muted)"; - const showGridLines = gridEnabled && (gridCfg.lines ?? true); - const gridLineColor = gridCfg.lineColor ?? "var(--chart-grid)"; - const gridLineOpacity = gridCfg.lineOpacity ?? 1; - const gridLineWidth = gridCfg.lineWidth ?? 1; - - return ( -
- {W > 0 && H > 0 && ( - <> - {/* Grid background bands */} - {gridEnabled && ( - - )} - - {/* Segments */} -
- {data.map((stage, i) => { - const normStart = norms[i] ?? 0; - const normEnd = norms[Math.min(i + 1, n - 1)] ?? 0; - const firstStop = stage.gradient?.[0]; - const segColor = firstStop - ? firstStop.color - : (stage.color ?? color); - - return horiz ? ( - - ) : ( - - ); - })} -
- - {/* Grid lines */} - {gridEnabled && showGridLines && ( - - )} - - {/* Label overlays — hover triggers */} - {data.map((stage, i) => { - const pct = (stage.value / max) * 100; - const posStyle: CSSProperties = horiz - ? { left: (segW + gap) * i, width: segW, top: 0, height: H } - : { top: (segH + gap) * i, height: segH, left: 0, width: W }; - const isDimmed = hoveredIndex !== null && hoveredIndex !== i; - - return ( - setHoveredIndex(i)} - onMouseLeave={() => setHoveredIndex(null)} - style={{ ...posStyle, zIndex: 20 }} - transition={SPRING} - > - - - ); - })} - - )} -
- ); -} - -FunnelChart.displayName = "FunnelChart"; - -export default FunnelChart; diff --git a/components/ui/line-chart.tsx b/components/ui/line-chart.tsx deleted file mode 100644 index 7fdd3711..00000000 --- a/components/ui/line-chart.tsx +++ /dev/null @@ -1,1027 +0,0 @@ -"use client"; - -import { curveMonotoneX } from "d3-shape"; -import { scaleLinear, scalePoint } from "d3-scale"; -import { localPoint, LinePath, GridRows, ParentSize } from "@/lib/charts/primitives"; -import { - AnimatePresence, - motion, - useSpring, -} from "framer-motion"; -import { - Children, - createContext, - isValidElement, - useCallback, - useContext, - useEffect, - useId, - useLayoutEffect, - useMemo, - useRef, - useState, - type ReactNode, - type RefObject, -} from "react"; -import { createPortal } from "react-dom"; -import useMeasure from "react-use-measure"; -import { type ClassValue, clsx } from "clsx"; -import { twMerge } from "tailwind-merge"; -import { type CurveFactory } from "d3-shape"; -import { - SPRING, - TIMING, - EASE_APPLE, - DURATION_FAST, - DURATION_SLOW, -} from "@/lib/motion"; - -// ─── Utils ──────────────────────────────────────────────────────────────────── - -function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); -} - -// ─── CSS Vars ───────────────────────────────────────────────────────────────── - -const chartCssVars = { - background: "var(--chart-background)", - grid: "var(--chart-grid)", - crosshair: "var(--chart-crosshair)", - label: "var(--chart-label)", -}; - -const chartColors = [ - "var(--chart-1)", - "var(--chart-2)", - "var(--chart-3)", - "var(--chart-4)", - "var(--chart-5)", -]; - -// ─── Types ──────────────────────────────────────────────────────────────────── - -export interface Margin { - top: number; - right: number; - bottom: number; - left: number; -} - -interface LineConfig { - dataKey: string; - stroke: string; - strokeWidth: number; - strokeDasharray?: string; - showDots: boolean; - curve: CurveFactory; -} - -interface TooltipState { - point: Record; - index: number; - x: number; - yPositions: Record; -} - -// ─── Context ────────────────────────────────────────────────────────────────── - -interface LineChartContextValue { - data: Record[]; - xScale: ReturnType>; - yScale: ReturnType>; - width: number; - height: number; - innerWidth: number; - innerHeight: number; - margin: Margin; - xDataKey: string; - lines: LineConfig[]; - tooltipData: TooltipState | null; - containerRef: RefObject; - isLoaded: boolean; -} - -const LineChartContext = createContext(null); - -export function useLineChart(): LineChartContextValue { - const ctx = useContext(LineChartContext); - if (!ctx) { - throw new Error("useLineChart must be used within a "); - } - return ctx; -} - -// ─── Tooltip internals ──────────────────────────────────────────────────────── - -interface TooltipContentProps { - title?: string; - rows: { color: string; label: string; value: string | number }[]; - children?: ReactNode; -} - -function TooltipContent({ title, rows, children }: TooltipContentProps) { - const [measureRef, bounds] = useMeasure({ debounce: 0, scroll: false }); - const [committedHeight, setCommittedHeight] = useState(null); - const hasChildren = !!children; - - useEffect(() => { - if (bounds.height > 0) setCommittedHeight(bounds.height); - }, [bounds.height]); - - return ( - -
- {title && ( -
{title}
- )} -
- {rows.map((row) => ( -
-
- - {row.label} -
- - {typeof row.value === "number" - ? row.value.toLocaleString() - : row.value} - -
- ))} -
- - {hasChildren && ( - - {children} - - )} - -
-
- ); -} - -TooltipContent.displayName = "TooltipContent"; - -interface TooltipBoxProps { - x: number; - y: number; - visible: boolean; - containerRef: RefObject; - containerWidth: number; - containerHeight: number; - offset?: number; - className?: string; - children: ReactNode; -} - -function TooltipBox({ - x, - y, - visible, - containerRef, - containerWidth, - containerHeight, - offset = 16, - className = "", - children, -}: TooltipBoxProps) { - const tooltipRef = useRef(null); - const [tooltipWidth, setTooltipWidth] = useState(180); - const [tooltipHeight, setTooltipHeight] = useState(80); - const [mounted, setMounted] = useState(false); - - useEffect(() => { - setMounted(true); - }, []); - - useLayoutEffect(() => { - if (tooltipRef.current) { - const w = tooltipRef.current.offsetWidth; - const h = tooltipRef.current.offsetHeight; - if (w > 0 && w !== tooltipWidth) setTooltipWidth(w); - if (h > 0 && h !== tooltipHeight) setTooltipHeight(h); - } - }, [tooltipWidth, tooltipHeight]); - - const shouldFlipX = x + tooltipWidth + offset > containerWidth; - const targetX = shouldFlipX ? x - offset - tooltipWidth : x + offset; - const targetY = Math.max( - offset, - Math.min(y - tooltipHeight / 2, containerHeight - tooltipHeight - offset) - ); - - const prevFlipRef = useRef(shouldFlipX); - const [flipKey, setFlipKey] = useState(0); - useEffect(() => { - if (prevFlipRef.current !== shouldFlipX) { - setFlipKey((k) => k + 1); - prevFlipRef.current = shouldFlipX; - } - }, [shouldFlipX]); - - const springConfig = { stiffness: 100, damping: 20 }; - const animatedLeft = useSpring(targetX, springConfig); - const animatedTop = useSpring(targetY, springConfig); - - useEffect(() => { - animatedLeft.set(targetX); - }, [targetX, animatedLeft]); - useEffect(() => { - animatedTop.set(targetY); - }, [targetY, animatedTop]); - - const transformOrigin = shouldFlipX ? "right top" : "left top"; - const container = containerRef.current; - if (!(mounted && container && visible)) return null; - - return createPortal( - - - {children} - - , - container - ); -} - -TooltipBox.displayName = "TooltipBox"; - -// ─── Crosshair ──────────────────────────────────────────────────────────────── - -function Crosshair({ x, height }: { x: number; height: number }) { - const uniqueId = useId(); - const gradientId = `line-crosshair-${uniqueId}`; - const springConfig = { stiffness: 300, damping: 30 }; - const animatedX = useSpring(x, springConfig); - useEffect(() => { - animatedX.set(x); - }, [x, animatedX]); - - return ( - - - - - - - - - - - - ); -} - -// ─── HoverDot ───────────────────────────────────────────────────────────────── - -function HoverDot({ - x, - y, - color, -}: { - x: number; - y: number; - color: string; -}) { - const springConfig = { stiffness: 300, damping: 30 }; - const animX = useSpring(x, springConfig); - const animY = useSpring(y, springConfig); - useEffect(() => { - animX.set(x); - }, [x, animX]); - useEffect(() => { - animY.set(y); - }, [y, animY]); - - return ( - - ); -} - -// ─── ChartTooltip ───────────────────────────────────────────────────────────── - -export interface ChartTooltipProps { - showCrosshair?: boolean; - showDots?: boolean; - content?: (props: { - point: Record; - index: number; - }) => ReactNode; - rows?: (point: Record) => { - color: string; - label: string; - value: string | number; - }[]; - children?: ReactNode; - className?: string; -} - -export function ChartTooltip({ - showCrosshair = true, - showDots = true, - content, - rows: rowsRenderer, - children, - className = "", -}: ChartTooltipProps) { - const { - tooltipData, - width, - height, - margin, - innerHeight, - lines, - xDataKey, - containerRef, - } = useLineChart(); - - const [mounted, setMounted] = useState(false); - useEffect(() => { - setMounted(true); - }, []); - - const visible = tooltipData !== null; - const x = tooltipData?.x ?? 0; - const xWithMargin = x + margin.left; - - const tooltipRows = useMemo(() => { - if (!tooltipData) return []; - if (rowsRenderer) return rowsRenderer(tooltipData.point); - return lines.map((line) => ({ - color: line.stroke, - label: line.dataKey, - value: (tooltipData.point[line.dataKey] as number) ?? 0, - })); - }, [tooltipData, lines, rowsRenderer]); - - const title = useMemo(() => { - if (!tooltipData) return undefined; - return String(tooltipData.point[xDataKey] ?? ""); - }, [tooltipData, xDataKey]); - - const container = containerRef.current; - if (!(mounted && container)) return null; - - const firstLineY = lines[0] - ? (tooltipData?.yPositions[lines[0].dataKey] ?? 0) - : 0; - - return createPortal( - <> - {showCrosshair && visible && ( - - )} - {showDots && visible && ( - - )} - - {content ? ( - content({ - point: tooltipData?.point ?? {}, - index: tooltipData?.index ?? 0, - }) - ) : ( - - {children} - - )} - - , - container - ); -} - -ChartTooltip.displayName = "ChartTooltip"; - -// ─── Grid ───────────────────────────────────────────────────────────────────── - -export interface GridProps { - numTicks?: number; - tickValues?: number[]; - stroke?: string; - strokeDasharray?: string; - strokeOpacity?: number; - strokeWidth?: number; - fade?: boolean; -} - -export function Grid({ - numTicks = 5, - tickValues, - stroke = chartCssVars.grid, - strokeDasharray = "4,4", - strokeOpacity = 1, - strokeWidth = 1, - fade = true, -}: GridProps) { - const { yScale, innerWidth, innerHeight } = useLineChart(); - const uniqueId = useId(); - const maskId = `line-grid-mask-${uniqueId}`; - const gradientId = `line-grid-grad-${uniqueId}`; - - return ( - - {fade && ( - - - - - - - - - - - - )} - - - - - ); -} - -Grid.displayName = "Grid"; - -// ─── XAxis ──────────────────────────────────────────────────────────────────── - -export interface XAxisProps { - maxLabels?: number; - formatLabel?: (label: string) => string; -} - -export function XAxis({ maxLabels = 12, formatLabel }: XAxisProps) { - const { xScale, margin, containerRef, tooltipData } = useLineChart(); - const [mounted, setMounted] = useState(false); - useEffect(() => { - setMounted(true); - }, []); - - const labels = useMemo(() => { - const domain = xScale.domain(); - let items = domain.map((label) => ({ - label, - x: (xScale(label) ?? 0) + margin.left, - })); - if (items.length > maxLabels) { - const step = Math.ceil(items.length / maxLabels); - items = items.filter((_, i) => i % step === 0); - } - return items; - }, [xScale, margin.left, maxLabels]); - - const crosshairX = tooltipData ? tooltipData.x + margin.left : null; - const container = containerRef.current; - if (!(mounted && container)) return null; - - return createPortal( -
- {labels.map((item) => { - let opacity = 1; - if (crosshairX !== null) { - const dist = Math.abs(item.x - crosshairX); - if (dist < 30) opacity = 0; - else if (dist < 60) opacity = (dist - 30) / 30; - } - return ( -
- - {formatLabel ? formatLabel(item.label) : item.label} - -
- ); - })} -
, - container - ); -} - -XAxis.displayName = "XAxis"; - -// ─── YAxis ──────────────────────────────────────────────────────────────────── - -export interface YAxisProps { - numTicks?: number; - formatValue?: (value: number) => string; -} - -export function YAxis({ numTicks = 5, formatValue }: YAxisProps) { - const { yScale, margin, containerRef } = useLineChart(); - const [container, setContainer] = useState(null); - useEffect(() => { - setContainer(containerRef.current); - }, [containerRef]); - - const ticks = useMemo(() => { - const [min, max] = yScale.domain() as [number, number]; - const step = (max - min) / (numTicks - 1); - return Array.from({ length: numTicks }, (_, i) => { - const value = min + step * i; - const label = formatValue - ? formatValue(value) - : value >= 1000 - ? `${(value / 1000).toFixed(value % 1000 === 0 ? 0 : 1)}k` - : Math.round(value).toLocaleString(); - return { value, y: (yScale(value) ?? 0) + margin.top, label }; - }); - }, [yScale, margin.top, numTicks, formatValue]); - - if (!container) return null; - - return createPortal( -
- {ticks.map((tick) => ( -
- - {tick.label} - -
- ))} -
, - container - ); -} - -YAxis.displayName = "YAxis"; - -// ─── Line ───────────────────────────────────────────────────────────────────── - -export interface LineProps { - dataKey: string; - stroke?: string; - strokeWidth?: number; - strokeDasharray?: string; - showDots?: boolean; - curve?: CurveFactory; - colorIndex?: number; -} - -export function Line({ - dataKey, - stroke, - strokeWidth = 2, - strokeDasharray, - showDots = false, - curve = curveMonotoneX, - colorIndex = 0, -}: LineProps) { - const { data, xScale, yScale, xDataKey, innerHeight, isLoaded } = - useLineChart(); - const resolvedStroke = stroke ?? chartColors[colorIndex % chartColors.length] ?? "var(--chart-1)"; - - // Entrance animation via stroke-dashoffset - const pathRef = useRef(null); - const [pathLength, setPathLength] = useState(0); - - useEffect(() => { - if (pathRef.current) { - const len = pathRef.current.getTotalLength(); - if (len > 0) setPathLength(len); - } - }); - - const points = useMemo( - () => - data - .map((d) => { - const xVal = String(d[xDataKey] ?? ""); - const yVal = typeof d[dataKey] === "number" ? (d[dataKey] as number) : null; - if (yVal === null) return null; - const xPos = xScale(xVal); - const yPos = yScale(yVal); - if (xPos === undefined || yPos === undefined) return null; - return { x: xPos, y: yPos }; - }) - .filter((p): p is { x: number; y: number } => p !== null), - [data, dataKey, xDataKey, xScale, yScale] - ); - - if (points.length < 2) return null; - - return ( - - {/* The actual line — rendered first so we can measure totalLength */} - p !== null} - innerRef={pathRef} - stroke={resolvedStroke} - strokeDasharray={strokeDasharray} - strokeLinecap="round" - strokeLinejoin="round" - strokeWidth={strokeWidth} - x={(p) => p.x} - y={(p) => p.y} - style={ - pathLength > 0 - ? { - strokeDasharray: pathLength, - strokeDashoffset: isLoaded ? 0 : pathLength, - transition: isLoaded - ? "stroke-dashoffset 0.9s cubic-bezier(0.16, 1, 0.3, 1)" - : "none", - } - : undefined - } - fill="none" - /> - - {/* Optional static dots at each data point */} - {showDots && - points.map((p, i) => ( - - ))} - - ); -} - -Line.displayName = "Line"; - -// ─── extractLineConfigs ─────────────────────────────────────────────────────── - -function extractLineConfigs(children: ReactNode): LineConfig[] { - const configs: LineConfig[] = []; - let colorIndex = 0; - Children.forEach(children, (child) => { - if (!isValidElement(child)) return; - const type = child.type as { displayName?: string }; - if (type.displayName !== "Line") return; - const p = child.props as LineProps; - if (!p.dataKey) return; - configs.push({ - dataKey: p.dataKey, - stroke: - p.stroke ?? - chartColors[colorIndex % chartColors.length] ?? - "var(--chart-1)", - strokeWidth: p.strokeWidth ?? 2, - strokeDasharray: p.strokeDasharray, - showDots: p.showDots ?? false, - curve: p.curve ?? curveMonotoneX, - }); - colorIndex++; - }); - return configs; -} - -// ─── LineChartInner ─────────────────────────────────────────────────────────── - -interface LineChartInnerProps { - width: number; - height: number; - data: Record[]; - xDataKey: string; - margin: Margin; - containerRef: RefObject; - children: ReactNode; -} - -function LineChartInner({ - width, - height, - data, - xDataKey, - margin, - containerRef, - children, -}: LineChartInnerProps) { - const [isLoaded, setIsLoaded] = useState(false); - const [tooltipData, setTooltipData] = useState(null); - - const innerWidth = width - margin.left - margin.right; - const innerHeight = height - margin.top - margin.bottom; - - const lines = useMemo(() => extractLineConfigs(children), [children]); - - const xScale = useMemo(() => { - const domain = data.map((d) => String(d[xDataKey] ?? "")); - return scalePoint() - .range([0, innerWidth]) - .domain(domain) - .padding(0.5); - }, [data, xDataKey, innerWidth]); - - const yScale = useMemo(() => { - let min = Infinity; - let max = -Infinity; - for (const line of lines) { - for (const d of data) { - const v = d[line.dataKey]; - if (typeof v === "number") { - if (v < min) min = v; - if (v > max) max = v; - } - } - } - if (!isFinite(min) || !isFinite(max)) { - min = 0; - max = 100; - } - const padding = (max - min) * 0.1 || 10; - return scaleLinear() - .range([innerHeight, 0]) - .domain([Math.max(0, min - padding), max + padding]) - .nice(); - }, [data, lines, innerHeight]); - - // Trigger entrance animation on mount - useEffect(() => { - const timer = setTimeout(() => setIsLoaded(true), 50); - return () => clearTimeout(timer); - }, []); - - const handleMouseMove = useCallback( - (event: React.MouseEvent) => { - const point = localPoint(event); - if (!point) return; - const chartX = point.x - margin.left; - - // Snap to nearest domain point - const domain = xScale.domain(); - let nearest = domain[0] ?? ""; - let nearestDist = Infinity; - for (const label of domain) { - const px = xScale(label) ?? 0; - const dist = Math.abs(px - chartX); - if (dist < nearestDist) { - nearestDist = dist; - nearest = label; - } - } - const idx = domain.indexOf(nearest); - if (idx < 0) return; - const d = data[idx]; - if (!d) return; - - const snapX = xScale(nearest) ?? 0; - const yPositions: Record = {}; - for (const line of lines) { - const v = d[line.dataKey]; - if (typeof v === "number") yPositions[line.dataKey] = yScale(v) ?? 0; - } - setTooltipData({ point: d, index: idx, x: snapX, yPositions }); - }, - [xScale, yScale, data, lines, margin] - ); - - const handleMouseLeave = useCallback(() => { - setTooltipData(null); - }, []); - - if (width < 10 || height < 10) return null; - - const contextValue: LineChartContextValue = { - data, - xScale, - yScale, - width, - height, - innerWidth, - innerHeight, - margin, - xDataKey, - lines, - tooltipData, - containerRef, - isLoaded, - }; - - return ( - - - - ); -} - -// ─── LineChart ──────────────────────────────────────────────────────────────── - -export interface LineChartProps { - data: Record[]; - xDataKey?: string; - margin?: Partial; - className?: string; - aspectRatio?: string; - children: ReactNode; -} - -const DEFAULT_MARGIN: Margin = { top: 20, right: 24, bottom: 40, left: 48 }; - -export function LineChart({ - data, - xDataKey = "date", - margin: marginProp, - className = "", - aspectRatio = "2 / 1", - children, -}: LineChartProps) { - const containerRef = useRef(null); - const margin = { ...DEFAULT_MARGIN, ...marginProp }; - - if (!data || data.length === 0) { - return ( -
- No data -
- ); - } - - return ( -
- - {({ width, height }) => ( - - {children} - - )} - -
- ); -} - -LineChart.displayName = "LineChart"; -export default LineChart; From 90311013d95029e9635ecd1d7060bc6787a75e44 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Fri, 3 Jul 2026 14:59:54 +0200 Subject: [PATCH 08/83] fix(privacy): stop leaking visited domains to Google via favicon lookups Favicons in the authenticated Referrers/Pages panels (and the marketing demo + share-page OG image) loaded straight from Google's s2 service, sending every customer's referrer and page domains plus the user's IP to Google. All consumers now go through a same-origin /api/favicon proxy: the upstream fetch happens server-side behind the CDN cache, so browsers only ever talk to Pulse. CSP drops the google/gstatic allowances so any regression fails visibly instead of silently. --- app/api/favicon/route.ts | 60 +++++++++++++++++++ app/share/[id]/layout.tsx | 4 +- .../mockups/pulse-features-carousel.tsx | 5 +- lib/utils/favicon.ts | 10 +++- next.config.ts | 7 ++- 5 files changed, 77 insertions(+), 9 deletions(-) create mode 100644 app/api/favicon/route.ts diff --git a/app/api/favicon/route.ts b/app/api/favicon/route.ts new file mode 100644 index 00000000..195f81a8 --- /dev/null +++ b/app/api/favicon/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from 'next/server' + +/** + * Same-origin favicon proxy. + * + * Browsers previously loaded favicons straight from Google's s2 service, + * which sent every referrer/page domain — plus the user's IP — to Google + * from inside the authenticated app. The upstream fetch now happens + * server-side, so the only party that ever contacts Google is our origin + * on a CDN cache miss; the user's browser only talks to Pulse. CSP has + * dropped the google/gstatic allowances, so a regression fails loudly. + */ + +const UPSTREAM = 'https://www.google.com/s2/favicons' + +// * Sizes actually used by the app (see FAVICON_SERVICE_URL consumers). +const ALLOWED_SIZES = new Set(['16', '32', '64', '128']) + +// * Bare hostname only — no scheme, port, path, or IP-literal shapes beyond +// * dotted labels. Anything else 400s and the falls back to its icon. +const DOMAIN_RE = /^(?=.{4,253}$)[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/ + +export async function GET(request: NextRequest) { + const domain = request.nextUrl.searchParams.get('domain')?.trim().toLowerCase() ?? '' + const sz = request.nextUrl.searchParams.get('sz') ?? '32' + + if (!DOMAIN_RE.test(domain) || !ALLOWED_SIZES.has(sz)) { + return new NextResponse(null, { status: 400 }) + } + + try { + const upstream = await fetch(`${UPSTREAM}?domain=${encodeURIComponent(domain)}&sz=${sz}`, { + signal: AbortSignal.timeout(4000), + // * The CDN caches via the response headers below; keep Next's data + // * cache out of the way so misses don't accumulate on disk. + cache: 'no-store', + }) + const contentType = upstream.headers.get('content-type') ?? '' + if (!upstream.ok || !contentType.startsWith('image/')) { + return new NextResponse(null, { + status: 404, + headers: { 'Cache-Control': 'public, max-age=3600' }, + }) + } + const body = await upstream.arrayBuffer() + return new NextResponse(body, { + headers: { + 'Content-Type': contentType, + 'Cache-Control': 'public, max-age=86400, s-maxage=604800, stale-while-revalidate=604800', + 'X-Content-Type-Options': 'nosniff', + }, + }) + } catch { + // * Upstream unreachable/timed out — short-cache the miss to absorb storms. + return new NextResponse(null, { + status: 404, + headers: { 'Cache-Control': 'public, max-age=300' }, + }) + } +} diff --git a/app/share/[id]/layout.tsx b/app/share/[id]/layout.tsx index 565ad48d..5d0ba186 100644 --- a/app/share/[id]/layout.tsx +++ b/app/share/[id]/layout.tsx @@ -49,7 +49,9 @@ export async function generateMetadata({ params }: SharePageParams): Promise } -const FAVICON_URL = 'https://www.google.com/s2/favicons' - export function ReferrersCard() { const data = [ { label: 'Direct', value: 186 }, @@ -145,7 +144,7 @@ export function ReferrersCard() { maxValue={max} icon={getReferrerIcon( d.label, - 'domain' in d ? `${FAVICON_URL}?domain=${d.domain}&sz=32` : undefined + 'domain' in d ? `${FAVICON_SERVICE_URL}?domain=${d.domain}&sz=32` : undefined )} /> ))} diff --git a/lib/utils/favicon.ts b/lib/utils/favicon.ts index 8e77df8f..851c7dd9 100644 --- a/lib/utils/favicon.ts +++ b/lib/utils/favicon.ts @@ -1,8 +1,12 @@ /** - * Google's public favicon service base URL. - * Append `?domain=&sz=` to get a favicon. + * Same-origin favicon proxy endpoint (see app/api/favicon/route.ts). + * Append `?domain=&sz=` to get a favicon (sz ∈ 16/32/64/128). + * + * Never point this at a third-party service directly: favicons render in the + * authenticated app, so a third-party URL leaks every customer's referrer and + * page domains (plus the user's IP) to that party on each dashboard view. * * Kept in a separate module so server components can import it * without pulling in the React-dependent icon registry. */ -export const FAVICON_SERVICE_URL = 'https://www.google.com/s2/favicons' +export const FAVICON_SERVICE_URL = '/api/favicon' diff --git a/next.config.ts b/next.config.ts index e10f968c..fbc8b5b8 100644 --- a/next.config.ts +++ b/next.config.ts @@ -14,9 +14,12 @@ const cspDirectives = [ // Next.js requires 'unsafe-inline' for its bootstrap scripts; 'unsafe-eval' only in dev (HMR) `script-src 'self' 'unsafe-inline' https://js.ciphera.net https://api.help.ciphera.net${process.env.NODE_ENV === 'development' ? " 'unsafe-eval'" : ''}`, "style-src 'self' 'unsafe-inline'", - "img-src 'self' data: blob: https://www.google.com https://*.gstatic.com https://ciphera.net https://captcha.ciphera.net https://*.cartocdn.com https://cdn.ciphera.net", + // * google/gstatic were only ever here for favicons — those now go through + // * the same-origin /api/favicon proxy, and CSP enforces that nothing + // * regresses to loading them from Google directly. + "img-src 'self' data: blob: https://ciphera.net https://captcha.ciphera.net https://*.cartocdn.com https://cdn.ciphera.net", "font-src 'self'", - `connect-src 'self' https://*.ciphera.net wss://*.ciphera.net https://ciphera.net https://www.google.com https://*.gstatic.com https://cdn.jsdelivr.net https://*.cartocdn.com${process.env.NODE_ENV === 'development' ? ' http://localhost:* ws://localhost:*' : ''}`, + `connect-src 'self' https://*.ciphera.net wss://*.ciphera.net https://ciphera.net https://cdn.jsdelivr.net https://*.cartocdn.com${process.env.NODE_ENV === 'development' ? ' http://localhost:* ws://localhost:*' : ''}`, "worker-src 'self' blob:", "frame-src https://api.help.ciphera.net", "object-src 'none'", From 3d4a632dcfea7b1f8166ff829ea00f232d9ce6b4 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Fri, 3 Jul 2026 14:59:54 +0200 Subject: [PATCH 09/83] fix(settings): surface bot-spam session load failures instead of fake-empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sessions request error was swallowed, so a 500 rendered as "No suspicious sessions found" — a failed request looked like a healthy site. The list now has explicit error (with retry) and loading states; the empty state only renders for a genuinely empty successful response. --- .../settings/unified/tabs/SiteBotSpamTab.tsx | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/components/settings/unified/tabs/SiteBotSpamTab.tsx b/components/settings/unified/tabs/SiteBotSpamTab.tsx index b5aa4ed2..c10eaa25 100644 --- a/components/settings/unified/tabs/SiteBotSpamTab.tsx +++ b/components/settings/unified/tabs/SiteBotSpamTab.tsx @@ -26,7 +26,7 @@ export default function SiteBotSpamTab({ siteId }: { siteId: string }) { const [selectedSessions, setSelectedSessions] = useState>(new Set()) const [botDateRange] = useState(() => getDateRange(7)) - const { data: sessionsData, mutate: mutateSessions } = useSessions(siteId, { start_date: botDateRange.start, end_date: botDateRange.end, suspicious: botView === 'review' ? suspiciousOnly : undefined }) + const { data: sessionsData, error: sessionsError, isLoading: sessionsLoading, mutate: mutateSessions } = useSessions(siteId, { start_date: botDateRange.start, end_date: botDateRange.end, suspicious: botView === 'review' ? suspiciousOnly : undefined }) const sessions = sessionsData?.sessions const hasInitialized = useRef(false) @@ -176,6 +176,27 @@ export default function SiteBotSpamTab({ siteId }: { siteId: string }) { {/* Session cards */}
+ {sessionsError ? ( + /* * A failed request must never masquerade as "no suspicious sessions" — + * that would read as a healthy site while detection is actually down. */ +
+

Couldn't load sessions

+

The session list failed to load, so this is a server error — not an empty result. Try again in a moment.

+ +
+ ) : sessionsLoading ? ( +
+ +
+ ) : ( + <> {(sessions || []) .filter(s => botView === 'blocked' ? s.quarantined : !s.quarantined) .map(session => ( @@ -228,6 +249,8 @@ export default function SiteBotSpamTab({ siteId }: { siteId: string }) { className="py-8" /> )} + + )}
From 70fe00130026795ec07e49daa522927032056f21 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Fri, 3 Jul 2026 14:59:54 +0200 Subject: [PATCH 10/83] fix(settings): explicit notice when encrypted profile details are unavailable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Display Name and Email rendered silently empty whenever the vault PII was never provisioned (a sign-in through Pulse's own form never sets the ciphera_pii cookie that Ciphera ID drops on .ciphera.net — same behaviour on prod). The tab now explains why the fields are empty and points at Ciphera ID as the recovery path, instead of pretending the profile has no name and no email. --- .../unified/tabs/AccountProfileTab.tsx | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/components/settings/unified/tabs/AccountProfileTab.tsx b/components/settings/unified/tabs/AccountProfileTab.tsx index e248fee9..2fdb579f 100644 --- a/components/settings/unified/tabs/AccountProfileTab.tsx +++ b/components/settings/unified/tabs/AccountProfileTab.tsx @@ -60,6 +60,12 @@ export default function AccountProfileTab() { if (!user) return
+ // * Zero-knowledge accounts: the server never stores PII, so name/email are + // * hydrated from the encrypted-vault cookie that Ciphera ID sets on + // * .ciphera.net. A sign-in through Pulse's own form never provisions that + // * cookie, leaving PII empty — say so instead of rendering blank fields. + const piiUnavailable = !user.email + return (
@@ -67,6 +73,24 @@ export default function AccountProfileTab() {

Manage your personal account settings.

+ {piiUnavailable && ( +
+

Profile details unavailable in this session

+

+ Your name and email are end-to-end encrypted and weren't unlocked when you signed + in here. Sign in on Ciphera ID once, then reload Pulse to restore them. +

+ + Open Ciphera ID → + +
+ )} + {/* Display Name */}
@@ -80,7 +104,7 @@ export default function AccountProfileTab() {
- +

Email changes require password verification. Use Ciphera ID to change your email.

From 143a205ddf9b858978642d5aed8e339bb81ccbac Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Fri, 3 Jul 2026 15:13:43 +0200 Subject: [PATCH 11/83] fix(ui): floating layers cast a shadow per the Facet floating-layer rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local overlays (dialog, tooltip, select dropdown, date-range picker) shipped border-only and read flat against the dark canvas, while Facet's own Modal/Tooltip cast shadow-lg/xl — inconsistent depth within one product. Dialogs get shadow-xl, smaller floats shadow-lg; the command palette composes DialogContent so it inherits the fix. --- components/ui/DateRangePicker.tsx | 2 +- components/ui/dialog.tsx | 2 +- components/ui/select.tsx | 2 +- components/ui/tooltip.tsx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/components/ui/DateRangePicker.tsx b/components/ui/DateRangePicker.tsx index fc0851b6..28589f1d 100644 --- a/components/ui/DateRangePicker.tsx +++ b/components/ui/DateRangePicker.tsx @@ -226,7 +226,7 @@ export default function DateRangePicker({ animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, y: 4, scale: 0.98 }} transition={{ duration: 0.15 }} - className="fixed z-50 flex rounded-none border border-neutral-800 bg-neutral-900 overflow-hidden" + className="fixed z-50 flex rounded-none border border-neutral-800 bg-neutral-900 shadow-lg overflow-hidden" style={pos ? { left: pos.left, top: pos.top } : undefined} >
diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx index e72c4b48..41289445 100644 --- a/components/ui/dialog.tsx +++ b/components/ui/dialog.tsx @@ -38,7 +38,7 @@ const DialogContent = React.forwardRef< { const r = triggerRef.current!.getBoundingClientRect() return { right: window.innerWidth - r.right, top: r.bottom + 4 } diff --git a/components/ui/tooltip.tsx b/components/ui/tooltip.tsx index e3de5472..50584e66 100644 --- a/components/ui/tooltip.tsx +++ b/components/ui/tooltip.tsx @@ -22,7 +22,7 @@ const TooltipContent = React.forwardRef< ref={ref} sideOffset={sideOffset} className={cn( - "relative z-50 max-w-[280px] rounded-none border border-border bg-popover px-3 py-1.5 text-sm text-popover-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + "relative z-50 max-w-[280px] rounded-none border border-border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-lg animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className, )} {...props} From 89eec37d1ebc43685de4a20c1552ffab31dee8d0 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Fri, 3 Jul 2026 15:13:43 +0200 Subject: [PATCH 12/83] fix(settings): keep the settings header and left nav visible while scrolling Long tabs (Privacy, Notifications) scrolled both away, losing context and the tab switcher. The header sticks to the top of the scroll container; the nav sticks below it and scrolls internally when taller than the viewport. --- components/settings/SettingsShell.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/components/settings/SettingsShell.tsx b/components/settings/SettingsShell.tsx index d320f190..c17533c9 100644 --- a/components/settings/SettingsShell.tsx +++ b/components/settings/SettingsShell.tsx @@ -155,8 +155,9 @@ export default function SettingsShell({ children }: { children: React.ReactNode return (
- {/* Page header */} -
+ {/* Page header — sticky so long tab content keeps its context. bg must be + opaque (matches the shell canvas) or scrolled content shows through. */} +

{title}

{subtitle &&

{subtitle}

}
@@ -174,8 +175,9 @@ export default function SettingsShell({ children }: { children: React.ReactNode {/* Main layout */}
- {/* Left nav */} -
@@ -126,9 +126,23 @@ export default function SetupInstallPage() { )} {!site && ( - + /* * The heading promises a snippet — when no site is attached to this + * setup session there is none to show, so say that instead of a + * silent empty gap above a bare Continue button. */ +
+
+

+ No site is attached to this setup session, so there's no snippet to + show yet. Create your site first and the snippet will appear here. +

+
+ + +
)} diff --git a/app/setup/done/page.tsx b/app/setup/done/page.tsx index fcf39edc..fcbe7da2 100644 --- a/app/setup/done/page.tsx +++ b/app/setup/done/page.tsx @@ -100,8 +100,8 @@ export default function SetupDonePage() { if (site) sessionStorage.setItem('pulse_active_site', site.id) }} > -
- +
+

Set up a goal

@@ -113,8 +113,8 @@ export default function SetupDonePage() { href="/settings/organization/members" className="flex items-center gap-3 p-3 rounded-none border border-neutral-800 hover:border-neutral-700 hover:bg-neutral-800/30 transition-all" > -
- +
+

Invite your team

@@ -128,8 +128,8 @@ export default function SetupDonePage() { rel="noopener noreferrer" className="flex items-center gap-3 p-3 rounded-none border border-neutral-800 hover:border-neutral-700 hover:bg-neutral-800/30 transition-all" > -
- +
+

Read the docs

From f2b649c4575194e2331c45d6422a0a08d1df8e53 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Fri, 3 Jul 2026 15:14:01 +0200 Subject: [PATCH 21/83] fix(charts): thin x-axis ticks to the chart width Five DD/MM ticks collide at 390px (real dashboard and the marketing demo). numTicks is now an upper bound; narrow charts budget ~80px per label and drop to what fits. --- components/ui/area-chart.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/components/ui/area-chart.tsx b/components/ui/area-chart.tsx index abae2ddd..db7a67fe 100644 --- a/components/ui/area-chart.tsx +++ b/components/ui/area-chart.tsx @@ -1347,7 +1347,7 @@ function XAxisLabel({ } export function XAxis({ numTicks = 5, tickerHalfWidth = 50, formatLabel }: XAxisProps) { - const { xScale, margin, tooltipData, containerRef } = useChart(); + const { xScale, margin, tooltipData, containerRef, innerWidth } = useChart(); const [mounted, setMounted] = useState(false); useEffect(() => { @@ -1366,7 +1366,10 @@ export function XAxis({ numTicks = 5, tickerHalfWidth = 50, formatLabel }: XAxis const startTime = startDate.getTime(); const endTime = endDate.getTime(); const rangeMs = endTime - startTime; - const tickCount = Math.max(2, numTicks); + // numTicks is an upper bound — narrow charts (mobile 390px) fit fewer + // DD/MM labels before they collide, so budget ~80px per tick. + const widthBudget = innerWidth > 0 ? Math.floor(innerWidth / 80) : numTicks; + const tickCount = Math.max(2, Math.min(numTicks, widthBudget)); // Generate all natural boundary dates, then thin to fit numTicks const allDates: Date[] = []; @@ -1431,7 +1434,7 @@ export function XAxis({ numTicks = 5, tickerHalfWidth = 50, formatLabel }: XAxis x: (xScale(date) ?? 0) + margin.left, label: fmt(date), })); - }, [xScale, margin.left, numTicks, formatLabel]); + }, [xScale, margin.left, numTicks, formatLabel, innerWidth]); const isHovering = tooltipData !== null; const crosshairX = tooltipData ? tooltipData.x + margin.left : null; From 77be7fc0e329aa1b0eff022e22b73b8a602299a8 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Fri, 3 Jul 2026 15:26:00 +0200 Subject: [PATCH 22/83] fix(dashboard): neutralise KPI deltas on tiny comparison windows, explain engagement percentiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A previous period with fewer than 10 visitors turns every delta into double-digit noise and paints the whole KPI strip red; those windows now show no delta. The cryptic engagement sub-score row (S P77 · T P88 …) gets a plain-language hover explanation of the percentile ranks. --- components/dashboard/Chart.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/components/dashboard/Chart.tsx b/components/dashboard/Chart.tsx index 53db92f6..f3ac2cf6 100644 --- a/components/dashboard/Chart.tsx +++ b/components/dashboard/Chart.tsx @@ -197,7 +197,12 @@ export default function Chart({ // ─── Metrics with trends ────────────────────────────────────────── - const metricsWithTrends = useMemo(() => METRIC_CONFIGS.map((m) => { + const metricsWithTrends = useMemo(() => { + // * A tiny comparison window (< 10 previous visitors) turns every delta + // * into ±double-digit noise and paints the whole strip red — treat those + // * windows as having no meaningful delta instead. + const insufficientBase = (prevStats?.visitors ?? 0) < 10 + return METRIC_CONFIGS.map((m) => { const value = m.key === 'engagement' ? (engagementData?.summary?.score ?? 0) : m.key === 'pages_per_visit' @@ -208,7 +213,7 @@ export default function Chart({ : m.key === 'pages_per_visit' ? (prevStats && prevStats.visitors > 0 ? prevStats.pageviews / prevStats.visitors : undefined) : prevStats?.[m.key as keyof Stats] - const change = previousValue != null && previousValue > 0 + const change = !insufficientBase && previousValue != null && previousValue > 0 ? ((value - previousValue) / previousValue) * 100 : null const isPositive = change !== null ? (m.isNegative ? change < 0 : change > 0) : null @@ -220,7 +225,7 @@ export default function Chart({ change, isPositive, } - }), [stats, prevStats, engagementData]) + })}, [stats, prevStats, engagementData]) const hasData = data.length > 0 const hasAnyNonZero = hasData && chartData.some((d) => (d[metric] as number) > 0 @@ -271,7 +276,10 @@ export default function Chart({ : } {m.key === 'engagement' && engagementData && engagementData.data_days >= 7 && ( -
+
S P{Math.round(engagementData.summary.scroll_pctl)} · T P{Math.round(engagementData.summary.time_pctl)} From a3c1105318d01889f4ade99fa441ed30227d5361 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Fri, 3 Jul 2026 15:26:00 +0200 Subject: [PATCH 23/83] fix(billing): preselect the org's current billing interval on /switch A yearly org landed on a Monthly-defaulted toggle, misquoting their own plan. Ref-guarded so revalidation doesn't stomp a manual toggle. --- app/switch/page.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/app/switch/page.tsx b/app/switch/page.tsx index 586f9e1f..6016638f 100644 --- a/app/switch/page.tsx +++ b/app/switch/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { Fragment, Suspense, useState } from 'react' +import { Fragment, Suspense, useState, useEffect, useRef } from 'react' import { useRouter } from 'next/navigation' import { motion, AnimatePresence } from 'framer-motion' import useSWR from 'swr' @@ -116,6 +116,16 @@ function SwitchPlanContent() { const [estimateError, setEstimateError] = useState(false) const [switching, setSwitching] = useState(false) + // * Preselect the org's current billing interval — a yearly org defaulting + // * to Monthly misquotes their own plan. Ref-guarded so an SWR revalidation + // * doesn't stomp a manual toggle. + const intervalInitialized = useRef(false) + useEffect(() => { + if (!subscription || intervalInitialized.current) return + setIsYearly(subscription.billing_interval === 'year') + intervalInitialized.current = true + }, [subscription]) + if (isLoading) { return } From c2c59437e2bbc9c4715957f715d781f86c1768b4 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Fri, 3 Jul 2026 15:26:00 +0200 Subject: [PATCH 24/83] feat(billing): usage progress bar for pageview headroom 693 / 100,000 takes arithmetic to read as 0.7%; the bar makes headroom visible at a glance and turns red at 90%. --- components/settings/unified/tabs/WorkspaceBillingTab.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/components/settings/unified/tabs/WorkspaceBillingTab.tsx b/components/settings/unified/tabs/WorkspaceBillingTab.tsx index 384ac528..0a3b27e1 100644 --- a/components/settings/unified/tabs/WorkspaceBillingTab.tsx +++ b/components/settings/unified/tabs/WorkspaceBillingTab.tsx @@ -11,7 +11,7 @@ import { formatDateLong, formatDate } from '@/lib/utils/formatDate' import { getAuthErrorMessage } from '@ciphera-net/facet' import { cdnUrl } from '@/lib/cdn' import { useCan } from '@/lib/auth/permissions' -import { formatPlanName } from '@/lib/utils/plan' +import { formatPlanName } from '@/lib/plans' const PAYMENT_METHODS = [ { id: 'creditcard', label: 'Cards', icons: ['/icons/payment/visa.svg', '/icons/payment/mastercard.svg'] }, @@ -199,6 +199,12 @@ export default function WorkspaceBillingTab() {

Pageviews

{subscription.pageview_usage.toLocaleString()} / {subscription.pageview_limit.toLocaleString()}

+
+
= 0.9 ? 'bg-red-500' : 'bg-brand-orange'}`} + style={{ width: `${Math.min(100, (subscription.pageview_usage / subscription.pageview_limit) * 100)}%` }} + /> +
)} {subscription.current_period_end && ( From 676a8ce3e49b22c7db4b2cbba65bc537ab82ba24 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Fri, 3 Jul 2026 15:26:00 +0200 Subject: [PATCH 25/83] fix(dashboard): page headers on the CDN and Search not-connected states Every other tool tab keeps its h1 in the empty state; these two dropped straight into a centered connect card with no page identity. --- app/sites/[id]/cdn/page.tsx | 12 +++++++++++- app/sites/[id]/search/page.tsx | 12 +++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/app/sites/[id]/cdn/page.tsx b/app/sites/[id]/cdn/page.tsx index 96007963..b2df482f 100644 --- a/app/sites/[id]/cdn/page.tsx +++ b/app/sites/[id]/cdn/page.tsx @@ -187,7 +187,17 @@ export default function CDNPage() { if (bunnyStatus && !bunnyStatus.connected) { return (
-
+ {/* * Same page header as the connected view — without it this tab was + * the only one with no h1 and read as a different app. */} +
+

+ CDN Analytics +

+

+ BunnyCDN performance, bandwidth, and cache metrics +

+
+
diff --git a/app/sites/[id]/search/page.tsx b/app/sites/[id]/search/page.tsx index 25172dd0..16e527ec 100644 --- a/app/sites/[id]/search/page.tsx +++ b/app/sites/[id]/search/page.tsx @@ -187,7 +187,17 @@ export default function SearchConsolePage() { if (gscStatus && !gscStatus.connected) { return (
-
+ {/* * Same page header as the connected view — without it this tab was + * the only one with no h1 and read as a different app. */} +
+

+ Search Console +

+

+ Google Search performance, queries, and page rankings +

+
+
From 225e853f9d8d92839ebfec62218d752df0ed4639 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Fri, 3 Jul 2026 15:26:00 +0200 Subject: [PATCH 26/83] refactor(plans): formatPlanName lives in lib/plans with the other plan helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also routes the sites/new limit toast through it — it interpolated the raw plan_id ("free plan limit reached"). --- app/sites/new/page.tsx | 4 ++-- .../settings/unified/tabs/SitePrivacyTab.tsx | 2 +- lib/plans.ts | 14 ++++++++++++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/app/sites/new/page.tsx b/app/sites/new/page.tsx index 39a1b05f..908d973d 100644 --- a/app/sites/new/page.tsx +++ b/app/sites/new/page.tsx @@ -7,7 +7,7 @@ import Link from 'next/link' import { createSite, getSite, type Site } from '@/lib/api/sites' import { useSites, mutateSites } from '@/lib/swr/sites' import { getSubscription } from '@/lib/api/billing' -import { getSitesLimitForPlan } from '@/lib/plans' +import { getSitesLimitForPlan, formatPlanName } from '@/lib/plans' import { trackSiteCreatedFromDashboard, trackSiteCreatedScriptCopied } from '@/lib/welcomeAnalytics' import { toast } from '@ciphera-net/facet' import { getAuthErrorMessage } from '@ciphera-net/facet' @@ -61,7 +61,7 @@ export default function NewSitePage() { const siteLimit = subscription?.plan_id ? getSitesLimitForPlan(subscription.plan_id) : null if (siteLimit != null && sites.length >= siteLimit) { setAtLimit(true) - toast.error(`${subscription.plan_id} plan limit reached (${siteLimit} site${siteLimit === 1 ? '' : 's'}). Please upgrade to add more sites.`) + toast.error(`${formatPlanName(subscription.plan_id)} plan limit reached (${siteLimit} site${siteLimit === 1 ? '' : 's'}). Please upgrade to add more sites.`) router.replace('/') } } catch (error) { diff --git a/components/settings/unified/tabs/SitePrivacyTab.tsx b/components/settings/unified/tabs/SitePrivacyTab.tsx index 3160a0ea..8c33d85c 100644 --- a/components/settings/unified/tabs/SitePrivacyTab.tsx +++ b/components/settings/unified/tabs/SitePrivacyTab.tsx @@ -13,7 +13,7 @@ import Link from 'next/link' import SettingsSections from '@/components/settings/SettingsSections' import SettingsSaveBar from '@/components/settings/SettingsSaveBar' import { useCan } from '@/lib/auth/permissions' -import { formatPlanName } from '@/lib/utils/plan' +import { formatPlanName } from '@/lib/plans' const GEO_OPTIONS = [ { value: 'full', label: 'Full (country, region, city)' }, diff --git a/lib/plans.ts b/lib/plans.ts index a082ca5f..d9a603b0 100644 --- a/lib/plans.ts +++ b/lib/plans.ts @@ -13,6 +13,20 @@ export const PLAN_ID_SOLO = 'solo' export const PLAN_ID_TEAM = 'team' export const PLAN_ID_BUSINESS = 'business' +/** + * Display name for a subscription plan_id — the single source of truth. + * Ad-hoc ternaries drift (the Privacy tab once recognised only 'pro' and + * labelled a Pioneer org "Free"). + * + * plan_id shapes seen in the wild: 'free' (marketing name: Hobby), legacy + * Stripe 'price_…' ids (Pro), and plain ids like 'solo' / 'team' / 'pioneer'. + */ +export function formatPlanName(planId?: string | null): string { + if (!planId || planId === 'free') return 'Hobby' + if (planId.startsWith('price_')) return 'Pro' + return planId.charAt(0).toUpperCase() + planId.slice(1) +} + /** Sites limit per plan. */ export function getSitesLimitForPlan(planId: string | null | undefined): number | null { if (!planId || planId === 'free') return 1 From fcbaa73289ecd7404e8d2118ee71f003da6d1efa Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Fri, 3 Jul 2026 15:26:16 +0200 Subject: [PATCH 27/83] fix(copy): align labels with reality across installation, join, funnels, site cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Installation referenced "Settings → Goals & Events"; the tab is Goals. - Invalid-invite page sent anonymous visitors to "Go to dashboard" (which is a login wall) — now "Go to Pulse". - Funnels showed two identical orange create CTAs when empty and mixed "Create Funnel"/"Create funnel" casing. - All-zero 24h site-card stats render as em dashes instead of a wall of 0s that reads like broken tracking. --- app/installation/page.tsx | 2 +- app/join/[code]/page.tsx | 6 +++--- app/sites/[id]/funnels/page.tsx | 6 ++++-- components/sites/SiteList.tsx | 6 ++++-- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/app/installation/page.tsx b/app/installation/page.tsx index 36e750f3..14d147c8 100644 --- a/app/installation/page.tsx +++ b/app/installation/page.tsx @@ -66,7 +66,7 @@ export default function InstallationPage() {

Custom events (goals)

- Track custom events (e.g. signup, purchase) with pulse.track('event_name'). Use letters, numbers, and underscores only. Define goals in your site Settings → Goals & Events to see counts in the dashboard. + Track custom events (e.g. signup, purchase) with pulse.track('event_name'). Use letters, numbers, and underscores only. Define goals in your site Settings → Goals to see counts in the dashboard.

diff --git a/app/join/[code]/page.tsx b/app/join/[code]/page.tsx index 5679f7ea..7ca4cf08 100644 --- a/app/join/[code]/page.tsx +++ b/app/join/[code]/page.tsx @@ -138,7 +138,7 @@ function JoinContent() { href="/" className="block w-full rounded-none bg-neutral-800 px-4 py-2.5 text-center text-sm font-medium text-white hover:bg-neutral-700 transition-colors" > - Go to dashboard + Go to Pulse
@@ -171,7 +171,7 @@ function JoinContent() { href="/" className="block w-full rounded-none bg-neutral-800 px-4 py-2.5 text-center text-sm font-medium text-white hover:bg-neutral-700 transition-colors" > - Go to dashboard + Go to Pulse
@@ -199,7 +199,7 @@ function JoinContent() { href="/" className="block w-full rounded-none bg-brand-orange px-4 py-2.5 text-center text-sm font-semibold text-white hover:bg-brand-orange/90 transition-colors" > - Go to dashboard + Go to Pulse
diff --git a/app/sites/[id]/funnels/page.tsx b/app/sites/[id]/funnels/page.tsx index 6a3f04fe..6786e2a4 100644 --- a/app/sites/[id]/funnels/page.tsx +++ b/app/sites/[id]/funnels/page.tsx @@ -342,10 +342,12 @@ export default function FunnelsPage() { onDateRangeChange={setDateRange} onShift={shiftPeriod} /> - {canManageFunnels && ( + {/* * The empty state below carries its own create CTA — showing this + * header button too meant two identical orange CTAs on one screen. */} + {canManageFunnels && funnels.length > 0 && ( )}
diff --git a/components/sites/SiteList.tsx b/components/sites/SiteList.tsx index 9ce14188..a4239220 100644 --- a/components/sites/SiteList.tsx +++ b/components/sites/SiteList.tsx @@ -80,16 +80,18 @@ function SiteCard({ site, stats, statsLoading }: SiteCardProps) { {/* Mini Stats Grid */}
+ {/* * Zero renders as "—": a card full of hard 0s reads as "tracking is + * broken" when it usually just means no traffic in the window. */}

Visitors (24h)

- {statsLoading ? '--' : formatNumber(visitors24h)} + {statsLoading ? '--' : visitors24h === 0 ? '—' : formatNumber(visitors24h)}

Pageviews

- {statsLoading ? '--' : formatNumber(pageviews)} + {statsLoading ? '--' : pageviews === 0 ? '—' : formatNumber(pageviews)}

From e9faec45a86b8ab0d01104c448fd48e598126ee5 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Fri, 3 Jul 2026 15:26:16 +0200 Subject: [PATCH 28/83] refactor(motion): use the shared EASE_APPLE constant in the roles tab Two transitions inlined the tuple literally; drift-prone copies of a constant that already exists in lib/motion. --- components/settings/unified/tabs/WorkspaceRolesTab.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/components/settings/unified/tabs/WorkspaceRolesTab.tsx b/components/settings/unified/tabs/WorkspaceRolesTab.tsx index dddff17c..65b65594 100644 --- a/components/settings/unified/tabs/WorkspaceRolesTab.tsx +++ b/components/settings/unified/tabs/WorkspaceRolesTab.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react' import { motion, AnimatePresence } from 'framer-motion' +import { EASE_APPLE } from '@/lib/motion' import { Button, Input, Spinner, toast, getAuthErrorMessage } from '@ciphera-net/facet' import { Checkbox } from '@/components/ui/checkbox' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' @@ -494,7 +495,7 @@ function RoleCard({ initial={{ height: 0, opacity: 0 }} animate={{ height: 'auto', opacity: 1 }} exit={{ height: 0, opacity: 0 }} - transition={{ duration: 0.2, ease: [0.32, 0.72, 0, 1] }} + transition={{ duration: 0.2, ease: EASE_APPLE }} className="overflow-hidden" >
@@ -682,7 +683,7 @@ export default function WorkspaceRolesTab() { initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -8 }} - transition={{ duration: 0.2, ease: [0.32, 0.72, 0, 1] }} + transition={{ duration: 0.2, ease: EASE_APPLE }} > Date: Fri, 3 Jul 2026 15:26:16 +0200 Subject: [PATCH 29/83] fix(ui): solid slider thumb with a padded touch target At the min position there is no orange range beside the thumb and the background-coloured square vanished into the canvas (pricing slider read as a sliver). Solid fill plus a ::before overlay that extends the touch area past the visual 20px. --- components/ui/slider.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/components/ui/slider.tsx b/components/ui/slider.tsx index 68e54a19..19b456b4 100644 --- a/components/ui/slider.tsx +++ b/components/ui/slider.tsx @@ -58,7 +58,10 @@ const Slider = React.forwardRef< const renderThumb = (value: number) => { const thumb = ( ); From a174973943cc60a8216e6c2478e6349a58c7b701 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Fri, 3 Jul 2026 15:26:16 +0200 Subject: [PATCH 30/83] fix(admin): consistent em-dash empties, orange active tab, card hover affordance Empty table cells mixed "-" and blank; the quarantine active-tab underline was white where the app convention is orange; the admin nav cards gained a border hover state so they read as clickable. --- app/admin/orgs/[id]/page.tsx | 6 +++--- app/admin/orgs/page.tsx | 2 +- app/admin/page.tsx | 4 ++-- app/admin/quarantine/page.tsx | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/admin/orgs/[id]/page.tsx b/app/admin/orgs/[id]/page.tsx index 49cd017f..e345bec8 100644 --- a/app/admin/orgs/[id]/page.tsx +++ b/app/admin/orgs/[id]/page.tsx @@ -136,14 +136,14 @@ export default function AdminOrgDetailPage() { Period End: - {org.current_period_end ? formatDateTime(new Date(org.current_period_end)) : '-'} + {org.current_period_end ? formatDateTime(new Date(org.current_period_end)) : '—'} Customer ID: - {org.billing_customer_id || '-'} + {org.billing_customer_id || '—'} Subscription ID: - {org.billing_subscription_id || '-'} + {org.billing_subscription_id || '—'}
diff --git a/app/admin/orgs/page.tsx b/app/admin/orgs/page.tsx index 9d499e16..1ef62831 100644 --- a/app/admin/orgs/page.tsx +++ b/app/admin/orgs/page.tsx @@ -82,7 +82,7 @@ export default function AdminOrgsPage() { - {org.subscription_status || '-'} + {org.subscription_status || '—'} {new Intl.NumberFormat().format(org.pageview_limit)} diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 2fc06679..8dbd6567 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -7,7 +7,7 @@ export default function AdminDashboard() {

Organizations

Manage organization plans and limits

@@ -17,7 +17,7 @@ export default function AdminDashboard() {

Quarantine & Reputation

Monitor quarantined traffic and domain reputation

diff --git a/app/admin/quarantine/page.tsx b/app/admin/quarantine/page.tsx index 25d7a4b9..98477be3 100644 --- a/app/admin/quarantine/page.tsx +++ b/app/admin/quarantine/page.tsx @@ -275,7 +275,7 @@ function EventsTab() { {ev.path} - {ev.referrer || '-'} + {ev.referrer || '—'} @@ -568,7 +568,7 @@ export default function AdminQuarantinePage() { onClick={() => setTab(t.key)} className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${ tab === t.key - ? 'border-white text-white' + ? 'border-brand-orange text-white' : 'border-transparent text-neutral-400 hover:text-neutral-200' } ease-apple`} > From 0e8c87c57bfe2e6a026602d6583ff78d7a02b68a Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Fri, 3 Jul 2026 15:36:00 +0200 Subject: [PATCH 31/83] fix(privacy): let the CDN cache the favicon proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The blanket no-cache header rule (there to stop stale HTML) also hit /api/favicon and forced cdn-cache: BYPASS — every favicon render became an origin hit plus an upstream fetch. The route sets its own long-lived Cache-Control; exclude it from the blanket rule. --- next.config.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/next.config.ts b/next.config.ts index fbc8b5b8..5f72e321 100644 --- a/next.config.ts +++ b/next.config.ts @@ -64,7 +64,9 @@ const nextConfig: NextConfig = { { // * Prevent CDN/browser from serving stale HTML after deploys. // * Static assets (/_next/static/*) are content-hashed and cached separately by Next.js. - source: '/((?!_next/static|_next/image).*)', + // * /api/favicon sets its own long-lived Cache-Control (the CDN must cache + // * it, or every favicon render becomes an origin hit + upstream fetch). + source: '/((?!_next/static|_next/image|api/favicon).*)', headers: [ { key: 'Cache-Control', value: 'no-cache, must-revalidate' }, ], From 70ea0fe3559f0623c1625c268b993bc0556ef7ab Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Fri, 3 Jul 2026 15:36:00 +0200 Subject: [PATCH 32/83] fix(changelog): collapse each older release to its heading The single show-older fold still left ~21k px of expanded content. Unreleased and the latest release stay expanded; every older version renders as a heading row that expands on demand. --- app/changelog/page.tsx | 38 +++++++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/app/changelog/page.tsx b/app/changelog/page.tsx index 8a4cbfa9..64facbb1 100644 --- a/app/changelog/page.tsx +++ b/app/changelog/page.tsx @@ -8,16 +8,35 @@ export const metadata: Metadata = { description: 'Release history and notable changes for Pulse, privacy-first web analytics.', } -// * How many release sections (incl. Unreleased) render expanded; the rest -// * collapse behind a native
so the page stays a few screens tall +// * How many release sections (incl. Unreleased) render expanded; every other +// * version collapses to its heading so the page stays a few screens tall // * instead of ~29k px. -const EXPANDED_SECTIONS = 5 +const EXPANDED_SECTIONS = 2 const PROSE_CLASSES = `prose prose-invert max-w-none prose-headings:font-semibold prose-headings:tracking-tight prose-a:text-brand-orange prose-a:no-underline hover:prose-a:underline prose-ul:my-4 prose-li:my-0.5` +// * A collapsed release: the `## [x.y.z] - date` heading becomes the summary +// * row; the body only renders (and only costs height) once opened. +function CollapsedRelease({ section, linkDefs }: { section: string; linkDefs: string }) { + const newline = section.indexOf('\n') + const heading = (newline === -1 ? section : section.slice(0, newline)).replace(/^## /, '').replace(/[[\]]/g, '') + const body = newline === -1 ? '' : section.slice(newline + 1) + return ( +
+ + {heading} + ↓ + +
+ {[body, linkDefs].join('\n')} +
+
+ ) +} + /** * Reads CHANGELOG.md from the project root and renders it on the /changelog page. * Content is loaded at build time; redeploy to show new releases. @@ -65,14 +84,11 @@ export default function ChangelogPage() { {[preamble, ...recent, linkDefs].join('\n')} {older.length > 0 && ( -
- - Show {older.length} older release{older.length === 1 ? '' : 's'} ↓ - -
- {[...older, linkDefs].join('\n')} -
-
+
+ {older.map((section) => ( + + ))} +
)}
) From b58552e6443d6aa1fa8c155c8c2a4f7927f55eb8 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Fri, 3 Jul 2026 15:45:22 +0200 Subject: [PATCH 33/83] chore: drop the superseded lib/utils/plan.ts formatPlanName moved to lib/plans.ts in the previous commit; this file was orphaned (zero importers) but its staged deletion missed the pathspec-limited commit. --- lib/utils/plan.ts | 15 --------------- 1 file changed, 15 deletions(-) delete mode 100644 lib/utils/plan.ts diff --git a/lib/utils/plan.ts b/lib/utils/plan.ts deleted file mode 100644 index afc5c331..00000000 --- a/lib/utils/plan.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Display name for a subscription plan_id. - * - * Single source of truth — ad-hoc ternaries drift (the Privacy tab once - * recognised only 'pro' and labelled every other plan "Free", so a Pioneer - * org read "Your Free plan…"). - * - * plan_id shapes seen in the wild: 'free' (marketing name: Hobby), legacy - * Stripe 'price_…' ids (Pro), and plain ids like 'solo' / 'team' / 'pioneer'. - */ -export function formatPlanName(planId?: string | null): string { - if (!planId || planId === 'free') return 'Hobby' - if (planId.startsWith('price_')) return 'Pro' - return planId.charAt(0).toUpperCase() + planId.slice(1) -} From d9f0161153f3f9864ca396b62799d789d150f20d Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Fri, 3 Jul 2026 22:04:20 +0200 Subject: [PATCH 34/83] fix(settings): pin the sticky nav to the header's actual height MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nav offset was a guessed 68px against a content-sized header — any rendering where the header came out taller let the nav and content slide underneath it. The header now has a fixed h-16 box and the nav sticks at the matching top-16, with a bottom border so content passing under the bar reads as intentional. --- components/settings/SettingsShell.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/components/settings/SettingsShell.tsx b/components/settings/SettingsShell.tsx index c17533c9..c464a9de 100644 --- a/components/settings/SettingsShell.tsx +++ b/components/settings/SettingsShell.tsx @@ -155,9 +155,11 @@ export default function SettingsShell({ children }: { children: React.ReactNode return (
- {/* Page header — sticky so long tab content keeps its context. bg must be - opaque (matches the shell canvas) or scrolled content shows through. */} -
+ {/* Page header — sticky so long tab content keeps its context. Fixed h-16 + so the nav's sticky offset (top-16) is exactly the header's height — + a guessed pixel offset here lets the nav slide underneath. The border + marks the edge content is meant to pass under; bg must be opaque. */} +

{title}

{subtitle &&

{subtitle}

}
@@ -177,7 +179,7 @@ export default function SettingsShell({ children }: { children: React.ReactNode
{/* Left nav — sticks below the header; taller-than-viewport navs scroll internally instead of pinning the tail out of reach. */} -