diff --git a/apps/docs/app/(home)/pricing/_pricing/gated-feature-rows.tsx b/apps/docs/app/(home)/pricing/_pricing/gated-feature-rows.tsx index 60554db6a..dc362bbf9 100644 --- a/apps/docs/app/(home)/pricing/_pricing/gated-feature-rows.tsx +++ b/apps/docs/app/(home)/pricing/_pricing/gated-feature-rows.tsx @@ -9,7 +9,8 @@ import { PLAN_IDS, type PlanId, } from "@databuddy/shared/types/features"; -import { XMarkIcon as XIcon } from "@databuddy/ui/icons"; +import { CheckIcon, XMarkIcon as XIcon } from "@databuddy/ui/icons"; +import Link from "next/link"; import type { ReactNode } from "react"; /** Docs pricing column ids → shared plan ids (enterprise maps to Scale limits). */ @@ -30,12 +31,18 @@ function isUnlimitedOnAllPlans(featureId: GatedFeatureId): boolean { return true; } -function pricingVisibleGatedFeatures(): GatedFeatureId[] { +function featuresWithLimits(): GatedFeatureId[] { return (Object.values(GATED_FEATURES) as GatedFeatureId[]) .filter((id) => !HIDDEN_PRICING_FEATURES.includes(id)) .filter((id) => !isUnlimitedOnAllPlans(id)); } +function featuresUnlimitedOnAll(): GatedFeatureId[] { + return (Object.values(GATED_FEATURES) as GatedFeatureId[]) + .filter((id) => !HIDDEN_PRICING_FEATURES.includes(id)) + .filter((id) => isUnlimitedOnAllPlans(id)); +} + function FeatureX() { return ( @@ -44,6 +51,14 @@ function FeatureX() { ); } +function FeatureCheck() { + return ( + + + + ); +} + function formatLimitCell( limit: FeatureLimit, featureId: GatedFeatureId @@ -52,7 +67,7 @@ function formatLimitCell( return ; } if (limit === "unlimited") { - return Unlimited; + return ; } const meta = FEATURE_METADATA[featureId]; const unit = meta.unit; @@ -86,52 +101,162 @@ interface GatedFeaturePricingRowsProps { planTdClassName: (planId: string) => string; } +const GATED_FEATURE_LINKS: Partial> = { + [GATED_FEATURES.FEATURE_FLAGS]: "/feature-flags", + [GATED_FEATURES.WEB_VITALS]: "/web-vitals", + [GATED_FEATURES.ERROR_TRACKING]: "/errors", +}; + +interface PlatformFeature { + name: string; + description: string; + href?: string; +} + +const PLATFORM_FEATURES: PlatformFeature[] = [ + { name: "Uptime Monitoring", description: "Endpoint checks, alerts, and status pages", href: "/uptime" }, + { name: "Short Links", description: "Branded links with click analytics and deep linking", href: "/links" }, + { name: "Revenue Tracking", description: "Stripe and Paddle revenue attribution" }, + { name: "Alerts & Notifications", description: "Traffic, error, and anomaly alerts" }, + { name: "Team Members", description: "Unlimited seats on all plans" }, + { name: "Websites", description: "Unlimited websites on all plans" }, + { name: "API Access", description: "REST API with scoped API keys" }, + { name: "Slack Integration", description: "Alerts and digests in Slack" }, + { name: "SDKs", description: "JavaScript, React, Vue, Swift" }, +]; + +function FeatureLabel({ + name, + description, + href, +}: { + name: string; + description: string; + href?: string; +}) { + if (href) { + return ( + + {name} + + ); + } + return {name}; +} + +function AllPlansCheckRow({ + name, + description, + href, + plans, + planTdClassName, +}: { + name: string; + description: string; + href?: string; + plans: Array<{ id: string }>; + planTdClassName: (planId: string) => string; +}) { + return ( + + + + + {plans.map((p) => ( + + + + ))} + + ); +} + export function GatedFeaturePricingRows({ plans, planTdClassName, }: GatedFeaturePricingRowsProps) { - const features = pricingVisibleGatedFeatures(); - if (features.length === 0) { - return null; - } - + const limited = featuresWithLimits(); + const unlimited = featuresUnlimitedOnAll(); const colSpan = 1 + plans.length; return ( <> + {limited.length > 0 && ( + <> + + + Analytics features + + + {limited.map((featureId) => { + const meta = FEATURE_METADATA[featureId]; + const href = GATED_FEATURE_LINKS[featureId]; + return ( + + + + + {plans.map((p) => ( + + + + ))} + + ); + })} + {unlimited.map((featureId) => { + const meta = FEATURE_METADATA[featureId]; + const href = GATED_FEATURE_LINKS[featureId]; + return ( + + ); + })} + + )} - Product features + Platform - {features.map((featureId) => { - const meta = FEATURE_METADATA[featureId]; - return ( - - - {meta.name} - - {plans.map((p) => ( - - - - ))} - - ); - })} + {PLATFORM_FEATURES.map((feat) => ( + + ))} ); } diff --git a/apps/docs/app/(home)/pricing/_pricing/table.tsx b/apps/docs/app/(home)/pricing/_pricing/table.tsx index 899cd8eb4..295e82617 100644 --- a/apps/docs/app/(home)/pricing/_pricing/table.tsx +++ b/apps/docs/app/(home)/pricing/_pricing/table.tsx @@ -163,6 +163,15 @@ export function PlansComparisonTable({ plans }: Props) { plans={plans} planTdClassName={planComparisonTdClass} /> + {/* Enterprise section header */} + + + Enterprise + + {/* Support row */} @@ -282,14 +291,19 @@ export function PlansComparisonTable({ plans }: Props) {

What counts as an event? A pageview, custom event, error, or Web Vital measurement. Feature flag - evaluations are free and don't count toward your quota. + evaluations and uptime checks are free and don't count toward your + quota.

Agent credits power Databunny, the AI assistant that analyzes your data, answers questions, and surfaces insights automatically.

-

Overage is tiered. Lower rates apply as volume increases.

+

+ Unlimited seats & sites.{" "} + Team members, websites, and API access are unlimited on every plan. + Overage is tiered with lower rates as volume increases. +

); diff --git a/apps/docs/app/(home)/pricing/page.tsx b/apps/docs/app/(home)/pricing/page.tsx index 00e26297f..b50383a1e 100644 --- a/apps/docs/app/(home)/pricing/page.tsx +++ b/apps/docs/app/(home)/pricing/page.tsx @@ -47,9 +47,9 @@ export default function PricingPage() { Every feature, every plan.

- Analytics, errors, vitals, and flags included at every tier. Pick a - plan based on volume, not features. Fair tiered overage, and you - only pay for what you use. + Analytics, uptime monitoring, link management, error tracking, web + vitals, feature flags, and more included at every tier. Pick a plan + based on volume, not features.

diff --git a/apps/docs/components/footer.tsx b/apps/docs/components/footer.tsx index b8cf1cf7d..f31c92b1e 100644 --- a/apps/docs/components/footer.tsx +++ b/apps/docs/components/footer.tsx @@ -1,13 +1,12 @@ "use client"; -import { SiDiscord, SiX } from "@icons-pack/react-simple-icons"; import { Button } from "@databuddy/ui"; import { EnvelopeIcon } from "@databuddy/ui/icons"; +import { SiDiscord, SiX } from "@icons-pack/react-simple-icons"; import Image from "next/image"; import Link from "next/link"; import { CCPAIcon } from "./icons/ccpa"; import { GDPRIcon } from "./icons/gdpr"; -import { Wordmark } from "./landing/wordmark"; import { LogoContent } from "./logo"; import { NavLink } from "./nav-link"; import { NewsletterForm } from "./newsletter-form"; @@ -281,7 +280,6 @@ export function Footer() { - ); diff --git a/apps/docs/components/landing/wordmark.tsx b/apps/docs/components/landing/wordmark.tsx deleted file mode 100644 index 67c5cb934..000000000 --- a/apps/docs/components/landing/wordmark.tsx +++ /dev/null @@ -1,253 +0,0 @@ -"use client"; - -import { - animate, - motion, - useMotionTemplate, - useMotionValue, -} from "motion/react"; -import React, { useCallback, useEffect, useRef } from "react"; -import { cn } from "@/lib/utils"; - -export const Wordmark = () => { - return ( -
-
- {/* Logo SVG */} -
-
- - - - - - - - - - - -
-
-
-
- ); -}; - -interface MagicSVGProps { - children: React.ReactNode; - className?: string; - fill?: string; - gradientFrom?: string; - gradientSize?: number; - gradientTo?: string; - height: number; - strokeColor?: string; - strokeWidth?: number; - width: number; -} - -export function MagicSVG({ - children, - width, - height, - className, - gradientSize = 50, - gradientFrom = "#E3A514", - gradientTo = "#B74677", - strokeWidth = 1, - fill = "none", - strokeColor = "#27282D", -}: MagicSVGProps) { - const svgRef = useRef(null); - - const animatedX = useMotionValue(-gradientSize * 2); - const animatedY = useMotionValue(-gradientSize * 2); - - const handleMouseMove = useCallback( - (e: MouseEvent) => { - if (svgRef.current) { - const { left, top } = svgRef.current.getBoundingClientRect(); - const clientX = e.clientX; - const clientY = e.clientY; - const newX = clientX - left; - const newY = clientY - top; - - animate(animatedX, newX, { - type: "spring", - stiffness: 150, - damping: 25, - mass: 0.8, - }); - - animate(animatedY, newY, { - type: "spring", - stiffness: 150, - damping: 25, - mass: 0.8, - }); - } - }, - [animatedX, animatedY] - ); - - const handleMouseLeave = useCallback(() => { - animate(animatedX, -gradientSize * 2, { - type: "spring", - stiffness: 100, - damping: 30, - }); - - animate(animatedY, -gradientSize * 2, { - type: "spring", - stiffness: 100, - damping: 30, - }); - }, [animatedX, animatedY, gradientSize]); - - const handleMouseEnter = useCallback(() => { - document.addEventListener("mousemove", handleMouseMove); - }, [handleMouseMove]); - - useEffect(() => { - const svgElement = svgRef.current; - if (svgElement) { - svgElement.addEventListener("mouseenter", handleMouseEnter); - svgElement.addEventListener("mouseleave", handleMouseLeave); - } - return () => { - if (svgElement) { - svgElement.removeEventListener("mouseenter", handleMouseEnter); - svgElement.removeEventListener("mouseleave", handleMouseLeave); - } - document.removeEventListener("mousemove", handleMouseMove); - }; - }, [handleMouseEnter, handleMouseLeave, handleMouseMove]); - - useEffect(() => { - animatedX.set(-gradientSize * 2); - animatedY.set(-gradientSize * 2); - }, [gradientSize, animatedX, animatedY]); - - const gradientId = "magic-gradient-wordmark"; - const maskId = "magic-mask-wordmark"; - - return ( - - Magic SVG - - - - - - - - - - - - - - {React.Children.map(children, (child) => { - if (React.isValidElement(child)) { - const childType = (child as React.ReactElement).type; - if ( - childType === "defs" || - childType === "mask" || - childType === "clipPath" - ) { - return child; - } - } - return null; - })} - - - {React.Children.map(children, (child) => { - if (React.isValidElement(child)) { - const childType = (child as React.ReactElement).type; - if ( - childType !== "defs" && - childType !== "mask" && - childType !== "clipPath" - ) { - return React.cloneElement( - child as React.ReactElement>, - { - stroke: strokeColor, - strokeWidth, - fill, - } - ); - } - } - return null; - })} - - - - {React.Children.map(children, (child) => { - if (React.isValidElement(child)) { - const childType = (child as React.ReactElement).type; - if ( - childType !== "defs" && - childType !== "mask" && - childType !== "clipPath" - ) { - return React.cloneElement( - child as React.ReactElement>, - { - stroke: `url(#${gradientId})`, - strokeWidth: strokeWidth + 1, - fill, - } - ); - } - } - return null; - })} - - - ); -} diff --git a/apps/insights/src/generation.ts b/apps/insights/src/generation.ts index 427115689..b02742417 100644 --- a/apps/insights/src/generation.ts +++ b/apps/insights/src/generation.ts @@ -48,8 +48,9 @@ import { buildInvestigationPrompt, buildSystemPrompt, fetchDismissedPatterns, - fetchRecentAnnotations, fetchInsightHistory, + fetchRecentAnnotations, + fetchSiteCapabilities, formatOrgWebsitesContext, type OrgWebsiteRow, } from "./prompts"; @@ -295,17 +296,24 @@ async function analyzeWebsite(params: { const investigationMode = enrichedSignals.length > 0; - const [annotationContext, historyBlock, siteContext, dismissedBlock] = - await Promise.all([ - fetchRecentAnnotations(params.websiteId, params.config), - fetchInsightHistory( - params.organizationId, - params.websiteId, - params.config - ), - getCachedSiteContext(params.domain), - fetchDismissedPatterns(params.organizationId, params.websiteId), - ]); + const [ + annotationContext, + historyBlock, + siteContext, + dismissedBlock, + capabilitiesBlock, + ] = await Promise.all([ + fetchRecentAnnotations(params.websiteId, params.config), + fetchInsightHistory(params.organizationId, params.websiteId, params.config), + getCachedSiteContext(params.domain), + fetchDismissedPatterns(params.organizationId, params.websiteId), + fetchSiteCapabilities( + params.websiteId, + params.config.timezone, + currentRange.from, + currentRange.to + ), + ]); const allowedTools = normalizeAllowedTools(params.config.allowedTools); const orgContext = formatOrgWebsitesContext( @@ -324,10 +332,11 @@ async function analyzeWebsite(params: { historyBlock, annotationContext, dismissedBlock, + capabilitiesBlock, orgContext, siteContext: siteBlock, }) - : `Analyze ${params.domain} (${currentRange.from} to ${currentRange.to} vs ${previousRange.from} to ${previousRange.to}, ${params.config.timezone}). Use web_metrics with period="both" to compare periods efficiently.${siteBlock} + : `Analyze ${params.domain} (${currentRange.from} to ${currentRange.to} vs ${previousRange.from} to ${previousRange.to}, ${params.config.timezone}). Use web_metrics with period="both" to compare periods efficiently.${siteBlock}${capabilitiesBlock} ${orgContext}${annotationContext}${historyBlock}${dismissedBlock}`; const { tools: analyticsTools } = createInsightsAgentTools({ diff --git a/apps/insights/src/prompts.ts b/apps/insights/src/prompts.ts index e243cdd61..01f72cc18 100644 --- a/apps/insights/src/prompts.ts +++ b/apps/insights/src/prompts.ts @@ -1,8 +1,11 @@ import type { WeekOverWeekPeriod } from "@databuddy/ai/insights/types"; +import { executeQuery } from "@databuddy/ai/query"; import { and, db, desc, eq, gte, isNull } from "@databuddy/db"; import { analyticsInsights, annotations, + funnelDefinitions, + goals, type InsightGenerationConfigSnapshot, insightUserFeedback, } from "@databuddy/db/schema"; @@ -11,6 +14,130 @@ import type { EnrichedSignal } from "./enrichment"; const RECENT_INSIGHTS_PROMPT_LIMIT = 12; +export async function fetchSiteCapabilities( + websiteId: string, + timezone: string, + from: string, + to: string +): Promise { + const [customEventRows, errorSummaryRows, vitalRows, funnelRows, goalRows] = + await Promise.all([ + executeQuery( + { + projectId: websiteId, + type: "custom_events_discovery", + from, + to, + timezone, + limit: 50, + }, + undefined, + timezone + ), + executeQuery( + { + projectId: websiteId, + type: "error_summary", + from, + to, + timezone, + }, + undefined, + timezone + ), + executeQuery( + { + projectId: websiteId, + type: "vitals_overview", + from, + to, + timezone, + }, + undefined, + timezone + ), + db + .select({ name: funnelDefinitions.name }) + .from(funnelDefinitions) + .where( + and( + eq(funnelDefinitions.websiteId, websiteId), + eq(funnelDefinitions.isActive, true), + isNull(funnelDefinitions.deletedAt) + ) + ) + .limit(20), + db + .select({ name: goals.name, type: goals.type, target: goals.target }) + .from(goals) + .where( + and( + eq(goals.websiteId, websiteId), + eq(goals.isActive, true), + isNull(goals.deletedAt) + ) + ) + .limit(20), + ]); + + const parts: string[] = []; + + const eventNames = [ + ...new Set( + ( + customEventRows as Array<{ + event_name?: string; + total_events?: number; + }> + ) + .filter((r) => r.event_name && (r.total_events ?? 0) > 0) + .map((r) => r.event_name as string) + ), + ]; + if (eventNames.length > 0) { + parts.push( + `Custom events (${eventNames.length}): ${eventNames.join(", ")}` + ); + } else { + parts.push("Custom events: none configured"); + } + + const errors = errorSummaryRows[0] as { totalErrors?: number } | undefined; + const errorCount = Number(errors?.totalErrors ?? 0); + parts.push( + errorCount > 0 + ? `Errors: ${errorCount} in current period` + : "Errors: none recorded" + ); + + const vitals = (vitalRows as Array<{ metric_name?: string }>).map( + (r) => r.metric_name + ); + if (vitals.length > 0) { + parts.push(`Vitals: ${vitals.join(", ")}`); + } else { + parts.push("Vitals: no data"); + } + + if (funnelRows.length > 0) { + parts.push( + `Funnels (${funnelRows.length}): ${funnelRows.map((f) => f.name).join(", ")}` + ); + } else { + parts.push("Funnels: none configured"); + } + + if (goalRows.length > 0) { + parts.push( + `Goals (${goalRows.length}): ${goalRows.map((g) => `${g.name} (${g.type}: ${g.target})`).join(", ")}` + ); + } else { + parts.push("Goals: none configured"); + } + + return `\nSite capabilities:\n${parts.join("\n")}`; +} + export function promptLookbackDays( config: InsightGenerationConfigSnapshot ): number { @@ -284,6 +411,7 @@ export function buildInvestigationPrompt( enrichedSignals: EnrichedSignal[], params: { annotationContext: string; + capabilitiesBlock: string; dismissedBlock: string; domain: string; githubRepo?: { owner: string; repo: string }; @@ -305,7 +433,7 @@ export function buildInvestigationPrompt( return `Investigating ${enrichedSignals.length} anomalies on ${domain}. Period: ${period.current.from} to ${period.current.to} vs ${period.previous.from} to ${period.previous.to} (${timezone}) -${params.siteContext} +${params.siteContext}${params.capabilitiesBlock} SIGNALS: diff --git a/packages/evals/ui/index.html b/packages/evals/ui/index.html index bdc50fe4c..b1b442c00 100644 --- a/packages/evals/ui/index.html +++ b/packages/evals/ui/index.html @@ -794,16 +794,16 @@

Latest model board

const id = escapeHtml(c.id); const cost = (c.metrics?.costUsd || 0) + (c.metrics?.judgeCostUsd || 0); return ` -
${id}
- ${escapeHtml(c.category || "case")} - ${c.passed ? "Pass" : "Fail"} - ${c.scores?.tool_routing ?? "--"} - ${c.scores?.quality ?? "--"} - ${((c.metrics?.latencyMs || 0) / 1000).toFixed(1)}s - ${c.metrics?.steps ?? "--"} - ${money(cost)} - -
${detail(c)}
`; +
${id}
+ ${escapeHtml(c.category || "case")} + ${c.passed ? "Pass" : "Fail"} + ${c.scores?.tool_routing ?? "--"} + ${c.scores?.quality ?? "--"} + ${((c.metrics?.latencyMs || 0) / 1000).toFixed(1)}s + ${c.metrics?.steps ?? "--"} + ${money(cost)} + +
${detail(c)}
`; } function detail(c) { @@ -815,7 +815,7 @@

Latest model board

.map((t) => `${escapeHtml(t)}`) .join("") || 'No tools called'; return `

Response

${escapeHtml(c.response || "No response captured.")}
-

Failures

    ${failures}

Tools

${tools}
`; +

Failures

    ${failures}

Tools

${tools}
`; } function toggle(id) { diff --git a/packages/shared/src/types/features.ts b/packages/shared/src/types/features.ts index 77df9eef4..0aa899fbe 100644 --- a/packages/shared/src/types/features.ts +++ b/packages/shared/src/types/features.ts @@ -34,9 +34,7 @@ export const GATED_FEATURES = { export type GatedFeatureId = (typeof GATED_FEATURES)[keyof typeof GATED_FEATURES]; -export const HIDDEN_PRICING_FEATURES: GatedFeatureId[] = [ - GATED_FEATURES.ERROR_TRACKING, -]; +export const HIDDEN_PRICING_FEATURES: GatedFeatureId[] = []; export type FeatureLimit = number | "unlimited" | false;