diff --git a/.agents/skills/databuddy-internal/SKILL.md b/.agents/skills/databuddy-internal/SKILL.md
index 7b8b0cf924..8cffabc852 100644
--- a/.agents/skills/databuddy-internal/SKILL.md
+++ b/.agents/skills/databuddy-internal/SKILL.md
@@ -77,6 +77,7 @@ Keep additions **minimal**: one bullet, a new `rg` hint, or a routing note—eno
- `packages/sdk`: published analytics SDK for React, Vue, and Node
- `packages/tracker`: internal tracker script build and release package
- `packages/encryption`, `packages/notifications`, `packages/cache`, `packages/redis`, `packages/services`, `packages/validation`, `packages/api-keys`: shared infra and domain packages
+- Knip is configured in root `knip.json` (run `bun run knip`); per-workspace test globs are required because the root `test:watch` (`bun test --watch ./apps`) script shadows the Bun plugin's per-workspace script parsing; `apps/cron` is ignored (standalone scripts, no package.json)
Read [codebase-map.md](./references/codebase-map.md) when you need deeper routing guidance.
diff --git a/.changeset/sdk-storage-error-fix.md b/.changeset/sdk-storage-error-fix.md
new file mode 100644
index 0000000000..54d4fb7143
--- /dev/null
+++ b/.changeset/sdk-storage-error-fix.md
@@ -0,0 +1,5 @@
+---
+"@databuddy/sdk": patch
+---
+
+`getAnonymousId` and `getSessionId` now return `null` instead of throwing when `localStorage` or `sessionStorage` access raises a `DOMException`. Follows the same try/catch pattern already used by `getProfileId`. URL params continue to take priority without touching storage.
diff --git a/.github/workflows/sdk-e2e.yml b/.github/workflows/sdk-e2e.yml
new file mode 100644
index 0000000000..a4a5800abe
--- /dev/null
+++ b/.github/workflows/sdk-e2e.yml
@@ -0,0 +1,63 @@
+name: SDK E2E
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - ".github/workflows/sdk-e2e.yml"
+ - "packages/sdk/**"
+ - "packages/tracker/**"
+ - "bun.lock"
+ - "package.json"
+ - "turbo.json"
+ pull_request:
+ branches: [main, staging]
+ paths:
+ - ".github/workflows/sdk-e2e.yml"
+ - "packages/sdk/**"
+ - "packages/tracker/**"
+ - "bun.lock"
+ - "package.json"
+ - "turbo.json"
+
+permissions:
+ contents: read
+
+concurrency:
+ group: sdk-e2e-${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.head_ref || github.ref_name }}
+ cancel-in-progress: true
+
+jobs:
+ sdk-e2e:
+ name: SDK Playwright
+ runs-on: blacksmith-4vcpu-ubuntu-2404
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
+ - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
+ with:
+ bun-version: "1.3.14"
+ - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
+ with:
+ path: ~/.bun/install/cache
+ key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
+ restore-keys: ${{ runner.os }}-bun-
+ - run: bun install --frozen-lockfile --ignore-scripts
+ - name: Build sdk and tracker
+ run: bunx turbo run build --filter @databuddy/sdk --filter @databuddy/tracker
+ - name: Install Playwright browser
+ run: bun run --cwd packages/sdk playwright install --with-deps chromium
+ - name: Run sdk E2E
+ run: bun run --cwd packages/sdk playwright test --project=chromium
+ - name: Run tracker E2E
+ run: bun run --cwd packages/tracker playwright test --project=chromium
+ - name: Upload Playwright artifacts
+ if: failure()
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+ with:
+ name: sdk-e2e-artifacts
+ path: |
+ packages/sdk/test-results
+ packages/tracker/test-results
+ if-no-files-found: ignore
+ retention-days: 7
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 8a4d0d05e4..43362c2881 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -18,12 +18,15 @@ git clone https://github.com/databuddy-analytics/Databuddy.git
cd databuddy
```
-2. Install dependencies:
+2. Install dependencies (requires Bun 1.2.0+, check with `bun --version`):
```bash
bun install
```
+> [!NOTE]
+> Bun 1.2.0 is the minimum for `catalog:` dependency support. The repo pins an exact version in the `packageManager` field of `package.json`; match it with `bun upgrade` or by installing that version from https://bun.sh.
+
3. Set up environment variables:
```bash
diff --git a/apps/api/package.json b/apps/api/package.json
index b165224748..9af45a747a 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -10,7 +10,6 @@
"test:watch": "TZ=UTC bunx --bun vitest"
},
"dependencies": {
- "@ai-sdk/provider": "^3.0.5",
"@contextcompany/otel": "^1.0.13",
"@databuddy/ai": "workspace:*",
"@databuddy/api-keys": "workspace:*",
@@ -22,7 +21,6 @@
"@databuddy/notifications": "workspace:*",
"@databuddy/redis": "workspace:*",
"@databuddy/rpc": "workspace:*",
- "@databuddy/sdk": "workspace:*",
"@databuddy/services": "workspace:*",
"@databuddy/shared": "workspace:*",
"@databuddy/validation": "workspace:*",
@@ -37,21 +35,17 @@
"ai": "^6.0.188",
"autumn-js": "catalog:",
"bullmq": "^5.78.0",
- "dayjs": "^1.11.19",
"elysia": "catalog:",
"evlog": "catalog:",
- "jszip": "^3.10.1",
"keypal": "0.2.0",
"lru-cache": "^11.2.7",
"resend": "^4.0.1",
- "supermemory": "^4.17.0",
"svix": "^1.84.1",
"zod": "catalog:"
},
"devDependencies": {
"@databuddy/test": "workspace:*",
"@types/bun": "catalog:",
- "mitata": "^1.0.34",
"vitest": "^4.1.5"
},
"peerDependencies": {
diff --git a/apps/api/src/http/cors.ts b/apps/api/src/http/cors.ts
index 4a9b0e78d3..95dd3cb265 100644
--- a/apps/api/src/http/cors.ts
+++ b/apps/api/src/http/cors.ts
@@ -4,7 +4,7 @@ const DATABUDDY_HOST_RE = /(?:^|\.)databuddy\.cc$/;
const allowedApiOrigins = new Set(config.cors.apiOrigins);
const MCP_PATHS = new Set(["/v1/mcp", "/v1/mcp/", "/mcp", "/mcp/"]);
-export function isMcpRequest(request: Request): boolean {
+function isMcpRequest(request: Request): boolean {
return MCP_PATHS.has(new URL(request.url).pathname);
}
diff --git a/apps/api/src/integration/cache-auth-bypass.test.ts b/apps/api/src/integration/cache-auth-bypass.test.ts
index 4c42718133..cd94c66e35 100644
--- a/apps/api/src/integration/cache-auth-bypass.test.ts
+++ b/apps/api/src/integration/cache-auth-bypass.test.ts
@@ -1,7 +1,7 @@
import "@databuddy/test/env";
import { flags, targetGroups } from "@databuddy/db/schema";
-import { appRouter, type Context } from "@databuddy/rpc";
+import { appRouter } from "@databuddy/rpc";
import {
addToOrganization,
apiKeyContext,
@@ -16,16 +16,12 @@ import {
signUp,
userContext,
} from "@databuddy/test";
-import { createProcedureClient } from "@orpc/server";
import { randomUUIDv7 } from "bun";
import { afterAll, beforeEach, describe, expect, it } from "vitest";
+import { call } from "./helpers";
const iit = hasTestDb ? it : it.skip;
-function call(procedure: T, ctx: Context) {
- return createProcedureClient(procedure as any, { context: ctx });
-}
-
async function setupOwnedSite(siteOverrides?: { isPublic?: boolean }) {
const user = await signUp();
const org = await insertOrganization();
diff --git a/apps/api/src/integration/helpers.ts b/apps/api/src/integration/helpers.ts
new file mode 100644
index 0000000000..4e6540c6eb
--- /dev/null
+++ b/apps/api/src/integration/helpers.ts
@@ -0,0 +1,6 @@
+import type { Context } from "@databuddy/rpc";
+import { type AnyProcedure, createProcedureClient } from "@orpc/server";
+
+export function call(procedure: T, context: Context) {
+ return createProcedureClient(procedure, { context });
+}
diff --git a/apps/api/src/integration/insights-handlers.test.ts b/apps/api/src/integration/insights-handlers.test.ts
index 0988e88f5c..424d628274 100644
--- a/apps/api/src/integration/insights-handlers.test.ts
+++ b/apps/api/src/integration/insights-handlers.test.ts
@@ -13,7 +13,6 @@ import {
appRouter,
createInternalPrincipal,
createRPCContext,
- type Context,
} from "@databuddy/rpc";
import {
closeInsightsQueue,
@@ -33,9 +32,9 @@ import {
signUp,
userContext,
} from "@databuddy/test";
-import { createProcedureClient, type AnyProcedure } from "@orpc/server";
import { randomUUIDv7 } from "bun";
import { afterAll, beforeEach, describe, expect, it } from "vitest";
+import { call } from "./helpers";
const iit = hasTestDb ? it : it.skip;
@@ -67,10 +66,6 @@ function investigationOutcome(nextType: "act" | "watch"): InvestigationOutcome {
};
}
-function call(procedure: T, context: Context) {
- return createProcedureClient(procedure, { context });
-}
-
async function seedExecutableGoalAction() {
const member = await signUp();
const organization = await insertOrganization();
@@ -692,15 +687,6 @@ describe("insight investigation timeline", () => {
type: "resolve",
},
publish: true,
- recommendation: {
- action:
- "Add “Counts completed signup events” to Signup completed’s description.",
- changes: {
- description: "Counts completed signup events.",
- name: null,
- },
- operation: "edit",
- },
rootCause: null,
summary: "Signup conversion improved from 40% to 50%.",
title: "Signup conversion improved",
@@ -796,15 +782,6 @@ describe("insight investigation timeline", () => {
expect(firstPage.insights[0]).toMatchObject({
impact: "Ten more visitors completed signup per 100 entrants.",
investigationId: null,
- recommendation: {
- action:
- "Add “Counts completed signup events” to Signup completed’s description.",
- changes: {
- description: "Counts completed signup events.",
- name: null,
- },
- operation: "edit",
- },
signal: {
changePercent: 25,
sentiment: "positive",
@@ -828,677 +805,9 @@ describe("insight investigation timeline", () => {
websiteId: secondWebsite.id,
});
expect(websiteOnly.insights).toHaveLength(1);
- expect(websiteOnly.insights[0]?.recommendation).toBeNull();
expect(websiteOnly.insights[0]?.websiteId).toBe(secondWebsite.id);
});
- iit("returns only the current recommendation for each signal", async () => {
- const member = await signUp();
- const organization = await insertOrganization();
- await addToOrganization(member.id, organization.id, "member");
- const website = await insertWebsite({ organizationId: organization.id });
- const secondWebsite = await insertWebsite({
- organizationId: organization.id,
- });
- const emptyWebsite = await insertWebsite({
- organizationId: organization.id,
- });
- const otherOrganization = await insertOrganization();
- const otherWebsite = await insertWebsite({
- organizationId: otherOrganization.id,
- });
- const recommendationOutcome = (
- title: string,
- action: string,
- operation: "edit" | "delete" | null = "edit"
- ): InvestigationOutcome => ({
- evidence: [`${title} is supported by current analytics.`],
- impact: null,
- next: {
- reason: "This suggestion does not need an investigation.",
- type: "resolve",
- },
- publish: true,
- recommendation: {
- action,
- changes:
- operation === "edit"
- ? { description: `${title} definition.`, name: null }
- : null,
- operation,
- },
- rootCause: null,
- summary: `${title} has a concrete improvement available.`,
- title,
- });
- const observation = (input: {
- action?: string;
- asOf: string;
- createdAt?: string;
- entity?: { id: string; label: string; type: "goal" };
- organizationId?: string;
- operation?: "edit" | "delete" | null;
- publish?: boolean;
- signalKey: string;
- title: string;
- websiteId?: string;
- }) => {
- const outcome = input.action
- ? recommendationOutcome(
- input.title,
- input.action,
- input.operation === undefined ? "edit" : input.operation
- )
- : {
- ...investigationOutcome("watch"),
- recommendation: null,
- title: input.title,
- };
- outcome.publish = input.publish ?? true;
- return {
- asOf: new Date(input.asOf),
- createdAt: new Date(input.createdAt ?? input.asOf),
- id: randomUUIDv7(),
- insightId: null,
- organizationId: input.organizationId ?? organization.id,
- outcome,
- recheckAt: new Date("2026-02-01T00:00:00.000Z"),
- signal: input.entity
- ? { ...signal(input.signalKey), entity: input.entity }
- : signal(input.signalKey),
- signalKey: input.signalKey,
- websiteId: input.websiteId ?? website.id,
- };
- };
-
- await db().insert(insightObservations).values([
- observation({
- action: "Use the original signup goal.",
- asOf: "2026-01-01T00:00:00.000Z",
- signalKey: "goal:signup",
- title: "Original signup recommendation",
- }),
- observation({
- asOf: "2026-01-03T00:00:00.000Z",
- publish: false,
- signalKey: "goal:signup",
- title: "Routine signup recheck",
- }),
- observation({
- action: "Use the updated signup goal.",
- asOf: "2026-01-02T00:00:00.000Z",
- signalKey: "goal:signup",
- title: "Updated signup recommendation",
- }),
- observation({
- action: "Add the measured checkout goal.",
- asOf: "2026-01-04T00:00:00.000Z",
- publish: false,
- signalKey: "goal:checkout",
- title: "Checkout recommendation",
- }),
- observation({
- action: "Use the old activation goal.",
- asOf: "2026-01-05T00:00:00.000Z",
- signalKey: "goal:stale",
- title: "Stale recommendation",
- }),
- observation({
- asOf: "2026-01-06T00:00:00.000Z",
- signalKey: "goal:stale",
- title: "Stale recommendation retired",
- }),
- observation({
- action: "Add the activation goal.",
- asOf: "2026-01-07T00:00:00.000Z",
- signalKey: "goal:activation",
- title: "Activation recommendation",
- websiteId: secondWebsite.id,
- }),
- observation({
- action: "Do not expose this recommendation.",
- asOf: "2026-01-08T00:00:00.000Z",
- organizationId: otherOrganization.id,
- signalKey: "goal:other",
- title: "Other organization recommendation",
- websiteId: otherWebsite.id,
- }),
- observation({
- action: "Keep this display-only suggestion out of the feed.",
- asOf: "2026-01-09T00:00:00.000Z",
- operation: null,
- signalKey: "goal:display-only",
- title: "Display-only suggestion",
- }),
- ]);
-
- const context = userContext(member, organization.id);
- const firstPage = await call(appRouter.insights.recommendations, context)({
- limit: 10,
- offset: 0,
- organizationId: organization.id,
- });
- expect(firstPage.hasMore).toBe(false);
- expect(firstPage.total).toBe(2);
- expect(
- firstPage.recommendations.map((item) => item.recommendation.action)
- ).toEqual([
- "Add the activation goal.",
- "Add the measured checkout goal.",
- ]);
-
- const websiteOnly = await call(
- appRouter.insights.recommendations,
- context
- )({
- limit: 10,
- offset: 0,
- organizationId: organization.id,
- websiteId: website.id,
- });
- expect(
- websiteOnly.recommendations.map((item) => item.recommendation.action)
- ).toEqual(["Add the measured checkout goal."]);
- expect(websiteOnly.total).toBe(1);
-
- const emptyScope = await call(appRouter.insights.recommendations, context)({
- limit: 10,
- offset: 0,
- organizationId: organization.id,
- websiteId: emptyWebsite.id,
- });
- expect(emptyScope).toMatchObject({
- hasMore: false,
- recommendations: [],
- total: 0,
- });
-
- const editableGoalId = randomUUIDv7();
- const deletedGoalId = randomUUIDv7();
- await db().insert(goals).values([
- {
- createdBy: member.id,
- description: "An incomplete definition.",
- id: editableGoalId,
- name: "Tracked signup",
- target: "signup_completed",
- type: "EVENT",
- websiteId: website.id,
- },
- {
- createdBy: member.id,
- id: deletedGoalId,
- name: "Retired signup",
- target: "signup_started",
- type: "EVENT",
- websiteId: website.id,
- },
- ]);
- await db().insert(insightObservations).values([
- observation({
- action: "Clarify the tracked signup goal.",
- asOf: "2026-01-10T00:00:00.000Z",
- entity: {
- id: editableGoalId,
- label: "Tracked signup",
- type: "goal",
- },
- signalKey: "goal:tracked-signup",
- title: "Tracked signup",
- }),
- observation({
- action: "Delete the retired signup goal.",
- asOf: "2026-01-11T00:00:00.000Z",
- entity: {
- id: deletedGoalId,
- label: "Retired signup",
- type: "goal",
- },
- operation: "delete",
- signalKey: "goal:retired-signup",
- title: "Retired signup",
- }),
- ]);
- const currentDefinitionActions = await call(
- appRouter.insights.recommendations,
- context
- )({ limit: 10, offset: 0, organizationId: organization.id });
- expect(
- currentDefinitionActions.recommendations.map(
- (item) => item.recommendation.action
- )
- ).toEqual(
- expect.arrayContaining([
- "Clarify the tracked signup goal.",
- "Delete the retired signup goal.",
- ])
- );
-
- const completedAt = new Date("2026-01-12T00:00:00.000Z");
- await db()
- .update(goals)
- .set({ description: "Tracked signup definition." })
- .where(eq(goals.id, editableGoalId));
- await db()
- .update(goals)
- .set({ deletedAt: completedAt, isActive: false })
- .where(eq(goals.id, deletedGoalId));
- const completedDefinitionActions = await call(
- appRouter.insights.recommendations,
- context
- )({ limit: 10, offset: 0, organizationId: organization.id });
- expect(
- completedDefinitionActions.recommendations.map(
- (item) => item.recommendation.action
- )
- ).not.toEqual(
- expect.arrayContaining([
- "Clarify the tracked signup goal.",
- "Delete the retired signup goal.",
- ])
- );
- expect(
- completedDefinitionActions.completed
- .map((item) => item.recommendation.action)
- .sort()
- ).toEqual([
- "Clarify the tracked signup goal.",
- "Delete the retired signup goal.",
- ]);
- });
-
- iit("expires standalone setup recommendations at their renewal deadline", async () => {
- const member = await signUp();
- const organization = await insertOrganization();
- await addToOrganization(member.id, organization.id, "member");
- const website = await insertWebsite({ organizationId: organization.id });
- const now = Date.now();
- const expired = new Date(now - 60_000);
- const fresh = new Date(now + 24 * 60 * 60_000);
- const standaloneRecommendation = (
- action: string,
- kind: "databuddy_setup" | "instrumentation"
- ): InvestigationOutcome => ({
- evidence: ["The current setup leaves one product question unanswered."],
- impact: null,
- next: {
- reason: "This setup recommendation does not need an investigation.",
- type: "resolve",
- },
- publish: false,
- recommendation:
- kind === "instrumentation"
- ? {
- action,
- events: [
- {
- description:
- "Measure only after the confirmed signup outcome.",
- name: "signup_completed",
- },
- ],
- kind,
- }
- : { action, feature: "tracking", kind },
- rootCause: null,
- summary: "The current setup cannot answer the measured product question.",
- title: "Product behavior needs setup",
- });
- const activeSetup: InvestigationOutcome = {
- ...standaloneRecommendation(
- "Keep the active identity recommendation.",
- "databuddy_setup"
- ),
- impact: "The affected error cohort cannot yet be tied to profiles.",
- next: {
- question:
- "Can you connect the repository that owns the affected application?",
- type: "ask",
- },
- publish: true,
- };
- const observation = (
- signalKey: string,
- outcome: InvestigationOutcome,
- recheckAt: Date
- ) => ({
- asOf: new Date(now - 120_000),
- createdAt: new Date(now - 120_000),
- id: randomUUIDv7(),
- insightId: null,
- organizationId: organization.id,
- outcome,
- recheckAt,
- signal: signal(signalKey),
- signalKey,
- websiteId: website.id,
- });
-
- await db().insert(insightObservations).values([
- observation(
- "measurement:expired-instrumentation",
- standaloneRecommendation(
- "Add the expired completion event.",
- "instrumentation"
- ),
- expired
- ),
- observation(
- "measurement:fresh-instrumentation",
- standaloneRecommendation(
- "Add the current completion event.",
- "instrumentation"
- ),
- fresh
- ),
- observation(
- "measurement:expired-setup",
- standaloneRecommendation(
- "Add the expired tracking setup.",
- "databuddy_setup"
- ),
- expired
- ),
- observation(
- "measurement:fresh-setup",
- standaloneRecommendation(
- "Add the current tracking setup.",
- "databuddy_setup"
- ),
- fresh
- ),
- observation("error:active-setup", activeSetup, expired),
- ]);
-
- const result = await call(appRouter.insights.recommendations, userContext(member, organization.id))({
- limit: 10,
- offset: 0,
- organizationId: organization.id,
- });
- expect(result.total).toBe(3);
- expect(
- result.recommendations.map((item) => item.recommendation.action).sort()
- ).toEqual([
- "Add the current completion event.",
- "Add the current tracking setup.",
- "Keep the active identity recommendation.",
- ]);
- expect(result.completed).toEqual([]);
- });
-
- iit("keeps precise measurement drafts current until their definition exists", async () => {
- const member = await signUp();
- const organization = await insertOrganization();
- await addToOrganization(member.id, organization.id, "member");
- const website = await insertWebsite({ organizationId: organization.id });
- const observedAt = new Date("2026-01-10T00:00:00.000Z");
- const goalSignalKey = "measurement:uncovered-event:account_created";
- const funnelSignalKey = "measurement:conversion-coverage";
- const goalDraft = {
- evidence: ["An observed completion event is not covered by a goal."],
- impact: "The team cannot review this completion as a goal.",
- next: {
- reason: "The draft is ready for teammate review.",
- type: "resolve",
- },
- publish: true,
- recommendation: {
- action: "Review a goal for account creation.",
- draft: {
- description: "Counts successful account creation.",
- filters: [],
- ignoreHistoricData: false,
- name: "Account created",
- target: "account_created",
- type: "EVENT",
- },
- kind: "goal_draft",
- },
- rootCause: null,
- summary: "A high-reach completion event has no reviewed goal.",
- title: "Account creation lacks a goal",
- } satisfies InvestigationOutcome;
- const priorGoalDraft = {
- ...goalDraft,
- recommendation: {
- ...goalDraft.recommendation,
- draft: {
- ...goalDraft.recommendation.draft,
- name: "Earlier account creation",
- target: "earlier_account_created",
- },
- },
- } satisfies InvestigationOutcome;
- const funnelDraft: InvestigationOutcome = {
- evidence: ["Two inspected steps form an uncovered product journey."],
- impact: "The team cannot measure the ordered journey.",
- next: {
- reason: "The draft is ready for teammate review.",
- type: "resolve",
- },
- publish: true,
- recommendation: {
- action: "Review the account-creation journey.",
- draft: {
- description: "Tracks account creation from the landing page.",
- filters: [],
- ignoreHistoricData: false,
- name: "Landing to account creation",
- steps: [
- { name: "Landing", target: "/", type: "PAGE_VIEW" },
- {
- name: "Account created",
- target: "account_created",
- type: "EVENT",
- },
- ],
- },
- kind: "funnel_draft",
- },
- rootCause: null,
- summary: "An observed journey has no reviewed funnel.",
- title: "Account creation lacks a funnel",
- };
-
- await db().insert(goals).values({
- createdBy: member.id,
- filters: [],
- id: randomUUIDv7(),
- ignoreHistoricData: false,
- name: "Unrelated engagement",
- target: "nav_clicked",
- type: "EVENT",
- websiteId: website.id,
- });
- await db().insert(funnelDefinitions).values({
- createdBy: member.id,
- filters: [],
- id: randomUUIDv7(),
- ignoreHistoricData: false,
- name: "Unrelated journey",
- steps: [
- { name: "Docs", target: "/docs", type: "PAGE_VIEW" },
- { name: "Pricing", target: "/pricing", type: "PAGE_VIEW" },
- ],
- websiteId: website.id,
- });
- await db().insert(insightObservations).values([
- {
- asOf: new Date("2026-01-09T00:00:00.000Z"),
- id: randomUUIDv7(),
- insightId: null,
- organizationId: organization.id,
- outcome: priorGoalDraft,
- recheckAt: observedAt,
- signal: signal(goalSignalKey),
- signalKey: goalSignalKey,
- websiteId: website.id,
- },
- {
- asOf: observedAt,
- id: randomUUIDv7(),
- insightId: null,
- organizationId: organization.id,
- outcome: goalDraft,
- recheckAt: observedAt,
- signal: signal(goalSignalKey),
- signalKey: goalSignalKey,
- websiteId: website.id,
- },
- {
- asOf: observedAt,
- id: randomUUIDv7(),
- insightId: null,
- organizationId: organization.id,
- outcome: funnelDraft,
- recheckAt: observedAt,
- signal: signal(funnelSignalKey),
- signalKey: funnelSignalKey,
- websiteId: website.id,
- },
- ]);
-
- const context = userContext(member, organization.id);
- const beforeCreation = await call(
- appRouter.insights.recommendations,
- context
- )({ limit: 10, offset: 0, organizationId: organization.id });
- expect(beforeCreation.total).toBe(2);
- expect(
- beforeCreation.recommendations
- .map((item) => item.recommendation.kind)
- .sort()
- ).toEqual(["funnel_draft", "goal_draft"]);
- expect(beforeCreation.completed).toEqual([]);
-
- await db().insert(goals).values({
- createdBy: member.id,
- filters: [],
- id: randomUUIDv7(),
- ignoreHistoricData: false,
- name: "Team account conversion",
- target: "account_created",
- type: "EVENT",
- websiteId: website.id,
- });
- const afterGoalCreation = await call(
- appRouter.insights.recommendations,
- context
- )({ limit: 10, offset: 0, organizationId: organization.id });
- expect(
- afterGoalCreation.recommendations.map(
- (item) => item.recommendation.kind
- )
- ).toEqual(["funnel_draft"]);
- expect(afterGoalCreation.total).toBe(1);
- expect(
- afterGoalCreation.completed.map((item) => item.recommendation.kind)
- ).toEqual(["goal_draft"]);
-
- const resolvedGoal: InvestigationOutcome = {
- evidence: ["The account_created event is now covered by a goal."],
- impact: "The team can review this completion as a goal.",
- next: {
- reason: "The current goal covers the event.",
- type: "resolve",
- },
- publish: false,
- recommendation: null,
- rootCause: null,
- summary: "Account creation is now covered by a goal.",
- title: "Account creation goal is configured",
- };
- await db().insert(insightObservations).values({
- asOf: new Date("2026-01-11T00:00:00.000Z"),
- createdAt: new Date("2026-01-11T00:00:00.000Z"),
- id: randomUUIDv7(),
- insightId: null,
- organizationId: organization.id,
- outcome: resolvedGoal,
- recheckAt: new Date("2026-01-18T00:00:00.000Z"),
- signal: signal(goalSignalKey),
- signalKey: goalSignalKey,
- websiteId: website.id,
- });
- const afterGoalRecheck = await call(
- appRouter.insights.recommendations,
- context
- )({ limit: 10, offset: 0, organizationId: organization.id });
- expect(
- afterGoalRecheck.recommendations.map((item) => item.recommendation.kind)
- ).toEqual(["funnel_draft"]);
- expect(afterGoalRecheck.total).toBe(1);
- expect(
- afterGoalRecheck.completed.map((item) => item.recommendation.kind)
- ).toEqual(["goal_draft"]);
-
- await db().insert(funnelDefinitions).values({
- createdBy: member.id,
- filters: [],
- id: randomUUIDv7(),
- ignoreHistoricData: false,
- name: "Conditional account journey",
- steps: [
- {
- conditions: { source: "ads" },
- name: "Entry",
- target: "/",
- type: "PAGE_VIEW",
- },
- {
- name: "Registered account",
- target: "account_created",
- type: "EVENT",
- },
- ],
- websiteId: website.id,
- });
- const afterConditionalFunnelCreation = await call(
- appRouter.insights.recommendations,
- context
- )({ limit: 10, offset: 0, organizationId: organization.id });
- expect(
- afterConditionalFunnelCreation.recommendations.map(
- (item) => item.recommendation.kind
- )
- ).toEqual(["funnel_draft"]);
- expect(
- afterConditionalFunnelCreation.completed.map(
- (item) => item.recommendation.kind
- )
- ).toEqual(["goal_draft"]);
-
- await db().insert(funnelDefinitions).values({
- createdBy: member.id,
- filters: [],
- id: randomUUIDv7(),
- ignoreHistoricData: false,
- name: "Team account journey",
- steps: [
- { name: "Entry", target: "/", type: "PAGE_VIEW" },
- {
- name: "Registered account",
- target: "account_created",
- type: "EVENT",
- },
- ],
- websiteId: website.id,
- });
- const afterFunnelCreation = await call(
- appRouter.insights.recommendations,
- context
- )({ limit: 10, offset: 0, organizationId: organization.id });
- expect(afterFunnelCreation).toMatchObject({
- recommendations: [],
- total: 0,
- });
- expect(
- afterFunnelCreation.completed
- .map((item) => item.recommendation.kind)
- .sort()
- ).toEqual(["funnel_draft", "goal_draft"]);
- });
-
iit("persists a reply beside every observation for the same signal", async () => {
const member = await signUp();
const organization = await insertOrganization();
diff --git a/apps/api/src/integration/link-handlers.test.ts b/apps/api/src/integration/link-handlers.test.ts
index 82abc8a22b..5ec6c7558a 100644
--- a/apps/api/src/integration/link-handlers.test.ts
+++ b/apps/api/src/integration/link-handlers.test.ts
@@ -1,8 +1,7 @@
import "@databuddy/test/env";
import { describe, it, expect, beforeEach, afterAll } from "vitest";
-import { createProcedureClient } from "@orpc/server";
-import { appRouter, type Context } from "@databuddy/rpc";
+import { appRouter } from "@databuddy/rpc";
import {
reset,
cleanup,
@@ -14,13 +13,10 @@ import {
signUp,
addToOrganization,
} from "@databuddy/test";
+import { call } from "./helpers";
const iit = hasTestDb ? it : it.skip;
-function call(procedure: T, ctx: Context) {
- return createProcedureClient(procedure as any, { context: ctx });
-}
-
beforeEach(() => reset());
afterAll(() => cleanup());
diff --git a/apps/api/src/integration/profile-handlers.test.ts b/apps/api/src/integration/profile-handlers.test.ts
index c3524c2dea..0645c6593e 100644
--- a/apps/api/src/integration/profile-handlers.test.ts
+++ b/apps/api/src/integration/profile-handlers.test.ts
@@ -6,7 +6,7 @@ import {
profileTraitChanges,
} from "@databuddy/db/schema";
import { eq } from "@databuddy/db";
-import { appRouter, type Context } from "@databuddy/rpc";
+import { appRouter } from "@databuddy/rpc";
import {
getTraitDistribution,
resolveTraitSegment,
@@ -25,15 +25,11 @@ import {
signUp,
userContext,
} from "@databuddy/test";
-import { createProcedureClient } from "@orpc/server";
import { afterAll, beforeEach, describe, expect, it } from "vitest";
+import { call } from "./helpers";
const iit = hasTestDb ? it : it.skip;
-function call(procedure: T, ctx: Context) {
- return createProcedureClient(procedure as any, { context: ctx });
-}
-
beforeEach(() => reset());
afterAll(() => cleanup());
diff --git a/apps/api/src/integration/uptime-handlers.test.ts b/apps/api/src/integration/uptime-handlers.test.ts
index 1affb5bc35..0d709ce095 100644
--- a/apps/api/src/integration/uptime-handlers.test.ts
+++ b/apps/api/src/integration/uptime-handlers.test.ts
@@ -23,10 +23,10 @@ import {
signUp,
userContext,
} from "@databuddy/test";
-import { createProcedureClient, type AnyProcedure } from "@orpc/server";
import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest";
import { randomUUIDv7 } from "bun";
import type { Job } from "bullmq";
+import { call } from "./helpers";
const canRun =
hasTestDb &&
@@ -35,10 +35,6 @@ const canRun =
const iit = canRun ? it : it.skip;
const scheduleIds = new Set();
-function call(procedure: T, context: Context) {
- return createProcedureClient(procedure, { context });
-}
-
beforeEach(async () => {
await reset();
scheduleIds.clear();
@@ -103,7 +99,6 @@ async function createSchedule(values: {
granularity: "five_minutes" as const,
timeout: 5000,
cacheBust: true,
- jsonParsingConfig: { enabled: false },
});
return scheduleIdFrom(result);
}
@@ -167,11 +162,9 @@ describe("uptime router BullMQ integration", () => {
expect(row?.url).toBe("https://create.example.com/health");
expect(row?.websiteId).toBe(website.id);
expect(row?.granularity).toBe("five_minutes");
- expect(row?.cron).toBe("*/5 * * * *");
expect(row?.isPaused).toBe(false);
expect(row?.timeout).toBe(5000);
expect(row?.cacheBust).toBe(true);
- expect(row?.jsonParsingConfig).toEqual({ enabled: false });
expect(await scheduler(scheduleId)).toBeTruthy();
const jobs = await jobsForSchedule(scheduleId);
@@ -194,16 +187,13 @@ describe("uptime router BullMQ integration", () => {
granularity: "ten_minutes",
timeout: null,
cacheBust: false,
- jsonParsingConfig: { enabled: true },
});
const row = await scheduleRow(scheduleId);
expect(row?.name).toBe("Renamed API");
expect(row?.granularity).toBe("ten_minutes");
- expect(row?.cron).toBe("*/10 * * * *");
expect(row?.timeout).toBeNull();
expect(row?.cacheBust).toBe(false);
- expect(row?.jsonParsingConfig).toEqual({ enabled: true });
expect(await scheduler(scheduleId)).toBeTruthy();
expect(await jobsForSchedule(scheduleId)).toHaveLength(1);
});
diff --git a/apps/api/src/integration/with-workspace.test.ts b/apps/api/src/integration/with-workspace.test.ts
index 484f45472d..8572ccd524 100644
--- a/apps/api/src/integration/with-workspace.test.ts
+++ b/apps/api/src/integration/with-workspace.test.ts
@@ -1,13 +1,7 @@
import "@databuddy/test/env";
import { afterAll, beforeEach, describe, expect, it } from "vitest";
-import { createProcedureClient } from "@orpc/server";
-import {
- withWorkspace,
- withPublicWorkspace,
- appRouter,
- type Context,
-} from "@databuddy/rpc";
+import { withWorkspace, withPublicWorkspace, appRouter } from "@databuddy/rpc";
import {
reset,
cleanup,
@@ -21,13 +15,10 @@ import {
signUp,
addToOrganization,
} from "@databuddy/test";
+import { call } from "./helpers";
const iit = hasTestDb ? it : it.skip;
-function call(procedure: T, ctx: Context) {
- return createProcedureClient(procedure as any, { context: ctx });
-}
-
beforeEach(() => reset());
afterAll(() => cleanup());
diff --git a/apps/api/src/lib/api-key.test.ts b/apps/api/src/lib/api-key.test.ts
index dbbba42d7d..761fbce91e 100644
--- a/apps/api/src/lib/api-key.test.ts
+++ b/apps/api/src/lib/api-key.test.ts
@@ -1,5 +1,41 @@
-import { describe, expect, it } from "vitest";
-import { hasScope, isExpired } from "keypal";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const state = vi.hoisted(() => ({
+ findFirst: vi.fn(async () => state.row),
+ lastUsedWrites: 0,
+ lockReply: "OK" as "OK" | null,
+ redisSet: vi.fn(async () => state.lockReply),
+ row: null as unknown,
+}));
+
+vi.mock("@databuddy/db", () => ({
+ db: {
+ transaction: async (fn: (tx: unknown) => Promise) =>
+ fn({
+ execute: async () => undefined,
+ query: { apikey: { findFirst: state.findFirst } },
+ }),
+ update: () => ({
+ set: () => ({
+ where: async () => {
+ state.lastUsedWrites += 1;
+ },
+ }),
+ }),
+ },
+ eq: vi.fn(),
+ sql: () => "",
+}));
+
+vi.mock("@databuddy/db/schema", () => ({ apikey: {} }));
+
+vi.mock("@databuddy/redis", () => ({
+ cacheNamespaces: { apiKeyByHash: "api-key-by-hash" },
+ cacheable: (fn: (...args: never[]) => unknown) =>
+ Object.assign(fn, { invalidate: vi.fn(async () => undefined) }),
+ redis: { set: state.redisSet },
+}));
+
import {
type ApiKeyRow,
extractSecret,
@@ -13,158 +49,214 @@ import {
hasWebsiteAnyScope,
hasWebsiteScope,
isApiKeyPresent,
+ resolveApiKey,
+ resolveApiKeySecret,
resolveEffectiveScopesForWebsite,
} from "@databuddy/api-keys/resolve";
-const createMockKey = (overrides: Partial = {}): ApiKeyRow =>
- ({
+const VALID_SECRET = "dbdy_test123";
+
+function createMockKey(overrides: Partial = {}): ApiKeyRow {
+ const now = new Date("2026-08-01T00:00:00.000Z");
+ return {
+ createdAt: now,
+ enabled: true,
+ expiresAt: null,
id: "key-123",
- name: "Test Key",
- prefix: "dbdy",
- start: "dbdy_abc",
keyHash: "hashed",
- userId: "user-1",
+ lastUsedAt: null,
+ metadata: {},
+ name: "Test Key",
organizationId: null,
- type: "user",
- scopes: ["read:data", "write:data"],
- enabled: true,
- revokedAt: null,
- expiresAt: null,
+ prefix: "dbdy",
rateLimitEnabled: true,
rateLimitMax: null,
rateLimitTimeWindow: null,
- metadata: {},
- createdAt: new Date(),
- updatedAt: new Date(),
+ revokedAt: null,
+ scopes: ["read:data", "write:data"],
+ start: "dbdy_abc",
+ type: "user",
+ updatedAt: now,
+ userId: "user-1",
...overrides,
- }) as ApiKeyRow;
+ };
+}
+
+beforeEach(() => {
+ state.findFirst.mockClear();
+ state.lastUsedWrites = 0;
+ state.lockReply = "OK";
+ state.redisSet.mockClear();
+ state.row = null;
+});
describe("isApiKeyPresent", () => {
- it("returns true when x-api-key header is present", () => {
- const headers = new Headers({ "x-api-key": "dbdy_test123" });
- expect(isApiKeyPresent(headers)).toBe(true);
- });
-
- it("returns true when Bearer token is present", () => {
- const headers = new Headers({ authorization: "Bearer dbdy_test123" });
- expect(isApiKeyPresent(headers)).toBe(true);
- });
-
- it("returns false when no API key headers", () => {
- const headers = new Headers({});
- expect(isApiKeyPresent(headers)).toBe(false);
- });
-
- it("returns false for non-Bearer authorization", () => {
- const headers = new Headers({ authorization: "Basic dXNlcjpwYXNz" });
- expect(isApiKeyPresent(headers)).toBe(false);
- });
-
- it("returns true for lowercase bearer", () => {
- const headers = new Headers({ authorization: "bearer dbdy_test123" });
- expect(isApiKeyPresent(headers)).toBe(true);
- });
-
- it("returns false for empty x-api-key", () => {
- const headers = new Headers({ "x-api-key": "" });
- expect(isApiKeyPresent(headers)).toBe(false);
+ it.each([
+ ["x-api-key header", { "x-api-key": VALID_SECRET }, true],
+ ["Bearer token", { authorization: `Bearer ${VALID_SECRET}` }, true],
+ ["lowercase bearer", { authorization: `bearer ${VALID_SECRET}` }, true],
+ ["no headers", {}, false],
+ ["Basic authorization", { authorization: "Basic dXNlcjpwYXNz" }, false],
+ ["empty x-api-key", { "x-api-key": "" }, false],
+ ])("%s -> %s", (_name, headers, expected) => {
+ expect(isApiKeyPresent(new Headers(headers))).toBe(expected);
});
});
describe("extractSecret", () => {
- it("extracts x-api-key header", () => {
- const headers = new Headers({ "x-api-key": "dbdy_test123" });
- expect(extractSecret(headers)).toBe("dbdy_test123");
- });
-
- it("extracts Bearer token from authorization", () => {
- const headers = new Headers({ authorization: "Bearer dbdy_test123" });
- expect(extractSecret(headers)).toBe("dbdy_test123");
+ it.each([
+ ["x-api-key header", { "x-api-key": VALID_SECRET }, VALID_SECRET],
+ [
+ "Bearer token",
+ { authorization: `Bearer ${VALID_SECRET}` },
+ VALID_SECRET,
+ ],
+ [
+ "x-api-key over Bearer",
+ {
+ authorization: "Bearer dbdy_bearer_token",
+ "x-api-key": "dbdy_xapikey_token",
+ },
+ "dbdy_xapikey_token",
+ ],
+ [
+ "whitespace around x-api-key",
+ { "x-api-key": ` ${VALID_SECRET} ` },
+ VALID_SECRET,
+ ],
+ [
+ "whitespace around Bearer token",
+ { authorization: `Bearer ${VALID_SECRET} ` },
+ VALID_SECRET,
+ ],
+ [
+ "case-insensitive Bearer",
+ { authorization: `BEARER ${VALID_SECRET}` },
+ VALID_SECRET,
+ ],
+ ["no headers", {}, null],
+ ["Basic authorization", { authorization: "Basic dXNlcjpwYXNz" }, null],
+ ["whitespace-only x-api-key", { "x-api-key": " " }, null],
+ ["empty Bearer token", { authorization: "Bearer " }, null],
+ [
+ "Bearer token without dbdy_ prefix",
+ { authorization: "Bearer invalid_token" },
+ null,
+ ],
+ ["Bearer token below minimum length", { authorization: "Bearer dbdy_" }, null],
+ [
+ "Bearer token above maximum length",
+ { authorization: `Bearer dbdy_${"a".repeat(200)}` },
+ null,
+ ],
+ ["x-api-key without dbdy_ prefix", { "x-api-key": "invalid_token" }, null],
+ ["x-api-key below minimum length", { "x-api-key": "dbdy_" }, null],
+ [
+ "x-api-key above maximum length",
+ { "x-api-key": `dbdy_${"a".repeat(200)}` },
+ null,
+ ],
+ ])("%s", (_name, headers, expected) => {
+ expect(extractSecret(new Headers(headers))).toBe(expected);
});
+});
- it("prefers x-api-key over Bearer", () => {
- const headers = new Headers({
- "x-api-key": "dbdy_xapikey",
- authorization: "Bearer dbdy_bearer",
+describe("resolveApiKeySecret", () => {
+ it.each([
+ ["wrong prefix", `sk_live_${"a".repeat(20)}`],
+ ["below minimum length", "dbdy_a"],
+ ["above maximum length", `dbdy_${"a".repeat(200)}`],
+ ])("rejects %s without hitting the database", async (_name, secret) => {
+ await expect(resolveApiKeySecret(secret)).resolves.toEqual({
+ key: null,
+ outcome: "invalid",
});
- expect(extractSecret(headers)).toBe("dbdy_xapikey");
+ expect(state.findFirst).not.toHaveBeenCalled();
});
- it("returns null when no API key present", () => {
- const headers = new Headers({});
- expect(extractSecret(headers)).toBeNull();
- });
+ it("reports unknown secrets as invalid with prefix diagnostics", async () => {
+ const secret = "dbdy_unknown_secret_value";
- it("returns null for non-Bearer authorization", () => {
- const headers = new Headers({ authorization: "Basic dXNlcjpwYXNz" });
- expect(extractSecret(headers)).toBeNull();
+ await expect(resolveApiKeySecret(secret)).resolves.toEqual({
+ key: null,
+ outcome: "invalid",
+ prefix: "dbdy",
+ start: secret.slice(0, 8),
+ });
+ expect(state.findFirst).toHaveBeenCalledTimes(1);
});
- it("trims whitespace from x-api-key", () => {
- const headers = new Headers({ "x-api-key": " dbdy_test123 " });
- expect(extractSecret(headers)).toBe("dbdy_test123");
- });
+ it.each([
+ ["disabled", { enabled: false }],
+ ["revoked", { revokedAt: new Date("2026-08-01T00:00:00.000Z") }],
+ ["expired", { expiresAt: new Date(Date.now() - 1000) }],
+ ])("returns no key for a %s key", async (outcome, overrides) => {
+ state.row = createMockKey(overrides);
- it("trims whitespace from Bearer token", () => {
- const headers = new Headers({ authorization: "Bearer dbdy_test123 " });
- expect(extractSecret(headers)).toBe("dbdy_test123");
- });
+ const result = await resolveApiKeySecret(VALID_SECRET);
- it("handles case-insensitive Bearer", () => {
- const headers = new Headers({ authorization: "bearer dbdy_test123" });
- expect(extractSecret(headers)).toBe("dbdy_test123");
+ expect(result.outcome).toBe(outcome);
+ expect(result.key).toBeNull();
});
- it("returns null for empty x-api-key after trim", () => {
- const headers = new Headers({ "x-api-key": " " });
- expect(extractSecret(headers)).toBeNull();
- });
+ it("resolves an enabled key with a future expiration", async () => {
+ state.row = createMockKey({
+ expiresAt: new Date(Date.now() + 86_400_000),
+ });
- it("handles BEARER in uppercase", () => {
- const headers = new Headers({ authorization: "BEARER dbdy_test123" });
- expect(extractSecret(headers)).toBe("dbdy_test123");
- });
+ const result = await resolveApiKeySecret(VALID_SECRET);
- it("rejects Bearer token without dbdy_ prefix", () => {
- const headers = new Headers({ authorization: "Bearer invalid_token" });
- expect(extractSecret(headers)).toBeNull();
+ expect(result.outcome).toBe("ok");
+ expect(result.key?.id).toBe("key-123");
+ expect(result.prefix).toBe("dbdy");
+ expect(result.start).toBe(VALID_SECRET.slice(0, 8));
});
- it("rejects Bearer token that is too short", () => {
- const headers = new Headers({ authorization: "Bearer dbdy_" });
- expect(extractSecret(headers)).toBeNull();
- });
+ it("records last-used once per debounce window without blocking resolution", async () => {
+ state.row = createMockKey();
- it("rejects Bearer token that is too long", () => {
- const longToken = "dbdy_" + "a".repeat(200);
- const headers = new Headers({ authorization: `Bearer ${longToken}` });
- expect(extractSecret(headers)).toBeNull();
- });
+ await resolveApiKeySecret(VALID_SECRET);
+ await vi.waitFor(() => expect(state.lastUsedWrites).toBe(1));
+ expect(state.redisSet).toHaveBeenCalledWith(
+ "api-key:last-used-lock:key-123",
+ "1",
+ "EX",
+ expect.any(Number),
+ "NX"
+ );
- it("rejects empty Bearer token", () => {
- const headers = new Headers({ authorization: "Bearer " });
- expect(extractSecret(headers)).toBeNull();
+ state.lockReply = null;
+ await resolveApiKeySecret(VALID_SECRET);
+ await vi.waitFor(() => expect(state.redisSet).toHaveBeenCalledTimes(2));
+ expect(state.lastUsedWrites).toBe(1);
});
+});
- it("rejects Bearer token with only whitespace", () => {
- const headers = new Headers({ authorization: "Bearer " });
- expect(extractSecret(headers)).toBeNull();
+describe("resolveApiKey", () => {
+ it("returns missing when no API key headers are present", async () => {
+ await expect(resolveApiKey(new Headers())).resolves.toEqual({
+ key: null,
+ outcome: "missing",
+ });
+ expect(state.findFirst).not.toHaveBeenCalled();
});
- it("rejects x-api-key without dbdy_ prefix", () => {
- const headers = new Headers({ "x-api-key": "invalid_token" });
- expect(extractSecret(headers)).toBeNull();
+ it("returns invalid when a header is present but malformed", async () => {
+ await expect(
+ resolveApiKey(new Headers({ "x-api-key": "invalid_token" }))
+ ).resolves.toEqual({ key: null, outcome: "invalid" });
+ expect(state.findFirst).not.toHaveBeenCalled();
});
- it("rejects x-api-key that is too short", () => {
- const headers = new Headers({ "x-api-key": "dbdy_" });
- expect(extractSecret(headers)).toBeNull();
- });
+ it("resolves a well-formed header against the database", async () => {
+ state.row = createMockKey();
+
+ const result = await resolveApiKey(
+ new Headers({ "x-api-key": VALID_SECRET })
+ );
- it("rejects x-api-key that is too long", () => {
- const longToken = "dbdy_" + "a".repeat(200);
- const headers = new Headers({ "x-api-key": longToken });
- expect(extractSecret(headers)).toBeNull();
+ expect(result.outcome).toBe("ok");
+ expect(result.key?.id).toBe("key-123");
});
});
@@ -173,519 +265,209 @@ describe("getEffectiveScopes", () => {
expect(getEffectiveScopes(null)).toEqual([]);
});
- it("returns key scopes when no resources", () => {
+ it("returns base scopes when metadata has no resources", () => {
const key = createMockKey({ scopes: ["read:data", "write:data"] });
- const scopes = getEffectiveScopes(key);
- expect(scopes).toContain("read:data");
- expect(scopes).toContain("write:data");
- expect(scopes).toHaveLength(2);
- });
-
- it("returns key scopes when resources is empty", () => {
- const key = createMockKey({
- scopes: ["read:data"],
- metadata: { resources: {} },
- });
- const scopes = getEffectiveScopes(key);
- expect(scopes).toEqual(["read:data"]);
+ expect(getEffectiveScopes(key).sort()).toEqual(["read:data", "write:data"]);
});
- it("includes global resource scopes", () => {
+ it("handles null metadata", () => {
const key = createMockKey({
+ metadata: null as unknown as Record,
scopes: ["read:data"],
- metadata: { resources: { global: ["admin:apikeys"] } },
});
- const scopes = getEffectiveScopes(key);
- expect(scopes).toContain("read:data");
- expect(scopes).toContain("admin:apikeys");
+ expect(getEffectiveScopes(key)).toEqual(["read:data"]);
});
- it("includes resource-specific scopes when resource matches", () => {
+ it("combines base, global, and matching resource scopes", () => {
const key = createMockKey({
- scopes: ["read:data"],
metadata: {
resources: {
+ global: ["track:events"],
"website:site-123": ["write:data", "read:analytics"],
},
},
- });
-
- const scopes = getEffectiveScopes(key, "website:site-123");
- expect(scopes).toContain("read:data");
- expect(scopes).toContain("write:data");
- expect(scopes).toContain("read:analytics");
- });
-
- it("does not include resource scopes when resource does not match", () => {
- const key = createMockKey({
scopes: ["read:data"],
- metadata: {
- resources: {
- "website:site-123": ["write:data"],
- },
- },
});
- const scopes = getEffectiveScopes(key, "website:site-456");
- expect(scopes).toContain("read:data");
- expect(scopes).not.toContain("write:data");
+ expect(getEffectiveScopes(key, "website:site-123").sort()).toEqual([
+ "read:analytics",
+ "read:data",
+ "track:events",
+ "write:data",
+ ]);
});
- it("combines global and resource-specific scopes", () => {
+ it("excludes scopes of non-matching resources", () => {
const key = createMockKey({
+ metadata: { resources: { "website:site-123": ["write:data"] } },
scopes: ["read:data"],
- metadata: {
- resources: {
- global: ["track:events"],
- "website:site-123": ["write:data"],
- },
- },
});
- const scopes = getEffectiveScopes(key, "website:site-123");
- expect(scopes).toContain("read:data");
- expect(scopes).toContain("track:events");
- expect(scopes).toContain("write:data");
+ expect(getEffectiveScopes(key, "website:site-456")).toEqual(["read:data"]);
});
- it("deduplicates scopes", () => {
+ it("deduplicates scopes repeated across base and resources", () => {
const key = createMockKey({
- scopes: ["read:data"],
metadata: {
resources: {
global: ["read:data"],
"website:site-123": ["read:data"],
},
},
- });
-
- const scopes = getEffectiveScopes(key, "website:site-123");
- expect(scopes.filter((s) => s === "read:data")).toHaveLength(1);
- });
-
- it("handles key with empty scopes array", () => {
- const key = createMockKey({
- scopes: [],
- metadata: { resources: { global: ["read:data"] } },
- });
- const scopes = getEffectiveScopes(key);
- expect(scopes).toEqual(["read:data"]);
- });
-
- it("handles null metadata", () => {
- const key = createMockKey({
scopes: ["read:data"],
- metadata: null as unknown as Record,
});
- const scopes = getEffectiveScopes(key);
- expect(scopes).toEqual(["read:data"]);
- });
-});
-describe("hasKeyScope", () => {
- it("returns false for null key", () => {
- expect(hasKeyScope(null, "read:data")).toBe(false);
- });
-
- it("returns true when key has scope in base scopes", () => {
- const key = createMockKey({ scopes: ["read:data", "write:data"] });
- expect(hasKeyScope(key, "read:data")).toBe(true);
- });
-
- it("returns false when key does not have scope", () => {
- const key = createMockKey({ scopes: ["read:data"] });
- expect(hasKeyScope(key, "admin:apikeys")).toBe(false);
- });
-
- it("checks resource-specific scopes with matching resource", () => {
- const key = createMockKey({
- scopes: [],
- metadata: {
- resources: { "website:site-123": ["read:analytics"] },
- },
- });
-
- expect(hasKeyScope(key, "read:analytics", "website:site-123")).toBe(true);
- });
-
- it("returns false for resource-specific scopes with non-matching resource", () => {
- const key = createMockKey({
- scopes: [],
- metadata: {
- resources: { "website:site-123": ["read:analytics"] },
- },
- });
-
- expect(hasKeyScope(key, "read:analytics", "website:site-456")).toBe(false);
- });
-
- it("checks global scopes even when resource is specified", () => {
- const key = createMockKey({
- scopes: ["read:data"],
- metadata: {},
- });
-
- expect(hasKeyScope(key, "read:data", "website:site-123")).toBe(true);
+ expect(getEffectiveScopes(key, "website:site-123")).toEqual(["read:data"]);
});
});
-describe("hasKeyAnyScope", () => {
- it("returns false for null key", () => {
+describe("scope predicates", () => {
+ it("all predicates deny a null key", () => {
+ expect(hasKeyScope(null, "read:data")).toBe(false);
expect(hasKeyAnyScope(null, ["read:data"])).toBe(false);
+ expect(hasKeyAllScopes(null, ["read:data"])).toBe(false);
+ expect(hasWebsiteScope(null, "site-123", "read:data")).toBe(false);
+ expect(hasWebsiteAnyScope(null, "site-123", ["read:data"])).toBe(false);
+ expect(hasWebsiteAllScopes(null, "site-123", ["read:data"])).toBe(false);
+ expect(hasGlobalAccess(null)).toBe(false);
+ expect(resolveEffectiveScopesForWebsite(null, "site-123").size).toBe(0);
+ expect(getAccessibleWebsiteIds(null)).toEqual([]);
});
- it("returns true when key has any of the scopes", () => {
- const key = createMockKey({ scopes: ["read:data"] });
- expect(hasKeyAnyScope(key, ["read:data", "write:data"])).toBe(true);
- });
-
- it("returns false when key has none of the scopes", () => {
- const key = createMockKey({ scopes: ["track:events"] });
- expect(hasKeyAnyScope(key, ["read:data", "write:data"])).toBe(false);
- });
+ it("hasKeyScope checks base, resource, and global scopes", () => {
+ const base = createMockKey({ scopes: ["read:data"] });
+ expect(hasKeyScope(base, "read:data")).toBe(true);
+ expect(hasKeyScope(base, "admin:apikeys")).toBe(false);
+ expect(hasKeyScope(base, "read:data", "website:site-123")).toBe(true);
- it("checks resource-specific scopes", () => {
- const key = createMockKey({
- scopes: [],
+ const scoped = createMockKey({
metadata: { resources: { "website:site-123": ["read:analytics"] } },
+ scopes: [],
});
- expect(
- hasKeyAnyScope(key, ["read:analytics", "write:data"], "website:site-123")
- ).toBe(true);
- });
-});
-
-describe("hasKeyAllScopes", () => {
- it("returns false for null key", () => {
- expect(hasKeyAllScopes(null, ["read:data"])).toBe(false);
- });
-
- it("returns true when key has all scopes", () => {
- const key = createMockKey({ scopes: ["read:data", "write:data"] });
- expect(hasKeyAllScopes(key, ["read:data", "write:data"])).toBe(true);
+ expect(hasKeyScope(scoped, "read:analytics", "website:site-123")).toBe(
+ true
+ );
+ expect(hasKeyScope(scoped, "read:analytics", "website:site-456")).toBe(
+ false
+ );
});
- it("returns false when key is missing a scope", () => {
+ it("hasKeyAnyScope passes when any scope matches", () => {
const key = createMockKey({ scopes: ["read:data"] });
- expect(hasKeyAllScopes(key, ["read:data", "write:data"])).toBe(false);
+ expect(hasKeyAnyScope(key, ["read:data", "write:data"])).toBe(true);
+ expect(hasKeyAnyScope(key, ["track:events", "write:data"])).toBe(false);
});
- it("combines base and resource scopes", () => {
+ it("hasKeyAllScopes requires every scope across base and resources", () => {
const key = createMockKey({
- scopes: ["read:data"],
metadata: { resources: { "website:site-123": ["write:data"] } },
+ scopes: ["read:data"],
});
expect(
hasKeyAllScopes(key, ["read:data", "write:data"], "website:site-123")
).toBe(true);
+ expect(hasKeyAllScopes(key, ["read:data", "write:data"])).toBe(false);
});
});
-describe("resolveEffectiveScopesForWebsite", () => {
- it("returns empty set for null key", () => {
- const scopes = resolveEffectiveScopesForWebsite(null, "site-123");
- expect(scopes.size).toBe(0);
- });
-
- it("returns scopes for website resource", () => {
- const key = createMockKey({
- scopes: ["read:data"],
- metadata: {
- resources: { "website:site-123": ["write:data"] },
- },
- });
-
- const scopes = resolveEffectiveScopesForWebsite(key, "site-123");
- expect(scopes.has("read:data")).toBe(true);
- expect(scopes.has("write:data")).toBe(true);
- });
-
- it("formats websiteId with website: prefix", () => {
- const key = createMockKey({
- scopes: [],
- metadata: {
- resources: { "website:my-site": ["read:analytics"] },
- },
- });
-
- const scopes = resolveEffectiveScopesForWebsite(key, "my-site");
- expect(scopes.has("read:analytics")).toBe(true);
- });
-
- it("includes global scopes", () => {
- const key = createMockKey({
- scopes: [],
- metadata: {
- resources: { global: ["track:events"] },
- },
- });
-
- const scopes = resolveEffectiveScopesForWebsite(key, "site-123");
- expect(scopes.has("track:events")).toBe(true);
- });
-});
-
-describe("hasWebsiteScope", () => {
- it("returns false for null key", () => {
- expect(hasWebsiteScope(null, "site-123", "read:data")).toBe(false);
- });
-
- it("returns true when key has website-specific scope", () => {
+describe("website scope helpers", () => {
+ it("hasWebsiteScope resolves the website resource prefix", () => {
const key = createMockKey({
+ metadata: { resources: { "website:site-123": ["read:analytics"] } },
scopes: [],
- metadata: {
- resources: { "website:site-123": ["read:analytics"] },
- },
});
-
expect(hasWebsiteScope(key, "site-123", "read:analytics")).toBe(true);
+ expect(hasWebsiteScope(key, "site-456", "read:analytics")).toBe(false);
});
- it("returns true when key has scope in base scopes", () => {
- const key = createMockKey({ scopes: ["read:data"] });
- expect(hasWebsiteScope(key, "site-123", "read:data")).toBe(true);
- });
-
- it("returns true when key has scope in global resources", () => {
- const key = createMockKey({
- scopes: [],
- metadata: { resources: { global: ["track:events"] } },
- });
- expect(hasWebsiteScope(key, "site-123", "track:events")).toBe(true);
- });
-
- it("returns false when key lacks required scope", () => {
- const key = createMockKey({ scopes: ["read:data"] });
- expect(hasWebsiteScope(key, "site-123", "admin:apikeys")).toBe(false);
- });
-
- it("returns false when scope exists for different website", () => {
- const key = createMockKey({
- scopes: [],
- metadata: {
- resources: { "website:site-456": ["read:analytics"] },
- },
- });
-
- expect(hasWebsiteScope(key, "site-123", "read:analytics")).toBe(false);
- });
-});
-
-describe("hasWebsiteAnyScope", () => {
- it("returns false for null key", () => {
- expect(hasWebsiteAnyScope(null, "site-123", ["read:data"])).toBe(false);
- });
-
- it("returns true when key has any of the scopes for website", () => {
- const key = createMockKey({
- scopes: [],
- metadata: { resources: { "website:site-123": ["read:analytics"] } },
- });
+ it("hasWebsiteScope accepts base and global scopes for any website", () => {
expect(
- hasWebsiteAnyScope(key, "site-123", ["read:analytics", "write:data"])
+ hasWebsiteScope(
+ createMockKey({ scopes: ["read:data"] }),
+ "site-123",
+ "read:data"
+ )
).toBe(true);
- });
-
- it("returns false when key has none of the scopes", () => {
- const key = createMockKey({ scopes: ["track:events"] });
expect(
- hasWebsiteAnyScope(key, "site-123", ["read:analytics", "write:data"])
- ).toBe(false);
- });
-});
-
-describe("hasWebsiteAllScopes", () => {
- it("returns false for null key", () => {
- expect(hasWebsiteAllScopes(null, "site-123", ["read:data"])).toBe(false);
+ hasWebsiteScope(
+ createMockKey({
+ metadata: { resources: { global: ["track:events"] } },
+ scopes: [],
+ }),
+ "site-123",
+ "track:events"
+ )
+ ).toBe(true);
});
- it("returns true when key has all scopes for website", () => {
+ it("hasWebsiteAnyScope and hasWebsiteAllScopes evaluate against the website resource", () => {
const key = createMockKey({
+ metadata: { resources: { "website:site-123": ["read:analytics"] } },
scopes: ["read:data"],
- metadata: { resources: { "website:site-123": ["write:data"] } },
});
expect(
- hasWebsiteAllScopes(key, "site-123", ["read:data", "write:data"])
+ hasWebsiteAnyScope(key, "site-123", ["read:analytics", "write:data"])
+ ).toBe(true);
+ expect(hasWebsiteAnyScope(key, "site-456", ["read:analytics"])).toBe(false);
+ expect(
+ hasWebsiteAllScopes(key, "site-123", ["read:data", "read:analytics"])
).toBe(true);
- });
-
- it("returns false when key is missing a scope", () => {
- const key = createMockKey({
- scopes: [],
- metadata: { resources: { "website:site-123": ["read:analytics"] } },
- });
expect(
hasWebsiteAllScopes(key, "site-123", ["read:analytics", "write:data"])
).toBe(false);
});
-});
-
-describe("key validity simulation (matches getApiKeyFromHeader logic)", () => {
- const isKeyValid = (key: ApiKeyRow | null): boolean => {
- if (!key?.enabled || key.revokedAt || isExpired(key.expiresAt)) {
- return false;
- }
- return true;
- };
-
- it("returns false for null key", () => {
- expect(isKeyValid(null)).toBe(false);
- });
-
- it("returns false for disabled key", () => {
- const key = createMockKey({ enabled: false });
- expect(isKeyValid(key)).toBe(false);
- });
-
- it("returns false for revoked key", () => {
- const key = createMockKey({ revokedAt: new Date() });
- expect(isKeyValid(key)).toBe(false);
- });
-
- it("returns false for expired key", () => {
- const key = createMockKey({
- expiresAt: new Date(Date.now() - 1000).toISOString(),
- });
- expect(isKeyValid(key)).toBe(false);
- });
-
- it("returns true for valid enabled key", () => {
- const key = createMockKey({
- enabled: true,
- revokedAt: null,
- expiresAt: null,
- });
- expect(isKeyValid(key)).toBe(true);
- });
- it("returns true for key with future expiration", () => {
+ it("resolveEffectiveScopesForWebsite returns the combined scope set", () => {
const key = createMockKey({
- enabled: true,
- expiresAt: new Date(Date.now() + 86_400_000).toISOString(),
- });
- expect(isKeyValid(key)).toBe(true);
- });
-
- it("returns false for disabled key even with valid expiration", () => {
- const key = createMockKey({
- enabled: false,
- expiresAt: new Date(Date.now() + 86_400_000).toISOString(),
+ metadata: {
+ resources: {
+ global: ["track:events"],
+ "website:site-123": ["write:data"],
+ },
+ },
+ scopes: ["read:data"],
});
- expect(isKeyValid(key)).toBe(false);
- });
- it("returns false for revoked key even if enabled", () => {
- const key = createMockKey({
- enabled: true,
- revokedAt: new Date(),
- });
- expect(isKeyValid(key)).toBe(false);
+ expect(resolveEffectiveScopesForWebsite(key, "site-123")).toEqual(
+ new Set(["read:data", "track:events", "write:data"])
+ );
});
});
describe("hasGlobalAccess", () => {
- it("returns false for null key", () => {
- expect(hasGlobalAccess(null)).toBe(false);
- });
-
- it("returns false when no resources", () => {
- const key = createMockKey({ metadata: {} });
- expect(hasGlobalAccess(key)).toBe(false);
- });
-
- it("returns false when no global resource", () => {
- const key = createMockKey({
- metadata: { resources: { "website:site-123": ["read:data"] } },
- });
- expect(hasGlobalAccess(key)).toBe(false);
- });
-
- it("returns false when global resource is empty", () => {
- const key = createMockKey({
- metadata: { resources: { global: [] } },
- });
- expect(hasGlobalAccess(key)).toBe(false);
- });
-
- it("returns true when global resource has scopes", () => {
- const key = createMockKey({
- metadata: { resources: { global: ["read:data"] } },
- });
- expect(hasGlobalAccess(key)).toBe(true);
+ it.each([
+ ["no resources", {}, false],
+ ["only website resources", { resources: { "website:site-123": ["read:data"] } }, false],
+ ["empty global resource", { resources: { global: [] } }, false],
+ ["populated global resource", { resources: { global: ["read:data"] } }, true],
+ ])("%s -> %s", (_name, metadata, expected) => {
+ expect(hasGlobalAccess(createMockKey({ metadata }))).toBe(expected);
});
});
describe("getAccessibleWebsiteIds", () => {
- it("returns empty array for null key", () => {
- expect(getAccessibleWebsiteIds(null)).toEqual([]);
- });
-
- it("returns empty array when no resources", () => {
- const key = createMockKey({ metadata: {} });
- expect(getAccessibleWebsiteIds(key)).toEqual([]);
- });
-
- it("returns empty array when no website resources", () => {
- const key = createMockKey({
- metadata: { resources: { global: ["read:data"] } },
- });
- expect(getAccessibleWebsiteIds(key)).toEqual([]);
+ it("returns empty array when no website resources exist", () => {
+ expect(getAccessibleWebsiteIds(createMockKey({ metadata: {} }))).toEqual(
+ []
+ );
+ expect(
+ getAccessibleWebsiteIds(
+ createMockKey({ metadata: { resources: { global: ["read:data"] } } })
+ )
+ ).toEqual([]);
});
- it("returns website ids from resources", () => {
+ it("extracts ids from website resources only", () => {
const key = createMockKey({
metadata: {
resources: {
+ global: ["track:events"],
"website:site-1": ["read:data"],
"website:site-2": ["write:data"],
- global: ["track:events"],
},
},
});
- const ids = getAccessibleWebsiteIds(key);
- expect(ids).toContain("site-1");
- expect(ids).toContain("site-2");
- expect(ids).toHaveLength(2);
- });
-
- it("extracts id correctly from website:id format", () => {
- const key = createMockKey({
- metadata: {
- resources: { "website:my-long-id-123": ["read:data"] },
- },
- });
- expect(getAccessibleWebsiteIds(key)).toEqual(["my-long-id-123"]);
- });
-});
-
-describe("keypal utilities used in implementation", () => {
- it("hasScope returns true when scope exists", () => {
- expect(hasScope(["read:data", "write:data"], "read:data")).toBe(true);
- });
-
- it("hasScope returns false when scope does not exist", () => {
- expect(hasScope(["read:data"], "write:data")).toBe(false);
- });
-
- it("hasScope handles undefined scopes", () => {
- expect(hasScope(undefined, "read:data")).toBe(false);
- });
-
- it("isExpired returns false for null", () => {
- expect(isExpired(null)).toBe(false);
- });
-
- it("isExpired returns false for undefined", () => {
- expect(isExpired(undefined)).toBe(false);
- });
-
- it("isExpired returns true for past date", () => {
- const past = new Date(Date.now() - 1000).toISOString();
- expect(isExpired(past)).toBe(true);
- });
- it("isExpired returns false for future date", () => {
- const future = new Date(Date.now() + 100_000).toISOString();
- expect(isExpired(future)).toBe(false);
+ expect(getAccessibleWebsiteIds(key).sort()).toEqual(["site-1", "site-2"]);
});
});
diff --git a/apps/api/src/lib/autumn-mount.ts b/apps/api/src/lib/autumn-mount.ts
index 80e884013a..0c8d444c67 100644
--- a/apps/api/src/lib/autumn-mount.ts
+++ b/apps/api/src/lib/autumn-mount.ts
@@ -1,4 +1,3 @@
-/** Elysia `.mount("/api/autumn", …)` strips the prefix, so the inner pathname is `/attach` not `/api/autumn/attach`. Autumn's router matches full paths under `/api/autumn`. */
const AUTUMN_API_PREFIX = "/api/autumn";
export function withAutumnApiPath(request: Request): Request {
diff --git a/apps/api/src/lib/tcc-otel.ts b/apps/api/src/lib/tcc-otel.ts
index d5e653212d..78bf99db4b 100644
--- a/apps/api/src/lib/tcc-otel.ts
+++ b/apps/api/src/lib/tcc-otel.ts
@@ -8,12 +8,6 @@ import {
import pkg from "../../package.json";
let sdk: NodeSDK | null = null;
-
-/**
- * Registers OpenTelemetry with The Context Company's span processor so
- * Vercel AI SDK `experimental_telemetry` spans (ai.*) are exported to TCC.
- * No-op when TCC_API_KEY is unset (local dev without observability).
- */
export function initTccTracing(): void {
if (sdk || !process.env.TCC_API_KEY) {
return;
diff --git a/apps/api/src/middleware/api-key-rate-limit.ts b/apps/api/src/middleware/api-key-rate-limit.ts
index 3f51f4b868..3fe14a01fe 100644
--- a/apps/api/src/middleware/api-key-rate-limit.ts
+++ b/apps/api/src/middleware/api-key-rate-limit.ts
@@ -123,7 +123,6 @@ export function releaseApiKeyInFlight(
}
export interface EnforceApiKeyRateLimitOptions {
- /** undefined means auth was not pre-resolved; null means it resolved without a key. */
apiKey?: ApiKeyRow | null;
dependencies?: ApiKeyAdmissionDependencies;
}
@@ -196,12 +195,6 @@ export function enforceApiKeyInFlightLimit(
"Too many concurrent API key requests"
);
}
-
-/**
- * Enforce an API key's configured distributed rolling-window limit after auth
- * resolution. Every presented key has already passed through the local
- * in-flight gate above.
- */
export async function enforceApiKeyRateLimit(
request: Request,
setHeader: (name: string, value: string) => void,
diff --git a/apps/api/src/middleware/website-auth.ts b/apps/api/src/middleware/website-auth.ts
deleted file mode 100644
index 0199fcbbd3..0000000000
--- a/apps/api/src/middleware/website-auth.ts
+++ /dev/null
@@ -1,195 +0,0 @@
-import {
- getApiKeyFromHeader,
- hasWebsiteScopeForOrganization,
- isApiKeyPresent,
-} from "@databuddy/api-keys/resolve";
-import { auth } from "@databuddy/auth";
-import { db } from "@databuddy/db";
-import { Elysia } from "elysia";
-import { getResolvedAuth } from "../lib/auth-wide-event";
-import { getCachedWebsite, getTimezone } from "@databuddy/ai/lib/website-utils";
-
-interface SessionUser {
- email: string;
- id: string;
- name: string;
-}
-
-function json(status: number, body: unknown) {
- return new Response(JSON.stringify(body), {
- status,
- headers: { "Content-Type": "application/json" },
- });
-}
-
-function getSessionUser(
- session: Awaited> | null
-): SessionUser | null {
- if (!session?.user) {
- return null;
- }
-
- return {
- email: session.user.email,
- id: session.user.id,
- name: session.user.name,
- };
-}
-
-export function websiteAuth() {
- return new Elysia()
- .derive(async ({ request }) => {
- if (isPreflight(request)) {
- return {
- user: null,
- session: null,
- website: undefined,
- timezone: "UTC",
- _apiKey: null,
- _apiKeyPresent: false,
- _authChecked: true,
- } as const;
- }
-
- const url = new URL(request.url);
- const websiteId = url.searchParams.get("website_id");
-
- const preResolved = getResolvedAuth(request.headers);
- let sessionUser: SessionUser | null = null;
- let session: Awaited> | null =
- null;
- let apiKey: Awaited> | null = null;
- const apiKeyPresent = isApiKeyPresent(request.headers);
-
- if (preResolved) {
- session = preResolved.session;
- sessionUser = getSessionUser(session);
- apiKey = preResolved.apiKeyResult?.key ?? null;
- } else {
- const [resolvedApiKey, resolvedSession] = await Promise.all([
- apiKeyPresent ? getApiKeyFromHeader(request.headers) : null,
- auth.api.getSession({ headers: request.headers }),
- ]);
- session = resolvedSession;
- sessionUser = getSessionUser(session);
- apiKey = resolvedApiKey;
- }
-
- const website = websiteId ? await getCachedWebsite(websiteId) : undefined;
-
- const timezone = session?.user
- ? await getTimezone(request, session)
- : await getTimezone(request, null);
-
- return {
- user: sessionUser,
- session,
- website,
- timezone,
- _apiKey: apiKey,
- _apiKeyPresent: apiKeyPresent,
- _authChecked: true,
- } as const;
- })
- .onBeforeHandle(({ user, website, _apiKey, _apiKeyPresent, request }) => {
- if (isPreflight(request)) {
- return;
- }
-
- const url = new URL(request.url);
- const websiteId = url.searchParams.get("website_id");
-
- if (!websiteId) {
- if (user || _apiKey) {
- return null;
- }
- return json(401, {
- success: false,
- error: "Authentication required",
- code: "AUTH_REQUIRED",
- });
- }
-
- return checkWebsiteAuth(
- websiteId,
- user,
- website ?? null,
- _apiKey,
- _apiKeyPresent
- );
- });
-}
-
-function isPreflight(request: Request): boolean {
- return request.method === "OPTIONS" || request.method === "HEAD";
-}
-
-async function checkWebsiteAuth(
- _websiteId: string,
- sessionUser: SessionUser | null,
- website: Awaited> | null,
- apiKey: Awaited> | null,
- apiKeyPresent: boolean
-): Promise {
- if (!website) {
- return json(404, {
- success: false,
- error: "Website not found",
- code: "NOT_FOUND",
- });
- }
- if (website.isPublic) {
- return null;
- }
-
- if (sessionUser) {
- if (!website.organizationId) {
- return json(403, {
- success: false,
- error: "Website must belong to a workspace",
- code: "FORBIDDEN",
- });
- }
-
- const membership = await db.query.member.findFirst({
- where: { userId: sessionUser.id, organizationId: website.organizationId },
- columns: {
- id: true,
- },
- });
-
- if (membership) {
- return null;
- }
-
- return json(403, {
- success: false,
- error: "Access denied to this website",
- code: "FORBIDDEN",
- });
- }
-
- if (!apiKeyPresent) {
- return json(401, {
- success: false,
- error: "Authentication required",
- code: "AUTH_REQUIRED",
- });
- }
- if (!apiKey) {
- return json(401, {
- success: false,
- error: "Invalid or expired API key",
- code: "AUTH_REQUIRED",
- });
- }
- const ok = hasWebsiteScopeForOrganization(apiKey, website, "read:data");
- if (!ok) {
- return json(403, {
- success: false,
- error: "Insufficient permissions",
- code: "FORBIDDEN",
- });
- }
- return null;
-}
diff --git a/apps/api/src/routes/public/flags-boundary.test.ts b/apps/api/src/routes/public/flags-boundary.test.ts
index e8c33b877b..296c6900bb 100644
--- a/apps/api/src/routes/public/flags-boundary.test.ts
+++ b/apps/api/src/routes/public/flags-boundary.test.ts
@@ -3,6 +3,7 @@ import { Elysia } from "elysia";
import { describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
+ findFirst: vi.fn(async () => null as unknown),
flags: [
{
defaultValue: true,
@@ -18,6 +19,7 @@ const state = vi.hoisted(() => ({
variants: null,
},
],
+ rateLimited: false,
}));
vi.mock("@databuddy/db", async (importOriginal) => ({
@@ -25,6 +27,7 @@ vi.mock("@databuddy/db", async (importOriginal) => ({
db: {
query: {
flags: {
+ findFirst: state.findFirst,
findMany: vi.fn(async () => state.flags),
},
},
@@ -37,8 +40,13 @@ vi.mock("@databuddy/redis", async (importOriginal) => ({
}));
vi.mock("@databuddy/redis/rate-limit", () => ({
- getRateLimitHeaders: () => ({}),
- ratelimit: async () => ({ success: true }),
+ getRateLimitHeaders: () => ({ "x-ratelimit-remaining": "0" }),
+ ratelimit: async () => ({
+ limit: 600,
+ remaining: state.rateLimited ? 0 : 599,
+ reset: Date.now() + 60_000,
+ success: !state.rateLimited,
+ }),
}));
const { flagsRoute } = await import("./flags");
@@ -111,4 +119,69 @@ describe("public bulk flags boundary", () => {
});
expect(postResponse.status).toBe(422);
});
+
+ it("rejects requests without a usable clientId", async () => {
+ const missing = await request("/v1/flags/bulk");
+ expect(missing.status).toBe(422);
+
+ const blank = await request("/v1/flags/bulk?clientId=");
+ expect(blank.status).toBe(400);
+ expect(await blank.json()).toMatchObject({
+ count: 0,
+ error: "Missing required clientId parameter",
+ });
+ });
+});
+
+describe("public flag evaluation boundary", () => {
+ it("rejects evaluation without a usable clientId or key", async () => {
+ const missingParams = await request("/v1/flags/evaluate?key=&clientId=");
+ expect(missingParams.status).toBe(400);
+ expect(await missingParams.json()).toMatchObject({
+ enabled: false,
+ reason: "MISSING_REQUIRED_PARAMS",
+ });
+
+ const missingClientId = await request("/v1/flags/evaluate?key=some-flag");
+ expect(missingClientId.status).toBe(422);
+ });
+
+ it("caches missing flags so repeated misses skip the database", async () => {
+ const path = "/v1/flags/evaluate?key=absent-flag&clientId=neg_cache_site";
+
+ const first = await request(path);
+ const second = await request(path);
+
+ expect(first.status).toBe(200);
+ expect(await first.json()).toMatchObject({
+ enabled: false,
+ reason: "FLAG_NOT_FOUND",
+ });
+ expect(await second.json()).toMatchObject({ reason: "FLAG_NOT_FOUND" });
+ expect(state.findFirst).toHaveBeenCalledTimes(1);
+ });
+
+ it("returns 429 with rate limit headers when the per-client budget is exhausted", async () => {
+ state.rateLimited = true;
+ try {
+ const evaluate = await request(
+ "/v1/flags/evaluate?key=some-flag&clientId=limited_site"
+ );
+ expect(evaluate.status).toBe(429);
+ expect(evaluate.headers.get("x-ratelimit-remaining")).toBe("0");
+ expect(await evaluate.json()).toMatchObject({
+ enabled: false,
+ reason: "RATE_LIMITED",
+ });
+
+ const bulk = await request("/v1/flags/bulk?clientId=limited_site");
+ expect(bulk.status).toBe(429);
+ expect(await bulk.json()).toMatchObject({
+ count: 0,
+ reason: "RATE_LIMITED",
+ });
+ } finally {
+ state.rateLimited = false;
+ }
+ });
});
diff --git a/apps/api/src/routes/public/flags.test.ts b/apps/api/src/routes/public/flags.test.ts
index f6ca750ad1..94febbd0ae 100644
--- a/apps/api/src/routes/public/flags.test.ts
+++ b/apps/api/src/routes/public/flags.test.ts
@@ -66,173 +66,25 @@ describe("evaluateStringRule", () => {
enabled: true,
batch: false,
});
-
- it("handles all operators correctly", () => {
- expect(evaluateStringRule("user-123", rule("equals", "user-123"))).toBe(
- true
- );
- expect(evaluateStringRule("other", rule("equals", "user-123"))).toBe(false);
-
- expect(
- evaluateStringRule("user@company.com", rule("contains", "@company"))
- ).toBe(true);
- expect(
- evaluateStringRule("user@other.com", rule("contains", "@company"))
- ).toBe(false);
-
- expect(
- evaluateStringRule("admin-user", rule("starts_with", "admin-"))
- ).toBe(true);
- expect(
- evaluateStringRule("user-admin", rule("starts_with", "admin-"))
- ).toBe(false);
-
- expect(evaluateStringRule("file.com", rule("ends_with", ".com"))).toBe(
- true
- );
- expect(evaluateStringRule("file.org", rule("ends_with", ".com"))).toBe(
- false
- );
-
- const vals = ["a", "b", "c"];
- expect(evaluateStringRule("b", rule("in", undefined, vals))).toBe(true);
- expect(evaluateStringRule("z", rule("in", undefined, vals))).toBe(false);
- expect(evaluateStringRule("z", rule("not_in", undefined, vals))).toBe(true);
- expect(evaluateStringRule("a", rule("not_in", undefined, vals))).toBe(
- false
- );
-
- expect(evaluateStringRule("test", rule("unknown_op", "test"))).toBe(false);
- expect(evaluateStringRule(undefined, rule("equals", "test"))).toBe(false);
- });
-});
-
-describe("evaluateStringRule - email pattern matching", () => {
- const emailRule = (op: string, val?: string, vals?: string[]) => ({
- type: "email" as const,
- operator: op,
- value: val,
- values: vals,
- enabled: true,
- batch: false,
- });
-
- it("handles ends_with for email domain patterns", () => {
- // Common use case: target users by email domain
- expect(
- evaluateStringRule(
- "user@databuddy.cc",
- emailRule("ends_with", "@databuddy.cc")
- )
- ).toBe(true);
- expect(
- evaluateStringRule(
- "admin@databuddy.cc",
- emailRule("ends_with", "@databuddy.cc")
- )
- ).toBe(true);
- expect(
- evaluateStringRule(
- "user@other.com",
- emailRule("ends_with", "@databuddy.cc")
- )
- ).toBe(false);
- expect(
- evaluateStringRule("user@company.io", emailRule("ends_with", ".io"))
- ).toBe(true);
- expect(
- evaluateStringRule("user@company.com", emailRule("ends_with", ".io"))
- ).toBe(false);
- });
-
- it("handles starts_with for email prefix patterns", () => {
- // Target emails starting with a prefix (e.g., admin@, support@)
- expect(
- evaluateStringRule(
- "admin@company.com",
- emailRule("starts_with", "admin@")
- )
- ).toBe(true);
- expect(
- evaluateStringRule("admin@other.org", emailRule("starts_with", "admin@"))
- ).toBe(true);
- expect(
- evaluateStringRule("user@company.com", emailRule("starts_with", "admin@"))
- ).toBe(false);
- expect(
- evaluateStringRule(
- "support@company.com",
- emailRule("starts_with", "support")
- )
- ).toBe(true);
- });
-
- it("handles contains for partial email matching", () => {
- // Target emails containing a substring
- expect(
- evaluateStringRule(
- "user@company.internal.com",
- emailRule("contains", "internal")
- )
- ).toBe(true);
- expect(
- evaluateStringRule(
- "internal-user@company.com",
- emailRule("contains", "internal")
- )
- ).toBe(true);
- expect(
- evaluateStringRule("user@company.com", emailRule("contains", "internal"))
- ).toBe(false);
- expect(
- evaluateStringRule(
- "beta-tester@company.com",
- emailRule("contains", "beta")
- )
- ).toBe(true);
- });
-
- it("handles exact match for full email addresses", () => {
- expect(
- evaluateStringRule(
- "user@databuddy.cc",
- emailRule("equals", "user@databuddy.cc")
- )
- ).toBe(true);
- expect(
- evaluateStringRule(
- "other@databuddy.cc",
- emailRule("equals", "user@databuddy.cc")
- )
- ).toBe(false);
- });
-
- it("handles in/not_in for email lists", () => {
- const allowedEmails = ["admin@co.com", "support@co.com", "dev@co.com"];
- expect(
- evaluateStringRule(
- "admin@co.com",
- emailRule("in", undefined, allowedEmails)
- )
- ).toBe(true);
- expect(
- evaluateStringRule(
- "random@co.com",
- emailRule("in", undefined, allowedEmails)
- )
- ).toBe(false);
- expect(
- evaluateStringRule(
- "random@co.com",
- emailRule("not_in", undefined, allowedEmails)
- )
- ).toBe(true);
- expect(
- evaluateStringRule(
- "admin@co.com",
- emailRule("not_in", undefined, allowedEmails)
- )
- ).toBe(false);
+ const vals = ["a", "b", "c"];
+
+ it.each([
+ ["user-123", rule("equals", "user-123"), true],
+ ["other", rule("equals", "user-123"), false],
+ ["user@company.com", rule("contains", "@company"), true],
+ ["user@other.com", rule("contains", "@company"), false],
+ ["admin-user", rule("starts_with", "admin-"), true],
+ ["user-admin", rule("starts_with", "admin-"), false],
+ ["file.com", rule("ends_with", ".com"), true],
+ ["file.org", rule("ends_with", ".com"), false],
+ ["b", rule("in", undefined, vals), true],
+ ["z", rule("in", undefined, vals), false],
+ ["z", rule("not_in", undefined, vals), true],
+ ["a", rule("not_in", undefined, vals), false],
+ ["test", rule("unknown_op", "test"), false],
+ [undefined, rule("equals", "test"), false],
+ ])("%j with %j -> %s", (value, testRule, expected) => {
+ expect(evaluateStringRule(value, testRule)).toBe(expected);
});
});
@@ -246,32 +98,24 @@ describe("evaluateValueRule", () => {
batch: false,
});
- it("handles all operators correctly", () => {
- expect(evaluateValueRule(25, rule("equals", 25))).toBe(true);
- expect(evaluateValueRule(30, rule("equals", 25))).toBe(false);
- expect(evaluateValueRule("professional", rule("contains", "pro"))).toBe(
- true
- );
- expect(evaluateValueRule("basic", rule("contains", "pro"))).toBe(false);
- expect(
- evaluateValueRule("pro", rule("in", undefined, ["pro", "ent"]))
- ).toBe(true);
- expect(
- evaluateValueRule("free", rule("in", undefined, ["pro", "ent"]))
- ).toBe(false);
- expect(
- evaluateValueRule("ok", rule("not_in", undefined, ["bad", "worse"]))
- ).toBe(true);
- expect(
- evaluateValueRule("bad", rule("not_in", undefined, ["bad", "worse"]))
- ).toBe(false);
- expect(evaluateValueRule("val", rule("exists"))).toBe(true);
- expect(evaluateValueRule(0, rule("exists"))).toBe(true);
- expect(evaluateValueRule(undefined, rule("exists"))).toBe(false);
- expect(evaluateValueRule(null, rule("exists"))).toBe(false);
- expect(evaluateValueRule(undefined, rule("not_exists"))).toBe(true);
- expect(evaluateValueRule("x", rule("not_exists"))).toBe(false);
- expect(evaluateValueRule("x", rule("unknown_op"))).toBe(false);
+ it.each([
+ [25, rule("equals", 25), true],
+ [30, rule("equals", 25), false],
+ ["professional", rule("contains", "pro"), true],
+ ["basic", rule("contains", "pro"), false],
+ ["pro", rule("in", undefined, ["pro", "ent"]), true],
+ ["free", rule("in", undefined, ["pro", "ent"]), false],
+ ["ok", rule("not_in", undefined, ["bad", "worse"]), true],
+ ["bad", rule("not_in", undefined, ["bad", "worse"]), false],
+ ["val", rule("exists"), true],
+ [0, rule("exists"), true],
+ [undefined, rule("exists"), false],
+ [null, rule("exists"), false],
+ [undefined, rule("not_exists"), true],
+ ["x", rule("not_exists"), false],
+ ["x", rule("unknown_op"), false],
+ ])("%j with %j -> %s", (value, testRule, expected) => {
+ expect(evaluateValueRule(value, testRule)).toBe(expected);
});
});
@@ -848,23 +692,6 @@ describe("edge cases and stress tests", () => {
}
});
- it("handles rapid sequential evaluations", () => {
- const flag = {
- key: "rapid",
- type: "rollout" as const,
- rolloutPercentage: 50,
- status: "active" as const,
- defaultValue: false,
- };
-
- const start = performance.now();
- for (let i = 0; i < 10_000; i += 1) {
- evaluateFlag(flag, { userId: `u${i}` });
- }
- const duration = performance.now() - start;
- expect(duration).toBeLessThan(1000);
- });
-
it("handles percentage edge values", () => {
for (let i = 0; i < 100; i += 1) {
const ctx = { userId: randomId() };
diff --git a/apps/api/src/routes/public/flags.ts b/apps/api/src/routes/public/flags.ts
index 04b33cc867..f40edad867 100644
--- a/apps/api/src/routes/public/flags.ts
+++ b/apps/api/src/routes/public/flags.ts
@@ -133,6 +133,24 @@ const bulkFlagBodySchema = t.Object({
environment: t.Optional(t.String()),
});
+interface TargetGroupJoin {
+ targetGroup: {
+ deletedAt: Date | null;
+ id: string;
+ rules: FlagRule[];
+ } | null;
+}
+
+function resolveTargetGroups(joins: TargetGroupJoin[]): TargetGroupData[] {
+ const resolved: TargetGroupData[] = [];
+ for (const { targetGroup } of joins) {
+ if (targetGroup && !targetGroup.deletedAt) {
+ resolved.push({ id: targetGroup.id, rules: targetGroup.rules });
+ }
+ }
+ return resolved;
+}
+
const getCachedFlag = cacheable(
async (key: string, clientId: string, environment?: string) => {
const flag = await db.query.flags.findFirst({
@@ -162,16 +180,9 @@ const getCachedFlag = cacheable(
return null;
}
- const resolvedTargetGroups: TargetGroupData[] = flag.flagsToTargetGroups
- .filter((ftg) => ftg.targetGroup && !ftg.targetGroup.deletedAt)
- .map((ftg) => ({
- id: ftg.targetGroup.id,
- rules: ftg.targetGroup.rules,
- }));
-
return {
...flag,
- resolvedTargetGroups,
+ resolvedTargetGroups: resolveTargetGroups(flag.flagsToTargetGroups),
};
},
{
@@ -210,19 +221,10 @@ const getCachedFlagsForClient = cacheable(
},
});
- return flagsList.map((flag) => {
- const resolvedTargetGroups: TargetGroupData[] = flag.flagsToTargetGroups
- .filter((ftg) => ftg.targetGroup && !ftg.targetGroup.deletedAt)
- .map((ftg) => ({
- id: ftg.targetGroup.id,
- rules: ftg.targetGroup.rules,
- }));
-
- return {
- ...flag,
- resolvedTargetGroups,
- };
- });
+ return flagsList.map((flag) => ({
+ ...flag,
+ resolvedTargetGroups: resolveTargetGroups(flag.flagsToTargetGroups),
+ }));
},
{
expireInSec: 30,
@@ -285,19 +287,10 @@ const getCachedFlagsForUser = cacheable(
},
});
- return flagsList.map((flag) => {
- const resolvedTargetGroups: TargetGroupData[] = flag.flagsToTargetGroups
- .filter((ftg) => ftg.targetGroup && !ftg.targetGroup.deletedAt)
- .map((ftg) => ({
- id: ftg.targetGroup.id,
- rules: ftg.targetGroup.rules,
- }));
-
- return {
- ...flag,
- resolvedTargetGroups,
- };
- });
+ return flagsList.map((flag) => ({
+ ...flag,
+ resolvedTargetGroups: resolveTargetGroups(flag.flagsToTargetGroups),
+ }));
},
{
expireInSec: 30,
@@ -587,10 +580,6 @@ export function evaluateFlag(
};
}
- let enabled = Boolean(flag.defaultValue);
- let value = enabled;
- let reason = "DEFAULT_VALUE";
-
if (flag.type === "rollout") {
let identifier: string;
@@ -605,24 +594,22 @@ export function evaluateFlag(
identifier = context.userId || context.email || "anonymous";
}
- const hash = hashString(`${flag.key}:${identifier}`);
- const percentage = hash % 100;
- const rolloutPercentage = flag.rolloutPercentage || 0;
-
- enabled = percentage < rolloutPercentage;
- value = enabled;
- reason = enabled ? "ROLLOUT_ENABLED" : "ROLLOUT_DISABLED";
- } else {
- enabled = Boolean(flag.defaultValue);
- value = enabled;
- reason = "BOOLEAN_DEFAULT";
+ const percentage = hashString(`${flag.key}:${identifier}`) % 100;
+ const enabled = percentage < (flag.rolloutPercentage || 0);
+ return {
+ enabled,
+ value: enabled,
+ payload: enabled ? flag.payload : null,
+ reason: enabled ? "ROLLOUT_ENABLED" : "ROLLOUT_DISABLED",
+ };
}
+ const enabled = Boolean(flag.defaultValue);
return {
enabled,
- value,
+ value: enabled,
payload: enabled ? flag.payload : null,
- reason,
+ reason: "BOOLEAN_DEFAULT",
};
}
diff --git a/apps/api/src/routes/query.ts b/apps/api/src/routes/query.ts
index a326efa798..e3e34bf9f8 100644
--- a/apps/api/src/routes/query.ts
+++ b/apps/api/src/routes/query.ts
@@ -26,6 +26,8 @@ import { getOrganizationOwnerId } from "@databuddy/rpc/organization";
import {
type GatedFeatureId,
GATED_FEATURES,
+ getFeatureUnavailableMessage,
+ getNextPlanForFeature,
isFeatureAvailable,
} from "@databuddy/shared/types/features";
import {
@@ -473,13 +475,19 @@ async function enforceFeatureGatesForQueryTypes(
const ownerId = website.organizationId
? await getOrganizationOwnerId(website.organizationId)
: null;
- if (!ownerId) {
- return null;
- }
- const billing = await getBillingOwner(ownerId, website.organizationId);
+ const planId = ownerId
+ ? (await getBillingOwner(ownerId, website.organizationId)).planId
+ : null;
+
for (const feature of required) {
- if (!isFeatureAvailable(billing.planId, feature)) {
- return { error: "This feature is not available on the plan", feature };
+ if (!isFeatureAvailable(planId, feature)) {
+ return {
+ error: getFeatureUnavailableMessage(
+ feature,
+ getNextPlanForFeature(planId, feature)
+ ),
+ feature,
+ };
}
}
return null;
diff --git a/apps/api/src/routes/webhooks/autumn-inbox.ts b/apps/api/src/routes/webhooks/autumn-inbox.ts
index b58778efdc..1b58868c94 100644
--- a/apps/api/src/routes/webhooks/autumn-inbox.ts
+++ b/apps/api/src/routes/webhooks/autumn-inbox.ts
@@ -25,7 +25,7 @@ const DEAD_LETTER_RETENTION_MS = 90 * 24 * 60 * 60 * 1000;
const RETRY_BASE_MS = 5 * 60 * 1000;
const RETRY_MAX_MS = 6 * 60 * 60 * 1000;
-export const AUTUMN_WEBHOOK_LEASE_MS = 5 * 60 * 1000;
+const AUTUMN_WEBHOOK_LEASE_MS = 5 * 60 * 1000;
export const AUTUMN_WEBHOOK_MAX_ATTEMPTS = 12;
export interface StoredAutumnWebhook {
diff --git a/apps/api/src/routes/webhooks/autumn.test.ts b/apps/api/src/routes/webhooks/autumn.test.ts
index b0f8ade46d..681a4261a9 100644
--- a/apps/api/src/routes/webhooks/autumn.test.ts
+++ b/apps/api/src/routes/webhooks/autumn.test.ts
@@ -128,11 +128,11 @@ vi.mock("./autumn-inbox", () => ({
vi.mock("@databuddy/db", () => ({
and: (...conditions: unknown[]) => ({ conditions }),
- db: {
- query: {
- member: {
- findMany: vi.fn(async () => state.ownedOrganizations),
- },
+ db: {
+ query: {
+ member: {
+ findMany: vi.fn(async () => state.ownedOrganizations),
+ },
organization: { findFirst: vi.fn(async () => null) },
user: { findFirst: vi.fn(async () => state.userRow) },
},
@@ -140,11 +140,11 @@ vi.mock("@databuddy/db", () => ({
eq: (field: unknown, value: unknown) => ({ field, op: "eq", value }),
gt: (field: unknown, value: unknown) => ({ field, op: "gt", value }),
isNull: (field: unknown) => ({ field, op: "isNull" }),
- normalizeEmailNotificationSettings: (raw?: {
- billing?: { usageWarnings?: boolean };
- }) => ({
- billing: { usageWarnings: raw?.billing?.usageWarnings ?? true },
- }),
+ normalizeEmailNotificationSettings: (raw?: {
+ billing?: { usageWarnings?: boolean };
+ }) => ({
+ billing: { usageWarnings: raw?.billing?.usageWarnings ?? true },
+ }),
or: (...conditions: unknown[]) => ({ conditions, op: "or" }),
sql: (strings: TemplateStringsArray, ...values: unknown[]) => ({
strings: Array.from(strings),
@@ -359,13 +359,13 @@ describe("sendAlertEmail", () => {
"send",
"insert",
]);
- expect(state.send).toHaveBeenCalledWith({
- from: "alerts@databuddy.cc",
- to: "member@example.com",
- subject: "Limit reached",
- html: " ",
- text: " ",
- });
+ expect(state.send).toHaveBeenCalledWith({
+ from: "alerts@databuddy.cc",
+ to: "member@example.com",
+ subject: "Limit reached",
+ html: " ",
+ text: " ",
+ });
expect(state.inserted).toEqual([
expect.objectContaining({
alertType: "included",
@@ -827,4 +827,49 @@ describe("Autumn webhook inbox", () => {
expect(state.send).toHaveBeenCalledTimes(1);
expect(state.storedWebhooks.get("msg-replay")?.status).toBe("completed");
});
+
+ it("fails a replay for an unknown webhook id without side effects", async () => {
+ await expect(replayDeferredAutumnWebhook("msg-unknown")).resolves.toEqual({
+ message: "Stored webhook not found",
+ success: false,
+ });
+ expect(state.send).not.toHaveBeenCalled();
+ });
+
+ it("leaves a webhook claimed by another worker queued", async () => {
+ state.storedWebhooks.set("msg-claimed", {
+ attempts: 3,
+ claimToken: "claim-other-worker",
+ id: "msg-claimed",
+ payload: {},
+ status: "processing",
+ type: "balances.limit_reached",
+ });
+
+ await expect(replayDeferredAutumnWebhook("msg-claimed")).resolves.toEqual({
+ disposition: "deferred",
+ message: "Webhook already queued for replay",
+ success: true,
+ });
+ expect(state.send).not.toHaveBeenCalled();
+ expect(state.storedWebhooks.get("msg-claimed")?.status).toBe("processing");
+ });
+
+ it("acknowledges dead-lettered webhooks without reprocessing them", async () => {
+ state.storedWebhooks.set("msg-dead", {
+ attempts: 12,
+ claimToken: null,
+ id: "msg-dead",
+ payload: {},
+ status: "dead_letter",
+ type: "balances.limit_reached",
+ });
+
+ await expect(replayDeferredAutumnWebhook("msg-dead")).resolves.toEqual({
+ disposition: "duplicate",
+ message: "Webhook retained for investigation",
+ success: true,
+ });
+ expect(state.send).not.toHaveBeenCalled();
+ });
});
diff --git a/apps/api/src/routes/webhooks/autumn.ts b/apps/api/src/routes/webhooks/autumn.ts
index 4035448505..09847a9d9a 100644
--- a/apps/api/src/routes/webhooks/autumn.ts
+++ b/apps/api/src/routes/webhooks/autumn.ts
@@ -195,7 +195,7 @@ const getBillingRecipient = cacheable(
}
);
-export async function resolveBillingOrganization(
+async function resolveBillingOrganization(
customerId: string,
entityId?: string
): Promise {
@@ -749,6 +749,35 @@ function webhookIdempotencyKey(svixId: string): string {
return createHash("sha256").update(svixId).digest("hex");
}
+async function recordRetryAttempt(
+ stored: ClaimedAutumnWebhook,
+ status: "deferred" | "pending",
+ message: string
+): Promise {
+ const outcome = await recordAutumnWebhookAttempt({
+ attempts: stored.attempts,
+ claimToken: stored.claimToken,
+ errorMessage: message,
+ id: stored.id,
+ status,
+ });
+ if (outcome === "completed") {
+ return {
+ disposition: "duplicate",
+ message: "Webhook already processed",
+ success: true,
+ };
+ }
+ if (outcome === "dead_letter") {
+ return {
+ disposition: "dead_letter",
+ message: "Webhook moved to dead letter",
+ success: true,
+ };
+ }
+ return null;
+}
+
async function processClaimedAutumnWebhook(
stored: ClaimedAutumnWebhook
): Promise {
@@ -763,82 +792,35 @@ async function processClaimedAutumnWebhook(
}
result = await dispatch(event, webhookIdempotencyKey(stored.id));
} catch (error) {
- const status = await recordAutumnWebhookAttempt({
- attempts: stored.attempts,
- claimToken: stored.claimToken,
- errorMessage: errorMessage(error),
- id: stored.id,
- status: "pending",
- });
- if (status === "completed") {
- return {
- disposition: "duplicate",
- message: "Webhook already processed",
- success: true,
- };
- }
- if (status === "dead_letter") {
- return {
- disposition: "dead_letter",
- message: "Webhook moved to dead letter",
- success: true,
- };
+ const settled = await recordRetryAttempt(
+ stored,
+ "pending",
+ errorMessage(error)
+ );
+ if (settled) {
+ return settled;
}
throw error;
}
if (result.disposition === "deferred") {
- const status = await recordAutumnWebhookAttempt({
- attempts: stored.attempts,
- claimToken: stored.claimToken,
- errorMessage: result.message,
- id: stored.id,
- status: "deferred",
- });
- if (status === "completed") {
- return {
- disposition: "duplicate",
- message: "Webhook already processed",
- success: true,
- };
- }
- if (status === "dead_letter") {
- return {
- disposition: "dead_letter",
- message: "Webhook moved to dead letter",
+ const settled = await recordRetryAttempt(
+ stored,
+ "deferred",
+ result.message
+ );
+ return (
+ settled ?? {
+ disposition: "deferred",
+ message: "Webhook stored for replay",
success: true,
- };
- }
- return {
- disposition: "deferred",
- message: "Webhook stored for replay",
- success: true,
- };
+ }
+ );
}
if (!result.success) {
- const status = await recordAutumnWebhookAttempt({
- attempts: stored.attempts,
- claimToken: stored.claimToken,
- errorMessage: result.message,
- id: stored.id,
- status: "pending",
- });
- if (status === "completed") {
- return {
- disposition: "duplicate",
- message: "Webhook already processed",
- success: true,
- };
- }
- if (status === "dead_letter") {
- return {
- disposition: "dead_letter",
- message: "Webhook moved to dead letter",
- success: true,
- };
- }
- return result;
+ const settled = await recordRetryAttempt(stored, "pending", result.message);
+ return settled ?? result;
}
await recordAutumnWebhookAttempt({
diff --git a/apps/api/src/rpc/handlers.ts b/apps/api/src/rpc/handlers.ts
index 4efe76d812..5e08689631 100644
--- a/apps/api/src/rpc/handlers.ts
+++ b/apps/api/src/rpc/handlers.ts
@@ -26,7 +26,7 @@ export const rpcHandler = new RPCHandler(appRouter, {
interceptors: [createAbortSignalInterceptor(), onError(logOrpcHandlerError)],
});
-export function createAuthenticatedOrpcContext(request: Request) {
+function createAuthenticatedOrpcContext(request: Request) {
const preResolvedAuth = getPreResolvedAuth(request.headers);
return createRPCContext(
{ headers: request.headers, requestId: getRequestId(request) },
@@ -34,7 +34,7 @@ export function createAuthenticatedOrpcContext(request: Request) {
);
}
-export function createAnonymousOrpcContext(request: Request) {
+function createAnonymousOrpcContext(request: Request) {
return createRPCContext(
{ headers: request.headers, requestId: getRequestId(request) },
ANONYMOUS_AUTH
diff --git a/apps/api/src/schemas/query-schemas.ts b/apps/api/src/schemas/query-schemas.ts
index 111920daa9..b5342e2843 100644
--- a/apps/api/src/schemas/query-schemas.ts
+++ b/apps/api/src/schemas/query-schemas.ts
@@ -6,14 +6,11 @@ const QUERY_BUILDER_TYPES = Object.keys(QueryBuilders) as Array<
keyof typeof QueryBuilders
>;
-export {
- DatePresets,
- type DatePreset,
-} from "@databuddy/ai/lib/date-presets";
+export { DatePresets } from "@databuddy/ai/lib/date-presets";
-export const DatePresetSchema = t.Enum(DatePresets);
+const DatePresetSchema = t.Enum(DatePresets);
-export const FilterSchema = t.Object({
+const FilterSchema = t.Object({
field: t.String(),
op: t.Enum({
eq: "eq",
@@ -31,7 +28,7 @@ export const FilterSchema = t.Object({
]),
});
-export const ParameterWithDatesSchema = t.Object({
+const ParameterWithDatesSchema = t.Object({
name: t.String(),
start_date: t.Optional(t.String()),
end_date: t.Optional(t.String()),
@@ -90,7 +87,7 @@ export const CompileRequestSchema = t.Object({
offset: t.Optional(t.Number({ minimum: 0 })),
});
-export interface FilterType {
+interface FilterType {
field: string;
op:
| "eq"
@@ -103,7 +100,7 @@ export interface FilterType {
value: string | number | Array;
}
-export interface ParameterWithDatesType {
+interface ParameterWithDatesType {
end_date?: string;
granularity?: "hourly" | "daily" | "hour" | "day";
id?: string;
diff --git a/apps/basket/package.json b/apps/basket/package.json
index 419e2ab675..7c7be39893 100644
--- a/apps/basket/package.json
+++ b/apps/basket/package.json
@@ -22,18 +22,13 @@
"@databuddy/shared": "workspace:*",
"@databuddy/validation": "workspace:*",
"@maxmind/geoip2-node": "^6.3.4",
- "@types/ua-parser-js": "^0.7.39",
- "async-mutex": "^0.5.0",
"effect": "^4.0.0-beta.90",
"elysia": "catalog:",
"evlog": "catalog:",
"kafkajs": "^2.2.4",
- "keypal": "0.2.0",
- "ua-parser-js": "^2.0.7",
"zod": "catalog:"
},
"devDependencies": {
- "bun-types": "catalog:",
"vitest": "^4.1.4"
},
"packageManager": "bun@1.3.14"
diff --git a/apps/basket/src/hooks/auth.ts b/apps/basket/src/hooks/auth.ts
index 3180727653..1f03ea1592 100644
--- a/apps/basket/src/hooks/auth.ts
+++ b/apps/basket/src/hooks/auth.ts
@@ -1,10 +1,3 @@
-/**
- * Website Authentication Hook for Analytics
- *
- * This hook provides authentication for website tracking by validating
- * client IDs and origins against registered websites.
- */
-
import { db } from "@databuddy/db";
import type { Website } from "@databuddy/db/schema";
import { cacheNamespaces } from "@databuddy/redis/cache-invalidation";
@@ -14,10 +7,7 @@ import { captureError, record } from "@lib/tracing";
import { isValidOriginFromSettings } from "@utils/origin-ip-validation";
import { createError, EvlogError } from "evlog";
-export {
- isValidIpFromSettings,
- isValidOriginFromSettings,
-} from "@utils/origin-ip-validation";
+export { isValidIpFromSettings } from "@utils/origin-ip-validation";
type WebsiteWithOwner = Website & {
ownerId: string | null;
@@ -72,14 +62,7 @@ export const resolveApiKeyOwnerId = cacheable(
staleTime: 60,
}
);
-
-/**
- * Validates if an origin header matches or is a subdomain of the allowed domain
- */
-export function isValidOrigin(
- originHeader: string,
- allowedDomain: string
-): boolean {
+function isValidOrigin(originHeader: string, allowedDomain: string): boolean {
const trimmedOrigin = originHeader?.trim();
if (!trimmedOrigin) {
return true;
@@ -108,11 +91,7 @@ export function isValidOrigin(
return false;
}
}
-
-/**
- * Normalizes a domain by removing the protocol, port, and "www." prefix.
- */
-export function normalizeDomain(domain: string): string {
+function normalizeDomain(domain: string): string {
if (!domain) {
return "";
}
@@ -153,17 +132,14 @@ export function normalizeDomain(domain: string): string {
}
}
-export function isSubdomain(
- originDomain: string,
- allowedDomain: string
-): boolean {
+function isSubdomain(originDomain: string, allowedDomain: string): boolean {
return (
originDomain.endsWith(`.${allowedDomain}`) &&
originDomain.length > allowedDomain.length + 1
);
}
-export function isValidDomainFormat(domain: string): boolean {
+function isValidDomainFormat(domain: string): boolean {
if (
!domain ||
domain.length > 253 ||
diff --git a/apps/basket/src/lib/api-key.ts b/apps/basket/src/lib/api-key.ts
index 37aca03315..c9f0f8be8b 100644
--- a/apps/basket/src/lib/api-key.ts
+++ b/apps/basket/src/lib/api-key.ts
@@ -10,7 +10,7 @@ import {
import { record } from "@lib/tracing";
import { useLogger } from "evlog/elysia";
-export type { ApiKeyRow, ApiScope } from "@databuddy/api-keys/resolve";
+export type { ApiKeyRow } from "@databuddy/api-keys/resolve";
export const hasKeyScope = _hasKeyScope;
export const hasGlobalAccess = _hasGlobalAccess;
diff --git a/apps/basket/src/lib/billing.test.ts b/apps/basket/src/lib/billing.test.ts
index 851876762f..e8e523f4b1 100644
--- a/apps/basket/src/lib/billing.test.ts
+++ b/apps/basket/src/lib/billing.test.ts
@@ -39,8 +39,6 @@ describe("checkAutumnUsage", () => {
mockLoggerWarn.mockReset();
});
- // ── Enforcement ──
-
test("allowed response → allowed", async () => {
mockCheck.mockResolvedValue({
allowed: true,
@@ -73,8 +71,6 @@ describe("checkAutumnUsage", () => {
});
});
- // ── Still calls Autumn (metering for paying customers) ──
-
test("calls autumn.check with sendEvent: true", async () => {
mockCheck.mockResolvedValue({
allowed: true,
@@ -107,8 +103,6 @@ describe("checkAutumnUsage", () => {
});
});
- // ── Logging ──
-
test("logs balance context from Autumn response", async () => {
mockCheck.mockResolvedValue({
allowed: true,
diff --git a/apps/basket/src/lib/cors-safe-json.ts b/apps/basket/src/lib/cors-safe-json.ts
index cf56a64da0..30d0065f9a 100644
--- a/apps/basket/src/lib/cors-safe-json.ts
+++ b/apps/basket/src/lib/cors-safe-json.ts
@@ -2,11 +2,6 @@ interface ParseContext {
contentType: string;
request: Request;
}
-
-/**
- * Unload beacons use text/plain so cross-origin delivery remains a CORS simple
- * request. Parse that JSON before the normal ingest schemas validate it.
- */
export async function parseCorsSafeJson({
contentType,
request,
diff --git a/apps/basket/src/lib/event-service.test.ts b/apps/basket/src/lib/event-service.test.ts
index 353fe536cd..d4ea4bcae5 100644
--- a/apps/basket/src/lib/event-service.test.ts
+++ b/apps/basket/src/lib/event-service.test.ts
@@ -2,8 +2,6 @@ import { describe, expect, test } from "vitest";
import { CONTROL_CHARS, longString, XSS_PAYLOADS } from "../test-helpers";
import { buildTrackEvent, type TrackEventContext } from "./event-service";
-// ── Fixtures ──
-
const NOW = 1_700_000_000_000;
const fullTrackData = {
@@ -65,89 +63,72 @@ const fullCtx: TrackEventContext = {
now: NOW,
};
-// ── Field mapping snapshot ──
-
describe("buildTrackEvent — field mapping", () => {
test("full input → every field mapped correctly", () => {
const result = buildTrackEvent(fullTrackData, fullCtx);
- // Identity
- expect(result.id).toBeTruthy(); // randomUUIDv7
- expect(result.client_id).toBe("ws_test");
-
- // Names & content
- expect(result.event_name).toBe("pageview");
- expect(result.title).toBe("Dashboard | App");
- expect(result.referrer).toBe("https://google.com");
- expect(result.path).toBe("/dashboard");
- expect(result.url).toBe("/dashboard"); // url === path
-
- // User identity
- expect(result.anonymous_id).toBe("salted_anon_1");
- expect(result.session_id).toBe("sess_abc123");
-
- // Timestamps — uses trackData values when numeric
- expect(result.timestamp).toBe(1_700_000_001_000);
- expect(result.time).toBe(1_700_000_001_000);
- expect(result.created_at).toBe(NOW);
-
- // Geo
- expect(result.ip).toBe("abc123def456");
- expect(result.country).toBe("United States");
- expect(result.region).toBe("California");
- expect(result.city).toBe("San Francisco");
-
- // UA
- expect(result.user_agent).toBe(""); // always empty (privacy)
- expect(result.browser_name).toBe("Chrome");
- expect(result.browser_version).toBe("120.0");
- expect(result.os_name).toBe("Windows");
- expect(result.os_version).toBe("10");
- expect(result.device_type).toBe("desktop");
- expect(result.device_brand).toBe("Dell");
- expect(result.device_model).toBe("XPS");
-
- // Client context — passthrough
- expect(result.viewport_size).toBe("1024x768");
- expect(result.language).toBe("en-US");
- expect(result.timezone).toBe("America/New_York");
-
- // Engagement
- expect(result.time_on_page).toBe(30_000);
- expect(result.scroll_depth).toBe(75);
- expect(result.interaction_count).toBe(12);
- expect(result.page_count).toBe(3);
-
- // UTM
- expect(result.utm_source).toBe("google");
- expect(result.utm_medium).toBe("cpc");
- expect(result.utm_campaign).toBe("summer");
- expect(result.utm_term).toBe("analytics");
- expect(result.utm_content).toBe("banner");
- expect(result.gclid).toBe("gclid_abc");
-
- // Performance — validated through validatePerformanceMetric
- expect(result.dom_ready_time).toBe(800);
- expect(result.ttfb).toBe(200);
- expect(result.render_time).toBe(100);
-
- // Properties
- expect(result.properties).toBe('{"plan":"pro","color":"blue"}');
+ expect(result).toMatchObject({
+ client_id: "ws_test",
+ event_name: "pageview",
+ title: "Dashboard | App",
+ referrer: "https://google.com",
+ path: "/dashboard",
+ url: "/dashboard",
+ anonymous_id: "salted_anon_1",
+ session_id: "sess_abc123",
+ timestamp: 1_700_000_001_000,
+ time: 1_700_000_001_000,
+ created_at: NOW,
+ ip: "abc123def456",
+ country: "United States",
+ region: "California",
+ city: "San Francisco",
+ user_agent: "",
+ browser_name: "Chrome",
+ browser_version: "120.0",
+ os_name: "Windows",
+ os_version: "10",
+ device_type: "desktop",
+ device_brand: "Dell",
+ device_model: "XPS",
+ viewport_size: "1024x768",
+ language: "en-US",
+ timezone: "America/New_York",
+ time_on_page: 30_000,
+ scroll_depth: 75,
+ interaction_count: 12,
+ page_count: 3,
+ utm_source: "google",
+ utm_medium: "cpc",
+ utm_campaign: "summer",
+ utm_term: "analytics",
+ utm_content: "banner",
+ gclid: "gclid_abc",
+ dom_ready_time: 800,
+ ttfb: 200,
+ render_time: 100,
+ properties: '{"plan":"pro","color":"blue"}',
+ });
+ expect(result.id).toMatch(
+ /^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
+ );
});
test("minimal input → defaults applied", () => {
const result = buildTrackEvent({ name: "click" }, fullCtx);
- expect(result.event_name).toBe("click");
- expect(result.timestamp).toBe(NOW); // falls back to ctx.now
- expect(result.time).toBe(NOW);
- expect(result.page_count).toBe(1); // default
- expect(result.properties).toBe("{}"); // empty
- expect(result.referrer).toBe("");
- expect(result.path).toBe("");
- expect(result.url).toBe("");
- expect(result.title).toBe("");
- expect(result.session_id).toBe("");
+ expect(result).toMatchObject({
+ event_name: "click",
+ timestamp: NOW,
+ time: NOW,
+ page_count: 1,
+ properties: "{}",
+ referrer: "",
+ path: "",
+ url: "",
+ title: "",
+ session_id: "",
+ });
});
test("missing geo fields → empty strings", () => {
@@ -175,9 +156,9 @@ describe("buildTrackEvent — field mapping", () => {
expect(result.timestamp).toBe(NOW);
});
- test("performance metrics validated (negative → undefined)", () => {
+ test("performance metrics over the 300s cap → undefined", () => {
const result = buildTrackEvent({ name: "x", ttfb: 999_999 }, fullCtx);
- expect(result.ttfb).toBeUndefined(); // >300000
+ expect(result.ttfb).toBeUndefined();
});
test("event_name sanitized (truncated to 255)", () => {
@@ -201,8 +182,6 @@ describe("buildTrackEvent — field mapping", () => {
});
});
-// ── Sanitization boundary ──
-
describe("buildTrackEvent — sanitization boundary", () => {
for (const payload of XSS_PAYLOADS) {
test(`XSS in name: ${payload.slice(0, 30)}…`, () => {
@@ -252,16 +231,14 @@ describe("buildTrackEvent — sanitization boundary", () => {
}
});
- test("properties with XSS are JSON-stringified (not sanitized — stored as JSON)", () => {
+ test("properties are JSON-stringified verbatim, not HTML-sanitized", () => {
const result = buildTrackEvent(
{ name: "x", properties: { evil: "" } },
fullCtx
);
- // Properties are JSON-stringified, not HTML-sanitized (they're stored as JSON in CH)
- expect(result.properties).toContain("script");
- expect(typeof result.properties).toBe("string");
- // But it's valid JSON
- expect(() => JSON.parse(result.properties as string)).not.toThrow();
+ expect(JSON.parse(result.properties as string)).toEqual({
+ evil: "",
+ });
});
test("passthrough fields (language, timezone, etc.) are NOT sanitized", () => {
@@ -277,76 +254,11 @@ describe("buildTrackEvent — sanitization boundary", () => {
expect(result.timezone).toBe("America/New_York");
});
- test("session_id validated (rejects special chars)", () => {
+ test("session_id with stripped tags still passes the session id charset", () => {
const result = buildTrackEvent(
{ name: "x", sessionId: "sess",
@@ -109,8 +71,6 @@ export function longString(n: number, char = "a"): string {
return char.repeat(n);
}
-// ── Request factory ──
-
export function req(
url = "https://example.com",
headers: Record = {}
diff --git a/apps/basket/src/utils/ip-geo.test.ts b/apps/basket/src/utils/ip-geo.test.ts
index 5b2f746b1f..3cc6eb629f 100644
--- a/apps/basket/src/utils/ip-geo.test.ts
+++ b/apps/basket/src/utils/ip-geo.test.ts
@@ -1,4 +1,4 @@
-import { afterAll, describe, expect, test } from "vitest";
+import { afterAll, describe, expect, test, vi } from "vitest";
import { randomIPv4, randomPublicIPv4, req } from "../test-helpers";
import {
anonymizeIp,
@@ -11,8 +11,6 @@ const HEX12 = /^[a-f0-9]{12}$/;
afterAll(() => closeGeoIPReader());
-// ── anonymizeIp ──
-
describe("anonymizeIp", () => {
test("empty → empty", () => expect(anonymizeIp("")).toBe(""));
@@ -43,8 +41,6 @@ describe("anonymizeIp", () => {
});
});
-// ── extractIpFromRequest ──
-
describe("extractIpFromRequest", () => {
const table: [string, Record, string][] = [
["cf-connecting-ip", { "cf-connecting-ip": "1.2.3.4" }, "1.2.3.4"],
@@ -89,8 +85,6 @@ describe("extractIpFromRequest", () => {
});
});
-// ── getGeo ──
-
describe("getGeo", () => {
test("empty IP → empty anonymizedIP, no geo", async () => {
const r = await getGeo("");
@@ -116,48 +110,51 @@ describe("getGeo", () => {
}
});
- test("200 random public IPs → valid structure", {
- timeout: 60_000,
- }, async () => {
- const probe = await Promise.race([
- getGeo("8.8.8.8"),
- new Promise((r) => setTimeout(() => r(null), 30_000)),
- ]);
- if (!(probe && probe.anonymizedIP)) {
- console.log("Skipping: GeoIP CDN unreachable");
- return;
+ test("falls back to the Cloudflare country header when MaxMind is unavailable", async () => {
+ closeGeoIPReader();
+ const fetchSpy = vi
+ .spyOn(globalThis, "fetch")
+ .mockRejectedValue(new Error("CDN unreachable"));
+ try {
+ const withHeader = await getGeo(
+ randomPublicIPv4(),
+ req("https://x.com", { "cf-ipcountry": "US" })
+ );
+ expect(withHeader.country).toBe("US");
+ expect(withHeader.region).toBeUndefined();
+ expect(withHeader.city).toBeUndefined();
+
+ const badHeader = await getGeo(
+ randomPublicIPv4(),
+ req("https://x.com", { "cf-ipcountry": "USA" })
+ );
+ expect(badHeader.country).toBeUndefined();
+ } finally {
+ fetchSpy.mockRestore();
+ closeGeoIPReader();
}
+ });
- const results = await Promise.all(
- Array.from({ length: 200 }, () => getGeo(randomPublicIPv4()))
- );
- for (const r of results) {
- expect(typeof r.anonymizedIP).toBe("string");
- if (r.country !== undefined) {
- expect(typeof r.country).toBe("string");
- }
- if (r.region !== undefined) {
- expect(typeof r.region).toBe("string");
- }
- if (r.city !== undefined) {
- expect(typeof r.city).toBe("string");
+ test("accepts compressed and ipv4-mapped IPv6 addresses", async () => {
+ closeGeoIPReader();
+ const fetchSpy = vi
+ .spyOn(globalThis, "fetch")
+ .mockRejectedValue(new Error("CDN unreachable"));
+ try {
+ for (const ip of [
+ "2a00:1450:4009:81f::200e",
+ "2001:db8::1",
+ "::ffff:8.8.8.8",
+ ]) {
+ const r = await getGeo(
+ ip,
+ req("https://x.com", { "cf-ipcountry": "DE" })
+ );
+ expect(r.country).toBe("DE");
}
+ } finally {
+ fetchSpy.mockRestore();
+ closeGeoIPReader();
}
});
-
- test("same IP → consistent results", async () => {
- const ip = randomPublicIPv4();
- const [a, b] = await Promise.all([getGeo(ip), getGeo(ip)]);
- expect(a.anonymizedIP).toBe(b.anonymizedIP);
- expect(a.country).toBe(b.country);
- });
-
- test("Cloudflare country fallback", async () => {
- const r = await getGeo(
- "not-valid-ip",
- req("https://x.com", { "cf-ipcountry": "US" })
- );
- // Should either return CF country or undefined (depends on reader state)
- expect(typeof r.anonymizedIP).toBe("string");
- });
});
diff --git a/apps/basket/src/utils/ip-geo.ts b/apps/basket/src/utils/ip-geo.ts
index e2cce394c0..d579098f00 100644
--- a/apps/basket/src/utils/ip-geo.ts
+++ b/apps/basket/src/utils/ip-geo.ts
@@ -1,4 +1,5 @@
import { createHash } from "node:crypto";
+import { isIP } from "node:net";
import { captureError, mergeWideEvent, record } from "@lib/tracing";
import type { City } from "@maxmind/geoip2-node";
import {
@@ -110,13 +111,8 @@ function loadDatabase() {
const ignore = ["127.0.0.1", "::1"];
-const ipv4Regex =
- /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
-
-const ipv6Regex = /^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/;
-
function isValidIp(ip: string): boolean {
- return Boolean(ip && (ipv4Regex.test(ip) || ipv6Regex.test(ip)));
+ return isIP(ip) !== 0;
}
function getCloudflareCountry(headers: Headers): string | undefined {
diff --git a/apps/basket/src/utils/origin-ip-validation.test.ts b/apps/basket/src/utils/origin-ip-validation.test.ts
new file mode 100644
index 0000000000..f5b4f8d8f7
--- /dev/null
+++ b/apps/basket/src/utils/origin-ip-validation.test.ts
@@ -0,0 +1,122 @@
+import { describe, expect, test } from "vitest";
+import {
+ isValidIpFromSettings,
+ isValidOriginFromSettings,
+ normalizeIpv6,
+} from "./origin-ip-validation";
+
+describe("normalizeIpv6", () => {
+ test("expands compressed notation", () =>
+ expect(normalizeIpv6("2001:db8::1")).toBe(
+ "2001:0db8:0000:0000:0000:0000:0000:0001"
+ ));
+
+ test("strips leading zeros before padding", () =>
+ expect(normalizeIpv6("2001:0db8:0:0:0:0:0:0001")).toBe(
+ "2001:0db8:0000:0000:0000:0000:0000:0001"
+ ));
+
+ test("lowercases hex groups", () =>
+ expect(normalizeIpv6("2001:DB8::A")).toBe(
+ "2001:0db8:0000:0000:0000:0000:0000:000a"
+ ));
+
+ test("handles loopback", () =>
+ expect(normalizeIpv6("::1")).toBe(
+ "0000:0000:0000:0000:0000:0000:0000:0001"
+ ));
+
+ test("handles all zeros", () =>
+ expect(normalizeIpv6("::")).toBe(
+ "0000:0000:0000:0000:0000:0000:0000:0000"
+ ));
+
+ test("handles IPv4-mapped addresses", () =>
+ expect(normalizeIpv6("::ffff:192.168.1.1")).toBe(
+ "0000:0000:0000:0000:0000:ffff:c0a8:0101"
+ ));
+
+ test("rejects IPv4", () => expect(normalizeIpv6("192.168.1.1")).toBeNull());
+
+ test("rejects double compression", () =>
+ expect(normalizeIpv6("2001::db8::1")).toBeNull());
+
+ test("rejects too many groups", () =>
+ expect(normalizeIpv6("1:2:3:4:5:6:7:8:9")).toBeNull());
+
+ test("rejects full-length address with compression marker", () =>
+ expect(normalizeIpv6("1:2:3:4:5:6:7::8")).toBeNull());
+
+ test("rejects invalid hex", () =>
+ expect(normalizeIpv6("gggg::1")).toBeNull());
+
+ test("rejects zone identifiers", () =>
+ expect(normalizeIpv6("fe80::1%eth0")).toBeNull());
+});
+
+describe("isValidIpFromSettings IPv6", () => {
+ test("compressed client matches expanded allowlist entry", () =>
+ expect(
+ isValidIpFromSettings("2001:db8::1", [
+ "2001:0db8:0000:0000:0000:0000:0000:0001",
+ ])
+ ).toBe(true));
+
+ test("expanded client matches compressed allowlist entry", () =>
+ expect(
+ isValidIpFromSettings("2001:0db8:0000:0000:0000:0000:0000:0001", [
+ "2001:db8::1",
+ ])
+ ).toBe(true));
+
+ test("partially compressed forms match", () =>
+ expect(
+ isValidIpFromSettings("2001:db8:0:0:0:0:0:1", ["2001:db8::1"])
+ ).toBe(true));
+
+ test("case differences match", () =>
+ expect(isValidIpFromSettings("2001:DB8::A", ["2001:db8::a"])).toBe(true));
+
+ test("different IPv6 addresses do not match", () =>
+ expect(isValidIpFromSettings("2001:db8::2", ["2001:db8::1"])).toBe(false));
+
+ test("IPv6 client does not match IPv4 allowlist entry", () =>
+ expect(isValidIpFromSettings("2001:db8::1", ["192.168.1.1"])).toBe(false));
+
+ test("IPv4 client does not match IPv6 allowlist entry", () =>
+ expect(isValidIpFromSettings("192.168.1.1", ["2001:db8::1"])).toBe(false));
+
+ test("invalid IPv6 allowlist entry only matches exactly", () => {
+ expect(isValidIpFromSettings("2001::db8::1", ["2001::db8::1"])).toBe(true);
+ expect(isValidIpFromSettings("2001:db8::1", ["2001::db8::1"])).toBe(false);
+ });
+
+ test("exact IPv4 match still works", () =>
+ expect(isValidIpFromSettings("192.168.1.1", ["192.168.1.1"])).toBe(true));
+
+ test("IPv4 CIDR match still works", () =>
+ expect(isValidIpFromSettings("192.168.1.42", ["192.168.1.0/24"])).toBe(
+ true
+ ));
+
+ test("empty allowlist accepts any ip", () =>
+ expect(isValidIpFromSettings("2001:db8::1", [])).toBe(true));
+
+ test("empty ip with allowlist is rejected", () =>
+ expect(isValidIpFromSettings("", ["2001:db8::1"])).toBe(false));
+});
+
+describe("isValidOriginFromSettings", () => {
+ test("missing origin header is accepted", () =>
+ expect(isValidOriginFromSettings("", ["example.com"])).toBe(true));
+
+ test("origin matching allowlist is accepted", () =>
+ expect(
+ isValidOriginFromSettings("https://example.com", ["example.com"])
+ ).toBe(true));
+
+ test("origin not in allowlist is rejected", () =>
+ expect(
+ isValidOriginFromSettings("https://evil.com", ["example.com"])
+ ).toBe(false));
+});
diff --git a/apps/basket/src/utils/origin-ip-validation.ts b/apps/basket/src/utils/origin-ip-validation.ts
index 22d4074455..c56f27327d 100644
--- a/apps/basket/src/utils/origin-ip-validation.ts
+++ b/apps/basket/src/utils/origin-ip-validation.ts
@@ -78,23 +78,106 @@ export function isValidOriginFromSettings(
}
}
+const IPV6_GROUP_REGEX = /^[0-9a-f]{1,4}$/;
+const IPV4_TAIL_REGEX = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
+
+function ipv4TailToGroups(tail: string): string[] | null {
+ const match = tail.match(IPV4_TAIL_REGEX);
+ if (!match) {
+ return null;
+ }
+ const octets = match.slice(1, 5).map((part) => Number.parseInt(part, 10));
+ if (octets.some((octet) => octet > 255)) {
+ return null;
+ }
+ const [a, b, c, d] = octets as [number, number, number, number];
+ return [(a * 256 + b).toString(16), (c * 256 + d).toString(16)];
+}
+
+export function normalizeIpv6(ip: string): string | null {
+ const value = ip.trim().toLowerCase();
+ if (!value.includes(":") || value.includes("%")) {
+ return null;
+ }
+
+ const halves = value.split("::");
+ if (halves.length > 2) {
+ return null;
+ }
+
+ const parseSide = (side: string): string[] | null => {
+ if (side === "") {
+ return [];
+ }
+ const groups: string[] = [];
+ for (const group of side.split(":")) {
+ if (IPV6_GROUP_REGEX.test(group)) {
+ groups.push(group);
+ continue;
+ }
+ const ipv4Groups = ipv4TailToGroups(group);
+ if (!ipv4Groups) {
+ return null;
+ }
+ groups.push(...ipv4Groups);
+ }
+ return groups;
+ };
+
+ const head = parseSide(halves[0] ?? "");
+ const tail = halves.length === 2 ? parseSide(halves[1] ?? "") : [];
+ if (!(head && tail)) {
+ return null;
+ }
+
+ if (halves.length === 1) {
+ if (head.length !== 8) {
+ return null;
+ }
+ return head.map((group) => group.padStart(4, "0")).join(":");
+ }
+
+ const missing = 8 - head.length - tail.length;
+ if (missing < 1) {
+ return null;
+ }
+
+ const groups = [
+ ...head,
+ ...Array.from({ length: missing }, () => "0"),
+ ...tail,
+ ];
+ return groups.map((group) => group.padStart(4, "0")).join(":");
+}
+
export function isValidIpFromSettings(
ip: string,
allowedIps?: string[]
): boolean {
- if (!ip?.trim()) {
- return true;
- }
if (!allowedIps || allowedIps.length === 0) {
return true;
}
+ if (!ip?.trim()) {
+ return false;
+ }
const trimmedIp = ip.trim();
+ const normalizedIpv6 = trimmedIp.includes(":")
+ ? normalizeIpv6(trimmedIp)
+ : null;
for (const allowed of allowedIps) {
if (allowed === trimmedIp) {
return true;
}
+ if (
+ normalizedIpv6 &&
+ allowed.includes(":") &&
+ !allowed.includes("/") &&
+ normalizeIpv6(allowed) === normalizedIpv6
+ ) {
+ return true;
+ }
if (allowed.includes("/") && isIpInCidrRange(trimmedIp, allowed)) {
return true;
}
diff --git a/apps/basket/src/utils/parsing-helpers.test.ts b/apps/basket/src/utils/parsing-helpers.test.ts
index 62bb7ed9a8..16a1b59918 100644
--- a/apps/basket/src/utils/parsing-helpers.test.ts
+++ b/apps/basket/src/utils/parsing-helpers.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, test } from "vitest";
import type { z } from "zod";
+import { cases, longString } from "../test-helpers";
import {
batchBotIgnoredItem,
batchSchemaItemFailure,
@@ -8,72 +9,61 @@ import {
parseTimestamp,
} from "./parsing-helpers";
-// ── parseTimestamp ──
+cases(
+ "parseTimestamp keeps numeric timestamps verbatim",
+ [
+ ["positive epoch", 1_700_000_000, 1_700_000_000],
+ ["zero", 0, 0],
+ ["negative", -1, -1],
+ ],
+ (input) => parseTimestamp(input)
+);
-describe("parseTimestamp", () => {
- test("number → passthrough", () =>
- expect(parseTimestamp(1_700_000_000)).toBe(1_700_000_000));
- test("0 → 0", () => expect(parseTimestamp(0)).toBe(0));
- test("negative → passthrough", () => expect(parseTimestamp(-1)).toBe(-1));
- test("string → Date.now()", () => {
- const before = Date.now();
- const result = parseTimestamp("not-a-number");
- expect(result).toBeGreaterThanOrEqual(before);
- expect(result).toBeLessThanOrEqual(Date.now());
- });
- test("null → Date.now()", () => {
- const result = parseTimestamp(null);
- expect(typeof result).toBe("number");
- expect(result).toBeGreaterThan(0);
- });
- test("undefined → Date.now()", () => {
- const result = parseTimestamp(undefined);
- expect(typeof result).toBe("number");
- });
- test("object → Date.now()", () => {
- expect(typeof parseTimestamp({})).toBe("number");
- });
+describe("parseTimestamp replaces non-numeric input with the current time", () => {
+ test.each([["not-a-number"], [null], [undefined], [{}]])(
+ "%j falls back to Date.now()",
+ (input) => {
+ const before = Date.now();
+ const result = parseTimestamp(input);
+ expect(result).toBeGreaterThanOrEqual(before);
+ expect(result).toBeLessThanOrEqual(Date.now());
+ }
+ );
});
-// ── parseProperties ──
-
-describe("parseProperties", () => {
- test("object → JSON string", () =>
- expect(parseProperties({ a: 1 })).toBe('{"a":1}'));
- test("null → '{}'", () => expect(parseProperties(null)).toBe("{}"));
- test("undefined → '{}'", () => expect(parseProperties(undefined)).toBe("{}"));
- test("false → '{}'", () => expect(parseProperties(false)).toBe("{}"));
- test("0 → '{}'", () => expect(parseProperties(0)).toBe("{}"));
- test("empty string → '{}'", () => expect(parseProperties("")).toBe("{}"));
- test("non-empty string → JSON string", () =>
- expect(parseProperties("hello")).toBe('"hello"'));
- test("array → JSON array", () =>
- expect(parseProperties([1, 2])).toBe("[1,2]"));
- test("nested object", () =>
- expect(parseProperties({ a: { b: "c" } })).toBe('{"a":{"b":"c"}}'));
-});
-
-// ── parseEventId ──
+cases(
+ "parseProperties serializes truthy values and defaults the rest",
+ [
+ ["object", { a: 1 }, '{"a":1}'],
+ ["nested object", { a: { b: "c" } }, '{"a":{"b":"c"}}'],
+ ["array", [1, 2], "[1,2]"],
+ ["non-empty string", "hello", '"hello"'],
+ ["null", null, "{}"],
+ ["undefined", undefined, "{}"],
+ ["empty string", "", "{}"],
+ ],
+ (input) => parseProperties(input)
+);
describe("parseEventId", () => {
const gen = () => "generated-uuid";
- test("valid string → passthrough", () =>
- expect(parseEventId("evt_123", gen)).toBe("evt_123"));
- test("empty string → calls generator", () =>
- expect(parseEventId("", gen)).toBe("generated-uuid"));
- test("null → calls generator", () =>
- expect(parseEventId(null, gen)).toBe("generated-uuid"));
- test("undefined → calls generator", () =>
- expect(parseEventId(undefined, gen)).toBe("generated-uuid"));
- test("number → calls generator", () =>
- expect(parseEventId(123, gen)).toBe("generated-uuid"));
- test("long string → truncated to event id limit", () => {
- const long = "a".repeat(600);
- const result = parseEventId(long, gen);
- expect(result.length).toBe(512);
+ cases(
+ "keeps client ids and generates for unusable input",
+ [
+ ["valid string", "evt_123", "evt_123"],
+ ["empty string", "", "generated-uuid"],
+ ["null", null, "generated-uuid"],
+ ["number", 123, "generated-uuid"],
+ ],
+ (input) => parseEventId(input, gen)
+ );
+
+ test("truncated a long id to the event id limit", () => {
+ expect(parseEventId(longString(600), gen).length).toBe(512);
});
- test("generator called only when needed", () => {
+
+ test("did not invoke the generator for a usable id", () => {
let called = false;
parseEventId("valid", () => {
called = true;
@@ -83,15 +73,12 @@ describe("parseEventId", () => {
});
});
-// ── batchSchemaItemFailure ──
-
-describe("batchSchemaItemFailure", () => {
- test("returns structured error with issues", () => {
+describe("batch item failure shapes", () => {
+ test("schema failure flattens issue paths into field names", () => {
const issues = [
{ message: "bad", path: ["x"], code: "custom" as const },
] as z.core.$ZodIssue[];
- const result = batchSchemaItemFailure(issues, "track", "evt_1");
- expect(result).toEqual({
+ expect(batchSchemaItemFailure(issues, "track", "evt_1")).toEqual({
status: "error",
message: "Invalid event schema",
code: "INVALID_EVENT_SCHEMA",
@@ -100,16 +87,13 @@ describe("batchSchemaItemFailure", () => {
eventId: "evt_1",
});
});
-});
-
-// ── batchBotIgnoredItem ──
-describe("batchBotIgnoredItem", () => {
- test("returns bot-ignored structure", () => {
- const result = batchBotIgnoredItem("track");
- expect(result.status).toBe("error");
- expect(result.message).toBe("Bot detected");
- expect(result.eventType).toBe("track");
- expect(result.error).toBe("ignored");
+ test("bot-ignored item is an error marked as ignored", () => {
+ expect(batchBotIgnoredItem("track")).toEqual({
+ status: "error",
+ message: "Bot detected",
+ eventType: "track",
+ error: "ignored",
+ });
});
});
diff --git a/apps/basket/src/utils/parsing-helpers.ts b/apps/basket/src/utils/parsing-helpers.ts
index 0c45202599..3f59126acc 100644
--- a/apps/basket/src/utils/parsing-helpers.ts
+++ b/apps/basket/src/utils/parsing-helpers.ts
@@ -87,16 +87,9 @@ export function parseEventId(
eventId: unknown,
generateFn: () => string
): string {
- const sanitizeString = (str: unknown, maxLength: number): string => {
- if (typeof str !== "string") {
- return "";
- }
- return str.slice(0, maxLength);
- };
-
- const sanitized = sanitizeString(
- eventId,
- VALIDATION_LIMITS.EVENT_ID_MAX_LENGTH
- );
+ const sanitized =
+ typeof eventId === "string"
+ ? eventId.slice(0, VALIDATION_LIMITS.EVENT_ID_MAX_LENGTH)
+ : "";
return sanitized || generateFn();
}
diff --git a/apps/basket/src/utils/pixel.test.ts b/apps/basket/src/utils/pixel.test.ts
index 9c1856d132..684aa03898 100644
--- a/apps/basket/src/utils/pixel.test.ts
+++ b/apps/basket/src/utils/pixel.test.ts
@@ -1,8 +1,6 @@
import { describe, expect, test } from "vitest";
import { createPixelResponse, parsePixelQuery } from "./pixel";
-// ── createPixelResponse ──
-
describe("createPixelResponse", () => {
test("returns 200 image/gif with no-cache headers", async () => {
const r = createPixelResponse();
@@ -14,15 +12,11 @@ describe("createPixelResponse", () => {
const buf = await r.arrayBuffer();
expect(buf.byteLength).toBeGreaterThan(0);
- // GIF89a magic bytes
- expect(new Uint8Array(buf).slice(0, 3)).toEqual(
- new Uint8Array([0x47, 0x49, 0x46])
- );
+ const gifMagicBytes = new Uint8Array([0x47, 0x49, 0x46]);
+ expect(new Uint8Array(buf).slice(0, 3)).toEqual(gifMagicBytes);
});
});
-// ── parsePixelQuery ──
-
describe("parsePixelQuery", () => {
test("empty query → empty eventData, type=track", () => {
const { eventData, eventType } = parsePixelQuery({});
@@ -112,4 +106,25 @@ describe("parsePixelQuery", () => {
expect(eventData[`field_${i}`]).toBe(i);
}
});
+
+ test("flat key followed by nested key on the same name does not crash", () => {
+ const { eventData } = parsePixelQuery({ a: "1", "a[b]": "2" });
+ expect(eventData.a).toEqual({ b: 2 });
+ });
+
+ test("nested key deepened on a later param keeps the deepest write", () => {
+ const { eventData } = parsePixelQuery({ "a[b]": "1", "a[b][c]": "2" });
+ expect(eventData.a).toEqual({ b: { c: 2 } });
+ });
+
+ test("__proto__ and constructor paths are dropped without polluting prototypes", () => {
+ const { eventData } = parsePixelQuery({
+ "__proto__[polluted]": "yes",
+ "constructor[prototype][evil]": "1",
+ name: "pageview",
+ });
+ expect(eventData).toEqual({ name: "pageview" });
+ expect(({} as Record).polluted).toBeUndefined();
+ expect(({} as Record).evil).toBeUndefined();
+ });
});
diff --git a/apps/basket/src/utils/pixel.ts b/apps/basket/src/utils/pixel.ts
index 4b3f5b7186..7ec7cce2f3 100644
--- a/apps/basket/src/utils/pixel.ts
+++ b/apps/basket/src/utils/pixel.ts
@@ -1,18 +1,15 @@
-// Regex patterns for parsing query parameters
const NESTED_KEY_REGEX = /^([^[]+)(\[.*\])?$/;
const BRACKET_EXTRACT_REGEX = /\[([^\]]+)\]/g;
const INTEGER_REGEX = /^-?\d+$/;
const FLOAT_REGEX = /^-?\d*\.\d+$/;
-// 1x1 transparent GIF pixel (base64)
+const SKIPPED_KEYS = new Set(["sdk_name", "sdk_version", "client_id"]);
+const UNSAFE_KEY_SEGMENTS = new Set(["__proto__", "constructor", "prototype"]);
+
const TRANSPARENT_PIXEL = Buffer.from(
"R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",
"base64"
);
-
-/**
- * Returns a 1x1 transparent GIF response
- */
export function createPixelResponse(
options: { retryAfterSeconds?: number; status?: number } = {}
): Response {
@@ -30,10 +27,6 @@ export function createPixelResponse(
headers,
});
}
-
-/**
- * Parses string values to appropriate types
- */
function parseValue(value: string): string | number | boolean {
if (INTEGER_REGEX.test(value)) {
return Number.parseInt(value, 10);
@@ -49,11 +42,6 @@ function parseValue(value: string): string | number | boolean {
}
return value;
}
-
-/**
- * Converts pixel query parameters back into event data structure
- * Handles nested keys like "key[subkey]" and JSON-stringified properties
- */
export function parsePixelQuery(query: Record): {
eventData: Record;
eventType: string;
@@ -61,12 +49,10 @@ export function parsePixelQuery(query: Record): {
const result: Record = {};
for (const [key, value] of Object.entries(query)) {
- // Skip SDK metadata
- if (key === "sdk_name" || key === "sdk_version" || key === "client_id") {
+ if (SKIPPED_KEYS.has(key)) {
continue;
}
- // Handle JSON-stringified properties
if (key === "properties") {
try {
result.properties = JSON.parse(value);
@@ -77,43 +63,24 @@ export function parsePixelQuery(query: Record): {
}
const match = key.match(NESTED_KEY_REGEX);
- if (!match) {
- result[key] = parseValue(value);
- continue;
- }
-
- const baseKey = match[1];
- const nestedPath = match[2];
-
- if (!nestedPath) {
- result[baseKey] = parseValue(value);
- continue;
- }
-
- // Extract nested keys from brackets
+ const baseKey = match?.[1] ?? key;
const nestedKeys =
- nestedPath.match(BRACKET_EXTRACT_REGEX)?.map((k) => k.slice(1, -1)) || [];
-
- if (nestedKeys.length === 0) {
- result[baseKey] = parseValue(value);
+ match?.[2]?.match(BRACKET_EXTRACT_REGEX)?.map((k) => k.slice(1, -1)) ??
+ [];
+ const path = [baseKey, ...nestedKeys];
+ if (path.some((segment) => UNSAFE_KEY_SEGMENTS.has(segment))) {
continue;
}
- // Build nested structure
- if (!result[baseKey]) {
- result[baseKey] = {};
- }
-
- let current = result[baseKey] as Record;
- const lastIndex = nestedKeys.length - 1;
- for (let i = 0; i < lastIndex; i++) {
- const nestedKey = nestedKeys[i];
- if (!current[nestedKey]) {
- current[nestedKey] = {};
+ let current = result;
+ for (const segment of path.slice(0, -1)) {
+ const next = current[segment];
+ if (!next || typeof next !== "object" || Array.isArray(next)) {
+ current[segment] = {};
}
- current = current[nestedKey] as Record;
+ current = current[segment] as Record;
}
- current[nestedKeys[lastIndex]] = parseValue(value);
+ current[path.at(-1) ?? baseKey] = parseValue(value);
}
return {
diff --git a/apps/basket/src/utils/user-agent.test.ts b/apps/basket/src/utils/user-agent.test.ts
index 554eb72fcd..04f6756327 100644
--- a/apps/basket/src/utils/user-agent.test.ts
+++ b/apps/basket/src/utils/user-agent.test.ts
@@ -39,8 +39,6 @@ const { detectBot, parseUserAgent } = await import("./user-agent");
const dummyReq = new Request("https://example.com");
-// ── detectBot wrapper — tests the legacy category mapping ──
-
describe("detectBot", () => {
test("not a bot → passes through", () => {
mockDetectBotShared.mockReturnValue({
@@ -113,24 +111,8 @@ describe("detectBot", () => {
expect(result.reason).toBe("suspicious_pattern");
expect(result.result).toEqual(sharedResult);
});
-
- test("non-bot has no category", () => {
- mockDetectBotShared.mockReturnValue({
- isBot: false,
- category: undefined,
- action: undefined,
- confidence: 0,
- reason: undefined,
- name: undefined,
- });
- const result = detectBot("Chrome/120", dummyReq);
- expect(result.category).toBeUndefined();
- expect(result.botName).toBeUndefined();
- });
});
-// ── parseUserAgent wrapper ──
-
describe("parseUserAgent", () => {
test("returns parsed fields from shared function", async () => {
mockParseUserAgentShared.mockReturnValue({
diff --git a/apps/basket/src/utils/validation.test.ts b/apps/basket/src/utils/validation.test.ts
index 035a9d5886..f31a752d5c 100644
--- a/apps/basket/src/utils/validation.test.ts
+++ b/apps/basket/src/utils/validation.test.ts
@@ -16,10 +16,7 @@ import {
validateSessionId,
} from "./validation";
-// ── sanitizeString ──
-
describe("sanitizeString", () => {
- // non-string → ""
for (const input of [null, undefined, 123, true, {}, []]) {
test(`${JSON.stringify(input)} → ""`, () =>
expect(sanitizeString(input)).toBe(""));
@@ -38,13 +35,17 @@ describe("sanitizeString", () => {
expect(sanitizeString("bold text")).toBe("bold text"));
test("strips dangerous chars <>'\",&", () => {
- // Angle brackets in HTML-like patterns are removed by tag stripper,
- // and bare <, >, ', ", & are removed by char stripper
expect(sanitizeString("a'b\"c&d")).toBe("abcd");
expect(sanitizeString("hellovalue")).toBe("testvalue");
});
+ test("defeats stacked-tag bypasses that reassemble after one strip pass", () => {
+ const result = sanitizeString("ipt>alert(1) ipt>");
+ expect(result).toBe("iptalert(1)ipt");
+ expect(result.toLowerCase()).not.toContain("`;
}
-
-/**
- * Generate full NPM code example with import and usage
- */
export function generateNpmCode(
websiteId: string,
trackingOptions: TrackingOptions
diff --git a/apps/dashboard/app/(main)/websites/[id]/_components/utils/tab-layout.tsx b/apps/dashboard/app/(main)/websites/[id]/_components/utils/tab-layout.tsx
deleted file mode 100644
index 7ea43999db..0000000000
--- a/apps/dashboard/app/(main)/websites/[id]/_components/utils/tab-layout.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-import type React from "react";
-import { Skeleton } from "@databuddy/ui";
-import { BORDER_RADIUS } from "./ui-components";
-
-interface TabLayoutProps {
- actions?: React.ReactNode;
- children: React.ReactNode;
- className?: string;
- description?: string;
- isLoading?: boolean;
- title?: string;
-}
-
-export function TabLayout({
- title,
- description,
- isLoading = false,
- children,
- className = "",
- actions,
-}: TabLayoutProps) {
- if (isLoading) {
- return ;
- }
-
- return (
-
- {(title || description || actions) && (
-
- {(title || description) && (
-
- {title &&
{title} }
- {description && (
-
{description}
- )}
-
- )}
- {actions &&
{actions}
}
-
- )}
- {children}
-
- );
-}
-
-export function TabLoadingSkeleton() {
- const loadingSkeletonIds = [
- "loading-skeleton-1",
- "loading-skeleton-2",
- "loading-skeleton-3",
- "loading-skeleton-4",
- ];
- return (
-
-
- {loadingSkeletonIds.map((id) => (
-
- ))}
-
-
-
-
-
-
-
- );
-}
diff --git a/apps/dashboard/app/(main)/websites/[id]/_components/utils/technology-helpers.tsx b/apps/dashboard/app/(main)/websites/[id]/_components/utils/technology-helpers.tsx
index 27b8265e02..da6483ac5d 100644
--- a/apps/dashboard/app/(main)/websites/[id]/_components/utils/technology-helpers.tsx
+++ b/apps/dashboard/app/(main)/websites/[id]/_components/utils/technology-helpers.tsx
@@ -1,45 +1,12 @@
-import Image from "next/image";
-import type React from "react";
-import { BrowserIcon, OSIcon } from "@/components/icon";
import {
DesktopIcon,
DeviceMobileIcon,
DeviceTabletIcon,
- GlobeIcon,
LaptopIcon,
QuestionIcon,
TelevisionIcon,
} from "@databuddy/ui/icons";
-// Regex patterns for browser name processing
-const MOBILE_PREFIX_REGEX = /^Mobile\s+/;
-const MOBILE_SUFFIX_REGEX = /\s+Mobile$/;
-
-export interface DeviceTypeEntry {
- device_brand?: string;
- device_model?: string;
- device_type: string;
- pageviews?: number;
- visitors: number;
-}
-
-export interface BrowserVersionEntry {
- browser: string;
- count?: number;
- pageviews?: number;
- version?: string;
- visitors: number;
-}
-
-export interface TechnologyTableEntry {
- category?: string;
- icon?: string;
- iconComponent?: React.ReactNode;
- name: string;
- percentage: number;
- visitors: number;
-}
-
export const getDeviceTypeIcon = (
deviceType: string | null | undefined,
size: "sm" | "md" | "lg" = "md"
@@ -97,113 +64,3 @@ export const getDeviceTypeIcon = (
return ;
};
-
-export const processDeviceData = (
- deviceTypes: DeviceTypeEntry[]
-): TechnologyTableEntry[] => {
- const deviceGroups: Record = {};
-
- for (const item of deviceTypes) {
- const deviceType = item.device_type || "Unknown";
- const capitalizedType =
- deviceType.charAt(0).toUpperCase() + deviceType.slice(1);
- deviceGroups[capitalizedType] =
- (deviceGroups[capitalizedType] || 0) + (item.visitors || 0);
- }
-
- const totalVisitors = Object.values(deviceGroups).reduce(
- (sum, count) => sum + count,
- 0
- );
-
- return Object.entries(deviceGroups)
- .sort(([, a], [, b]) => (b as number) - (a as number))
- .slice(0, 10)
- .map(([name, visitors]) => ({
- name,
- visitors,
- percentage:
- totalVisitors > 0 ? Math.round((visitors / totalVisitors) * 100) : 0,
- iconComponent: getDeviceTypeIcon(name, "md"),
- category: "device",
- }));
-};
-
-export const processBrowserData = (
- browserVersions: BrowserVersionEntry[]
-): TechnologyTableEntry[] => {
- const browserGroups: Record = {};
-
- for (const item of browserVersions) {
- let browserName = item.browser || "Unknown";
- browserName = browserName
- .replace(MOBILE_PREFIX_REGEX, "")
- .replace(MOBILE_SUFFIX_REGEX, "");
- browserGroups[browserName] =
- (browserGroups[browserName] || 0) + (item.visitors || 0);
- }
-
- const totalVisitors = Object.values(browserGroups).reduce(
- (sum, count) => sum + count,
- 0
- );
-
- return Object.entries(browserGroups)
- .sort(([, a], [, b]) => (b as number) - (a as number))
- .slice(0, 10)
- .map(([name, visitors]) => ({
- name,
- visitors,
- percentage:
- totalVisitors > 0 ? Math.round((visitors / totalVisitors) * 100) : 0,
- iconComponent: ,
- category: "browser",
- }));
-};
-
-export const TechnologyIcon = ({
- entry,
- size = "md",
-}: {
- entry: TechnologyTableEntry;
- size?: "sm" | "md" | "lg";
-}) => {
- if (entry.iconComponent) {
- return <>{entry.iconComponent}>;
- }
-
- // Use unified icon components for better consistency
- if (entry.category === "browser") {
- return ;
- }
-
- if (entry.category === "os") {
- return ;
- }
-
- // Fallback for other categories or when no category is specified
- if (entry.icon) {
- const sizeMap = {
- sm: 12,
- md: 16,
- lg: 20,
- };
- const iconSize = sizeMap[size];
-
- return (
-
-
-
- );
- }
-
- return ;
-};
diff --git a/apps/dashboard/app/(main)/websites/[id]/_components/utils/tracking-helpers.ts b/apps/dashboard/app/(main)/websites/[id]/_components/utils/tracking-helpers.ts
deleted file mode 100644
index 0c3aecb1ae..0000000000
--- a/apps/dashboard/app/(main)/websites/[id]/_components/utils/tracking-helpers.ts
+++ /dev/null
@@ -1,122 +0,0 @@
-import { RECOMMENDED_DEFAULTS } from "./tracking-defaults";
-import type { TrackingOptions } from "./types";
-
-/**
- * Toggle a specific tracking option
- */
-export function toggleTrackingOption(
- options: TrackingOptions,
- option: keyof TrackingOptions
-): TrackingOptions {
- return {
- ...options,
- [option]: !options[option],
- };
-}
-
-/**
- * Enable all basic tracking options
- */
-export function enableAllBasicTracking(
- options: TrackingOptions
-): TrackingOptions {
- return {
- ...options,
- trackInteractions: true,
- trackOutgoingLinks: true,
- };
-}
-
-/**
- * Enable all interaction tracking options
- */
-export function enableAllInteractionTracking(
- options: TrackingOptions
-): TrackingOptions {
- return {
- ...options,
- trackAttributes: true,
- trackOutgoingLinks: true,
- trackInteractions: true,
- trackHashChanges: true,
- };
-}
-
-/**
- * Enable all performance tracking options
- */
-export function enableAllPerformanceTracking(
- options: TrackingOptions
-): TrackingOptions {
- return {
- ...options,
- trackWebVitals: true,
- trackErrors: true,
- };
-}
-
-/**
- * Enable all advanced tracking options
- */
-export function enableAllAdvancedTracking(
- options: TrackingOptions
-): TrackingOptions {
- return {
- ...options,
- ...enableAllPerformanceTracking(options),
- trackErrors: true,
- trackWebVitals: true,
- };
-}
-
-/**
- * Enable batching with optimal settings
- */
-export function enableOptimalBatching(
- options: TrackingOptions
-): TrackingOptions {
- return {
- ...options,
- enableBatching: true,
- batchSize: 10,
- batchTimeout: 2000,
- };
-}
-
-/**
- * Enable all optimization options
- */
-export function enableAllOptimization(
- options: TrackingOptions
-): TrackingOptions {
- return {
- ...options,
- ...enableOptimalBatching(options),
- samplingRate: 1.0,
- enableRetries: true,
- maxRetries: 3,
- initialRetryDelay: 500,
- };
-}
-
-/**
- * Disable all tracking (privacy mode)
- */
-export function enablePrivacyMode(options: TrackingOptions): TrackingOptions {
- return {
- ...options,
- disabled: true,
- trackInteractions: false,
- trackOutgoingLinks: false,
- trackAttributes: false,
- trackWebVitals: false,
- trackErrors: false,
- };
-}
-
-/**
- * Reset options to recommended defaults
- */
-export function resetToDefaults(): TrackingOptions {
- return { ...RECOMMENDED_DEFAULTS };
-}
diff --git a/apps/dashboard/app/(main)/websites/[id]/_components/utils/types.ts b/apps/dashboard/app/(main)/websites/[id]/_components/utils/types.ts
index 31891590a6..1a11c95403 100644
--- a/apps/dashboard/app/(main)/websites/[id]/_components/utils/types.ts
+++ b/apps/dashboard/app/(main)/websites/[id]/_components/utils/types.ts
@@ -8,12 +8,12 @@ export interface DateRange {
timezone?: string;
}
-export interface BaseTabProps {
+interface BaseTabProps {
dateRange: DateRange;
websiteId: string;
}
-export type WebsiteData = ReturnType["data"];
+type WebsiteData = ReturnType["data"];
export type FullTabProps = BaseTabProps & {
websiteData: WebsiteData;
diff --git a/apps/dashboard/app/(main)/websites/[id]/_components/utils/ui-components.tsx b/apps/dashboard/app/(main)/websites/[id]/_components/utils/ui-components.tsx
index 3d0c666eee..a16f0eb092 100644
--- a/apps/dashboard/app/(main)/websites/[id]/_components/utils/ui-components.tsx
+++ b/apps/dashboard/app/(main)/websites/[id]/_components/utils/ui-components.tsx
@@ -1,160 +1,4 @@
import type React from "react";
-import { cn } from "@/lib/utils";
-import { PERFORMANCE_THRESHOLDS } from "./analytics-helpers";
-import { ArrowSquareOutIcon, QuestionIcon } from "@databuddy/ui/icons";
-import { StatusDot, Tooltip } from "@databuddy/ui";
-
-// Consistent border radius values
-export const BORDER_RADIUS = {
- sm: "rounded", // Small components like buttons
- md: "rounded", // Cards, panels
- lg: "rounded", // Large containers
- card: "rounded", // Standard card component
- container: "rounded", // Containers that hold cards
-};
-
-interface MetricToggleProps {
- checked: boolean;
- color: string;
- label: string;
- onChange: () => void;
-}
-
-export const MetricToggle: React.FC = ({
- label,
- checked,
- onChange,
- color,
-}) => {
- // Get proper hex values for the colors
- const getColorMap = (colorName: string) => {
- const colorMap: Record = {
- "blue-500": "#3b82f6",
- "green-500": "#22c55e",
- "emerald-500": "#10b981",
- "yellow-500": "#eab308",
- "red-500": "#ef4444",
- "purple-500": "#a855f7",
- "pink-500": "#ec4899",
- "indigo-500": "#6366f1",
- "orange-500": "#f97316",
- "sky-500": "#0ea5e9",
- "amber-500": "#fd9a00",
- };
-
- return colorMap[colorName] || "#3b82f6"; // Default to blue if color not found
- };
-
- const colorHex = getColorMap(color);
-
- return (
- {
- if (e.key === "Enter" || e.key === " ") {
- e.preventDefault();
- onChange();
- }
- }}
- tabIndex={0}
- type="button"
- >
-
-
- {label}
-
-
- );
-};
-
-interface MetricTogglesProps {
- colors: Record;
- labels?: Record;
- metrics: Record;
- onToggle: (metric: string) => void;
-}
-
-const EMPTY_LABELS: Record = {};
-
-export const MetricToggles: React.FC = ({
- metrics,
- onToggle,
- colors,
- labels = EMPTY_LABELS,
-}) => (
-
- {Object.keys(metrics).map((metric) => (
- onToggle(metric)}
- />
- ))}
-
-);
-
-interface ArrowSquareOutIconButtonProps {
- className?: string;
- href: string;
- label: string;
- showTooltip?: boolean;
- title?: string;
-}
-
-export const ArrowSquareOutIconButton: React.FC<
- ArrowSquareOutIconButtonProps
-> = ({
- href,
- label,
- title,
- className = "font-medium hover:text-primary hover:underline truncate max-w-[250px] flex items-center gap-1",
- showTooltip = true,
-}) => {
- const content = (
-
- {label}
-
-
- );
-
- if (!showTooltip) {
- return content;
- }
-
- return {content} ;
-};
export const EmptyState: React.FC<{
icon: React.ReactNode;
@@ -173,56 +17,3 @@ export const EmptyState: React.FC<{
);
-
-// Tooltip for performance metrics
-export const MetricTooltip = ({
- metricKey,
- label,
- children,
-}: {
- metricKey: keyof typeof PERFORMANCE_THRESHOLDS;
- label?: string;
- children: React.ReactNode;
-}) => {
- const threshold = PERFORMANCE_THRESHOLDS[metricKey];
- return (
-
-
- {label || String(metricKey).replace(/_/g, " ")}
-
-
-
-
-
- Good: < {threshold.good}
- {threshold.unit}
-
-
-
-
-
- Needs improvement: {threshold.good}
- {threshold.unit} - {threshold.average}
- {threshold.unit}
-
-
-
-
-
- Poor: > {threshold.average}
- {threshold.unit}
-
-
-
-
- }
- >
-
- {children}
-
-
-
- );
-};
diff --git a/apps/dashboard/app/(main)/websites/[id]/anomalies/_components/anomalies-page-content.tsx b/apps/dashboard/app/(main)/websites/[id]/anomalies/_components/anomalies-page-content.tsx
index a6ebc3536f..70d5d58204 100644
--- a/apps/dashboard/app/(main)/websites/[id]/anomalies/_components/anomalies-page-content.tsx
+++ b/apps/dashboard/app/(main)/websites/[id]/anomalies/_components/anomalies-page-content.tsx
@@ -86,7 +86,7 @@ export function AnomaliesPageContent({ params }: AnomaliesPageContentProps) {
,
title: "All clear",
}}
diff --git a/apps/dashboard/app/(main)/websites/[id]/flags/_components/flag-key.tsx b/apps/dashboard/app/(main)/websites/[id]/flags/_components/flag-key.tsx
deleted file mode 100644
index d86c812ee9..0000000000
--- a/apps/dashboard/app/(main)/websites/[id]/flags/_components/flag-key.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard";
-import { cn } from "@/lib/utils";
-import type { Flag } from "./types";
-import { CheckIcon, CopyIcon } from "@databuddy/ui/icons";
-import { Button, Tooltip } from "@databuddy/ui";
-
-export function FlagKey({
- flag,
- className,
- ...props
-}: { flag: Flag } & React.ComponentProps<"button">) {
- const { isCopied, copyToClipboard } = useCopyToClipboard();
-
- return (
-
- svg]:px-1.5 dark:text-foreground/70",
- className
- )}
- data-row-interactive="true"
- onClick={() => copyToClipboard(flag.key)}
- size="sm"
- variant="ghost"
- {...props}
- >
- {flag.key}
- {isCopied ? (
-
- ) : (
-
- )}
-
-
- );
-}
diff --git a/apps/dashboard/app/(main)/websites/[id]/flags/_components/flag-sheet.tsx b/apps/dashboard/app/(main)/websites/[id]/flags/_components/flag-sheet.tsx
index 15a8023f60..f4eee0e29d 100644
--- a/apps/dashboard/app/(main)/websites/[id]/flags/_components/flag-sheet.tsx
+++ b/apps/dashboard/app/(main)/websites/[id]/flags/_components/flag-sheet.tsx
@@ -13,6 +13,7 @@ import {
CodeBlockCopyButton,
} from "@/components/ai-elements/code-block";
import { orpc } from "@/lib/orpc";
+import { mutationErrorToast } from "@/lib/user-facing-error";
import { cn } from "@/lib/utils";
import { GroupSelector } from "../groups/_components/group-selector";
import { DependencySelector } from "./dependency-selector";
@@ -209,9 +210,11 @@ export function FlagSheet({
const createMutation = useMutation({
...orpc.flags.create.mutationOptions(),
+ ...mutationErrorToast,
});
const updateMutation = useMutation({
...orpc.flags.update.mutationOptions(),
+ ...mutationErrorToast,
});
const resetForm = useCallback(() => {
@@ -356,9 +359,7 @@ export function FlagSheet({
});
onCloseAction();
- } catch (error) {
- console.error("Flag mutation error:", error);
- }
+ } catch {}
};
const isLoading = createMutation.isPending || updateMutation.isPending;
diff --git a/apps/dashboard/app/(main)/websites/[id]/flags/_components/flags-list.tsx b/apps/dashboard/app/(main)/websites/[id]/flags/_components/flags-list.tsx
index 0ff9389964..81a3e37bc5 100644
--- a/apps/dashboard/app/(main)/websites/[id]/flags/_components/flags-list.tsx
+++ b/apps/dashboard/app/(main)/websites/[id]/flags/_components/flags-list.tsx
@@ -36,6 +36,7 @@ interface FlagsListProps {
stats: Map;
statsError: boolean;
statsLoading: boolean;
+ websiteId: string;
}
const FLAG_LIST_MIN_WIDTH_CLASS = "min-w-[980px]";
@@ -77,7 +78,7 @@ function GroupsDisplay({ groups }: { groups: TargetGroup[] }) {
);
}
-function StatusToggle({ flag }: { flag: Flag }) {
+function StatusToggle({ flag, websiteId }: { flag: Flag; websiteId: string }) {
const queryClient = useQueryClient();
const isActive = flag.status === "active";
@@ -86,7 +87,7 @@ function StatusToggle({ flag }: { flag: Flag }) {
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: orpc.flags.list.key({
- input: { websiteId: flag.websiteId ?? "" },
+ input: { websiteId },
}),
});
},
@@ -126,10 +127,12 @@ function StatusToggle({ flag }: { flag: Flag }) {
function FlagActions({
flag,
+ websiteId,
onEdit,
onDelete,
}: {
flag: Flag;
+ websiteId: string;
onEdit: (flag: Flag) => void;
onDelete: (flagId: string) => void;
}) {
@@ -140,7 +143,7 @@ function FlagActions({
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: orpc.flags.list.key({
- input: { websiteId: flag.websiteId ?? "" },
+ input: { websiteId },
}),
});
},
@@ -405,6 +408,7 @@ function FlagRow({
statsLoading,
onEdit,
onDelete,
+ websiteId,
}: {
flag: Flag;
groups: TargetGroup[];
@@ -416,6 +420,7 @@ function FlagRow({
statsLoading: boolean;
onEdit: (flag: Flag) => void;
onDelete: (flagId: string) => void;
+ websiteId: string;
}) {
const typeConfig =
TYPE_CONFIG[flag.type as keyof typeof TYPE_CONFIG] ?? TYPE_CONFIG.boolean;
@@ -517,12 +522,17 @@ function FlagRow({
Archived
) : (
-
+
)}
-
+
);
@@ -531,6 +541,7 @@ function FlagRow({
export function FlagsList({
flags,
groups,
+ websiteId,
onRetryStats,
stats,
statsError,
@@ -576,6 +587,7 @@ export function FlagsList({
statsLoading={statsLoading}
onDelete={onDelete}
onEdit={onEdit}
+ websiteId={websiteId}
/>
))}
diff --git a/apps/dashboard/app/(main)/websites/[id]/flags/_components/types.ts b/apps/dashboard/app/(main)/websites/[id]/flags/_components/types.ts
index a0607ce4aa..ef03854e75 100644
--- a/apps/dashboard/app/(main)/websites/[id]/flags/_components/types.ts
+++ b/apps/dashboard/app/(main)/websites/[id]/flags/_components/types.ts
@@ -67,8 +67,6 @@ export interface TargetGroup {
websiteId: string;
}
-export type FlagStatus = "active" | "inactive" | "archived";
-
export interface FlagSheetProps {
flag?: Flag | null;
isOpen: boolean;
diff --git a/apps/dashboard/app/(main)/websites/[id]/flags/archive/_components/archived-flag-item.tsx b/apps/dashboard/app/(main)/websites/[id]/flags/archive/_components/archived-flag-item.tsx
index aec8305bd1..a71a205e13 100644
--- a/apps/dashboard/app/(main)/websites/[id]/flags/archive/_components/archived-flag-item.tsx
+++ b/apps/dashboard/app/(main)/websites/[id]/flags/archive/_components/archived-flag-item.tsx
@@ -18,6 +18,7 @@ interface ArchivedFlagItemProps {
flag: Flag;
onDelete: (flagId: string) => void;
onEdit: (flag: Flag) => void;
+ websiteId: string;
}
export function ArchivedFlagItem({
@@ -25,6 +26,7 @@ export function ArchivedFlagItem({
onEdit,
onDelete,
className,
+ websiteId,
}: ArchivedFlagItemProps) {
const queryClient = useQueryClient();
@@ -33,7 +35,7 @@ export function ArchivedFlagItem({
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: orpc.flags.list.key({
- input: { websiteId: flag.websiteId ?? "" },
+ input: { websiteId },
}),
});
},
diff --git a/apps/dashboard/app/(main)/websites/[id]/flags/archive/page.tsx b/apps/dashboard/app/(main)/websites/[id]/flags/archive/page.tsx
index 58d4f87e15..c09606beb7 100644
--- a/apps/dashboard/app/(main)/websites/[id]/flags/archive/page.tsx
+++ b/apps/dashboard/app/(main)/websites/[id]/flags/archive/page.tsx
@@ -112,6 +112,7 @@ export default function ArchivePage() {
key={flag.id}
onDelete={handleDeleteFlagRequest}
onEdit={handleEditFlag}
+ websiteId={websiteId}
/>
))}
diff --git a/apps/dashboard/app/(main)/websites/[id]/flags/layout.tsx b/apps/dashboard/app/(main)/websites/[id]/flags/layout.tsx
index 92413d2100..7e937a5f9c 100644
--- a/apps/dashboard/app/(main)/websites/[id]/flags/layout.tsx
+++ b/apps/dashboard/app/(main)/websites/[id]/flags/layout.tsx
@@ -2,10 +2,13 @@
import { useFlag } from "@databuddy/sdk/react";
import { FLAG_STATS_WINDOW_DAYS } from "@databuddy/shared/flags";
+import { GATED_FEATURES } from "@databuddy/shared/types/features";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { useParams, usePathname } from "next/navigation";
import { useCallback, useMemo } from "react";
+import { toast } from "sonner";
+import { usePlanLimitMessage } from "@/components/feature-gate";
import { PageNavigation } from "@/components/layout/page-navigation";
import { orpc } from "@/lib/orpc";
import { isAnalyticsRefreshingAtom } from "@/stores/jotai/filterAtoms";
@@ -59,6 +62,11 @@ export default function FlagsLayout({
[flags]
);
+ const planLimitMessage = usePlanLimitMessage(
+ GATED_FEATURES.FEATURE_FLAGS,
+ activeFlags.length
+ );
+
const isGroupsPage = pathname?.includes("/groups");
const isTemplatesPage = pathname?.includes("/templates");
const isArchivePage = pathname?.includes("/archive");
@@ -130,9 +138,13 @@ export default function FlagsLayout({
onClick={() => {
if (isGroupsPage) {
setIsGroupSheetOpen(true);
- } else {
- setIsFlagSheetOpen(true);
+ return;
+ }
+ if (planLimitMessage) {
+ toast.info(planLimitMessage);
+ return;
}
+ setIsFlagSheetOpen(true);
}}
size="sm"
>
diff --git a/apps/dashboard/app/(main)/websites/[id]/flags/page.tsx b/apps/dashboard/app/(main)/websites/[id]/flags/page.tsx
index ce5e4b2b76..968636967b 100644
--- a/apps/dashboard/app/(main)/websites/[id]/flags/page.tsx
+++ b/apps/dashboard/app/(main)/websites/[id]/flags/page.tsx
@@ -6,8 +6,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { useParams } from "next/navigation";
import { Suspense, useMemo, useState } from "react";
+import { toast } from "sonner";
import { ErrorBoundary } from "@/components/error-boundary";
-import { FeatureGate } from "@/components/feature-gate";
+import { FeatureGate, usePlanLimitMessage } from "@/components/feature-gate";
import { orpc } from "@/lib/orpc";
import { isFlagSheetOpenAtom } from "@/stores/jotai/flagsAtoms";
import { FlagSheet } from "./_components/flag-sheet";
@@ -80,7 +81,16 @@ export default function FlagsPage() {
},
});
+ const planLimitMessage = usePlanLimitMessage(
+ GATED_FEATURES.FEATURE_FLAGS,
+ activeFlags.length
+ );
+
const handleCreateFlag = () => {
+ if (planLimitMessage) {
+ toast.info(planLimitMessage);
+ return;
+ }
setEditingFlag(null);
setIsFlagSheetOpen(true);
};
@@ -133,6 +143,7 @@ export default function FlagsPage() {
["draft"];
-
interface EditFunnelDialogProps {
autocompleteData?: AutocompleteData;
funnel: Funnel | null;
- initialDraft?: FunnelDraft;
isCreating?: boolean;
isOpen: boolean;
isUpdating: boolean;
@@ -60,7 +53,6 @@ export function EditFunnelDialog({
onSubmit,
onCreate,
funnel,
- initialDraft,
isUpdating,
isCreating = false,
autocompleteData,
@@ -68,18 +60,12 @@ export function EditFunnelDialog({
const [formData, setFormData] = useState(null);
const initializedFor = useRef(null);
const isCreateMode = !funnel;
- const isSuggestedDraft = isCreateMode && Boolean(initialDraft);
const formIdentity = useMemo(() => {
if (funnel) {
return `funnel:${funnel.id}`;
}
- if (initialDraft) {
- return `draft:${initialDraft.name}:${initialDraft.steps
- .map((step) => `${step.type}:${step.target}`)
- .join("|")}`;
- }
return "new";
- }, [funnel, initialDraft]);
+ }, [funnel]);
useEffect(() => {
if (!isOpen) {
@@ -101,21 +87,6 @@ export function EditFunnelDialog({
filters: sanitizedFilters,
ignoreHistoricData: funnel.ignoreHistoricData ?? false,
});
- } else if (initialDraft) {
- setFormData({
- id: "",
- name: initialDraft.name,
- description: initialDraft.description,
- steps: initialDraft.steps.map((step) => ({ ...step })),
- filters: initialDraft.filters.map((filter) => ({
- ...filter,
- operator: filter.operator || "equals",
- })),
- ignoreHistoricData: initialDraft.ignoreHistoricData,
- isActive: true,
- createdAt: "",
- updatedAt: "",
- });
} else {
setFormData({
id: "",
@@ -136,7 +107,7 @@ export function EditFunnelDialog({
updatedAt: "",
});
}
- }, [formIdentity, funnel, initialDraft, isOpen]);
+ }, [formIdentity, funnel, isOpen]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -161,7 +132,6 @@ export function EditFunnelDialog({
await onCreate(createData);
resetForm();
} catch {
- // The caller surfaces the error; retain the user's draft for correction.
return;
}
} else {
@@ -352,17 +322,11 @@ export function EditFunnelDialog({
- {isCreateMode
- ? isSuggestedDraft
- ? "Review Funnel Draft"
- : "New Funnel"
- : formData.name || "Edit Funnel"}
+ {isCreateMode ? "New Funnel" : formData.name || "Edit Funnel"}
{isCreateMode
- ? isSuggestedDraft
- ? "Review and adjust this proposed journey before creating it"
- : "Track user conversion journeys"
+ ? "Track user conversion journeys"
: `${formData.steps.length} steps configured`}
diff --git a/apps/dashboard/app/(main)/websites/[id]/funnels/_components/index.ts b/apps/dashboard/app/(main)/websites/[id]/funnels/_components/index.ts
index 619244defb..5a65f74772 100644
--- a/apps/dashboard/app/(main)/websites/[id]/funnels/_components/index.ts
+++ b/apps/dashboard/app/(main)/websites/[id]/funnels/_components/index.ts
@@ -1,9 +1,6 @@
-export { EditFunnelDialog } from "./edit-funnel-dialog";
export { FunnelAnalytics } from "./funnel-analytics";
export { FunnelAnalyticsByReferrer } from "./funnel-analytics-by-referrer";
-export { FunnelFlow } from "./funnel-flow";
export {
- FunnelItem,
type FunnelItemData,
FunnelItemSkeleton,
} from "./funnel-item";
diff --git a/apps/dashboard/app/(main)/websites/[id]/funnels/page.tsx b/apps/dashboard/app/(main)/websites/[id]/funnels/page.tsx
index 3a282f371e..011a9462da 100644
--- a/apps/dashboard/app/(main)/websites/[id]/funnels/page.tsx
+++ b/apps/dashboard/app/(main)/websites/[id]/funnels/page.tsx
@@ -1,6 +1,6 @@
"use client";
-import { FeatureGate } from "@/components/feature-gate";
+import { FeatureGate, usePlanLimitMessage } from "@/components/feature-gate";
import { List } from "@/components/ui/composables/list";
import { insightDefinitionEditChangesSchema } from "@databuddy/shared/insights";
import { useAutocompleteData } from "@/hooks/use-autocomplete";
@@ -92,6 +92,19 @@ export default function FunnelsPage() {
isUpdating,
} = useFunnels(websiteId, { dateRange });
+ const planLimitMessage = usePlanLimitMessage(
+ GATED_FEATURES.FUNNELS,
+ funnels.length
+ );
+
+ const openCreate = () => {
+ if (planLimitMessage) {
+ toast.info(planLimitMessage);
+ return;
+ }
+ setEditing("new");
+ };
+
useEffect(() => {
const command = searchParams.get("command");
const funnelId = searchParams.get("funnelId");
@@ -183,9 +196,7 @@ export default function FunnelsPage() {
try {
await createAction(data);
setEditing(null);
- } catch (err) {
- console.error("Failed to create funnel:", err);
- }
+ } catch {}
};
const handleUpdate = async (funnel: FunnelItemData) => {
@@ -198,9 +209,7 @@ export default function FunnelsPage() {
ignoreHistoricData: funnel.ignoreHistoricData,
});
setEditing(null);
- } catch (err) {
- console.error("Failed to update funnel:", err);
- }
+ } catch {}
};
const handleDelete = async (funnelId: string) => {
@@ -210,9 +219,7 @@ export default function FunnelsPage() {
setExpandedId(null);
}
setDeletingId(null);
- } catch (err) {
- console.error("Failed to delete funnel:", err);
- }
+ } catch {}
};
return (
@@ -234,7 +241,7 @@ export default function FunnelsPage() {
/>
{!isDemoRoute && (
- setEditing("new")} size="sm">
+
Create Funnel
@@ -248,7 +255,7 @@ export default function FunnelsPage() {
? undefined
: {
label: "Create a funnel",
- onClick: () => setEditing("new"),
+ onClick: openCreate,
},
description:
"Define a multi-step journey to see where users drop off.",
diff --git a/apps/dashboard/app/(main)/websites/[id]/goals/_components/edit-goal-dialog.tsx b/apps/dashboard/app/(main)/websites/[id]/goals/_components/edit-goal-dialog.tsx
index 757012c8c6..1332f6149a 100644
--- a/apps/dashboard/app/(main)/websites/[id]/goals/_components/edit-goal-dialog.tsx
+++ b/apps/dashboard/app/(main)/websites/[id]/goals/_components/edit-goal-dialog.tsx
@@ -2,7 +2,6 @@
import { AutocompleteInput } from "@/components/ui/autocomplete-input";
import { FilterRow } from "@/components/ui/filter-row";
-import type { InsightMeasurementRecommendation } from "@databuddy/shared/insights";
import type { AutocompleteData } from "@/hooks/use-autocomplete";
import { goalFunnelOperatorOptions, useFilters } from "@/hooks/use-filters";
import type { CreateGoalData, Goal } from "@/hooks/use-goals";
@@ -34,15 +33,9 @@ interface GoalFormData {
type: string;
}
-export type GoalDraft = Extract<
- InsightMeasurementRecommendation,
- { kind: "goal_draft" }
->["draft"];
-
interface EditGoalDialogProps {
autocompleteData?: AutocompleteData;
goal: Goal | null;
- initialDraft?: GoalDraft;
isOpen: boolean;
isSaving: boolean;
onClose: () => void;
@@ -54,14 +47,12 @@ export function EditGoalDialog({
onClose,
onSave,
goal,
- initialDraft,
isSaving,
autocompleteData,
}: EditGoalDialogProps) {
const [formData, setFormData] = useState(null);
const wasOpen = useRef(false);
const isCreateMode = !goal;
- const isSuggestedDraft = isCreateMode && Boolean(initialDraft);
useEffect(() => {
if (!isOpen) {
@@ -89,18 +80,6 @@ export function EditGoalDialog({
filters: sanitizedFilters,
ignoreHistoricData: goal.ignoreHistoricData ?? false,
});
- } else if (initialDraft) {
- setFormData({
- name: initialDraft.name,
- description: initialDraft.description,
- type: initialDraft.type,
- target: initialDraft.target,
- filters: initialDraft.filters.map((filter) => ({
- ...filter,
- operator: filter.operator || "equals",
- })),
- ignoreHistoricData: initialDraft.ignoreHistoricData,
- });
} else {
setFormData({
name: "",
@@ -111,7 +90,7 @@ export function EditGoalDialog({
ignoreHistoricData: false,
});
}
- }, [goal, initialDraft, isOpen]);
+ }, [goal, isOpen]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -229,17 +208,11 @@ export function EditGoalDialog({
- {isCreateMode
- ? isSuggestedDraft
- ? "Review Goal Draft"
- : "New Goal"
- : formData.name || "Edit Goal"}
+ {isCreateMode ? "New Goal" : formData.name || "Edit Goal"}
{isCreateMode
- ? isSuggestedDraft
- ? "Review and adjust this proposed conversion before creating it"
- : "Track single-step conversions"
+ ? "Track single-step conversions"
: "Update goal settings"}
diff --git a/apps/dashboard/app/(main)/websites/[id]/goals/_components/goal-analytics.tsx b/apps/dashboard/app/(main)/websites/[id]/goals/_components/goal-analytics.tsx
deleted file mode 100644
index 3940dbb308..0000000000
--- a/apps/dashboard/app/(main)/websites/[id]/goals/_components/goal-analytics.tsx
+++ /dev/null
@@ -1,207 +0,0 @@
-"use client";
-
-import { formatNumber } from "@/lib/formatters";
-import {
- ArrowClockwiseIcon as ArrowClockwise,
- TargetIcon as Target,
- TrendUpIcon as TrendUp,
- UsersIcon as Users,
-} from "@databuddy/ui/icons";
-import { Button, Card } from "@databuddy/ui";
-
-interface GoalAnalyticsProps {
- data: any;
- error: Error | null;
- isLoading: boolean;
- onRetry: () => void;
- summaryStats: {
- totalUsers: number;
- conversionRate: number;
- completions: number;
- };
-}
-
-export function GoalAnalytics({
- isLoading,
- error,
- data,
- summaryStats,
- onRetry,
-}: GoalAnalyticsProps) {
- if (isLoading) {
- return (
-
-
- {[...new Array(3)].map((_, i) => (
-
-
-
-
-
-
- ))}
-
-
- );
- }
-
- if (error) {
- return (
-
-
-
-
-
- Failed to load goal analytics
-
-
{error.message}
-
-
-
- Retry
-
-
-
-
- );
- }
-
- if (!(data?.success && data.data)) {
- return (
-
-
-
- No analytics data available
-
-
-
- );
- }
-
- const formatPercentage = (num: number) => `${num.toFixed(1)}%`;
-
- return (
-
-
-
-
-
-
-
-
-
-
- Total Users
-
-
- {formatNumber(summaryStats.totalUsers)}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Completions
-
-
- {formatNumber(summaryStats.completions)}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Conversion Rate
-
-
- {formatPercentage(summaryStats.conversionRate)}
-
-
-
-
-
-
-
-
-
- Goal Performance
-
-
-
-
-
- Performance Summary
-
- {data.date_range?.start_date} - {data.date_range?.end_date}
-
-
-
-
-
-
- Users who reached goal
-
-
- {formatNumber(summaryStats.completions)} /{" "}
- {formatNumber(summaryStats.totalUsers)}
-
-
-
-
- Success rate
-
-
- {formatPercentage(summaryStats.conversionRate)}
-
-
-
-
-
- {data.data.avg_completion_time > 0 && (
-
-
-
- Average Time to Complete
-
-
- {data.data.avg_completion_time_formatted ||
- `${Math.round(data.data.avg_completion_time)}s`}
-
-
-
- )}
-
-
-
-
- );
-}
diff --git a/apps/dashboard/app/(main)/websites/[id]/goals/page.tsx b/apps/dashboard/app/(main)/websites/[id]/goals/page.tsx
index 6015c79bf1..42b963aec7 100644
--- a/apps/dashboard/app/(main)/websites/[id]/goals/page.tsx
+++ b/apps/dashboard/app/(main)/websites/[id]/goals/page.tsx
@@ -14,7 +14,7 @@ import {
} from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
-import { FeatureGate } from "@/components/feature-gate";
+import { FeatureGate, usePlanLimitMessage } from "@/components/feature-gate";
import { List } from "@/components/ui/composables/list";
import { useAutocompleteData } from "@/hooks/use-autocomplete";
import { useDateFilters } from "@/hooks/use-date-filters";
@@ -92,6 +92,19 @@ export default function GoalsPage() {
isUpdating,
} = useGoals(websiteId);
+ const planLimitMessage = usePlanLimitMessage(
+ GATED_FEATURES.GOALS,
+ goals.length
+ );
+
+ const openCreate = () => {
+ if (planLimitMessage) {
+ toast.info(planLimitMessage);
+ return;
+ }
+ setEditingGoal(null);
+ };
+
useEffect(() => {
const command = searchParams.get("command");
const goalId = searchParams.get("goalId");
@@ -198,24 +211,14 @@ export default function GoalsPage() {
} as CreateGoalData);
}
setEditingGoal(undefined);
- } catch (error) {
- console.error("Failed to save goal:", error);
- toast.error(
- error instanceof Error ? error.message : "Could not save goal"
- );
- }
+ } catch {}
};
const handleDeleteGoal = async (goalId: string) => {
try {
await deleteGoal(goalId);
setDeletingGoalId(null);
- } catch (error) {
- console.error("Failed to delete goal:", error);
- toast.error(
- error instanceof Error ? error.message : "Could not delete goal"
- );
- }
+ } catch {}
};
const deletingGoal = goals.find((goal) => goal.id === deletingGoalId) ?? null;
@@ -239,7 +242,7 @@ export default function GoalsPage() {
/>
{!isDemoRoute && (
- setEditingGoal(null)} size="sm">
+
Create Goal
@@ -253,7 +256,7 @@ export default function GoalsPage() {
? undefined
: {
label: "Create a goal",
- onClick: () => setEditingGoal(null),
+ onClick: openCreate,
},
description:
"Track single-step conversions like signups, purchases, or activation events.",
diff --git a/apps/dashboard/app/(main)/websites/[id]/layout.tsx b/apps/dashboard/app/(main)/websites/[id]/layout.tsx
index 573811347f..af6a09d008 100644
--- a/apps/dashboard/app/(main)/websites/[id]/layout.tsx
+++ b/apps/dashboard/app/(main)/websites/[id]/layout.tsx
@@ -310,7 +310,7 @@ export default function WebsiteLayout({ children }: WebsiteLayoutProps) {
return (
- {!hideToolbar && (
+ {!(hideToolbar || showTrackingSetup) && (
<>
= {
- minute: "1m",
- ten_minutes: "10m",
- thirty_minutes: "30m",
- hour: "1h",
- six_hours: "6h",
- twelve_hours: "12h",
- day: "Daily",
-};
-
-interface MonitorCardProps {
- onDeleteAction: () => void;
- onEditAction: () => void;
- onRefetchAction: () => void;
- schedule: {
- id: string;
- granularity: string;
- cron: string;
- isPaused: boolean;
- createdAt: Date | string;
- updatedAt: Date | string;
- };
-}
-
-export function MonitorCard({
- schedule,
- onEditAction,
- onDeleteAction,
- onRefetchAction,
-}: MonitorCardProps) {
- const [isPausing, setIsPausing] = useState(false);
-
- const pauseMutation = useMutation({
- ...orpc.uptime.pauseSchedule.mutationOptions(),
- });
- const resumeMutation = useMutation({
- ...orpc.uptime.resumeSchedule.mutationOptions(),
- });
-
- const handleTogglePause = async () => {
- setIsPausing(true);
- try {
- if (schedule.isPaused) {
- await resumeMutation.mutateAsync({ scheduleId: schedule.id });
- toast.success("Monitor resumed");
- } else {
- await pauseMutation.mutateAsync({ scheduleId: schedule.id });
- toast.success("Monitor paused");
- }
- onRefetchAction();
- } catch (error) {
- const errorMessage =
- error instanceof Error ? error.message : "Failed to update monitor";
- toast.error(errorMessage);
- } finally {
- setIsPausing(false);
- }
- };
-
- return (
-
-
-
-
-
-
-
-
-
- Uptime Monitor
-
-
-
- Check Frequency:
-
- {granularityLabels[schedule.granularity] ||
- schedule.granularity}
-
-
-
- Status:
-
- {schedule.isPaused ? "Paused" : "Active"}
-
-
-
-
-
-
-
-
-
-
- }
- />
-
-
-
- Edit
-
-
-
- {schedule.isPaused ? "Resume" : "Pause"}
-
-
-
- Delete
-
-
-
-
-
-
- );
-}
diff --git a/apps/dashboard/app/(main)/websites/[id]/pulse/_components/recent-activity.tsx b/apps/dashboard/app/(main)/websites/[id]/pulse/_components/recent-activity.tsx
index 756681ebdc..811654cd86 100644
--- a/apps/dashboard/app/(main)/websites/[id]/pulse/_components/recent-activity.tsx
+++ b/apps/dashboard/app/(main)/websites/[id]/pulse/_components/recent-activity.tsx
@@ -78,33 +78,6 @@ function LoadMoreSkeletonRow() {
);
}
-function InitialTableSkeleton({ rows }: { rows: number }) {
- return (
-
-
-
-
- {Array.from({ length: rows }).map((_, i) => (
-
- ))}
-
-
-
- );
-}
-
-export function RecentActivityTableSkeleton({ rows = 8 }: { rows?: number }) {
- return ;
-}
-
export function RecentActivity({
checks,
hasMore = false,
diff --git a/apps/dashboard/app/(main)/websites/[id]/pulse/_components/status-header.tsx b/apps/dashboard/app/(main)/websites/[id]/pulse/_components/status-header.tsx
deleted file mode 100644
index 556c55917e..0000000000
--- a/apps/dashboard/app/(main)/websites/[id]/pulse/_components/status-header.tsx
+++ /dev/null
@@ -1,194 +0,0 @@
-"use client";
-
-import { useMutation } from "@tanstack/react-query";
-import { useState } from "react";
-import { toast } from "sonner";
-import { orpc } from "@/lib/orpc";
-import { cn } from "@/lib/utils";
-import {
- CircleIcon,
- PauseIcon,
- PencilIcon,
- PlayIcon,
- TrashIcon,
-} from "@databuddy/ui/icons";
-import { DropdownMenu } from "@databuddy/ui/client";
-import { Button, Card, GhostTriggerButton, fromNow } from "@databuddy/ui";
-
-const granularityLabels: Record = {
- minute: "Every minute",
- ten_minutes: "Every 10 minutes",
- thirty_minutes: "Every 30 minutes",
- hour: "Hourly",
- six_hours: "Every 6 hours",
- twelve_hours: "Every 12 hours",
- day: "Daily",
-};
-
-interface StatusHeaderProps {
- currentStatus?: "up" | "down" | "unknown";
- lastCheck?: {
- timestamp: string;
- status: number;
- probe_region?: string;
- };
- onDeleteAction: () => void;
- onEditAction: () => void;
- onRefetchAction: () => void;
- schedule: {
- id: string;
- granularity: string;
- cron: string;
- isPaused: boolean;
- createdAt: Date | string;
- updatedAt: Date | string;
- };
-}
-
-export function StatusHeader({
- schedule,
- currentStatus = "unknown",
- lastCheck,
- onEditAction,
- onDeleteAction,
- onRefetchAction,
-}: StatusHeaderProps) {
- const [isPausing, setIsPausing] = useState(false);
-
- const pauseMutation = useMutation({
- ...orpc.uptime.pauseSchedule.mutationOptions(),
- });
- const resumeMutation = useMutation({
- ...orpc.uptime.resumeSchedule.mutationOptions(),
- });
-
- const handleTogglePause = async () => {
- setIsPausing(true);
- try {
- if (schedule.isPaused) {
- await resumeMutation.mutateAsync({ scheduleId: schedule.id });
- toast.success("Monitor resumed");
- } else {
- await pauseMutation.mutateAsync({ scheduleId: schedule.id });
- toast.success("Monitor paused");
- }
- onRefetchAction();
- } catch (error) {
- const errorMessage =
- error instanceof Error ? error.message : "Failed to update monitor";
- toast.error(errorMessage);
- } finally {
- setIsPausing(false);
- }
- };
-
- const isOperational =
- !schedule.isPaused &&
- (currentStatus === "up" || currentStatus === "unknown");
- const isDown = !schedule.isPaused && currentStatus === "down";
- const isPaused = schedule.isPaused;
-
- return (
-
-
-
-
-
-
- {isPaused
- ? "Monitoring Paused"
- : isDown
- ? "System Outage"
- : "All Systems Operational"}
-
-
-
-
- {granularityLabels[schedule.granularity] || schedule.granularity}
-
- {lastCheck ? (
- <>
- •
- Last checked {fromNow(lastCheck.timestamp)}
- {lastCheck.probe_region ? (
- <>
- •
- from {lastCheck.probe_region}
- >
- ) : null}
- >
- ) : null}
-
-
-
-
-
- {schedule.isPaused ? (
- <>
-
- Resume
- >
- ) : (
- <>
-
- Pause
- >
- )}
-
-
-
- Configure
-
-
-
-
-
- }
- />
-
-
-
- Delete Monitor
-
-
-
-
-
-
- );
-}
diff --git a/apps/dashboard/app/(main)/websites/[id]/pulse/page.tsx b/apps/dashboard/app/(main)/websites/[id]/pulse/page.tsx
index f5b2d83d4a..85f7313219 100644
--- a/apps/dashboard/app/(main)/websites/[id]/pulse/page.tsx
+++ b/apps/dashboard/app/(main)/websites/[id]/pulse/page.tsx
@@ -27,10 +27,6 @@ interface Schedule {
granularity: string;
id: string;
isPaused: boolean;
- isPublic: boolean;
- jsonParsingConfig?: {
- enabled: boolean;
- } | null;
name?: string | null;
url: string;
}
@@ -44,9 +40,6 @@ export default function PulsePage() {
url: string;
name?: string | null;
granularity: string;
- jsonParsingConfig?: {
- enabled: boolean;
- } | null;
} | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
@@ -149,7 +142,6 @@ export default function PulsePage() {
url: schedule.url,
name: schedule.name,
granularity: schedule.granularity,
- jsonParsingConfig: schedule.jsonParsingConfig,
});
setIsDialogOpen(true);
}
@@ -210,8 +202,8 @@ export default function PulsePage() {
refetchUptimeData(),
refetchHeatmapData(),
]);
- } catch (error) {
- console.error("Failed to refresh:", error);
+ } catch {
+ toast.error("Failed to refresh monitor data");
} finally {
setIsRefreshing(false);
}
diff --git a/apps/dashboard/app/(main)/websites/[id]/realtime/_components/realtime-map.tsx b/apps/dashboard/app/(main)/websites/[id]/realtime/_components/realtime-map.tsx
index 0aa36e74de..d7266b49c6 100644
--- a/apps/dashboard/app/(main)/websites/[id]/realtime/_components/realtime-map.tsx
+++ b/apps/dashboard/app/(main)/websites/[id]/realtime/_components/realtime-map.tsx
@@ -186,7 +186,10 @@ export function RealtimeMap({ countries }: RealtimeMapProps) {
oy: number;
} | null>(null);
const [tooltip, setTooltip] = useState(null);
- countriesRef.current = countries;
+
+ useEffect(() => {
+ countriesRef.current = countries;
+ }, [countries]);
useEffect(() => {
const canvas = canvasRef.current;
diff --git a/apps/dashboard/app/(main)/websites/[id]/revenue/_components/revenue-attribution-tables.tsx b/apps/dashboard/app/(main)/websites/[id]/revenue/_components/revenue-attribution-tables.tsx
index cf949d468f..cbbb870508 100644
--- a/apps/dashboard/app/(main)/websites/[id]/revenue/_components/revenue-attribution-tables.tsx
+++ b/apps/dashboard/app/(main)/websites/[id]/revenue/_components/revenue-attribution-tables.tsx
@@ -13,6 +13,20 @@ import { useBatchDynamicQuery } from "@/hooks/use-dynamic-query";
import { WarningCircleIcon } from "@databuddy/ui/icons";
import { Card, EmptyState } from "@databuddy/ui";
+type BatchQueryResults = ReturnType["results"];
+
+function getRevenueData(
+ results: BatchQueryResults,
+ queryId: string,
+ parameter: string
+): RevenueEntry[] {
+ const result = results.find((r) => r.queryId === queryId);
+ if (!result?.success) {
+ return [];
+ }
+ return (result.data[parameter] as RevenueEntry[]) || [];
+}
+
interface RevenueAttributionTablesProps {
currency: string;
dateRange: DateRange;
@@ -70,7 +84,7 @@ export function RevenueAttributionTables({
[queryFilters]
);
- const { getDataForQuery, isError, isLoading, refetch } = useBatchDynamicQuery(
+ const { results, isError, isLoading, refetch } = useBatchDynamicQuery(
websiteId,
dateRange,
queries,
@@ -78,81 +92,57 @@ export function RevenueAttributionTables({
);
const productData = useMemo(
- () =>
- (getDataForQuery(
- "revenue-products",
- "revenue_by_product"
- ) as RevenueEntry[]) || [],
- [getDataForQuery]
+ () => getRevenueData(results, "revenue-products", "revenue_by_product"),
+ [results]
);
const trafficData = useMemo(
() => ({
- referrers:
- (getDataForQuery(
- "revenue-traffic",
- "revenue_by_referrer"
- ) as RevenueEntry[]) || [],
- utm_sources:
- (getDataForQuery(
- "revenue-traffic",
- "revenue_by_utm_source"
- ) as RevenueEntry[]) || [],
- utm_mediums:
- (getDataForQuery(
- "revenue-traffic",
- "revenue_by_utm_medium"
- ) as RevenueEntry[]) || [],
- utm_campaigns:
- (getDataForQuery(
- "revenue-traffic",
- "revenue_by_utm_campaign"
- ) as RevenueEntry[]) || [],
- entry_pages:
- (getDataForQuery(
- "revenue-traffic",
- "revenue_by_entry_page"
- ) as RevenueEntry[]) || [],
+ referrers: getRevenueData(
+ results,
+ "revenue-traffic",
+ "revenue_by_referrer"
+ ),
+ utm_sources: getRevenueData(
+ results,
+ "revenue-traffic",
+ "revenue_by_utm_source"
+ ),
+ utm_mediums: getRevenueData(
+ results,
+ "revenue-traffic",
+ "revenue_by_utm_medium"
+ ),
+ utm_campaigns: getRevenueData(
+ results,
+ "revenue-traffic",
+ "revenue_by_utm_campaign"
+ ),
+ entry_pages: getRevenueData(
+ results,
+ "revenue-traffic",
+ "revenue_by_entry_page"
+ ),
}),
- [getDataForQuery]
+ [results]
);
const geoData = useMemo(
() => ({
- countries:
- (getDataForQuery(
- "revenue-geo",
- "revenue_by_country"
- ) as RevenueEntry[]) || [],
- regions:
- (getDataForQuery(
- "revenue-geo",
- "revenue_by_region"
- ) as RevenueEntry[]) || [],
- cities:
- (getDataForQuery("revenue-geo", "revenue_by_city") as RevenueEntry[]) ||
- [],
+ countries: getRevenueData(results, "revenue-geo", "revenue_by_country"),
+ regions: getRevenueData(results, "revenue-geo", "revenue_by_region"),
+ cities: getRevenueData(results, "revenue-geo", "revenue_by_city"),
}),
- [getDataForQuery]
+ [results]
);
const techData = useMemo(
() => ({
- devices:
- (getDataForQuery(
- "revenue-tech",
- "revenue_by_device"
- ) as RevenueEntry[]) || [],
- browsers:
- (getDataForQuery(
- "revenue-tech",
- "revenue_by_browser"
- ) as RevenueEntry[]) || [],
- os:
- (getDataForQuery("revenue-tech", "revenue_by_os") as RevenueEntry[]) ||
- [],
+ devices: getRevenueData(results, "revenue-tech", "revenue_by_device"),
+ browsers: getRevenueData(results, "revenue-tech", "revenue_by_browser"),
+ os: getRevenueData(results, "revenue-tech", "revenue_by_os"),
}),
- [getDataForQuery]
+ [results]
);
const productColumns = useMemo(
diff --git a/apps/dashboard/app/(main)/websites/[id]/settings/export/page.tsx b/apps/dashboard/app/(main)/websites/[id]/settings/export/page.tsx
index c66c4a0cb3..6ff03aecf1 100644
--- a/apps/dashboard/app/(main)/websites/[id]/settings/export/page.tsx
+++ b/apps/dashboard/app/(main)/websites/[id]/settings/export/page.tsx
@@ -128,7 +128,8 @@ export default function ExportPage() {
Export Format
- Choose a format for your data export
+ Choose a format for the files inside your export. Exports download
+ as a .zip archive containing events, errors, and web vitals.
@@ -214,7 +215,8 @@ export default function ExportPage() {
Format:{" "}
{selectedFormat.toUpperCase()}
-
+ {" "}
+ as .zip
{useCustomRange && dateRange?.from && dateRange?.to && (
{dayjs(dateRange.from).format("MMM D, YYYY")} –{" "}
diff --git a/apps/dashboard/app/(main)/websites/[id]/settings/general/page.tsx b/apps/dashboard/app/(main)/websites/[id]/settings/general/page.tsx
index f9b9089a98..7ab36a06e9 100644
--- a/apps/dashboard/app/(main)/websites/[id]/settings/general/page.tsx
+++ b/apps/dashboard/app/(main)/websites/[id]/settings/general/page.tsx
@@ -456,9 +456,9 @@ export default function GeneralSettingsPage() {
Warning:
- All analytics data will be permanently deleted
- Tracking will stop immediately
- All website settings will be lost
+ The website and its settings are permanently deleted
+ Its dashboards become inaccessible
+ Tracking stops within a few minutes
diff --git a/apps/dashboard/app/(main)/websites/[id]/settings/security/page.tsx b/apps/dashboard/app/(main)/websites/[id]/settings/security/page.tsx
index 7c22fae642..fc290af160 100644
--- a/apps/dashboard/app/(main)/websites/[id]/settings/security/page.tsx
+++ b/apps/dashboard/app/(main)/websites/[id]/settings/security/page.tsx
@@ -364,8 +364,10 @@ export default function SecurityPage() {
Allowed Origins
- By default, only your registered domain can send analytics. Add
- additional origins for third-party integrations like{" "}
+ Browser requests are validated against your registered domain
+ using their Origin header; requests without an Origin header are
+ accepted unless you configure the allowed origins and IP rules
+ below. Add additional origins for third-party integrations like{" "}
cal.com
{" "}
@@ -446,7 +448,7 @@ export default function SecurityPage() {
}
/>
diff --git a/apps/dashboard/app/(main)/websites/[id]/vitals/columns.tsx b/apps/dashboard/app/(main)/websites/[id]/vitals/columns.tsx
index 17095bed80..d7a6c57eae 100644
--- a/apps/dashboard/app/(main)/websites/[id]/vitals/columns.tsx
+++ b/apps/dashboard/app/(main)/websites/[id]/vitals/columns.tsx
@@ -159,38 +159,6 @@ export const createBrowserColumns = (): ColumnDef[] => [
...createMetricColumns(),
];
-export const createDeviceColumns = (): ColumnDef[] => [
- {
- id: "name",
- accessorKey: "name",
- header: "Device",
- cell: ({ getValue }) => {
- const name = getValue() as string;
- const deviceLabels: Record = {
- mobile: "Mobile",
- desktop: "Desktop",
- tablet: "Tablet",
- };
- return (
-
- {deviceLabels[name.toLowerCase()] || name}
-
- );
- },
- },
- {
- id: "visitors",
- accessorKey: "visitors",
- header: "Visitors",
- cell: ({ getValue }) => (
-
- {formatNumber((getValue() as number) || 0)}
-
- ),
- },
- ...createMetricColumns(),
-];
-
export const createRegionColumns = (): ColumnDef[] => {
const getRegionCountryIcon = (name: string) => {
if (typeof name !== "string" || !name.includes(",")) {
diff --git a/apps/dashboard/app/(main)/websites/_components/website-card.tsx b/apps/dashboard/app/(main)/websites/_components/website-card.tsx
index f10aee9644..26a95a8e91 100644
--- a/apps/dashboard/app/(main)/websites/_components/website-card.tsx
+++ b/apps/dashboard/app/(main)/websites/_components/website-card.tsx
@@ -357,28 +357,3 @@ export const WebsiteCard = memo(
);
WebsiteCard.displayName = "WebsiteCard";
-
-export function WebsiteCardSkeleton() {
- return (
-
-
-
-
-
-
-
-
- );
-}
diff --git a/apps/dashboard/app/actions/users.ts b/apps/dashboard/app/actions/users.ts
index 9c24f6dbab..bd0e77acb3 100644
--- a/apps/dashboard/app/actions/users.ts
+++ b/apps/dashboard/app/actions/users.ts
@@ -2,7 +2,7 @@
import { auth } from "@databuddy/auth";
import { and, db, eq } from "@databuddy/db";
-import { account, user } from "@databuddy/db/schema";
+import { account } from "@databuddy/db/schema";
import { revalidatePath } from "next/cache";
import { headers } from "next/headers";
import { cache } from "react";
@@ -18,63 +18,11 @@ const getUser = cache(async () => {
return session.user;
});
-const profileUpdateSchema = z.object({
- firstName: z
- .string()
- .min(1, "First name is required")
- .max(50, "First name cannot exceed 50 characters"),
- lastName: z
- .string()
- .min(1, "Last name is required")
- .max(50, "Last name cannot exceed 50 characters"),
- image: z.url("Please enter a valid image URL").optional(),
-});
-
const passwordSchema = z
.string()
.min(8, "Password must be at least 8 characters")
.max(128, "Password cannot exceed 128 characters");
-export async function updateUserProfile(formData: FormData) {
- const currentUser = await getUser();
- if (!currentUser) {
- return { error: "Unauthorized" };
- }
-
- try {
- const firstName = formData.get("firstName");
- const lastName = formData.get("lastName");
- const image = formData.get("image");
-
- const validatedData = profileUpdateSchema.parse({
- firstName,
- lastName,
- image: image || undefined,
- });
-
- const _updated = await db
- .update(user)
- .set({
- firstName: validatedData.firstName,
- lastName: validatedData.lastName,
- image: validatedData.image,
- name: `${validatedData.firstName} ${validatedData.lastName}`,
- })
- .where(eq(user.id, currentUser.id))
- .returning();
-
- revalidatePath("/settings");
- return { success: true };
- } catch (error) {
- console.error("Profile update error:", error);
-
- if (error instanceof z.ZodError) {
- return { error: error.message };
- }
- return { error: "Failed to update profile" };
- }
-}
-
export async function setPasswordForOAuthUser(newPassword: string) {
const currentUser = await getUser();
if (!currentUser) {
diff --git a/apps/dashboard/app/global-error.tsx b/apps/dashboard/app/global-error.tsx
index ae5d65df79..461c1244e2 100644
--- a/apps/dashboard/app/global-error.tsx
+++ b/apps/dashboard/app/global-error.tsx
@@ -1,12 +1,6 @@
"use client";
import { Button } from "@databuddy/ui";
-
-/**
- * Root error boundary — must define its own and and cannot rely on
- * the root layout. Avoid `next/error` here: it expects Pages Router context and
- * breaks prerender (useContext) during `next build`.
- */
export default function GlobalError({
error,
reset,
diff --git a/apps/dashboard/app/public/[id]/layout.tsx b/apps/dashboard/app/public/[id]/layout.tsx
index 7ceb5b6f7a..83a4145488 100644
--- a/apps/dashboard/app/public/[id]/layout.tsx
+++ b/apps/dashboard/app/public/[id]/layout.tsx
@@ -15,8 +15,6 @@ import { PlanetIcon } from "@databuddy/ui/icons";
const poweredByLabelClass =
"shrink-0 text-balance font-medium text-muted-foreground text-sm";
-
-/** Header Powered by row: wraps cleanly on narrow viewports. */
const brandAttributionLinkClass =
"flex min-w-0 flex-wrap items-center gap-3 rounded transition-opacity hover:opacity-90";
diff --git a/apps/dashboard/app/public/public-dashboard-constants.ts b/apps/dashboard/app/public/public-dashboard-constants.ts
index fe280e4ec6..90e3623f5a 100644
--- a/apps/dashboard/app/public/public-dashboard-constants.ts
+++ b/apps/dashboard/app/public/public-dashboard-constants.ts
@@ -1,3 +1,2 @@
-/** Marketing site URL with ref for public dashboard header, footer CTA, and outbound links. */
export const publicDashboardMarketingHref =
"https://www.databuddy.cc?ref=public-dashboard" as const;
diff --git a/apps/dashboard/components/agent/agent-credit-balance.tsx b/apps/dashboard/components/agent/agent-credit-balance.tsx
index a7e67eaafc..a3a7c5d41e 100644
--- a/apps/dashboard/components/agent/agent-credit-balance.tsx
+++ b/apps/dashboard/components/agent/agent-credit-balance.tsx
@@ -30,7 +30,9 @@ export function AgentCreditBalance({
const prevStatusRef = useRef(status);
const refetchRef = useRef(refetch);
- refetchRef.current = refetch;
+ useEffect(() => {
+ refetchRef.current = refetch;
+ }, [refetch]);
useEffect(() => {
const prev = prevStatusRef.current;
@@ -87,7 +89,7 @@ export function AgentCreditBalance({
content={
isEmpty
? "Your investigation credit balance is empty. Open billing to add credits or change plans."
- : `${balance.toLocaleString()} of ${limit.toLocaleString()} investigation credits remain this month. Deeper investigations, replies, and rechecks use more.`
+ : `${balance.toLocaleString()} of ${limit.toLocaleString()} investigation credits remaining. Deeper investigations, replies, and rechecks use more.`
}
>
(null);
const replayFrameRef = useRef(null);
const inputSyncRef = useRef(input);
- inputSyncRef.current = input;
+ useEffect(() => {
+ inputSyncRef.current = input;
+ }, [input]);
const cancelPlaceholderReplay = useCallback(() => {
if (replayFrameRef.current === null) {
diff --git a/apps/dashboard/components/agent/agent-text-switch.tsx b/apps/dashboard/components/agent/agent-text-switch.tsx
index dec03d1f9c..81ca5734b5 100644
--- a/apps/dashboard/components/agent/agent-text-switch.tsx
+++ b/apps/dashboard/components/agent/agent-text-switch.tsx
@@ -109,10 +109,12 @@ function DualStaggerStack({
const layerDoneRef = useRef({ bottom: false, top: false });
const previousFlipRef = useRef(false);
- if (flip && !previousFlipRef.current) {
- layerDoneRef.current = { bottom: false, top: false };
- }
- previousFlipRef.current = flip;
+ useEffect(() => {
+ if (flip && !previousFlipRef.current) {
+ layerDoneRef.current = { bottom: false, top: false };
+ }
+ previousFlipRef.current = flip;
+ }, [flip]);
const spring = stagger ? SPRING : SPRING_NO_STAGGER;
@@ -263,11 +265,7 @@ export function AgentTextSwitch({
}, [active, clearHold]);
useEffect(() => {
- if (!(active && inView)) {
- clearHold();
- return;
- }
- if (flip) {
+ if (!(active && inView) || flip) {
clearHold();
} else {
scheduleHold();
diff --git a/apps/dashboard/components/agent/hooks/use-agent-chat.ts b/apps/dashboard/components/agent/hooks/use-agent-chat.ts
index 8439d4ea52..9884562c76 100644
--- a/apps/dashboard/components/agent/hooks/use-agent-chat.ts
+++ b/apps/dashboard/components/agent/hooks/use-agent-chat.ts
@@ -4,7 +4,7 @@ import { publicConfig } from "@databuddy/env/public";
import { DefaultChatTransport, type UIMessage } from "ai";
import { useAtomValue } from "jotai";
-import { useMemo, useRef } from "react";
+import { useEffect, useMemo, useRef } from "react";
import { normalizeAIComponentMessages } from "@/lib/ai-components/message-parts";
import {
agentMentionsAtom,
@@ -25,9 +25,11 @@ export function useAgentChatTransport(
const thinkingRef = useRef(thinking);
const tierRef = useRef(tier);
const mentionsRef = useRef(mentions);
- thinkingRef.current = thinking;
- tierRef.current = tier;
- mentionsRef.current = mentions;
+ useEffect(() => {
+ thinkingRef.current = thinking;
+ tierRef.current = tier;
+ mentionsRef.current = mentions;
+ }, [thinking, tier, mentions]);
return useMemo(
() =>
diff --git a/apps/dashboard/components/ai-elements/ai-component.tsx b/apps/dashboard/components/ai-elements/ai-component.tsx
index 84c46ee580..8f97b91f87 100644
--- a/apps/dashboard/components/ai-elements/ai-component.tsx
+++ b/apps/dashboard/components/ai-elements/ai-component.tsx
@@ -61,12 +61,6 @@ interface AIComponentProps {
input: RawComponentInput;
streaming?: boolean;
}
-
-/**
- * Renders an AI-generated component based on its type.
- * During streaming, skips strict validation and shows a skeleton
- * if the data is too incomplete to render.
- */
export function AIComponent({ input, className, streaming }: AIComponentProps) {
if (!hasComponent(input.type)) {
return null;
diff --git a/apps/dashboard/components/ai-elements/canvas.tsx b/apps/dashboard/components/ai-elements/canvas.tsx
deleted file mode 100644
index 05e66d648a..0000000000
--- a/apps/dashboard/components/ai-elements/canvas.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import { Background, ReactFlow, type ReactFlowProps } from "@xyflow/react";
-import type { ReactNode } from "react";
-import "@xyflow/react/dist/style.css";
-
-type CanvasProps = ReactFlowProps & {
- children?: ReactNode;
-};
-
-export const Canvas = ({ children, ...props }: CanvasProps) => (
-
-
- {children}
-
-);
diff --git a/apps/dashboard/components/ai-elements/chain-of-thought.tsx b/apps/dashboard/components/ai-elements/chain-of-thought.tsx
deleted file mode 100644
index 23a68f6065..0000000000
--- a/apps/dashboard/components/ai-elements/chain-of-thought.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-"use client";
-
-import type { ComponentProps, ReactNode } from "react";
-import { memo } from "react";
-import { cn } from "@/lib/utils";
-import { CheckCircleIcon, CircleNotchIcon } from "@databuddy/ui/icons";
-
-export type ToolStepProps = ComponentProps<"div"> & {
- label: ReactNode;
- status?: "complete" | "active";
-};
-
-export const ToolStep = memo(
- ({ className, label, status = "complete", ...props }: ToolStepProps) => (
-
- {status === "complete" ? (
-
- ) : (
-
- )}
- {label}
-
- )
-);
-
-ToolStep.displayName = "ToolStep";
diff --git a/apps/dashboard/components/ai-elements/code-block.tsx b/apps/dashboard/components/ai-elements/code-block.tsx
index ddd6a50e8c..7aa4bb69e8 100644
--- a/apps/dashboard/components/ai-elements/code-block.tsx
+++ b/apps/dashboard/components/ai-elements/code-block.tsx
@@ -49,7 +49,7 @@ const lineNumberTransformer: ShikiTransformer = {
},
};
-export async function highlightCode(
+async function highlightCode(
code: string,
language: BundledLanguage,
showLineNumbers = false
diff --git a/apps/dashboard/components/ai-elements/connection.tsx b/apps/dashboard/components/ai-elements/connection.tsx
deleted file mode 100644
index 99a58bc512..0000000000
--- a/apps/dashboard/components/ai-elements/connection.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-import type { ConnectionLineComponent } from "@xyflow/react";
-
-const HALF = 0.5;
-
-export const Connection: ConnectionLineComponent = ({
- fromX,
- fromY,
- toX,
- toY,
-}) => (
-
-
-
-
-);
diff --git a/apps/dashboard/components/ai-elements/context.tsx b/apps/dashboard/components/ai-elements/context.tsx
deleted file mode 100644
index eeaf3d7c30..0000000000
--- a/apps/dashboard/components/ai-elements/context.tsx
+++ /dev/null
@@ -1,422 +0,0 @@
-"use client";
-
-import {
- HoverCard,
- HoverCardContent,
- HoverCardTrigger,
-} from "@/components/ui/hover-card";
-import { cn } from "@/lib/utils";
-import type { LanguageModelUsage } from "ai";
-import { type ComponentProps, createContext, useContext } from "react";
-import type { SourceModel } from "tokenlens";
-import { computeTokenCostsForModel } from "tokenlens/helpers";
-import { vercelModels } from "tokenlens/providers/vercel";
-import { Button, Progress } from "@databuddy/ui";
-
-const lookupModel = (modelId: string): SourceModel | undefined => {
- const model =
- vercelModels.models[modelId as keyof typeof vercelModels.models];
- return model
- ? ({ canonical_id: model.id, ...model } satisfies SourceModel)
- : undefined;
-};
-
-const PERCENT_MAX = 100;
-const ICON_RADIUS = 10;
-const ICON_VIEWBOX = 24;
-const ICON_CENTER = 12;
-const ICON_STROKE_WIDTH = 2;
-
-type ModelId = string;
-
-interface ContextSchema {
- maxTokens: number;
- modelId?: ModelId;
- usage?: LanguageModelUsage;
- usedTokens: number;
-}
-
-const ContextContext = createContext(null);
-
-const useContextValue = () => {
- const context = useContext(ContextContext);
-
- if (!context) {
- throw new Error("Context components must be used within Context");
- }
-
- return context;
-};
-
-export type ContextProps = ComponentProps & ContextSchema;
-
-export const Context = ({
- usedTokens,
- maxTokens,
- usage,
- modelId,
- ...props
-}: ContextProps) => (
-
-
-
-);
-
-const ContextIcon = () => {
- const { usedTokens, maxTokens } = useContextValue();
- const circumference = 2 * Math.PI * ICON_RADIUS;
- const usedPercent = usedTokens / maxTokens;
- const dashOffset = circumference * (1 - usedPercent);
-
- return (
-
-
-
-
- );
-};
-
-export type ContextTriggerProps = ComponentProps;
-
-export const ContextTrigger = ({ children, ...props }: ContextTriggerProps) => {
- const { usedTokens, maxTokens } = useContextValue();
- const usedPercent = usedTokens / maxTokens;
- const renderedPercent = new Intl.NumberFormat("en-US", {
- style: "percent",
- maximumFractionDigits: 1,
- }).format(usedPercent);
-
- return (
-
- {children ?? (
-
-
- {renderedPercent}
-
-
-
- )}
-
- );
-};
-
-export type ContextContentProps = ComponentProps;
-
-export const ContextContent = ({
- className,
- ...props
-}: ContextContentProps) => (
-
-);
-
-export type ContextContentHeaderProps = ComponentProps<"div">;
-
-export const ContextContentHeader = ({
- children,
- className,
- ...props
-}: ContextContentHeaderProps) => {
- const { usedTokens, maxTokens } = useContextValue();
- const usedPercent = usedTokens / maxTokens;
- const displayPct = new Intl.NumberFormat("en-US", {
- style: "percent",
- maximumFractionDigits: 1,
- }).format(usedPercent);
- const used = new Intl.NumberFormat("en-US", {
- notation: "compact",
- }).format(usedTokens);
- const total = new Intl.NumberFormat("en-US", {
- notation: "compact",
- }).format(maxTokens);
-
- return (
-
- {children ?? (
- <>
-
-
{displayPct}
-
- {used} / {total}
-
-
-
- >
- )}
-
- );
-};
-
-export type ContextContentBodyProps = ComponentProps<"div">;
-
-export const ContextContentBody = ({
- children,
- className,
- ...props
-}: ContextContentBodyProps) => (
-
- {children}
-
-);
-
-export type ContextContentFooterProps = ComponentProps<"div">;
-
-export const ContextContentFooter = ({
- children,
- className,
- ...props
-}: ContextContentFooterProps) => {
- const { modelId, usage } = useContextValue();
- const model = modelId ? lookupModel(modelId) : undefined;
- const costUSD = model
- ? computeTokenCostsForModel({
- model,
- usage: {
- input_tokens: usage?.inputTokens ?? 0,
- output_tokens: usage?.outputTokens ?? 0,
- },
- }).totalTokenCostUSD
- : undefined;
- const totalCost = new Intl.NumberFormat("en-US", {
- style: "currency",
- currency: "USD",
- }).format(costUSD ?? 0);
-
- return (
-
- {children ?? (
- <>
- Total cost
- {totalCost}
- >
- )}
-
- );
-};
-
-export type ContextInputUsageProps = ComponentProps<"div">;
-
-export const ContextInputUsage = ({
- className,
- children,
- ...props
-}: ContextInputUsageProps) => {
- const { usage, modelId } = useContextValue();
- const inputTokens = usage?.inputTokens ?? 0;
-
- if (children) {
- return children;
- }
-
- if (!inputTokens) {
- return null;
- }
-
- const inputModel = modelId ? lookupModel(modelId) : undefined;
- const inputCost = inputModel
- ? computeTokenCostsForModel({
- model: inputModel,
- usage: { input_tokens: inputTokens, output_tokens: 0 },
- }).totalTokenCostUSD
- : undefined;
- const inputCostText = new Intl.NumberFormat("en-US", {
- style: "currency",
- currency: "USD",
- }).format(inputCost ?? 0);
-
- return (
-
- Input
-
-
- );
-};
-
-export type ContextOutputUsageProps = ComponentProps<"div">;
-
-export const ContextOutputUsage = ({
- className,
- children,
- ...props
-}: ContextOutputUsageProps) => {
- const { usage, modelId } = useContextValue();
- const outputTokens = usage?.outputTokens ?? 0;
-
- if (children) {
- return children;
- }
-
- if (!outputTokens) {
- return null;
- }
-
- const outputModel = modelId ? lookupModel(modelId) : undefined;
- const outputCost = outputModel
- ? computeTokenCostsForModel({
- model: outputModel,
- usage: { input_tokens: 0, output_tokens: outputTokens },
- }).totalTokenCostUSD
- : undefined;
- const outputCostText = new Intl.NumberFormat("en-US", {
- style: "currency",
- currency: "USD",
- }).format(outputCost ?? 0);
-
- return (
-
- Output
-
-
- );
-};
-
-export type ContextReasoningUsageProps = ComponentProps<"div">;
-
-export const ContextReasoningUsage = ({
- className,
- children,
- ...props
-}: ContextReasoningUsageProps) => {
- const { usage, modelId } = useContextValue();
- const reasoningTokens = usage?.reasoningTokens ?? 0;
-
- if (children) {
- return children;
- }
-
- if (!reasoningTokens) {
- return null;
- }
-
- const reasoningModel = modelId ? lookupModel(modelId) : undefined;
- const reasoningCost = reasoningModel
- ? computeTokenCostsForModel({
- model: reasoningModel,
- usage: { reasoning_tokens: reasoningTokens },
- }).totalTokenCostUSD
- : undefined;
- const reasoningCostText = new Intl.NumberFormat("en-US", {
- style: "currency",
- currency: "USD",
- }).format(reasoningCost ?? 0);
-
- return (
-
- Reasoning
-
-
- );
-};
-
-export type ContextCacheUsageProps = ComponentProps<"div">;
-
-export const ContextCacheUsage = ({
- className,
- children,
- ...props
-}: ContextCacheUsageProps) => {
- const { usage, modelId } = useContextValue();
- const cacheTokens = usage?.cachedInputTokens ?? 0;
-
- if (children) {
- return children;
- }
-
- if (!cacheTokens) {
- return null;
- }
-
- const cacheModel = modelId ? lookupModel(modelId) : undefined;
- const cacheCost = cacheModel
- ? computeTokenCostsForModel({
- model: cacheModel,
- usage: { cache_read_tokens: cacheTokens },
- }).totalTokenCostUSD
- : undefined;
- const cacheCostText = new Intl.NumberFormat("en-US", {
- style: "currency",
- currency: "USD",
- }).format(cacheCost ?? 0);
-
- return (
-
- Cache
-
-
- );
-};
-
-const TokensWithCost = ({
- tokens,
- costText,
-}: {
- tokens?: number;
- costText?: string;
-}) => (
-
- {tokens === undefined
- ? "—"
- : new Intl.NumberFormat("en-US", {
- notation: "compact",
- }).format(tokens)}
- {costText ? (
- • {costText}
- ) : null}
-
-);
diff --git a/apps/dashboard/components/ai-elements/controls.tsx b/apps/dashboard/components/ai-elements/controls.tsx
deleted file mode 100644
index dec68eb229..0000000000
--- a/apps/dashboard/components/ai-elements/controls.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-"use client";
-
-import { Controls as ControlsPrimitive } from "@xyflow/react";
-import type { ComponentProps } from "react";
-import { cn } from "@/lib/utils";
-
-export type ControlsProps = ComponentProps;
-
-export const Controls = ({ className, ...props }: ControlsProps) => (
- button]:rounded-md [&>button]:border-none! [&>button]:bg-transparent! [&>button]:hover:bg-secondary!",
- className
- )}
- {...props}
- />
-);
diff --git a/apps/dashboard/components/ai-elements/conversation.tsx b/apps/dashboard/components/ai-elements/conversation.tsx
index fc606ffa50..3c9db12250 100644
--- a/apps/dashboard/components/ai-elements/conversation.tsx
+++ b/apps/dashboard/components/ai-elements/conversation.tsx
@@ -33,41 +33,6 @@ export const ConversationContent = ({
/>
);
-export type ConversationEmptyStateProps = ComponentProps<"div"> & {
- title?: string;
- description?: string;
- icon?: React.ReactNode;
-};
-
-export const ConversationEmptyState = ({
- className,
- title = "No messages yet",
- description = "Start a conversation to see messages here",
- icon,
- children,
- ...props
-}: ConversationEmptyStateProps) => (
-
- {children ?? (
- <>
- {icon &&
{icon}
}
-
-
{title}
- {description && (
-
{description}
- )}
-
- >
- )}
-
-);
-
export type ConversationScrollButtonProps = ComponentProps;
export const ConversationScrollButton = ({
diff --git a/apps/dashboard/components/ai-elements/edge.tsx b/apps/dashboard/components/ai-elements/edge.tsx
deleted file mode 100644
index 15fa15108e..0000000000
--- a/apps/dashboard/components/ai-elements/edge.tsx
+++ /dev/null
@@ -1,140 +0,0 @@
-import {
- BaseEdge,
- type EdgeProps,
- getBezierPath,
- getSimpleBezierPath,
- type InternalNode,
- type Node,
- Position,
- useInternalNode,
-} from "@xyflow/react";
-
-const Temporary = ({
- id,
- sourceX,
- sourceY,
- targetX,
- targetY,
- sourcePosition,
- targetPosition,
-}: EdgeProps) => {
- const [edgePath] = getSimpleBezierPath({
- sourceX,
- sourceY,
- sourcePosition,
- targetX,
- targetY,
- targetPosition,
- });
-
- return (
-
- );
-};
-
-const getHandleCoordsByPosition = (
- node: InternalNode,
- handlePosition: Position
-) => {
- // Choose the handle type based on position - Left is for target, Right is for source
- const handleType = handlePosition === Position.Left ? "target" : "source";
-
- const handle = node.internals.handleBounds?.[handleType]?.find(
- (h) => h.position === handlePosition
- );
-
- if (!handle) {
- return [0, 0] as const;
- }
-
- let offsetX = handle.width / 2;
- let offsetY = handle.height / 2;
-
- // this is a tiny detail to make the markerEnd of an edge visible.
- // The handle position that gets calculated has the origin top-left, so depending which side we are using, we add a little offset
- // when the handlePosition is Position.Right for example, we need to add an offset as big as the handle itself in order to get the correct position
- switch (handlePosition) {
- case Position.Left:
- offsetX = 0;
- break;
- case Position.Right:
- offsetX = handle.width;
- break;
- case Position.Top:
- offsetY = 0;
- break;
- case Position.Bottom:
- offsetY = handle.height;
- break;
- default:
- throw new Error(`Invalid handle position: ${handlePosition}`);
- }
-
- const x = node.internals.positionAbsolute.x + handle.x + offsetX;
- const y = node.internals.positionAbsolute.y + handle.y + offsetY;
-
- return [x, y] as const;
-};
-
-const getEdgeParams = (
- source: InternalNode,
- target: InternalNode
-) => {
- const sourcePos = Position.Right;
- const [sx, sy] = getHandleCoordsByPosition(source, sourcePos);
- const targetPos = Position.Left;
- const [tx, ty] = getHandleCoordsByPosition(target, targetPos);
-
- return {
- sx,
- sy,
- tx,
- ty,
- sourcePos,
- targetPos,
- };
-};
-
-const Animated = ({ id, source, target, markerEnd, style }: EdgeProps) => {
- const sourceNode = useInternalNode(source);
- const targetNode = useInternalNode(target);
-
- if (!(sourceNode && targetNode)) {
- return null;
- }
-
- const { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParams(
- sourceNode,
- targetNode
- );
-
- const [edgePath] = getBezierPath({
- sourceX: sx,
- sourceY: sy,
- sourcePosition: sourcePos,
- targetX: tx,
- targetY: ty,
- targetPosition: targetPos,
- });
-
- return (
- <>
-
-
-
-
- >
- );
-};
-
-export const Edge = {
- Temporary,
- Animated,
-};
diff --git a/apps/dashboard/components/ai-elements/image.tsx b/apps/dashboard/components/ai-elements/image.tsx
deleted file mode 100644
index 6c5850c791..0000000000
--- a/apps/dashboard/components/ai-elements/image.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import type { Experimental_GeneratedImage } from "ai";
-import { cn } from "@/lib/utils";
-
-export type ImageProps = Experimental_GeneratedImage & {
- alt?: string;
- className?: string;
- height?: number;
- width?: number;
-};
-
-export const Image = ({
- base64,
- mediaType,
- uint8Array,
- height = 256,
- width = 256,
- ...props
-}: ImageProps) => (
-
-);
diff --git a/apps/dashboard/components/ai-elements/loader.tsx b/apps/dashboard/components/ai-elements/loader.tsx
deleted file mode 100644
index 8f872b2d57..0000000000
--- a/apps/dashboard/components/ai-elements/loader.tsx
+++ /dev/null
@@ -1,96 +0,0 @@
-import type { HTMLAttributes } from "react";
-import { cn } from "@/lib/utils";
-
-interface LoaderIconProps {
- size?: number;
-}
-
-const LoaderIcon = ({ size = 16 }: LoaderIconProps) => (
-
- Loader
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-);
-
-export type LoaderProps = HTMLAttributes & {
- size?: number;
-};
-
-export const Loader = ({ className, size = 16, ...props }: LoaderProps) => (
-
-
-
-);
diff --git a/apps/dashboard/components/ai-elements/message.tsx b/apps/dashboard/components/ai-elements/message.tsx
index 76caca0fc3..3d3a658e28 100644
--- a/apps/dashboard/components/ai-elements/message.tsx
+++ b/apps/dashboard/components/ai-elements/message.tsx
@@ -1,8 +1,8 @@
"use client";
-import type { FileUIPart, UIMessage } from "ai";
-import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
-import { createContext, memo, useContext, useEffect, useState } from "react";
+import type { UIMessage } from "ai";
+import type { ComponentProps, HTMLAttributes } from "react";
+import { memo } from "react";
import {
Streamdown,
TableCopyDropdown,
@@ -15,15 +15,7 @@ import {
MdTh,
MdThead,
} from "@/components/ai-elements/markdown-table";
-import { ButtonGroup, ButtonGroupText } from "@/components/ui/button-group";
import { cn } from "@/lib/utils";
-import {
- CaretLeftIcon,
- CaretRightIcon,
- PaperclipIcon,
- XMarkIcon as XIcon,
-} from "@databuddy/ui/icons";
-import { Button, Tooltip } from "@databuddy/ui";
export type MessageProps = HTMLAttributes & {
from: UIMessage["role"];
@@ -59,244 +51,6 @@ export const MessageContent = ({
{children}
);
-
-export type MessageActionsProps = ComponentProps<"div">;
-
-export const MessageActions = ({
- className,
- children,
- ...props
-}: MessageActionsProps) => (
-
- {children}
-
-);
-
-export type MessageActionProps = ComponentProps & {
- tooltip?: string;
- label?: string;
-};
-
-export const MessageAction = ({
- tooltip,
- children,
- label,
- variant = "ghost",
- size = "icon",
- ...props
-}: MessageActionProps) => {
- const button = (
-
- {children}
- {label || tooltip}
-
- );
-
- if (tooltip) {
- return {tooltip}
}>{button};
- }
-
- return button;
-};
-
-interface MessageBranchContextType {
- branches: ReactElement[];
- currentBranch: number;
- goToNext: () => void;
- goToPrevious: () => void;
- setBranches: (branches: ReactElement[]) => void;
- totalBranches: number;
-}
-
-const MessageBranchContext = createContext(
- null
-);
-
-const useMessageBranch = () => {
- const context = useContext(MessageBranchContext);
-
- if (!context) {
- throw new Error(
- "MessageBranch components must be used within MessageBranch"
- );
- }
-
- return context;
-};
-
-export type MessageBranchProps = HTMLAttributes & {
- defaultBranch?: number;
- onBranchChange?: (branchIndex: number) => void;
-};
-
-export const MessageBranch = ({
- defaultBranch = 0,
- onBranchChange,
- className,
- ...props
-}: MessageBranchProps) => {
- const [currentBranch, setCurrentBranch] = useState(defaultBranch);
- const [branches, setBranches] = useState([]);
-
- const handleBranchChange = (newBranch: number) => {
- setCurrentBranch(newBranch);
- onBranchChange?.(newBranch);
- };
-
- const goToPrevious = () => {
- const newBranch =
- currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
- handleBranchChange(newBranch);
- };
-
- const goToNext = () => {
- const newBranch =
- currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
- handleBranchChange(newBranch);
- };
-
- const contextValue: MessageBranchContextType = {
- currentBranch,
- totalBranches: branches.length,
- goToPrevious,
- goToNext,
- branches,
- setBranches,
- };
-
- return (
-
- div]:pb-0", className)}
- {...props}
- />
-
- );
-};
-
-export type MessageBranchContentProps = HTMLAttributes
;
-
-export const MessageBranchContent = ({
- children,
- ...props
-}: MessageBranchContentProps) => {
- const { currentBranch, setBranches, branches } = useMessageBranch();
- const childrenArray = Array.isArray(children) ? children : [children];
-
- // Use useEffect to update branches when they change
- useEffect(() => {
- if (branches.length !== childrenArray.length) {
- setBranches(childrenArray);
- }
- }, [childrenArray, branches, setBranches]);
-
- return childrenArray.map((branch, index) => (
- div]:pb-0",
- index === currentBranch ? "block" : "hidden"
- )}
- key={branch.key}
- {...props}
- >
- {branch}
-
- ));
-};
-
-export type MessageBranchSelectorProps = HTMLAttributes & {
- from: UIMessage["role"];
-};
-
-export const MessageBranchSelector = ({
- className,
- from,
- ...props
-}: MessageBranchSelectorProps) => {
- const { totalBranches } = useMessageBranch();
-
- // Don't render if there's only one branch
- if (totalBranches <= 1) {
- return null;
- }
-
- return (
-
- );
-};
-
-export type MessageBranchPreviousProps = ComponentProps;
-
-export const MessageBranchPrevious = ({
- children,
- ...props
-}: MessageBranchPreviousProps) => {
- const { goToPrevious, totalBranches } = useMessageBranch();
-
- return (
-
- {children ?? }
-
- );
-};
-
-export type MessageBranchNextProps = ComponentProps;
-
-export const MessageBranchNext = ({
- children,
- className,
- ...props
-}: MessageBranchNextProps) => {
- const { goToNext, totalBranches } = useMessageBranch();
-
- return (
-
- {children ?? }
-
- );
-};
-
-export type MessageBranchPageProps = HTMLAttributes;
-
-export const MessageBranchPage = ({
- className,
- ...props
-}: MessageBranchPageProps) => {
- const { currentBranch, totalBranches } = useMessageBranch();
-
- return (
-
- {currentBranch + 1} of {totalBranches}
-
- );
-};
-
export type MessageResponseProps = ComponentProps;
const TABLE_COMPONENTS = {
@@ -333,124 +87,3 @@ export const MessageResponse = memo(
);
MessageResponse.displayName = "MessageResponse";
-
-export type MessageAttachmentProps = HTMLAttributes & {
- data: FileUIPart;
- className?: string;
- onRemove?: () => void;
-};
-
-export function MessageAttachment({
- data,
- className,
- onRemove,
- ...props
-}: MessageAttachmentProps) {
- const filename = data.filename || "";
- const mediaType =
- data.mediaType?.startsWith("image/") && data.url ? "image" : "file";
- const isImage = mediaType === "image";
- const attachmentLabel = filename || (isImage ? "Image" : "Attachment");
-
- return (
-
- {isImage ? (
- <>
-
- {onRemove && (
-
{
- e.stopPropagation();
- onRemove();
- }}
- type="button"
- variant="ghost"
- >
-
- Remove
-
- )}
- >
- ) : (
- <>
-
{attachmentLabel}}>
-
-
- {onRemove && (
-
{
- e.stopPropagation();
- onRemove();
- }}
- type="button"
- variant="ghost"
- >
-
- Remove
-
- )}
- >
- )}
-
- );
-}
-
-export type MessageAttachmentsProps = ComponentProps<"div">;
-
-export function MessageAttachments({
- children,
- className,
- ...props
-}: MessageAttachmentsProps) {
- if (!children) {
- return null;
- }
-
- return (
-
- {children}
-
- );
-}
-
-export type MessageToolbarProps = ComponentProps<"div">;
-
-export const MessageToolbar = ({
- className,
- children,
- ...props
-}: MessageToolbarProps) => (
-
- {children}
-
-);
diff --git a/apps/dashboard/components/ai-elements/model-selector.tsx b/apps/dashboard/components/ai-elements/model-selector.tsx
deleted file mode 100644
index 9d38947318..0000000000
--- a/apps/dashboard/components/ai-elements/model-selector.tsx
+++ /dev/null
@@ -1,204 +0,0 @@
-import type { ComponentProps, ReactNode } from "react";
-import {
- Command,
- CommandDialog,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandItem,
- CommandList,
- CommandSeparator,
- CommandShortcut,
-} from "@/components/ui/command";
-import { cn } from "@/lib/utils";
-import { Dialog } from "@databuddy/ui/client";
-
-export type ModelSelectorProps = ComponentProps;
-
-export const ModelSelector = (props: ModelSelectorProps) => (
-
-);
-
-export type ModelSelectorTriggerProps = ComponentProps;
-
-export const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => (
-
-);
-
-export type ModelSelectorContentProps = ComponentProps<
- typeof Dialog.Content
-> & {
- title?: ReactNode;
-};
-
-export const ModelSelectorContent = ({
- className,
- children,
- title = "Model Selector",
- ...props
-}: ModelSelectorContentProps) => (
-
- {title}
-
-
- {children}
-
-
-
-);
-
-export type ModelSelectorDialogProps = ComponentProps;
-
-export const ModelSelectorDialog = (props: ModelSelectorDialogProps) => (
-
-);
-
-export type ModelSelectorInputProps = ComponentProps;
-
-export const ModelSelectorInput = ({
- className,
- ...props
-}: ModelSelectorInputProps) => (
-
-);
-
-export type ModelSelectorListProps = ComponentProps;
-
-export const ModelSelectorList = (props: ModelSelectorListProps) => (
-
-);
-
-export type ModelSelectorEmptyProps = ComponentProps;
-
-export const ModelSelectorEmpty = (props: ModelSelectorEmptyProps) => (
-
-);
-
-export type ModelSelectorGroupProps = ComponentProps;
-
-export const ModelSelectorGroup = (props: ModelSelectorGroupProps) => (
-
-);
-
-export type ModelSelectorItemProps = ComponentProps;
-
-export const ModelSelectorItem = (props: ModelSelectorItemProps) => (
-
-);
-
-export type ModelSelectorShortcutProps = ComponentProps;
-
-export const ModelSelectorShortcut = (props: ModelSelectorShortcutProps) => (
-
-);
-
-export type ModelSelectorSeparatorProps = ComponentProps<
- typeof CommandSeparator
->;
-
-export const ModelSelectorSeparator = (props: ModelSelectorSeparatorProps) => (
-
-);
-
-export type ModelSelectorLogoProps = Omit<
- ComponentProps<"img">,
- "src" | "alt"
-> & {
- provider:
- | "moonshotai-cn"
- | "lucidquery"
- | "moonshotai"
- | "zai-coding-plan"
- | "alibaba"
- | "xai"
- | "vultr"
- | "nvidia"
- | "upstage"
- | "groq"
- | "github-copilot"
- | "mistral"
- | "vercel"
- | "nebius"
- | "deepseek"
- | "alibaba-cn"
- | "google-vertex-anthropic"
- | "venice"
- | "chutes"
- | "cortecs"
- | "github-models"
- | "togetherai"
- | "azure"
- | "baseten"
- | "huggingface"
- | "opencode"
- | "fastrouter"
- | "google"
- | "google-vertex"
- | "cloudflare-workers-ai"
- | "inception"
- | "wandb"
- | "openai"
- | "zhipuai-coding-plan"
- | "perplexity"
- | "openrouter"
- | "zenmux"
- | "v0"
- | "iflowcn"
- | "synthetic"
- | "deepinfra"
- | "zhipuai"
- | "submodel"
- | "zai"
- | "inference"
- | "requesty"
- | "morph"
- | "lmstudio"
- | "anthropic"
- | "aihubmix"
- | "fireworks-ai"
- | "modelscope"
- | "llama"
- | "scaleway"
- | "amazon-bedrock"
- | "cerebras"
- | (string & {});
-};
-
-export const ModelSelectorLogo = ({
- provider,
- className,
- ...props
-}: ModelSelectorLogoProps) => (
-
-);
-
-export type ModelSelectorLogoGroupProps = ComponentProps<"div">;
-
-export const ModelSelectorLogoGroup = ({
- className,
- ...props
-}: ModelSelectorLogoGroupProps) => (
- img]:rounded-full [&>img]:bg-background [&>img]:p-px [&>img]:ring-1 dark:[&>img]:bg-foreground",
- className
- )}
- {...props}
- />
-);
-
-export type ModelSelectorNameProps = ComponentProps<"span">;
-
-export const ModelSelectorName = ({
- className,
- ...props
-}: ModelSelectorNameProps) => (
-
-);
diff --git a/apps/dashboard/components/ai-elements/node.tsx b/apps/dashboard/components/ai-elements/node.tsx
deleted file mode 100644
index f1c467e3a9..0000000000
--- a/apps/dashboard/components/ai-elements/node.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-import { Handle, Position } from "@xyflow/react";
-import type { ComponentProps } from "react";
-import { cn } from "@/lib/utils";
-import { Card } from "@databuddy/ui";
-
-export type NodeProps = ComponentProps
& {
- handles: {
- target: boolean;
- source: boolean;
- };
-};
-
-export const Node = ({ handles, className, ...props }: NodeProps) => (
-
- {handles.target && }
- {handles.source && }
- {props.children}
-
-);
-
-export type NodeHeaderProps = ComponentProps;
-
-export const NodeHeader = ({ className, ...props }: NodeHeaderProps) => (
-
-);
-
-export type NodeTitleProps = ComponentProps;
-
-export const NodeTitle = (props: NodeTitleProps) => ;
-
-export type NodeDescriptionProps = ComponentProps;
-
-export const NodeDescription = (props: NodeDescriptionProps) => (
-
-);
-
-export type NodeActionProps = ComponentProps;
-
-export const NodeAction = (props: NodeActionProps) => (
-
-);
-
-export type NodeContentProps = ComponentProps;
-
-export const NodeContent = ({ className, ...props }: NodeContentProps) => (
-
-);
-
-export type NodeFooterProps = ComponentProps;
-
-export const NodeFooter = ({ className, ...props }: NodeFooterProps) => (
-
-);
diff --git a/apps/dashboard/components/ai-elements/panel.tsx b/apps/dashboard/components/ai-elements/panel.tsx
deleted file mode 100644
index 5c769a5465..0000000000
--- a/apps/dashboard/components/ai-elements/panel.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-import { Panel as PanelPrimitive } from "@xyflow/react";
-import type { ComponentProps } from "react";
-import { cn } from "@/lib/utils";
-
-type PanelProps = ComponentProps;
-
-export const Panel = ({ className, ...props }: PanelProps) => (
-
-);
diff --git a/apps/dashboard/components/ai-elements/reasoning.tsx b/apps/dashboard/components/ai-elements/reasoning.tsx
index fbd966a89a..d9eb8adab8 100644
--- a/apps/dashboard/components/ai-elements/reasoning.tsx
+++ b/apps/dashboard/components/ai-elements/reasoning.tsx
@@ -27,7 +27,7 @@ interface ReasoningContextValue {
const ReasoningContext = createContext(null);
-export const useReasoning = () => {
+const useReasoning = () => {
const context = useContext(ReasoningContext);
if (!context) {
throw new Error("Reasoning components must be used within Reasoning");
diff --git a/apps/dashboard/components/ai-elements/suggestion.tsx b/apps/dashboard/components/ai-elements/suggestion.tsx
deleted file mode 100644
index e8b82fc9ad..0000000000
--- a/apps/dashboard/components/ai-elements/suggestion.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-"use client";
-
-import type { ComponentProps } from "react";
-import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
-import { cn } from "@/lib/utils";
-import { Button } from "@databuddy/ui";
-
-export type SuggestionsProps = ComponentProps;
-
-export const Suggestions = ({
- className,
- children,
- ...props
-}: SuggestionsProps) => (
-
-
- {children}
-
-
-
-);
-
-export type SuggestionProps = Omit, "onClick"> & {
- suggestion: string;
- onClick?: (suggestion: string) => void;
-};
-
-export const Suggestion = ({
- suggestion,
- onClick,
- className,
- variant = "secondary",
- size = "sm",
- children,
- ...props
-}: SuggestionProps) => {
- const handleClick = () => {
- onClick?.(suggestion);
- };
-
- return (
-
- {children || suggestion}
-
- );
-};
diff --git a/apps/dashboard/components/ai-elements/toolbar.tsx b/apps/dashboard/components/ai-elements/toolbar.tsx
deleted file mode 100644
index 3b4fbf0aea..0000000000
--- a/apps/dashboard/components/ai-elements/toolbar.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-import { NodeToolbar, Position } from "@xyflow/react";
-import type { ComponentProps } from "react";
-import { cn } from "@/lib/utils";
-
-type ToolbarProps = ComponentProps;
-
-export const Toolbar = ({ className, ...props }: ToolbarProps) => (
-
-);
diff --git a/apps/dashboard/components/analytics/index.tsx b/apps/dashboard/components/analytics/index.tsx
index 7921cc02f1..f975e4b9d1 100644
--- a/apps/dashboard/components/analytics/index.tsx
+++ b/apps/dashboard/components/analytics/index.tsx
@@ -2,8 +2,6 @@
export { DeviceTypeCell } from "./device-type-cell";
export { EventLimitIndicator } from "./event-limit-indicator";
-export { FaviconImage } from "./favicon-image";
export { LiveUserIndicator } from "./live-user-indicator";
-export { MapComponent } from "./map-component";
export { StatCard } from "./stat-card";
export { UnauthorizedAccessError } from "./unauthorized-access-error";
diff --git a/apps/dashboard/components/analytics/live-user-indicator.tsx b/apps/dashboard/components/analytics/live-user-indicator.tsx
index 18bc97f496..d6b782ac53 100644
--- a/apps/dashboard/components/analytics/live-user-indicator.tsx
+++ b/apps/dashboard/components/analytics/live-user-indicator.tsx
@@ -30,6 +30,12 @@ export function LiveUserIndicator({ websiteId }: LiveUserIndicatorProps) {
}
prevCountRef.current = count;
+
+ return () => {
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ }
+ };
}, [count]);
return (
diff --git a/apps/dashboard/components/analytics/map-component.tsx b/apps/dashboard/components/analytics/map-component.tsx
index 77a2079e6a..cd4e548aa3 100644
--- a/apps/dashboard/components/analytics/map-component.tsx
+++ b/apps/dashboard/components/analytics/map-component.tsx
@@ -6,7 +6,11 @@ import type { CountryData, LocationData } from "@/types/website";
import { GlobeIcon } from "@databuddy/ui/icons";
import { scalePow } from "d3-scale";
import type { Feature, GeoJsonObject } from "geojson";
-import type { Layer, Map as LeafletMap } from "leaflet";
+import type {
+ Layer,
+ LeafletEventHandlerFnMap,
+ Map as LeafletMap,
+} from "leaflet";
import "leaflet/dist/leaflet.css";
import { useTheme } from "next-themes";
import dynamic from "next/dynamic";
@@ -154,7 +158,7 @@ export function MapComponent({
const handleEachFeature = useCallback(
(feature: Feature, layer: Layer) => {
- layer.on({
+ const handlers: LeafletEventHandlerFnMap = {
mouseover: () => {
const code = feature.properties?.ISO_A2;
setHoveredId(code);
@@ -186,7 +190,9 @@ export function MapComponent({
);
}
},
- });
+ };
+ layer.on(handlers);
+ layer.once("remove", () => layer.off(handlers));
},
[countryData?.data]
);
diff --git a/apps/dashboard/components/analytics/res-gauge-card.tsx b/apps/dashboard/components/analytics/res-gauge-card.tsx
index b629800d87..caf6c7df5c 100644
--- a/apps/dashboard/components/analytics/res-gauge-card.tsx
+++ b/apps/dashboard/components/analytics/res-gauge-card.tsx
@@ -30,19 +30,12 @@ interface PercentileOption {
}
interface RESGaugeCardProps {
- /** Additional class names */
className?: string;
- /** Loading state */
isLoading?: boolean;
- /** Current period metrics with p75 values */
metrics: MetricInput[];
- /** Callback when percentile changes */
onPercentileChangeAction?: (value: string) => void;
- /** Percentile options for the selector */
percentileOptions?: PercentileOption[];
- /** Previous period metrics for trend comparison */
previousMetrics?: MetricInput[];
- /** Currently selected percentile */
selectedPercentile?: string;
}
diff --git a/apps/dashboard/components/analytics/stat-card.tsx b/apps/dashboard/components/analytics/stat-card.tsx
index 5642a6c61c..fac7eeb9fb 100644
--- a/apps/dashboard/components/analytics/stat-card.tsx
+++ b/apps/dashboard/components/analytics/stat-card.tsx
@@ -30,7 +30,7 @@ interface Trend {
previousPeriod: { start: string; end: string };
}
-export type StatCardDisplayMode = "compact" | "chart" | "text";
+type StatCardDisplayMode = "compact" | "chart" | "text";
interface StatCardProps {
chartData?: MiniChartDataPoint[];
diff --git a/apps/dashboard/components/atomic/FormattedNumber.tsx b/apps/dashboard/components/atomic/FormattedNumber.tsx
deleted file mode 100644
index 26c167a458..0000000000
--- a/apps/dashboard/components/atomic/FormattedNumber.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-"use client";
-
-import type React from "react";
-import { formatNumber } from "@/lib/formatters";
-
-interface FormattedNumberProps {
- className?: string;
- id?: string;
- value: number;
-}
-
-export const FormattedNumber: React.FC = ({
- id,
- value,
- className,
-}) => (
-
- {formatNumber(value)}
-
-);
-
-export default FormattedNumber;
diff --git a/apps/dashboard/components/atomic/PageLinkCell.tsx b/apps/dashboard/components/atomic/PageLinkCell.tsx
deleted file mode 100644
index 4cc10acba5..0000000000
--- a/apps/dashboard/components/atomic/PageLinkCell.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-"use client";
-
-import type React from "react";
-import { formatDomainLink } from "@/app/(main)/websites/[id]/_components/utils/analytics-helpers";
-import { cn } from "@/lib/utils";
-import { ArrowSquareOutIcon, FileTextIcon } from "@databuddy/ui/icons";
-
-export interface PageLinkCellData {
- id?: string;
- path: string;
- websiteDomain?: string;
-}
-
-type PageLinkCellProps = PageLinkCellData & {
- className?: string;
- iconClassName?: string;
- textClassName?: string;
- maxLength?: number;
-};
-
-export const PageLinkCell: React.FC = ({
- id,
- path,
- websiteDomain,
- className,
- iconClassName = "size-4 text-muted-foreground",
- textClassName = "text-sm",
- maxLength = 35,
-}) => {
- if (!path) {
- return (
-
- (not set)
-
- );
- }
-
- const { href, display } = formatDomainLink(path, websiteDomain, maxLength);
- const isExternal = href.startsWith("http");
-
- return (
-
-
-
- {display}
-
- {isExternal && (
-
- )}
-
- );
-};
-
-export default PageLinkCell;
diff --git a/apps/dashboard/components/autumn/pricing-table.tsx b/apps/dashboard/components/autumn/pricing-table.tsx
index dade28a0c4..2beefbdf99 100644
--- a/apps/dashboard/components/autumn/pricing-table.tsx
+++ b/apps/dashboard/components/autumn/pricing-table.tsx
@@ -778,5 +778,3 @@ function StaticFeatureItem({ label }: { label: string }) {
);
}
-
-export { FeatureItem as PricingFeatureItem, PricingCard };
diff --git a/apps/dashboard/components/bits/Aurora.tsx b/apps/dashboard/components/bits/Aurora.tsx
deleted file mode 100644
index 428a502505..0000000000
--- a/apps/dashboard/components/bits/Aurora.tsx
+++ /dev/null
@@ -1,216 +0,0 @@
-import { Color, Mesh, Program, Renderer, Triangle } from "ogl";
-import { useEffect, useRef } from "react";
-
-export interface CommonProps {
- onReady?: () => void;
-}
-
-export interface TimeProps {
- speed?: number;
- time?: number;
-}
-
-export interface ControlProps {
- paused?: boolean;
-}
-
-export interface AuroraProps extends CommonProps, TimeProps, ControlProps {
- amplitude?: number;
- colorStops?: [string, string, string];
-}
-
-const VERT = `#version 300 es
-in vec2 uv;
-in vec2 position;
-
-out vec2 vUv;
-
-void main() {
- vUv = uv;
- gl_Position = vec4(position, 0.0, 1.0);
-}
-`;
-
-const FRAG = `#version 300 es
-precision highp float;
-
-uniform float uTime;
-uniform float uAmplitude;
-uniform vec3 uColorStops[3];
-
-in vec2 vUv;
-out vec4 fragColor;
-
-vec3 permute(vec3 x) {
- return mod(((x * 34.0) + 1.0) * x, 289.0);
-}
-
-float snoise(vec2 v){
- const vec4 C = vec4(
- 0.211324865405187, 0.366025403784439,
- -0.577350269189626, 0.024390243902439
- );
- vec2 i = floor(v + dot(v, C.yy));
- vec2 x0 = v - i + dot(i, C.xx);
- vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
- vec4 x12 = x0.xyxy + C.xxzz;
- x12.xy -= i1;
- i = mod(i, 289.0);
-
- vec3 p = permute(
- permute(i.y + vec3(0.0, i1.y, 1.0))
- + i.x + vec3(0.0, i1.x, 1.0)
- );
-
- vec3 m = max(
- 0.5 - vec3(
- dot(x0, x0),
- dot(x12.xy, x12.xy),
- dot(x12.zw, x12.zw)
- ),
- 0.0
- );
- m = m * m;
- m = m * m;
-
- vec3 x = 2.0 * fract(p * C.www) - 1.0;
- vec3 h = abs(x) - 0.5;
- vec3 ox = floor(x + 0.5);
- vec3 a0 = x - ox;
- m *= 1.79284291400159 - 0.85373472095314 * (a0*a0 + h*h);
-
- vec3 g;
- g.x = a0.x * x0.x + h.x * x0.y;
- g.yz = a0.yz * x12.xz + h.yz * x12.yw;
- return 130.0 * dot(m, g);
-}
-
-struct ColorStop {
- vec3 color;
- float position;
-};
-
-/**
- * Helper macro to blend between consecutive ColorStops
- * based on vUv.x
- */
-#define COLOR_RAMP(colors, factor, finalColor) { \
- int index = 0; \
- for (int i = 0; i < colors.length() - 1; i++) { \
- ColorStop currentColor = colors[i]; \
- bool isInBetween = currentColor.position <= factor; \
- index = int(mix(float(index), float(i), float(isInBetween))); \
- } \
- ColorStop currentColor = colors[index]; \
- ColorStop nextColor = colors[index + 1]; \
- float range = nextColor.position - currentColor.position; \
- float lerpFactor = (factor - currentColor.position) / range; \
- finalColor = mix(currentColor.color, nextColor.color, lerpFactor); \
-}
-
-void main() {
- // Build our three color stops from uniform array uColorStops
- ColorStop colors[3];
- colors[0] = ColorStop(uColorStops[0], 0.0);
- colors[1] = ColorStop(uColorStops[1], 0.5);
- colors[2] = ColorStop(uColorStops[2], 1.0);
-
- // Interpolate color along vUv.x
- vec3 rampColor;
- COLOR_RAMP(colors, vUv.x, rampColor);
-
- // Noise-based "height," scaled by amplitude
- float height = snoise(vec2(vUv.x * 2.0 + uTime * 0.1, uTime * 0.25))
- * 0.5
- * uAmplitude;
- height = exp(height);
- height = (vUv.y * 2.0 - height + 0.2);
-
- fragColor.rgb = 0.6 * height * rampColor;
- fragColor.a = 1.0;
-}
-`;
-
-export default function Aurora(props: AuroraProps) {
- const { colorStops = ["#00d8ff", "#7cff67", "#00d8ff"], amplitude = 1.0 } =
- props;
-
- const propsRef = useRef(props);
- propsRef.current = props;
-
- const ctnDom = useRef(null);
-
- useEffect(() => {
- const ctn = ctnDom.current;
- if (!ctn) {
- return;
- }
-
- const renderer = new Renderer();
- const gl = renderer.gl;
- gl.clearColor(1, 1, 1, 1);
-
- function resize() {
- if (!ctn) {
- return;
- }
- renderer.setSize(ctn.offsetWidth, ctn.offsetHeight);
- }
- window.addEventListener("resize", resize);
- resize();
-
- const geometry = new Triangle(gl);
- geometry.addAttribute("uv", {
- size: 2,
- data: new Float32Array([0, 0, 2, 0, 0, 2]),
- });
-
- const colorStopsArray = colorStops.map((hex) => {
- const c = new Color(hex);
- return [c.r, c.g, c.b] as [number, number, number];
- });
-
- const program = new Program(gl, {
- vertex: VERT,
- fragment: FRAG,
- uniforms: {
- uTime: { value: 0 },
- uAmplitude: { value: amplitude },
- uColorStops: { value: colorStopsArray },
- },
- });
-
- const mesh = new Mesh(gl, { geometry, program });
- ctn.appendChild(gl.canvas);
-
- let animateId = 0;
-
- const update = (t: number) => {
- animateId = requestAnimationFrame(update);
-
- const { time = t * 0.01, speed = 1.0 } = propsRef.current;
- program.uniforms.uTime.value = time * speed * 0.1;
-
- program.uniforms.uAmplitude.value = propsRef.current.amplitude ?? 1.0;
- const stops = propsRef.current.colorStops ?? colorStops;
- program.uniforms.uColorStops.value = stops.map((hex) => {
- const c = new Color(hex);
- return [c.r, c.g, c.b] as [number, number, number];
- });
-
- renderer.render({ scene: mesh });
- };
- animateId = requestAnimationFrame(update);
-
- return () => {
- cancelAnimationFrame(animateId);
- window.removeEventListener("resize", resize);
-
- ctn?.removeChild(gl.canvas);
-
- gl.getExtension("WEBGL_lose_context")?.loseContext();
- };
- }, [amplitude, colorStops]);
-
- return
;
-}
diff --git a/apps/dashboard/components/charts/gauge-chart.tsx b/apps/dashboard/components/charts/gauge-chart.tsx
index 8d44b7f710..5efc489781 100644
--- a/apps/dashboard/components/charts/gauge-chart.tsx
+++ b/apps/dashboard/components/charts/gauge-chart.tsx
@@ -7,23 +7,14 @@ import { cn } from "@/lib/utils";
type GaugeRating = "good" | "needs-improvement" | "poor";
interface GaugeChartProps {
- /** Format the center label value */
formatValue?: (value: number) => string;
- /** Maximum value for the gauge (100% fill) */
max: number;
- /** Rating determines the color */
rating: GaugeRating;
- /** Size of the chart in pixels */
size?: number;
- /** Starting angle in degrees (-90 = top, 0 = right, 90 = bottom) */
startAngle?: number;
- /** Sweep angle in degrees (360 = full circle) */
sweepAngle?: number;
- /** Number of tick marks */
tickCount?: number;
- /** Optional unit to display below the value */
unit?: string;
- /** Current value to display */
value: number;
}
diff --git a/apps/dashboard/components/charts/metrics-constants.ts b/apps/dashboard/components/charts/metrics-constants.ts
index c0a1a5d6a4..da6df4d41b 100644
--- a/apps/dashboard/components/charts/metrics-constants.ts
+++ b/apps/dashboard/components/charts/metrics-constants.ts
@@ -152,12 +152,11 @@ export interface ChartDataRow {
sessions?: number;
unique_visitors?: number;
visitors?: number;
- /** Stable category for Recharts X-axis; usually rawDate (YYYY-MM-DD or hourly key) */
xKey?: string;
[key: string]: unknown;
}
-export interface MetricConfig {
+interface MetricConfig {
category?: "analytics" | "performance" | "core_web_vitals";
color: string;
formatValue?: (value: number, row: ChartDataRow) => string;
@@ -168,7 +167,7 @@ export interface MetricConfig {
yAxisId: string;
}
-export const formatPerformanceTime = (value: number): string => {
+const formatPerformanceTime = (value: number): string => {
if (!value || value === 0) {
return "N/A";
}
@@ -181,7 +180,7 @@ export const formatPerformanceTime = (value: number): string => {
: `${seconds.toFixed(1)}s`;
};
-export const formatCLS = (value: number): string => {
+const formatCLS = (value: number): string => {
if (value === null || value === undefined || Number.isNaN(value)) {
return "N/A";
}
@@ -206,7 +205,7 @@ const createMetric = (
category,
});
-export const ANALYTICS_METRICS: MetricConfig[] = [
+const ANALYTICS_METRICS: MetricConfig[] = [
createMetric("pageviews", "Pageviews", "pageviews", EyeIcon, (value) =>
formatLocaleNumber(value)
),
@@ -235,7 +234,7 @@ export const ANALYTICS_METRICS: MetricConfig[] = [
),
];
-export const PERFORMANCE_METRICS: MetricConfig[] = [
+const PERFORMANCE_METRICS: MetricConfig[] = [
createMetric(
"avg_load_time",
"Avg Load Time",
@@ -254,7 +253,7 @@ export const PERFORMANCE_METRICS: MetricConfig[] = [
),
];
-export const CORE_WEB_VITALS_METRICS: MetricConfig[] = [
+const CORE_WEB_VITALS_METRICS: MetricConfig[] = [
createMetric(
"avg_fcp",
"FCP (Avg)",
@@ -337,7 +336,7 @@ export const CORE_WEB_VITALS_METRICS: MetricConfig[] = [
),
];
-export const ERROR_METRICS: MetricConfig[] = [
+const ERROR_METRICS: MetricConfig[] = [
createMetric(
"total_errors",
"Total Errors",
diff --git a/apps/dashboard/components/charts/pie-chart.tsx b/apps/dashboard/components/charts/pie-chart.tsx
index ab15026c4e..d77b7bc1f4 100644
--- a/apps/dashboard/components/charts/pie-chart.tsx
+++ b/apps/dashboard/components/charts/pie-chart.tsx
@@ -24,7 +24,7 @@ interface PieChartDataPoint {
value: number;
}
-export type PieChartVariant = "pie" | "donut";
+type PieChartVariant = "pie" | "donut";
interface MiniPieChartProps {
className?: string;
diff --git a/apps/dashboard/components/charts/range-selection-popup.tsx b/apps/dashboard/components/charts/range-selection-popup.tsx
index 45e72100d8..10a8e38143 100644
--- a/apps/dashboard/components/charts/range-selection-popup.tsx
+++ b/apps/dashboard/components/charts/range-selection-popup.tsx
@@ -12,7 +12,6 @@ interface RangeSelectionPopupProps {
onAddAnnotationAction: () => void;
onCloseAction: () => void;
onZoomAction: (dateRange: { startDate: Date; endDate: Date }) => void;
- /** When false, only “Zoom to range” is shown (e.g. annotations disabled on chart). Default: true. */
showAnnotationAction?: boolean;
}
diff --git a/apps/dashboard/components/charts/simple-metrics-chart.tsx b/apps/dashboard/components/charts/simple-metrics-chart.tsx
index b9f42a5369..360d589967 100644
--- a/apps/dashboard/components/charts/simple-metrics-chart.tsx
+++ b/apps/dashboard/components/charts/simple-metrics-chart.tsx
@@ -21,9 +21,7 @@ interface SimpleMetricsChartProps {
height?: number;
isLoading?: boolean;
metrics: MetricConfig[];
- /** When true, the last segment (incomplete period) uses a dashed stroke, matching the overview traffic trends chart. Applies to area and line, not bar. */
partialLastSegment?: boolean;
- /** Area (default), line, or grouped bar. */
seriesKind?: ChartSeriesKind;
title?: string;
}
diff --git a/apps/dashboard/components/date-range-picker.tsx b/apps/dashboard/components/date-range-picker.tsx
index ec8fa56b3e..bb06c7a078 100644
--- a/apps/dashboard/components/date-range-picker.tsx
+++ b/apps/dashboard/components/date-range-picker.tsx
@@ -71,7 +71,7 @@ const PRESET_RANGES: PresetRange[] = [
label: "Last 90 days",
getValue: () => ({
from: dayjs().subtract(89, "day").startOf("day").toDate(),
- to: dayjs().subtract(1, "month").endOf("month").toDate(),
+ to: dayjs().endOf("day").toDate(),
}),
},
];
diff --git a/apps/dashboard/components/dev-tools/dev-tools-drawer.tsx b/apps/dashboard/components/dev-tools/dev-tools-drawer.tsx
deleted file mode 100644
index 95473ddc93..0000000000
--- a/apps/dashboard/components/dev-tools/dev-tools-drawer.tsx
+++ /dev/null
@@ -1,1092 +0,0 @@
-"use client";
-
-import { publicConfig } from "@databuddy/env/public";
-import { useQueryClient } from "@tanstack/react-query";
-import { useTheme } from "next-themes";
-import { useCallback, useEffect, useState } from "react";
-import { toast } from "sonner";
-import type {
- ChartCurveType,
- ChartSeriesKind,
-} from "@/components/ui/composables/chart";
-import {
- CHART_LOCATION_LABELS,
- CHART_LOCATIONS,
- type ChartLocation,
- useAllChartPreferences,
-} from "@/hooks/use-chart-preferences";
-import { cn } from "@/lib/utils";
-import { Button } from "../ui/button";
-import {
- Drawer,
- DrawerClose,
- DrawerContent,
- DrawerDescription,
- DrawerHeader,
- DrawerTitle,
-} from "../ui/drawer";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "../ui/select";
-import { Separator } from "../ui/separator";
-import {
- BugIcon,
- CaretDownIcon,
- ChartBarIcon,
- ChartLineIcon,
- CheckCircleIcon,
- ClipboardIcon,
- CopyIcon,
- DatabaseIcon,
- DesktopIcon,
- FunnelIcon,
- GearIcon,
- InfoIcon,
- LightningIcon,
- MonitorIcon,
- MoonIcon,
- PresentationChartIcon,
- SpinnerIcon,
- SquaresFourIcon,
- StackIcon,
- SunIcon,
- TrashIcon,
- WarningCircleIcon,
- XMarkIcon as XIcon,
-} from "@databuddy/ui/icons";
-import type { NavIcon } from "@/components/layout/navigation/types";
-
-function InfoSection({
- title,
- children,
-}: {
- title: string;
- children: React.ReactNode;
-}) {
- return (
-
-
-
- {title}
-
-
{children}
-
- );
-}
-
-function ActionButton({
- icon: Icon,
- label,
- onClick,
- variant = "outline",
-}: {
- icon: NavIcon;
- label: string;
- onClick: () => void;
- variant?: "outline" | "destructive";
-}) {
- return (
-
-
- {label}
-
- );
-}
-
-function EnvironmentInfo() {
- const apiUrl = publicConfig.urls.api;
- const env = process.env.NODE_ENV || "development";
-
- return (
-
-
-
- API URL:
- {apiUrl}
-
-
- Environment:
- {env}
-
-
- Hostname:
- {window.location.hostname}
-
-
-
- );
-}
-
-function ToastPreview() {
- return (
-
-
- Trigger Sonner to preview dashboard styling.
-
-
-
toast.success("Success")}
- />
- toast.error("Error")}
- variant="destructive"
- />
- toast.warning("Warning")}
- />
- toast.info("Info")}
- />
- toast.loading("Loading")}
- />
- toast("Default")}
- />
-
-
- );
-}
-
-function ReactQueryCache() {
- const queryClient = useQueryClient();
- const [cacheStats, setCacheStats] = useState({
- queries: 0,
- mutations: 0,
- });
-
- const updateStats = useCallback(() => {
- const cache = queryClient.getQueryCache();
- const mutationCache = queryClient.getMutationCache();
- setCacheStats({
- queries: cache.getAll().length,
- mutations: mutationCache.getAll().length,
- });
- }, [queryClient]);
-
- useEffect(() => {
- updateStats();
- const interval = setInterval(updateStats, 1000);
- return () => clearInterval(interval);
- }, [updateStats]);
-
- const handleClearCache = () => {
- queryClient.clear();
- updateStats();
- toast.success("React Query cache cleared");
- };
-
- const handleInvalidateAll = () => {
- queryClient.invalidateQueries();
- toast.success("All queries invalidated");
- };
-
- return (
-
-
-
- Active Queries:
- {cacheStats.queries}
-
-
- Active Mutations:
- {cacheStats.mutations}
-
-
-
-
- );
-}
-
-function StorageManagement() {
- const [storageStats, setStorageStats] = useState({
- localStorage: 0,
- sessionStorage: 0,
- });
-
- const updateStats = useCallback(() => {
- let localStorageSize = 0;
- let sessionStorageSize = 0;
-
- for (const key in localStorage) {
- if (Object.hasOwn(localStorage, key)) {
- localStorageSize += localStorage[key].length + key.length;
- }
- }
-
- for (const key in sessionStorage) {
- if (Object.hasOwn(sessionStorage, key)) {
- sessionStorageSize += sessionStorage[key].length + key.length;
- }
- }
-
- setStorageStats({
- localStorage: localStorageSize,
- sessionStorage: sessionStorageSize,
- });
- }, []);
-
- useEffect(() => {
- updateStats();
- const interval = setInterval(updateStats, 2000);
- return () => clearInterval(interval);
- }, [updateStats]);
-
- const formatBytes = (bytes: number) => {
- if (bytes === 0) {
- return "0 B";
- }
- const k = 1024;
- const sizes = ["B", "KB", "MB"];
- const i = Math.floor(Math.log(bytes) / Math.log(k));
- return `${(bytes / k ** i).toFixed(2)} ${sizes[i]}`;
- };
-
- const handleClearLocalStorage = () => {
- localStorage.clear();
- updateStats();
- toast.success("LocalStorage cleared");
- };
-
- const handleClearSessionStorage = () => {
- sessionStorage.clear();
- updateStats();
- toast.success("SessionStorage cleared");
- };
-
- return (
-
-
-
- LocalStorage:
-
- {formatBytes(storageStats.localStorage)}
-
-
-
- SessionStorage:
-
- {formatBytes(storageStats.sessionStorage)}
-
-
-
-
-
- );
-}
-
-function PerformanceInfo() {
- const queryClient = useQueryClient();
- const [memoryInfo, setMemoryInfo] = useState<{
- usedJSHeapSize?: number;
- totalJSHeapSize?: number;
- jsHeapSizeLimit?: number;
- }>({});
- const [advancedMemory, setAdvancedMemory] = useState<{
- bytes?: number;
- breakdown?: Array<{
- bytes: number;
- attribution: Array<{
- url: string;
- scope: string;
- }>;
- types: string[];
- }>;
- } | null>(null);
- const [isMeasuring, setIsMeasuring] = useState(false);
- const [breakdown, setBreakdown] = useState<{
- domNodes: number;
- eventListeners: number;
- reactQueryCache: number;
- storage: number;
- images: number;
- scripts: number;
- timers: number;
- intervals: number;
- websockets: number;
- workers: number;
- }>({
- domNodes: 0,
- eventListeners: 0,
- reactQueryCache: 0,
- storage: 0,
- images: 0,
- scripts: 0,
- timers: 0,
- intervals: 0,
- websockets: 0,
- workers: 0,
- });
-
- const calculateBreakdown = useCallback(() => {
- const domNodes = document.querySelectorAll("*").length;
-
- let eventListeners = 0;
- const allElements = document.querySelectorAll("*");
- for (const el of allElements) {
- if (
- el instanceof HTMLElement &&
- (el.onclick ||
- el.onmouseover ||
- el.onfocus ||
- el.getAttribute("onclick"))
- ) {
- eventListeners++;
- }
- }
-
- const cache = queryClient.getQueryCache();
- const queries = cache.getAll();
- let reactQueryCache = 0;
- for (const query of queries) {
- const state = query.state;
- if (state.data) {
- try {
- reactQueryCache += JSON.stringify(state.data).length;
- } catch {
- reactQueryCache += Object.keys(state.data).length * 100;
- }
- }
- }
-
- let storage = 0;
- for (const key in localStorage) {
- if (Object.hasOwn(localStorage, key)) {
- storage += localStorage[key].length + key.length;
- }
- }
- for (const key in sessionStorage) {
- if (Object.hasOwn(sessionStorage, key)) {
- storage += sessionStorage[key].length + key.length;
- }
- }
-
- const images = document.querySelectorAll("img");
- let imagesSize = 0;
- for (const img of images) {
- if (img.complete && img.naturalWidth && img.naturalHeight) {
- imagesSize += img.naturalWidth * img.naturalHeight * 4;
- }
- }
-
- const scripts = document.querySelectorAll("script").length;
-
- const timers = 0;
- const intervals = 0;
-
- const websockets = 0;
-
- const workers = 0;
-
- setBreakdown({
- domNodes,
- eventListeners,
- reactQueryCache,
- storage,
- images: imagesSize,
- scripts,
- timers,
- intervals,
- websockets,
- workers,
- });
- }, [queryClient]);
-
- const measureAdvancedMemory = useCallback(async () => {
- if (
- typeof performance !== "undefined" &&
- "measureUserAgentSpecificMemory" in performance &&
- typeof performance.measureUserAgentSpecificMemory === "function"
- ) {
- setIsMeasuring(true);
- try {
- const result = await performance.measureUserAgentSpecificMemory();
- setAdvancedMemory(result);
- toast.success("Advanced memory measurement completed");
- } catch (error) {
- if (error instanceof DOMException) {
- if (error.name === "SecurityError") {
- toast.error(
- "Memory measurement requires cross-origin isolation. Enable COOP/COEP headers."
- );
- } else {
- toast.error(`Memory measurement failed: ${error.message}`);
- }
- } else {
- toast.error("Failed to measure memory");
- }
- console.error("Memory measurement error:", error);
- } finally {
- setIsMeasuring(false);
- }
- } else {
- toast.error(
- "Advanced memory API not available. Use Chrome 89+ with cross-origin isolation."
- );
- }
- }, []);
-
- const openDevToolsMemory = useCallback(() => {
- toast.info(
- "Open Chrome DevTools → Memory tab → Take heap snapshot for detailed analysis"
- );
- console.log(
- "💡 Tip: Open Chrome DevTools (F12) → Memory tab → Take heap snapshot to see detailed JavaScript object memory usage"
- );
- }, []);
-
- useEffect(() => {
- let animationFrameId: number;
- let lastUpdate = 0;
- const throttleMs = 500;
-
- const updateMemory = (timestamp: number) => {
- if (timestamp - lastUpdate >= throttleMs) {
- if ("memory" in performance) {
- const mem = (performance as { memory?: typeof memoryInfo }).memory;
- if (mem) {
- setMemoryInfo({
- usedJSHeapSize: mem.usedJSHeapSize,
- totalJSHeapSize: mem.totalJSHeapSize,
- jsHeapSizeLimit: mem.jsHeapSizeLimit,
- });
- }
- }
- calculateBreakdown();
- lastUpdate = timestamp;
- }
-
- animationFrameId = requestAnimationFrame(updateMemory);
- };
-
- if ("memory" in performance) {
- const mem = (performance as { memory?: typeof memoryInfo }).memory;
- if (mem) {
- setMemoryInfo({
- usedJSHeapSize: mem.usedJSHeapSize,
- totalJSHeapSize: mem.totalJSHeapSize,
- jsHeapSizeLimit: mem.jsHeapSizeLimit,
- });
- }
- }
- calculateBreakdown();
-
- animationFrameId = requestAnimationFrame(updateMemory);
-
- return () => {
- cancelAnimationFrame(animationFrameId);
- };
- }, [calculateBreakdown]);
-
- const formatBytes = (bytes?: number) => {
- if (!bytes) {
- return "N/A";
- }
- const k = 1024;
- const sizes = ["B", "KB", "MB"];
- const i = Math.floor(Math.log(bytes) / Math.log(k));
- return `${(bytes / k ** i).toFixed(2)} ${sizes[i]}`;
- };
-
- const formatNumber = (num: number) => {
- if (num >= 1_000_000) {
- return `${(num / 1_000_000).toFixed(2)}M`;
- }
- if (num >= 1000) {
- return `${(num / 1000).toFixed(2)}K`;
- }
- return num.toString();
- };
-
- const canMeasureAdvanced =
- typeof performance !== "undefined" &&
- "measureUserAgentSpecificMemory" in performance &&
- typeof performance.measureUserAgentSpecificMemory === "function" &&
- typeof crossOriginIsolated !== "undefined" &&
- crossOriginIsolated;
-
- if (Object.keys(memoryInfo).length === 0) {
- return null;
- }
-
- return (
-
-
-
-
- Used Heap:
-
- {formatBytes(memoryInfo.usedJSHeapSize)}
-
-
-
- Total Heap:
-
- {formatBytes(memoryInfo.totalJSHeapSize)}
-
-
-
- Heap Limit:
-
- {formatBytes(memoryInfo.jsHeapSizeLimit)}
-
-
-
-
- {canMeasureAdvanced && (
- <>
-
-
-
-
- Advanced Measurement
-
-
- {isMeasuring ? "Measuring..." : "Measure Memory"}
-
-
- {advancedMemory && (
-
-
- Total Memory:
-
- {formatBytes(advancedMemory.bytes)}
-
-
- {advancedMemory.breakdown && (
-
-
- Breakdown:
-
- {advancedMemory.breakdown.map((item, idx) => (
-
-
- {item.attribution[0]?.url || "Unknown"}:
-
-
- {formatBytes(item.bytes)}
-
-
- ))}
-
- )}
-
- )}
-
- >
- )}
-
-
-
-
-
-
- Tracked Memory Usage
-
-
-
-
-
-
- These are measurable contributions. For detailed JavaScript object
- analysis, use Chrome DevTools Memory profiler (click icon above).
-
-
-
- React Query Cache:
-
- {formatBytes(breakdown.reactQueryCache)}
-
-
-
- Storage (LS/SS):
-
- {formatBytes(breakdown.storage)}
-
-
-
- Images (estimated):
-
- {formatBytes(breakdown.images)}
-
-
-
- DOM Nodes:
-
- {formatNumber(breakdown.domNodes)} nodes
-
-
-
- Scripts:
-
- {breakdown.scripts} loaded
-
-
-
-
-
-
- );
-}
-
-const THEME_OPTIONS = [
- {
- id: "light",
- name: "Light",
- icon: SunIcon,
- },
- {
- id: "dark",
- name: "Dark",
- icon: MoonIcon,
- },
- {
- id: "system",
- name: "System",
- icon: DesktopIcon,
- },
-] as const;
-
-const CHART_TYPE_OPTIONS: {
- id: ChartSeriesKind;
- name: string;
- icon: NavIcon;
-}[] = [
- { id: "bar", name: "Bar", icon: ChartBarIcon },
- { id: "line", name: "Line", icon: ChartLineIcon },
- { id: "area", name: "Area", icon: StackIcon },
-];
-
-const STEP_TYPE_OPTIONS: { id: ChartCurveType; name: string }[] = [
- { id: "monotone", name: "Smooth" },
- { id: "linear", name: "Linear" },
- { id: "step", name: "Step" },
- { id: "stepBefore", name: "Step Before" },
- { id: "stepAfter", name: "Step After" },
-];
-
-const LOCATION_ICONS: Record = {
- "overview-stats": SquaresFourIcon,
- "overview-main": PresentationChartIcon,
- funnels: FunnelIcon,
- "website-list": ChartLineIcon,
- events: ChartBarIcon,
-};
-
-function AppearanceSettings() {
- const { theme, setTheme } = useTheme();
- const { preferences, updateLocationPreferences, updateAllPreferences } =
- useAllChartPreferences();
- const [showGranular, setShowGranular] = useState(false);
- const [mounted, setMounted] = useState(false);
-
- useEffect(() => {
- setMounted(true);
- }, []);
-
- if (!mounted) {
- return null;
- }
-
- const currentTheme = theme ?? "system";
- const globalPrefs = preferences["overview-stats"] ?? {
- chartType: "area" as ChartSeriesKind,
- chartStepType: "monotone" as ChartCurveType,
- };
- const isGlobalBar = globalPrefs.chartType === "bar";
-
- return (
-
-
-
-
- Theme
-
-
- {THEME_OPTIONS.map(({ id, name, icon: Icon }) => (
- setTheme(id)}
- size="sm"
- variant={currentTheme === id ? "default" : "outline"}
- >
-
- {name}
-
- ))}
-
-
-
-
-
-
-
-
- Charts
-
-
-
-
-
All Charts
-
-
- updateAllPreferences({ chartType: v })
- }
- value={globalPrefs.chartType}
- >
-
-
-
-
- {CHART_TYPE_OPTIONS.map(({ id, name, icon: OptIcon }) => (
-
-
-
- {name}
-
-
- ))}
-
-
-
- updateAllPreferences({ chartStepType: v })
- }
- value={globalPrefs.chartStepType}
- >
-
-
-
-
- {STEP_TYPE_OPTIONS.map(({ id, name }) => (
-
- {name}
-
- ))}
-
-
-
-
-
-
setShowGranular(!showGranular)}
- size="sm"
- variant="ghost"
- >
-
- {showGranular ? "Hide per-location" : "Per-location settings"}
-
-
-
-
- {showGranular ? (
-
- {CHART_LOCATIONS.map((location) => {
- const prefs = preferences[location] ?? {
- chartType: "area" as ChartSeriesKind,
- chartStepType: "monotone" as ChartCurveType,
- };
- const isBar = prefs.chartType === "bar";
- const Icon = LOCATION_ICONS[location];
-
- return (
-
-
-
-
- {CHART_LOCATION_LABELS[location]}
-
-
-
-
- updateLocationPreferences(location, {
- chartType: v,
- })
- }
- value={prefs.chartType}
- >
- e.stopPropagation()}
- size="sm"
- >
-
-
-
- {CHART_TYPE_OPTIONS.map(
- ({ id, name, icon: OptIcon }) => (
-
-
-
- {name}
-
-
- )
- )}
-
-
-
- updateLocationPreferences(location, {
- chartStepType: v,
- })
- }
- value={prefs.chartStepType}
- >
- e.stopPropagation()}
- size="sm"
- >
-
-
-
- {STEP_TYPE_OPTIONS.map(({ id, name }) => (
-
- {name}
-
- ))}
-
-
-
-
- );
- })}
-
- ) : null}
-
-
-
- );
-}
-
-function QuickActions() {
- const handleCopyUrl = () => {
- navigator.clipboard.writeText(window.location.href);
- toast.success("URL copied to clipboard");
- };
-
- const handleCopyState = () => {
- const state = {
- url: window.location.href,
- timestamp: new Date().toISOString(),
- userAgent: navigator.userAgent,
- viewport: {
- width: window.innerWidth,
- height: window.innerHeight,
- },
- };
- console.table(state);
- navigator.clipboard.writeText(JSON.stringify(state, null, 2));
- toast.success("State copied to clipboard and logged to console");
- };
-
- const handleClearConsole = () => {
- console.clear();
- toast.success("Console cleared");
- };
-
- const handleReload = () => {
- window.location.reload();
- };
-
- return (
-
-
-
- Quick Actions
-
-
-
- );
-}
-
-export function DevToolsDrawer() {
- const [mounted, setMounted] = useState(false);
- const [open, setOpen] = useState(false);
- const [isLocalhost, setIsLocalhost] = useState(false);
-
- useEffect(() => {
- setMounted(true);
- const hostname = window.location.hostname;
- setIsLocalhost(hostname === "localhost" || hostname === "127.0.0.1");
- }, []);
-
- useEffect(() => {
- if (!isLocalhost) {
- return;
- }
-
- const handleKeyDown = (e: KeyboardEvent) => {
- if ((e.metaKey || e.ctrlKey) && e.key === ".") {
- e.preventDefault();
- setOpen((prev) => !prev);
- }
- };
-
- window.addEventListener("keydown", handleKeyDown);
- return () => window.removeEventListener("keydown", handleKeyDown);
- }, [isLocalhost]);
-
- if (!(mounted && isLocalhost)) {
- return null;
- }
-
- return (
- <>
- setOpen(true)}
- size="icon"
- variant="outline"
- >
-
-
-
-
-
-
-
-
-
- Dev Tools
-
-
-
-
-
-
-
-
- Development tools and debugging utilities
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Tip: Press{" "}
-
- ⌘
- {" "}
-
- .
- {" "}
- to toggle this drawer
-
-
-
-
-
-
- >
- );
-}
diff --git a/apps/dashboard/components/ds/context-menu.tsx b/apps/dashboard/components/ds/context-menu.tsx
index 00ab0e5e96..dd02e96ccd 100644
--- a/apps/dashboard/components/ds/context-menu.tsx
+++ b/apps/dashboard/components/ds/context-menu.tsx
@@ -9,13 +9,6 @@ const ContextMenuPositioner = ContextMenu.Positioner;
const ContextMenuPopup = ContextMenu.Popup;
const ContextMenuItem = ContextMenu.Item;
const ContextMenuSeparator = ContextMenu.Separator;
-const ContextMenuGroup = ContextMenu.Group;
-const ContextMenuGroupLabel = ContextMenu.GroupLabel;
-const ContextMenuRadioGroup = ContextMenu.RadioGroup;
-const ContextMenuRadioItem = ContextMenu.RadioItem;
-const ContextMenuRadioItemIndicator = ContextMenu.RadioItemIndicator;
-const ContextMenuCheckboxItem = ContextMenu.CheckboxItem;
-const ContextMenuCheckboxItemIndicator = ContextMenu.CheckboxItemIndicator;
export {
ContextMenuRoot,
@@ -25,11 +18,4 @@ export {
ContextMenuPopup,
ContextMenuItem,
ContextMenuSeparator,
- ContextMenuGroup,
- ContextMenuGroupLabel,
- ContextMenuRadioGroup,
- ContextMenuRadioItem,
- ContextMenuRadioItemIndicator,
- ContextMenuCheckboxItem,
- ContextMenuCheckboxItemIndicator,
};
diff --git a/apps/dashboard/components/empty-state.tsx b/apps/dashboard/components/empty-state.tsx
deleted file mode 100644
index b7d74bc240..0000000000
--- a/apps/dashboard/components/empty-state.tsx
+++ /dev/null
@@ -1,292 +0,0 @@
-"use client";
-
-import {
- cloneElement,
- memo,
- type ReactElement,
- type ReactNode,
- type SVGProps,
-} from "react";
-import { cn } from "@/lib/utils";
-import { PlusIcon } from "@databuddy/ui/icons";
-import { Button, Card } from "@databuddy/ui";
-
-export interface EmptyStateAction {
- label: string;
- onClick: () => void;
- size?: "sm" | "md" | "lg";
- tone?: "destructive";
- variant?: "primary" | "secondary" | "ghost";
-}
-
-export interface EmptyStateProps {
- /** Primary action button */
- action?: EmptyStateAction;
- /** Custom aria-label for screen readers */
- "aria-label"?: string;
- /** Custom className */
- className?: string;
- /** Description text */
- description?: string | ReactNode;
- /** Main icon to display */
- icon: ReactElement<
- SVGProps & { size?: number | string; weight?: string }
- >;
- /** Whether this is the main content area */
- isMainContent?: boolean;
- /** Custom padding */
- padding?: "sm" | "md" | "lg";
- /** Custom role for accessibility (defaults to 'region') */
- role?: "region" | "complementary" | "main";
- /** Secondary action button */
- secondaryAction?: EmptyStateAction;
- /** Whether to show the plus badge on the icon */
- showPlusBadge?: boolean;
- /** Main heading */
- title: string;
- /** Custom styling variants */
- variant?: "default" | "simple" | "minimal" | "error";
-}
-
-export const EmptyState = memo(function EmptyState({
- icon,
- title,
- description,
- action,
- secondaryAction,
- variant = "minimal",
- className,
- showPlusBadge = true,
- padding = "lg",
- role = "region",
- "aria-label": ariaLabel,
- isMainContent = false,
-}: EmptyStateProps) {
- const getPadding = () => {
- switch (padding) {
- case "sm":
- return "px-6 py-12";
- case "md":
- return "px-8 py-14";
- case "lg":
- return "px-8";
- default:
- return "px-8";
- }
- };
-
- const renderIcon = () => {
- if (!icon || typeof icon !== "object" || !("type" in icon)) {
- return null;
- }
-
- const iconProps = icon.props || {};
-
- if (variant === "simple" || variant === "minimal" || variant === "error") {
- return (
-
- {cloneElement(icon, {
- ...iconProps,
- className: cn(
- "size-6 text-accent",
- variant === "error" && "text-destructive",
- iconProps.className
- ),
- "aria-hidden": "true",
- size: 24,
- weight: "fill",
- })}
-
- );
- }
-
- return (
-
-
- {cloneElement(icon, {
- ...iconProps,
- className: cn("size-6 text-primary", iconProps.className),
- "aria-hidden": "true",
- size: 24,
- weight: "duotone",
- })}
-
- {showPlusBadge && (
-
{
- e.stopPropagation();
- action?.onClick();
- }}
- size="icon"
- variant="secondary"
- >
-
-
- )}
-
- );
- };
-
- const renderCard = () => {
- const cardClasses = cn(
- variant === "default" &&
- "rounded-xl border-2 border-dashed bg-gradient-to-br from-background to-muted/10",
- variant === "simple" && "rounded border-dashed bg-muted/10",
- variant === "minimal" &&
- "flex flex-1 rounded border-none bg-transparent shadow-none",
- variant === "error" &&
- "flex flex-1 rounded border-none bg-transparent shadow-none",
- "safe-area-inset-4 sm:safe-area-inset-6 lg:safe-area-inset-8",
- className
- );
-
- const contentClasses = cn(
- "flex flex-1 flex-col items-center justify-center text-center",
- getPadding(),
- "px-6 sm:px-8 lg:px-12"
- );
-
- return (
-
-
- {renderIcon()}
-
- {isMainContent ? (
-
- {title}
-
- ) : (
-
-
- {title}
-
-
{description}
-
- )}
- {(action || secondaryAction) && (
-
- {action && (
-
- {variant === "default" && (
-
- )}
- {variant === "default" && (
-
- )}
-
- {action.label}
-
-
- )}
- {secondaryAction && (
-
- {secondaryAction.label}
-
- )}
-
- )}
-
-
-
- );
- };
-
- return renderCard();
-});
-
-EmptyState.displayName = "EmptyState";
-
-export function FeatureEmptyState({
- icon,
- title,
- description,
- actionLabel,
- onAction,
-}: {
- icon: ReactElement<
- SVGProps & { size?: number | string; weight?: string }
- >;
- title: string;
- description: string;
- actionLabel: string;
- onAction: () => void;
-}) {
- return (
-
- );
-}
diff --git a/apps/dashboard/components/events/custom-events/index.ts b/apps/dashboard/components/events/custom-events/index.ts
index 5db3ed5fb7..672ec142b4 100644
--- a/apps/dashboard/components/events/custom-events/index.ts
+++ b/apps/dashboard/components/events/custom-events/index.ts
@@ -1,22 +1,9 @@
-export {
- classifyEventProperties,
- getPropertyTypeLabel,
-} from "./classify-properties";
+export { classifyEventProperties } from "./classify-properties";
export { EventsOverviewContent } from "./events-overview-content";
export {
EventsStatsGrid,
ORGANIZATION_EVENTS_METRICS,
WEBSITE_EVENTS_METRICS,
} from "./events-stats-grid";
-export { EventsTrendChart } from "./events-trend-chart";
-export {
- formatDateLabel,
- generateDateRange,
- getGranularity,
- normalizeDateKey,
- safePercentage,
-} from "./events-utils";
-export { PropertySummary } from "./property-summary";
export { PropertyValueCard } from "./property-value-card";
-export { useCustomEventsOverview } from "./use-custom-events-overview";
export type * from "./types";
diff --git a/apps/dashboard/components/events/custom-events/property-value-card.tsx b/apps/dashboard/components/events/custom-events/property-value-card.tsx
index 958cbe7f79..145ec75cc7 100644
--- a/apps/dashboard/components/events/custom-events/property-value-card.tsx
+++ b/apps/dashboard/components/events/custom-events/property-value-card.tsx
@@ -6,7 +6,7 @@ import { safePercentage } from "./events-utils";
import { FunnelIcon, TagIcon } from "@databuddy/ui/icons";
import { Badge } from "@databuddy/ui";
-export interface PropertyValueCardValue {
+interface PropertyValueCardValue {
count: number;
percentage: number;
property_value: string;
diff --git a/apps/dashboard/components/events/custom-events/types.ts b/apps/dashboard/components/events/custom-events/types.ts
index 6e9fd04746..a364e61209 100644
--- a/apps/dashboard/components/events/custom-events/types.ts
+++ b/apps/dashboard/components/events/custom-events/types.ts
@@ -56,7 +56,7 @@ export interface MiniChartDataPoint {
value: number;
}
-export type PropertyInferredType =
+type PropertyInferredType =
| "boolean"
| "numeric"
| "datetime"
@@ -66,7 +66,7 @@ export type PropertyInferredType =
| "text"
| "high_cardinality";
-export type PropertyRenderStrategy =
+type PropertyRenderStrategy =
| "distribution_bar"
| "top_n_chart"
| "top_n_with_other"
diff --git a/apps/dashboard/components/events/custom-events/use-custom-events-overview.ts b/apps/dashboard/components/events/custom-events/use-custom-events-overview.ts
index 5108f75de0..54cc5f717f 100644
--- a/apps/dashboard/components/events/custom-events/use-custom-events-overview.ts
+++ b/apps/dashboard/components/events/custom-events/use-custom-events-overview.ts
@@ -216,5 +216,3 @@ export function useCustomEventsOverview({
todayUsers: todayEvent?.unique_users ?? 0,
};
}
-
-export { getRawData };
diff --git a/apps/dashboard/components/events/events-stream-content.tsx b/apps/dashboard/components/events/events-stream-content.tsx
index 23e776e7e9..2a918f0a76 100644
--- a/apps/dashboard/components/events/events-stream-content.tsx
+++ b/apps/dashboard/components/events/events-stream-content.tsx
@@ -56,7 +56,7 @@ import {
export type { RecentCustomEvent } from "@/components/events/custom-events";
-export interface EventsStreamData {
+interface EventsStreamData {
error: Error | null;
events: StreamCustomEvent[] | undefined;
isError: boolean;
@@ -231,7 +231,9 @@ export function EventsStreamContent({
);
const pageRef = useRef(page);
- pageRef.current = page;
+ useEffect(() => {
+ pageRef.current = page;
+ }, [page]);
const justResetRef = useRef(false);
useEffect(() => {
@@ -325,9 +327,9 @@ export function EventsStreamContent({
}
const values = new Set();
for (const event of allEvents) {
- const val = event.properties[selectedPropertyKey];
- if (val !== undefined && val !== null) {
- values.add(String(val));
+ const propertyValue = event.properties[selectedPropertyKey];
+ if (propertyValue !== undefined && propertyValue !== null) {
+ values.add(String(propertyValue));
}
}
return Array.from(values).sort();
diff --git a/apps/dashboard/components/feature-gate.tsx b/apps/dashboard/components/feature-gate.tsx
index 4ad95af8f0..9ebc093366 100644
--- a/apps/dashboard/components/feature-gate.tsx
+++ b/apps/dashboard/components/feature-gate.tsx
@@ -4,6 +4,7 @@ import {
FEATURE_METADATA,
type GatedFeatureId,
getMinimumPlanForFeature,
+ getPlanLimitMessage,
PLAN_IDS,
} from "@databuddy/shared/types/features";
import Link from "next/link";
@@ -171,6 +172,21 @@ export function FeatureGate({
);
}
+export function usePlanLimitMessage(
+ feature: GatedFeatureId,
+ currentUsage: number
+): string | null {
+ const { getGatedFeatureAccess, currentPlanId, isLoading } =
+ useBillingContext();
+ const { limit, nextPlan } = getGatedFeatureAccess(feature);
+
+ if (isLoading || typeof limit !== "number" || currentUsage < limit) {
+ return null;
+ }
+
+ return getPlanLimitMessage(currentPlanId, feature, limit, nextPlan);
+}
+
export function useFeatureGate(feature: GatedFeatureId) {
const {
isFeatureEnabled,
diff --git a/apps/dashboard/components/icon.tsx b/apps/dashboard/components/icon.tsx
index 895eaf222a..8d096ccb4a 100644
--- a/apps/dashboard/components/icon.tsx
+++ b/apps/dashboard/components/icon.tsx
@@ -68,9 +68,7 @@ const OS_ICON_EXT: Record = {
const BROWSER_ICONS = Object.keys(BROWSER_ICON_EXT);
const OS_ICONS = Object.keys(OS_ICON_EXT);
-export type BrowserIconName = keyof typeof BROWSER_ICON_EXT;
-export type OSIconName = keyof typeof OS_ICON_EXT;
-export type IconType = "browser" | "os";
+type IconType = "browser" | "os";
interface PublicIconProps {
className?: string;
@@ -148,7 +146,7 @@ function createFallbackIcon(
);
}
-export function PublicIcon({
+function PublicIcon({
type,
name,
size = "md",
diff --git a/apps/dashboard/components/layout/help-dialog.tsx b/apps/dashboard/components/layout/help-dialog.tsx
deleted file mode 100644
index 80ed58a37b..0000000000
--- a/apps/dashboard/components/layout/help-dialog.tsx
+++ /dev/null
@@ -1,158 +0,0 @@
-"use client";
-
-import Link from "next/link";
-import { useState } from "react";
-import { KeyboardShortcuts } from "@/components/ui/keyboard-shortcuts";
-import { cn } from "@/lib/utils";
-import {
- BookOpenIcon,
- ChatTextIcon as ChatCircleIcon,
- CommandIcon as KeyboardIcon,
- PlayIcon,
-} from "@databuddy/ui/icons";
-import { Button, Text } from "@databuddy/ui";
-import { Dialog } from "@databuddy/ui/client";
-
-interface HelpDialogProps {
- onOpenChangeAction: (open: boolean) => void;
- open: boolean;
-}
-
-const HELP_ITEMS = [
- {
- href: "https://www.databuddy.cc/docs",
- icon: BookOpenIcon,
- title: "Documentation",
- description: "Read guides and API references",
- external: true,
- },
- {
- href: "mailto:support@databuddy.cc",
- icon: ChatCircleIcon,
- title: "Contact Support",
- description: "Get help from our support team",
- external: false,
- },
- {
- href: "https://www.youtube.com/@trydatabuddy",
- icon: PlayIcon,
- title: "Tutorials",
- description: "Learn Databuddy step by step",
- external: true,
- },
-] as const;
-
-function HelpRow({
- children,
- className,
- ...rest
-}: React.ButtonHTMLAttributes) {
- return (
-
- {children}
-
- );
-}
-
-export function HelpDialog({ open, onOpenChangeAction }: HelpDialogProps) {
- const [showShortcuts, setShowShortcuts] = useState(false);
-
- return (
- {
- if (!o) {
- setShowShortcuts(false);
- }
- onOpenChangeAction(o);
- }}
- open={open}
- >
-
-
- Help & Resources
-
- Get assistance and learn more about Databuddy
-
-
-
-
- {showShortcuts ? (
-
-
- Keyboard Shortcuts
- setShowShortcuts(false)}
- size="sm"
- variant="ghost"
- >
- Back
-
-
-
-
- ) : (
-
-
setShowShortcuts(true)}>
-
-
-
-
- Keyboard Shortcuts
-
- View all available keyboard shortcuts
-
-
-
-
- {HELP_ITEMS.map((item) => {
- const Icon = item.icon;
- return (
-
-
-
-
-
- {item.title}
-
- {item.description}
-
-
-
- );
- })}
-
- )}
-
-
-
- );
-}
diff --git a/apps/dashboard/components/layout/logo.tsx b/apps/dashboard/components/layout/logo.tsx
index b241f603f5..a161494972 100644
--- a/apps/dashboard/components/layout/logo.tsx
+++ b/apps/dashboard/components/layout/logo.tsx
@@ -2,7 +2,6 @@ import Link from "next/link";
import { Branding } from "../logo/branding";
export { Branding } from "../logo/branding";
-export type { BrandingProps, BrandVariant } from "../logo/branding";
export function Logo() {
return (
diff --git a/apps/dashboard/components/layout/navigation/navigation-config.tsx b/apps/dashboard/components/layout/navigation/navigation-config.tsx
index b34271a267..2b2a9156fc 100644
--- a/apps/dashboard/components/layout/navigation/navigation-config.tsx
+++ b/apps/dashboard/components/layout/navigation/navigation-config.tsx
@@ -40,7 +40,7 @@ import {
} from "@databuddy/ui/icons";
import type { NavigationGroup, NavigationItem } from "./types";
-export const createNavItem = (
+const createNavItem = (
name: string,
icon: NavigationItem["icon"],
href: string,
diff --git a/apps/dashboard/components/layout/navigation/types.ts b/apps/dashboard/components/layout/navigation/types.ts
index 0ea43c7ef8..e95232c7e3 100644
--- a/apps/dashboard/components/layout/navigation/types.ts
+++ b/apps/dashboard/components/layout/navigation/types.ts
@@ -34,7 +34,7 @@ export interface NavigationItem {
tag?: string;
}
-export interface NavigationSearchItem {
+interface NavigationSearchItem {
disabled?: boolean;
external?: boolean;
href?: string;
@@ -44,13 +44,6 @@ export interface NavigationSearchItem {
searchTags?: string[];
}
-export interface NavigationSection {
- flag?: string;
- icon: NavIcon;
- items: NavigationItem[];
- title: string;
-}
-
export interface NavigationGroup {
back?: { href: string; label: string };
flag?: string;
@@ -58,5 +51,3 @@ export interface NavigationGroup {
label: string;
pinToBottom?: boolean;
}
-
-export type NavigationEntry = NavigationSection | NavigationItem;
diff --git a/apps/dashboard/components/layout/organization-selector.tsx b/apps/dashboard/components/layout/organization-selector.tsx
index 6fc22c3c4e..c9b3da5364 100644
--- a/apps/dashboard/components/layout/organization-selector.tsx
+++ b/apps/dashboard/components/layout/organization-selector.tsx
@@ -274,38 +274,44 @@ export function OrganizationSelector({
}}
open={isOpen}
>
- }
+
-
-
- {isSwitching
- ? "Switching organization…"
- : (activeOrganization?.name ?? "Select organization")}
-
- {isSwitching ? (
-
- ) : (
- }
+ >
+
- )}
-
+
+ {isSwitching
+ ? "Switching organization…"
+ : (activeOrganization?.name ?? "Select organization")}
+
+ {isSwitching ? (
+
+ ) : (
+
+ )}
+
+
);
}
-
-export function ProfileButtonClient({
- user,
-}: {
- user: ProfileButtonUser | null;
-}) {
- const [isOpen, setIsOpen] = useState(false);
-
- if (!user) {
- return null;
- }
-
- return (
-
-
- }
- >
-
-
-
- setIsOpen(false)}
- user={user}
- />
-
- );
-}
diff --git a/apps/dashboard/components/layout/sidebar-navigation-provider.tsx b/apps/dashboard/components/layout/sidebar-navigation-provider.tsx
index a2d2c7a466..8d29029ec0 100644
--- a/apps/dashboard/components/layout/sidebar-navigation-provider.tsx
+++ b/apps/dashboard/components/layout/sidebar-navigation-provider.tsx
@@ -1,7 +1,6 @@
"use client";
import { authClient } from "@databuddy/auth/client";
-import { useQuery } from "@tanstack/react-query";
import { usePathname } from "next/navigation";
import {
createContext,
@@ -13,8 +12,6 @@ import {
useState,
} from "react";
import { useWebsitesLight } from "@/hooks/use-websites";
-import { insightQueries } from "@/lib/insight-api";
-import { useOrganizationsContext } from "@/components/providers/organizations-provider";
import {
getNavContext,
getNavDirection,
@@ -59,8 +56,6 @@ export function SidebarNavigationProvider({
}) {
const { data: session } = authClient.useSession();
const user = session?.user ?? null;
- const { activeOrganizationId, isSwitchingOrganization } =
- useOrganizationsContext();
const pathname = usePathname();
@@ -81,13 +76,6 @@ export function SidebarNavigationProvider({
);
const navContext = getNavContext(pathname);
- const recommendationTotal = useQuery(
- insightQueries.recommendationTotal(
- navContext === "main" && !isSwitchingOrganization
- ? (activeOrganizationId ?? undefined)
- : undefined
- )
- );
const prevContextRef = useRef(navContext);
const [transitionDirection, setTransitionDirection] = useState<
"left" | "right" | null
@@ -104,29 +92,7 @@ export function SidebarNavigationProvider({
}
}, [navContext]);
- const navigation = useMemo(() => {
- const baseNavigation = getNavigation(pathname);
- const count = recommendationTotal.data ?? 0;
- if (navContext !== "main" || isSwitchingOrganization || count === 0) {
- return baseNavigation;
- }
-
- return baseNavigation.map((group) => ({
- ...group,
- items: group.items.map((item) =>
- item.href === "/insights"
- ? {
- ...item,
- badge: {
- label: `${count} current recommendation${count === 1 ? "" : "s"}`,
- text: count > 99 ? "99+" : count.toString(),
- variant: "red" as const,
- },
- }
- : item
- ),
- }));
- }, [isSwitchingOrganization, navContext, pathname, recommendationTotal.data]);
+ const navigation = useMemo(() => getNavigation(pathname), [pathname]);
const currentWebsiteId = isWebsite || isDemo ? websiteId : undefined;
diff --git a/apps/dashboard/components/layout/sidebar.tsx b/apps/dashboard/components/layout/sidebar.tsx
index b6e713dc12..afc9feb463 100644
--- a/apps/dashboard/components/layout/sidebar.tsx
+++ b/apps/dashboard/components/layout/sidebar.tsx
@@ -301,22 +301,20 @@ function useGroupCollapse(groupKey: string, hasActiveChild: boolean) {
}, [groupKey, hasActiveChild]);
const toggle = useCallback(() => {
- setIsCollapsed((prev) => {
- const next = !prev;
- try {
- const stored = JSON.parse(
- localStorage.getItem(COLLAPSED_GROUPS_KEY) || "{}"
- );
- if (next) {
- stored[groupKey] = true;
- } else {
- delete stored[groupKey];
- }
- localStorage.setItem(COLLAPSED_GROUPS_KEY, JSON.stringify(stored));
- } catch {}
- return next;
- });
- }, [groupKey]);
+ const next = !isCollapsed;
+ setIsCollapsed(next);
+ try {
+ const stored = JSON.parse(
+ localStorage.getItem(COLLAPSED_GROUPS_KEY) || "{}"
+ );
+ if (next) {
+ stored[groupKey] = true;
+ } else {
+ delete stored[groupKey];
+ }
+ localStorage.setItem(COLLAPSED_GROUPS_KEY, JSON.stringify(stored));
+ } catch {}
+ }, [groupKey, isCollapsed]);
return { isCollapsed, toggle };
}
diff --git a/apps/dashboard/components/layout/top-bar.tsx b/apps/dashboard/components/layout/top-bar.tsx
index c8d845eaea..7dc8b1a2e7 100644
--- a/apps/dashboard/components/layout/top-bar.tsx
+++ b/apps/dashboard/components/layout/top-bar.tsx
@@ -89,7 +89,9 @@ function useTopBarSlot(name: string, content: ReactNode) {
const store = useStore();
const id = useId();
const contentRef = useRef(content);
- contentRef.current = content;
+ useEffect(() => {
+ contentRef.current = content;
+ }, [content]);
useEffect(() => {
store.setSlot(name, id, contentRef.current);
diff --git a/apps/dashboard/components/logo/branding.tsx b/apps/dashboard/components/logo/branding.tsx
index ebfb16ce83..348c4f2c8e 100644
--- a/apps/dashboard/components/logo/branding.tsx
+++ b/apps/dashboard/components/logo/branding.tsx
@@ -1,20 +1,14 @@
import Image from "next/image";
import { cn } from "@/lib/utils";
-export type BrandVariant =
- | "logomark"
- | "wordmark"
- | "primary-logo"
- | "secondary-logo";
+type BrandVariant = "logomark" | "wordmark" | "primary-logo" | "secondary-logo";
export interface BrandingProps {
className?: string;
- /** Height of the primary asset in pixels (width follows the SVG viewBox aspect ratio). */
heightPx?: number;
imageClassName?: string;
priority?: boolean;
variant: BrandVariant;
- /** When `variant` is `logomark`, also show the wordmark asset beside the icon. */
wordmark?: boolean;
}
@@ -33,7 +27,6 @@ const BRAND_PATH: Record = {
};
interface ThemeBrandImageProps {
- /** Primary image alt; the dark-mode twin is decorative. */
alt: string;
basePath: string;
className?: string;
diff --git a/apps/dashboard/components/logo/section-brand-overlay.tsx b/apps/dashboard/components/logo/section-brand-overlay.tsx
index edbbff03a6..5f28c30419 100644
--- a/apps/dashboard/components/logo/section-brand-overlay.tsx
+++ b/apps/dashboard/components/logo/section-brand-overlay.tsx
@@ -3,9 +3,7 @@ import { cn } from "@/lib/utils";
export interface SectionBrandOverlayProps {
className?: string;
- /** Corner overlay vs inline (e.g. chart card header). */
layout?: "overlay" | "inline";
- /** When `layout` is `overlay`: horizontal corner. */
position?: "start" | "end";
}
diff --git a/apps/dashboard/components/monitors/collapsible-section.tsx b/apps/dashboard/components/monitors/collapsible-section.tsx
deleted file mode 100644
index a012d0302c..0000000000
--- a/apps/dashboard/components/monitors/collapsible-section.tsx
+++ /dev/null
@@ -1,66 +0,0 @@
-"use client";
-
-import { AnimatePresence, motion } from "motion/react";
-import { cn } from "@/lib/utils";
-import { CaretDownIcon } from "@databuddy/ui/icons";
-import { Button } from "@databuddy/ui";
-
-interface CollapsibleSectionProps {
- badge?: number;
- children: React.ReactNode;
- icon: React.ComponentType<{ size?: number; weight?: "duotone" | "fill" }>;
- isExpanded: boolean;
- onToggleAction: () => void;
- title: string;
-}
-
-export function CollapsibleSection({
- icon: Icon,
- title,
- badge,
- isExpanded,
- onToggleAction,
- children,
-}: CollapsibleSectionProps) {
- return (
-
-
-
-
- {title}
- {badge !== undefined && badge > 0 && (
-
- {badge}
-
- )}
-
-
-
-
-
- {isExpanded && (
-
- {children}
-
- )}
-
-
- );
-}
diff --git a/apps/dashboard/components/monitors/monitor-row.tsx b/apps/dashboard/components/monitors/monitor-row.tsx
index 9bca9cc559..80d8cb571c 100644
--- a/apps/dashboard/components/monitors/monitor-row.tsx
+++ b/apps/dashboard/components/monitors/monitor-row.tsx
@@ -22,7 +22,7 @@ import {
PlayIcon,
TrashIcon,
} from "@databuddy/ui/icons";
-import { DropdownMenu } from "@databuddy/ui/client";
+import { DeleteDialog, DropdownMenu } from "@databuddy/ui/client";
import { Badge, Skeleton, dayjs, formatDateOnly } from "@databuddy/ui";
const GRANULARITY_LABELS: Record = {
@@ -49,7 +49,6 @@ interface MonitorRowProps {
url: string | null;
name: string | null;
granularity: string;
- cron: string;
isPaused: boolean;
createdAt: Date | string;
updatedAt: Date | string;
@@ -69,6 +68,7 @@ function MonitorActions({
}: MonitorRowProps) {
const [isPausing, setIsPausing] = useState(false);
const [isTransferOpen, setIsTransferOpen] = useState(false);
+ const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const pauseMutation = useMutation({
...orpc.uptime.pauseSchedule.mutationOptions(),
@@ -124,6 +124,7 @@ function MonitorActions({
try {
await deleteMutation.mutateAsync({ scheduleId: schedule.id });
toast.success("Monitor deleted");
+ setIsDeleteOpen(false);
onDeleteAction();
} catch (error) {
const errorMessage =
@@ -198,7 +199,7 @@ function MonitorActions({
setIsDeleteOpen(true)}
variant="destructive"
>
@@ -207,6 +208,15 @@ function MonitorActions({
+ setIsDeleteOpen(false)}
+ onConfirm={handleDelete}
+ title="Delete Monitor"
+ />
+
{schedule.organizationId ? (
= {
@@ -52,9 +50,6 @@ interface MonitorSheetProps {
cacheBust?: boolean;
granularity: string;
id: string;
- jsonParsingConfig?: {
- enabled: boolean;
- } | null;
name?: string | null;
timeout?: number | null;
url: string;
@@ -141,10 +136,9 @@ export function MonitorSheet({
const [name, setName] = useState("");
const [url, setUrl] = useState("");
const [granularity, setGranularity] =
- useState("ten_minutes");
+ useState("ten_minutes");
const [timeoutMs, setTimeoutMs] = useState(null);
const [cacheBust, setCacheBust] = useState(false);
- const [jsonParsingEnabled, setJsonParsingEnabled] = useState(true);
const [urlError, setUrlError] = useState(null);
const createMutation = useMutation({
@@ -187,9 +181,7 @@ export function MonitorSheet({
await queryClient.invalidateQueries({
queryKey: orpc.alarms.list.key(),
});
- } catch {
- toast.error("Failed to update alert");
- }
+ } catch {}
};
useEffect(() => {
@@ -213,11 +205,10 @@ export function MonitorSheet({
setName(initialName);
setUrl(initialUrl);
setGranularity(
- (schedule?.granularity as GranularityValue) ?? "ten_minutes"
+ parseUptimeGranularity(schedule?.granularity) ?? "ten_minutes"
);
setTimeoutMs(schedule?.timeout ?? null);
setCacheBust(schedule?.cacheBust ?? false);
- setJsonParsingEnabled(schedule?.jsonParsingConfig?.enabled ?? true);
setUrlError(null);
}, [open, schedule, website, isEditing]);
@@ -246,8 +237,6 @@ export function MonitorSheet({
return;
}
- const jsonParsingConfig = { enabled: jsonParsingEnabled };
-
try {
if (isEditing && schedule) {
await updateMutation.mutateAsync({
@@ -256,7 +245,6 @@ export function MonitorSheet({
granularity,
timeout: timeoutMs,
cacheBust,
- jsonParsingConfig,
});
toast.success("Monitor updated");
} else {
@@ -272,18 +260,16 @@ export function MonitorSheet({
granularity,
timeout: timeoutMs ?? undefined,
cacheBust,
- jsonParsingConfig,
});
toast.success("Monitor created");
- onCreatedAction?.(result.scheduleId as string);
+ onCreatedAction?.(result.scheduleId);
}
onSaveAction?.();
onCloseAction(false);
} catch {}
};
- const advancedCount =
- (timeoutMs ? 1 : 0) + (cacheBust ? 1 : 0) + (jsonParsingEnabled ? 0 : 1);
+ const advancedCount = (timeoutMs ? 1 : 0) + (cacheBust ? 1 : 0);
return (
@@ -389,10 +375,12 @@ export function MonitorSheet({
max={120}
min={1}
onChange={(e) => {
- const val = e.target.value;
- setTimeoutMs(val ? Number(val) * 1000 : null);
+ const seconds = e.target.value;
+ setTimeoutMs(
+ seconds ? Number(seconds) * 1000 : null
+ );
}}
- placeholder="30"
+ placeholder="60"
suffix="sec"
type="number"
value={timeoutMs ? timeoutMs / 1000 : ""}
@@ -408,16 +396,6 @@ export function MonitorSheet({
onCheckedChange={setCacheBust}
/>
-
-
-
-
diff --git a/apps/dashboard/components/openai-ads-pixel.tsx b/apps/dashboard/components/openai-ads-pixel.tsx
index 987adac43b..eff4363c7c 100644
--- a/apps/dashboard/components/openai-ads-pixel.tsx
+++ b/apps/dashboard/components/openai-ads-pixel.tsx
@@ -89,7 +89,7 @@ export function OpenAiAdsPixel() {
return null;
}
-export function measureOpenAiRegistrationCompleted(eventId?: string) {
+function measureOpenAiRegistrationCompleted(eventId?: string) {
if (!initOpenAiQueue()) {
return;
}
diff --git a/apps/dashboard/components/organizations/api-key-types.ts b/apps/dashboard/components/organizations/api-key-types.ts
index 48eaaf2af0..59f59c4f96 100644
--- a/apps/dashboard/components/organizations/api-key-types.ts
+++ b/apps/dashboard/components/organizations/api-key-types.ts
@@ -39,23 +39,6 @@ export function formatMaskedApiKey({
return `${startIncludesPrefix ? start : `${cleanPrefix}_${start}`}••••`;
}
-export type ApiResourceType =
- | "global"
- | "website"
- | "ab_experiment"
- | "feature_flag"
- | "analytics_data"
- | "error_data"
- | "web_vitals"
- | "custom_events"
- | "export_data";
-
-export interface ApiKeyAccessEntry {
- resourceId?: string | null;
- resourceType: ApiResourceType;
- scopes: ApiScope[];
-}
-
export interface ApiKeyListItem {
createdAt: Date;
description?: string | null;
@@ -79,20 +62,3 @@ export interface ApiKeyListItem {
type: "user" | "sdk" | "automation";
updatedAt: Date;
}
-
-export interface ApiKeyDetail extends ApiKeyListItem {
- access: Array<{ id: string } & ApiKeyAccessEntry>;
-}
-
-export interface CreateApiKeyInput {
- access?: ApiKeyAccessEntry[];
- expiresAt?: string;
- globalScopes?: ApiScope[];
- metadata?: Record;
- name: string;
- organizationId: string;
- rateLimitEnabled?: boolean;
- rateLimitMax?: number;
- rateLimitTimeWindow?: number;
- type?: "user" | "sdk" | "automation";
-}
diff --git a/apps/dashboard/components/providers/billing-provider.tsx b/apps/dashboard/components/providers/billing-provider.tsx
index 095612cd67..92536adf5a 100644
--- a/apps/dashboard/components/providers/billing-provider.tsx
+++ b/apps/dashboard/components/providers/billing-provider.tsx
@@ -3,9 +3,12 @@
import {
FEATURE_METADATA,
type FeatureId,
+ type FeatureLimit,
type GatedFeatureId,
getMinimumPlanForFeature,
+ getNextPlanForFeature,
getPlanCapabilities as getPlanCapabilitiesForPlan,
+ getPlanFeatureLimit,
isPlanFeatureEnabled,
PLAN_IDS,
type PlanCapabilities,
@@ -22,7 +25,7 @@ type HookCustomer = NonNullable["data"]>;
type HookPlan = NonNullable["data"]>[number];
type HookBalance = NonNullable[string];
-export interface FeatureAccess {
+interface FeatureAccess {
allowed: boolean;
balance: number;
limit: number;
@@ -30,9 +33,11 @@ export interface FeatureAccess {
usagePercent: number | null;
}
-export interface GatedFeatureAccess {
+interface GatedFeatureAccess {
allowed: boolean;
+ limit: FeatureLimit;
minPlan: PlanId | null;
+ nextPlan: PlanId | null;
upgradeMessage: string | null;
}
@@ -86,7 +91,9 @@ const DEMO_BILLING_VALUE: BillingContextValue = {
isFeatureEnabled: () => true,
getGatedFeatureAccess: () => ({
allowed: true,
+ limit: "unlimited",
minPlan: null,
+ nextPlan: null,
upgradeMessage: null,
}),
getUpgradeMessage: () => null,
@@ -242,7 +249,9 @@ function AuthenticatedBillingProvider({
const allowed = isPlanFeatureEnabled(currentPlanId, feature);
return {
allowed,
+ limit: getPlanFeatureLimit(currentPlanId, feature),
minPlan: getMinimumPlanForFeature(feature),
+ nextPlan: getNextPlanForFeature(currentPlanId, feature),
upgradeMessage: allowed
? null
: (FEATURE_METADATA[feature]?.upgradeMessage ?? null),
diff --git a/apps/dashboard/components/table/data-table.tsx b/apps/dashboard/components/table/data-table.tsx
index 1a378bc1a9..62bf43ea9a 100644
--- a/apps/dashboard/components/table/data-table.tsx
+++ b/apps/dashboard/components/table/data-table.tsx
@@ -98,6 +98,7 @@ export function DataTable({
const [activeTab, setActiveTab] = useState(tabs?.[0]?.id || "");
const { fullScreen, setFullScreen, hasMounted, modalRef } = useFullScreen();
+ const portalTarget = typeof document === "undefined" ? null : document.body;
const currentTabData = tabs?.find((tab) => tab.id === activeTab);
const tableData = currentTabData?.data || data || [];
@@ -175,6 +176,7 @@ export function DataTable({
{hasMounted &&
fullScreen &&
+ portalTarget &&
ReactDOM.createPortal(
({
/>
,
- document.body
+ portalTarget
)}
>
);
diff --git a/apps/dashboard/components/table/rows/icon-text-row.tsx b/apps/dashboard/components/table/rows/icon-text-row.tsx
index 3d026a4401..cb0ff5c3b5 100644
--- a/apps/dashboard/components/table/rows/icon-text-row.tsx
+++ b/apps/dashboard/components/table/rows/icon-text-row.tsx
@@ -1,76 +1 @@
-import type { CellContext, ColumnDef } from "@tanstack/react-table";
-import type { ReactNode } from "react";
-import { formatNumber } from "@/lib/formatters";
-import { PercentageBadge } from "@databuddy/ui";
-
-export interface IconTextEntry {
- name: string;
- pageviews?: number;
- percentage?: number;
- visitors: number;
-}
-
-interface IconTextRowProps {
- accessorKey?: string;
- getIcon: (name: string, entry?: IconTextEntry) => ReactNode;
- getSubtitle?: (entry: IconTextEntry) => string | undefined;
- header: string;
- includeMetrics?: boolean;
-}
-
-export function createIconTextColumns({
- header,
- accessorKey = "name",
- getIcon,
- getSubtitle,
- includeMetrics = true,
-}: IconTextRowProps): ColumnDef[] {
- const columns: ColumnDef[] = [
- {
- id: accessorKey,
- accessorKey,
- header,
- cell: (info: CellContext) => {
- const name = (info.getValue() as string) || "";
- const entry = info.row.original;
- const subtitle = getSubtitle?.(entry);
-
- return (
-
- {getIcon(name, entry)}
-
-
{name}
- {subtitle && (
-
{subtitle}
- )}
-
-
- );
- },
- },
- ];
-
- if (includeMetrics) {
- columns.push(
- {
- id: "visitors",
- accessorKey: "visitors",
- header: "Visitors",
- cell: (info: CellContext) => (
- {formatNumber(info.getValue())}
- ),
- },
- {
- id: "percentage",
- accessorKey: "percentage",
- header: "Share",
- cell: (info: CellContext) => {
- const percentage = info.getValue() as number;
- return ;
- },
- }
- );
- }
-
- return columns;
-}
+export {};
diff --git a/apps/dashboard/components/table/rows/referrer-row.tsx b/apps/dashboard/components/table/rows/referrer-row.tsx
index 711b17ed39..e8e1eb78a4 100644
--- a/apps/dashboard/components/table/rows/referrer-row.tsx
+++ b/apps/dashboard/components/table/rows/referrer-row.tsx
@@ -30,7 +30,7 @@ const DEFAULT_REFERRER_METRICS: ReferrerMetricColumn[] = [
{ id: "pageviews", header: "Views" },
];
-export function getReferrerDisplayValue(row: ReferrerSourceCellData): string {
+function getReferrerDisplayValue(row: ReferrerSourceCellData): string {
return row.name || row.source || row.referrer || "Direct";
}
diff --git a/apps/dashboard/components/table/table-content.tsx b/apps/dashboard/components/table/table-content.tsx
index cdffb99aff..10b931090f 100644
--- a/apps/dashboard/components/table/table-content.tsx
+++ b/apps/dashboard/components/table/table-content.tsx
@@ -40,7 +40,7 @@ const DEFAULT_CELL_STYLE = {
const COMPACT_COLUMN_WIDTHS: Record = {
clicks: 88,
- cls: 76,
+ cls: 78,
current_time: 108,
customers: 100,
fcp: 88,
diff --git a/apps/dashboard/components/ui/alert.tsx b/apps/dashboard/components/ui/alert.tsx
index 240bf847df..3a01dbfb30 100644
--- a/apps/dashboard/components/ui/alert.tsx
+++ b/apps/dashboard/components/ui/alert.tsx
@@ -60,4 +60,4 @@ function AlertDescription({
);
}
-export { Alert, AlertDescription, AlertTitle };
+export { Alert, AlertDescription, };
diff --git a/apps/dashboard/components/ui/aspect-ratio.tsx b/apps/dashboard/components/ui/aspect-ratio.tsx
deleted file mode 100644
index 956e8bb05d..0000000000
--- a/apps/dashboard/components/ui/aspect-ratio.tsx
+++ /dev/null
@@ -1,11 +0,0 @@
-"use client";
-
-import { AspectRatio as AspectRatioPrimitive } from "radix-ui";
-
-function AspectRatio({
- ...props
-}: React.ComponentProps) {
- return ;
-}
-
-export { AspectRatio };
diff --git a/apps/dashboard/components/ui/badge.tsx b/apps/dashboard/components/ui/badge.tsx
deleted file mode 100644
index 3c6f47bfdd..0000000000
--- a/apps/dashboard/components/ui/badge.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import { Slot } from "@radix-ui/react-slot";
-import { cva, type VariantProps } from "class-variance-authority";
-import type * as React from "react";
-
-import { cn } from "@/lib/utils";
-
-const badgeVariants = cva(
- "inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded border px-2 py-0.5 font-medium text-xs transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
- {
- variants: {
- variant: {
- default:
- "border border-brand-purple/35 bg-brand-purple text-white dark:border-brand-purple/55 dark:bg-brand-purple dark:text-white [a&]:hover:bg-brand-purple/90",
- gray: "border border-border bg-muted text-muted-foreground dark:border-border dark:bg-secondary dark:text-muted-foreground [a&]:hover:bg-muted/90",
- blue: "border border-brand-purple/25 bg-brand-purple/10 text-brand-purple dark:border-brand-purple/40 dark:bg-brand-purple/18 dark:text-[#C9BFE8] [a&]:hover:bg-brand-purple/15",
- green:
- "border border-emerald-600/25 bg-emerald-50 text-emerald-800 dark:border-emerald-500/35 dark:bg-emerald-950/50 dark:text-emerald-300 [a&]:hover:bg-emerald-100/90",
- amber:
- "border border-brand-amber/30 bg-brand-amber/12 text-amber-950 dark:border-brand-amber/40 dark:bg-brand-amber/14 dark:text-amber-300 [a&]:hover:bg-brand-amber/18",
- secondary:
- "border border-foreground/15 bg-foreground text-background dark:border-foreground/25 dark:bg-foreground dark:text-background [a&]:hover:bg-foreground/90",
- destructive:
- "border border-brand-coral/30 bg-brand-coral/12 text-brand-coral focus-visible:ring-brand-coral/20 dark:border-brand-coral/45 dark:bg-brand-coral/22 dark:text-[#E8A8BE] [a&]:hover:bg-brand-coral/18",
- outline:
- "border border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
- },
- },
- defaultVariants: {
- variant: "default",
- },
- }
-);
-
-function Badge({
- className,
- variant,
- asChild = false,
- ...props
-}: React.ComponentProps<"span"> &
- VariantProps & { asChild?: boolean }) {
- const Comp = asChild ? Slot : "span";
-
- return (
-
- );
-}
-
-export { Badge, badgeVariants };
diff --git a/apps/dashboard/components/ui/button-group.tsx b/apps/dashboard/components/ui/button-group.tsx
deleted file mode 100644
index 69c616fcd6..0000000000
--- a/apps/dashboard/components/ui/button-group.tsx
+++ /dev/null
@@ -1,82 +0,0 @@
-import { Slot } from "@radix-ui/react-slot";
-import { cva, type VariantProps } from "class-variance-authority";
-import { cn } from "@/lib/utils";
-import { Divider } from "@databuddy/ui";
-
-const buttonGroupVariants = cva(
- "flex w-fit items-stretch has-[>[data-slot=button-group]]:gap-2 [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
- {
- variants: {
- orientation: {
- horizontal:
- "[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
- vertical:
- "flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
- },
- },
- defaultVariants: {
- orientation: "horizontal",
- },
- }
-);
-
-function ButtonGroup({
- className,
- orientation,
- ...props
-}: React.ComponentProps<"div"> & VariantProps) {
- return (
-
- );
-}
-
-function ButtonGroupText({
- className,
- asChild = false,
- ...props
-}: React.ComponentProps<"div"> & {
- asChild?: boolean;
-}) {
- const Comp = asChild ? Slot : "div";
-
- return (
-
- );
-}
-
-function ButtonGroupSeparator({
- className,
- orientation = "vertical",
- ...props
-}: React.ComponentProps) {
- return (
-
- );
-}
-
-export {
- ButtonGroup,
- ButtonGroupSeparator,
- ButtonGroupText,
- buttonGroupVariants,
-};
diff --git a/apps/dashboard/components/ui/button.tsx b/apps/dashboard/components/ui/button.tsx
index 5948bdef7d..88423af602 100644
--- a/apps/dashboard/components/ui/button.tsx
+++ b/apps/dashboard/components/ui/button.tsx
@@ -65,4 +65,4 @@ function Button({
);
}
-export { Button, buttonVariants };
+export { Button, };
diff --git a/apps/dashboard/components/ui/card.tsx b/apps/dashboard/components/ui/card.tsx
deleted file mode 100644
index aa9c6f8a63..0000000000
--- a/apps/dashboard/components/ui/card.tsx
+++ /dev/null
@@ -1,92 +0,0 @@
-import type * as React from "react";
-
-import { cn } from "@/lib/utils";
-
-function Card({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- );
-}
-
-function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- );
-}
-
-function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- );
-}
-
-function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- );
-}
-
-function CardAction({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- );
-}
-
-function CardContent({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- );
-}
-
-function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- );
-}
-
-export {
- Card,
- CardAction,
- CardContent,
- CardDescription,
- CardFooter,
- CardHeader,
- CardTitle,
-};
diff --git a/apps/dashboard/components/ui/carousel.tsx b/apps/dashboard/components/ui/carousel.tsx
deleted file mode 100644
index b4134c2143..0000000000
--- a/apps/dashboard/components/ui/carousel.tsx
+++ /dev/null
@@ -1,249 +0,0 @@
-"use client";
-
-import useEmblaCarousel, {
- type UseEmblaCarouselType,
-} from "embla-carousel-react";
-import * as React from "react";
-import { cn } from "@/lib/utils";
-import {
- ArrowLeftIcon,
- ArrowRightIcon,
-} from "@databuddy/ui/icons";
-import { Button } from "@databuddy/ui";
-
-type CarouselApi = UseEmblaCarouselType[1];
-type UseCarouselParameters = Parameters;
-type CarouselOptions = UseCarouselParameters[0];
-type CarouselPlugin = UseCarouselParameters[1];
-
-type CarouselProps = {
- opts?: CarouselOptions;
- plugins?: CarouselPlugin;
- orientation?: "horizontal" | "vertical";
- setApi?: (api: CarouselApi) => void;
-};
-
-type CarouselContextProps = {
- carouselRef: ReturnType[0];
- api: ReturnType[1];
- scrollPrev: () => void;
- scrollNext: () => void;
- canScrollPrev: boolean;
- canScrollNext: boolean;
-} & CarouselProps;
-
-const CarouselContext = React.createContext(null);
-
-function useCarousel() {
- const context = React.useContext(CarouselContext);
-
- if (!context) {
- throw new Error("useCarousel must be used within a ");
- }
-
- return context;
-}
-
-function Carousel({
- orientation = "horizontal",
- opts,
- setApi,
- plugins,
- className,
- children,
- ...props
-}: React.ComponentProps<"div"> & CarouselProps) {
- const [carouselRef, api] = useEmblaCarousel(
- {
- ...opts,
- axis: orientation === "horizontal" ? "x" : "y",
- },
- plugins
- );
- const [canScrollPrev, setCanScrollPrev] = React.useState(false);
- const [canScrollNext, setCanScrollNext] = React.useState(false);
-
- const onSelect = React.useCallback((api: CarouselApi) => {
- if (!api) {
- return;
- }
- setCanScrollPrev(api.canScrollPrev());
- setCanScrollNext(api.canScrollNext());
- }, []);
-
- const scrollPrev = React.useCallback(() => {
- api?.scrollPrev();
- }, [api]);
-
- const scrollNext = React.useCallback(() => {
- api?.scrollNext();
- }, [api]);
-
- const handleKeyDown = React.useCallback(
- (event: React.KeyboardEvent) => {
- if (event.key === "ArrowLeft") {
- event.preventDefault();
- scrollPrev();
- } else if (event.key === "ArrowRight") {
- event.preventDefault();
- scrollNext();
- }
- },
- [scrollPrev, scrollNext]
- );
-
- React.useEffect(() => {
- if (!(api && setApi)) {
- return;
- }
- setApi(api);
- }, [api, setApi]);
-
- React.useEffect(() => {
- if (!api) {
- return;
- }
- onSelect(api);
- api.on("reInit", onSelect);
- api.on("select", onSelect);
-
- return () => {
- api?.off("select", onSelect);
- };
- }, [api, onSelect]);
-
- return (
-
-
- {children}
-
-
- );
-}
-
-function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
- const { carouselRef, orientation } = useCarousel();
-
- return (
-
- );
-}
-
-function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
- const { orientation } = useCarousel();
-
- return (
-
- );
-}
-
-function CarouselPrevious({
- className,
- variant = "secondary",
- size = "sm",
- ...props
-}: React.ComponentProps) {
- const { orientation, scrollPrev, canScrollPrev } = useCarousel();
-
- return (
-
-
- Previous slide
-
- );
-}
-
-function CarouselNext({
- className,
- variant = "secondary",
- size = "sm",
- ...props
-}: React.ComponentProps) {
- const { orientation, scrollNext, canScrollNext } = useCarousel();
-
- return (
-
-
- Next slide
-
- );
-}
-
-export {
- Carousel,
- type CarouselApi,
- CarouselContent,
- CarouselItem,
- CarouselNext,
- CarouselPrevious,
-};
diff --git a/apps/dashboard/components/ui/command.tsx b/apps/dashboard/components/ui/command.tsx
deleted file mode 100644
index ab33153220..0000000000
--- a/apps/dashboard/components/ui/command.tsx
+++ /dev/null
@@ -1,174 +0,0 @@
-"use client";
-
-import { Command as CommandPrimitive } from "cmdk";
-import type * as React from "react";
-import { cn } from "@/lib/utils";
-import {
- MagnifyingGlassIcon,
-} from "@databuddy/ui/icons";
-import { Dialog } from "@databuddy/ui/client";
-
-function Command({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- );
-}
-
-function CommandDialog({
- title = "Command Palette",
- description = "Search for a command to run...",
- children,
- ...props
-}: Omit, "children"> & {
- title?: string;
- description?: string;
- children?: React.ReactNode;
-}) {
- return (
-
-
-
- {title}
- {description}
-
-
-
- {children}
-
-
-
-
- );
-}
-
-function CommandInput({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
-
-
-
- );
-}
-
-function CommandList({
- className,
- ...props
-}: React.ComponentProps) {
- return (
- e.stopPropagation()}
- onWheel={(e) => e.stopPropagation()}
- {...props}
- />
- );
-}
-
-function CommandEmpty({
- ...props
-}: React.ComponentProps) {
- return (
-
- );
-}
-
-function CommandGroup({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- );
-}
-
-function CommandSeparator({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- );
-}
-
-function CommandItem({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- );
-}
-
-function CommandShortcut({
- className,
- ...props
-}: React.ComponentProps<"span">) {
- return (
-
- );
-}
-
-export {
- Command,
- CommandDialog,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandItem,
- CommandList,
- CommandSeparator,
- CommandShortcut,
-};
diff --git a/apps/dashboard/components/ui/composables/chart.tsx b/apps/dashboard/components/ui/composables/chart.tsx
index f96a3abb6e..c8bbb00b65 100644
--- a/apps/dashboard/components/ui/composables/chart.tsx
+++ b/apps/dashboard/components/ui/composables/chart.tsx
@@ -69,8 +69,6 @@ import {
type EmptyStateProps,
} from "@databuddy/ui";
-// ── Tooltip primitives ──────────────────────────────────────────────────
-
interface TooltipEntry {
color: string;
formattedValue?: string;
@@ -245,13 +243,6 @@ function formatTooltipDate(dateStr: string): string {
}
return parsed.format("MMM D");
}
-
-// ── Chart types ─────────────────────────────────────────────────────────
-
-/**
- * Series key → color/label map (e.g. `buildChartConfig` in AI chart renderers).
- * Theme variant uses light/dark CSS color strings.
- */
export type ChartConfig = {
[k in string]: {
label?: ReactNode;
@@ -304,7 +295,6 @@ export function mergeChartInteractiveFeatures(
}
export interface RechartsSingleValueTooltipParams {
- /** Overrides default `formatTooltipDate` for the tooltip subtitle line. */
formatLabelAction?: (label: string) => string;
formatValue?: (value: number) => string;
valueSuffixLabel?: string;
@@ -367,8 +357,6 @@ function readTooltipNumericValue(
}
return null;
}
-
-/** Recharts ` ` for single-series charts (`ChartTooltip` + `formatTooltipDate`). */
export function createRechartsSingleValueTooltip(
params: RechartsSingleValueTooltipParams
) {
@@ -417,12 +405,10 @@ interface ChartSingleSeriesProps {
fallbackClassName?: string;
height: number;
id: string;
- /** Recharts margin; defaults to `Chart.zeroMargin`. */
margin?: { bottom?: number; left?: number; right?: number; top?: number };
partialLastSegment?: boolean;
seriesKind?: ChartSeriesKind;
tooltip?: RechartsSingleValueTooltipParams | false;
- /** Passed to `YAxis` `domain` (e.g. mini charts use `dataMin - 5` / `dataMax + 5`). */
yDomain?: [number | string, number | string];
}
@@ -552,24 +538,15 @@ interface ChartCartesianAreaProps {
dataKey: string;
dateKey?: string;
fallbackClassName?: string;
- /** Tooltip title line (formatted date/time). */
formatTooltipLabel: (label: string) => string;
height: number;
id: string;
margin?: { bottom?: number; left?: number; right?: number; top?: number };
showGrid?: boolean;
strokeWidth?: number;
- /** Legend row label in the tooltip (e.g. “Clicks”). */
valueLabel: string;
- /** X tick labels (e.g. dayjs). */
xTickFormatter: (value: string) => string;
}
-
-/**
- * Single-series area chart with visible axes, optional horizontal grid, and
- * `Chart.Tooltip` multi-row layout—use instead of hand-rolling `AreaChart` +
- * `CartesianGrid` + `XAxis` + `YAxis` for standard dashboard line/area pages.
- */
function ChartCartesianArea({
data,
dataKey,
@@ -662,14 +639,11 @@ export interface ChartMultiSeriesDataPoint {
}
interface ChartMultiSeriesProps {
- /** Grouped (default) or stacked bars; only applies when `seriesKind` is `bar`. */
barLayout?: "grouped" | "stacked";
- /** `stackId` for stacked bars (default `"stack"`). */
barStackId?: string;
curveType?: ChartCurveType;
data: ChartMultiSeriesDataPoint[];
height: number;
- /** When false (default), shows date ticks on the X axis. Mini charts often hide this. */
hideXAxis?: boolean;
metrics: Array;
partialLastSegment?: boolean;
@@ -938,8 +912,6 @@ interface ChartPlotProps {
children: ReactNode;
className?: string;
}
-
-/** Chart drawing region (e.g. dotted background + ResponsiveContainer). */
function ChartPlot({ children, className }: ChartPlotProps) {
return (
({
}
ChartRoot.displayName = "Chart";
-
-/**
- * Recharts primitives for custom charts. Prefer `Chart.SingleSeries` / `Chart.MultiSeries`
- * when the use case matches; use these for pie, brush, reference lines, dual axes, etc.
- * `Legend` here is Recharts’ legend; `Chart.Legend` is the dashboard metric pills.
- */
const chartRecharts = {
Area,
AreaChart,
diff --git a/apps/dashboard/components/ui/composables/list.tsx b/apps/dashboard/components/ui/composables/list.tsx
index 6a23713c79..50ba61e90b 100644
--- a/apps/dashboard/components/ui/composables/list.tsx
+++ b/apps/dashboard/components/ui/composables/list.tsx
@@ -135,17 +135,11 @@ function ListCell({
interface ListContentBaseProps
{
children: (items: T[]) => ReactNode;
- /** Shown when outcome is empty; overrides emptyProps */
empty?: ReactNode;
- /** Passed to EmptyState when outcome is empty (unless `empty` is set) */
emptyProps?: EmptyStateProps;
- /** Shown when outcome is error; overrides errorProps */
error?: ReactNode;
- /** Passed to EmptyState with variant `error` when outcome is error (unless `error` is set) */
errorProps?: EmptyStateProps;
- /** Shown when outcome is loading; defaults to List.DefaultLoading */
loading?: ReactNode;
- /** Wrapper for default EmptyState branches (not applied to custom `empty` / `error` nodes) */
stateWrapperClassName?: string;
}
diff --git a/apps/dashboard/components/ui/dialog.tsx b/apps/dashboard/components/ui/dialog.tsx
deleted file mode 100644
index 485ee171d9..0000000000
--- a/apps/dashboard/components/ui/dialog.tsx
+++ /dev/null
@@ -1,146 +0,0 @@
-"use client";
-
-import { XMarkIcon as XIcon } from "@databuddy/ui/icons";
-import { Dialog as DialogPrimitive } from "radix-ui";
-import type * as React from "react";
-
-import { cn } from "@/lib/utils";
-
-function Dialog({
- ...props
-}: React.ComponentProps) {
- return ;
-}
-
-function DialogTrigger({
- ...props
-}: React.ComponentProps) {
- return ;
-}
-
-function DialogPortal({
- ...props
-}: React.ComponentProps) {
- return ;
-}
-
-function DialogClose({
- ...props
-}: React.ComponentProps) {
- return ;
-}
-
-function DialogOverlay({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- );
-}
-
-function DialogContent({
- className,
- children,
- showCloseButton = true,
- ...props
-}: React.ComponentProps & {
- showCloseButton?: boolean;
-}) {
- return (
-
-
-
- {children}
- {showCloseButton && (
-
-
- Close
-
- )}
-
-
- );
-}
-
-function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- );
-}
-
-function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- );
-}
-
-function DialogTitle({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- );
-}
-
-function DialogDescription({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- );
-}
-
-export {
- Dialog,
- DialogClose,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogOverlay,
- DialogPortal,
- DialogTitle,
- DialogTrigger,
-};
diff --git a/apps/dashboard/components/ui/dotmatrix/core.tsx b/apps/dashboard/components/ui/dotmatrix/core.tsx
index 2edcf57981..bb1200665f 100644
--- a/apps/dashboard/components/ui/dotmatrix/core.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/core.tsx
@@ -4,7 +4,7 @@ import type { CSSProperties } from "react";
import { useDotMatrixPhases, usePrefersReducedMotion } from "./hooks";
import type { DotMatrixPhase } from "./types";
-export type { DotMatrixPhase } from "./types";
+;
export type MatrixPattern = "diamond" | "full" | "outline" | "rose" | "cross" | "rings";
@@ -28,7 +28,7 @@ export interface DotMatrixCommonProps {
minSize?: number;
}
-export interface DotAnimationContext {
+interface DotAnimationContext {
index: number;
row: number;
col: number;
@@ -41,7 +41,7 @@ export interface DotAnimationContext {
reducedMotion: boolean;
}
-export interface DotAnimationState {
+interface DotAnimationState {
className?: string;
style?: CSSProperties;
}
@@ -57,30 +57,30 @@ const CENTER = Math.floor(MATRIX_SIZE / 2);
const RANGE = Array.from({ length: MATRIX_SIZE }, (_, index) => index);
const MAX_RADIUS = Math.hypot(CENTER, CENTER);
-export const FULL_INDEXES = RANGE.flatMap((row) => RANGE.map((col) => rowMajorIndex(row, col)));
+const FULL_INDEXES = RANGE.flatMap((row) => RANGE.map((col) => rowMajorIndex(row, col)));
-export const DIAMOND_INDEXES = FULL_INDEXES.filter((index) => {
+const DIAMOND_INDEXES = FULL_INDEXES.filter((index) => {
const { row, col } = indexToCoord(index);
return Math.abs(row - CENTER) + Math.abs(col - CENTER) <= 2;
});
-export const OUTLINE_INDEXES = FULL_INDEXES.filter((index) => {
+const OUTLINE_INDEXES = FULL_INDEXES.filter((index) => {
const { row, col } = indexToCoord(index);
return row === 0 || row === MATRIX_SIZE - 1 || col === 0 || col === MATRIX_SIZE - 1;
});
-export const CROSS_INDEXES = FULL_INDEXES.filter((index) => {
+const CROSS_INDEXES = FULL_INDEXES.filter((index) => {
const { row, col } = indexToCoord(index);
return row === CENTER || col === CENTER;
});
-export const RINGS_INDEXES = FULL_INDEXES.filter((index) => {
+const RINGS_INDEXES = FULL_INDEXES.filter((index) => {
const { row, col } = indexToCoord(index);
const radius = Math.hypot(row - CENTER, col - CENTER);
return Math.round(radius) === 1 || Math.round(radius) === 2;
});
-export const ROSE_INDEXES = FULL_INDEXES.filter((index) => {
+const ROSE_INDEXES = FULL_INDEXES.filter((index) => {
const { row, col } = indexToCoord(index);
const dx = col - CENTER;
const dy = row - CENTER;
@@ -99,7 +99,7 @@ const PATTERN_INDEXES: Record = {
rings: RINGS_INDEXES
};
-export function getPatternIndexes(pattern: MatrixPattern = "diamond"): number[] {
+function getPatternIndexes(pattern: MatrixPattern = "diamond"): number[] {
return PATTERN_INDEXES[pattern];
}
@@ -107,43 +107,43 @@ export function rowMajorIndex(row: number, col: number): number {
return row * MATRIX_SIZE + col;
}
-export function indexToCoord(index: number): { row: number; col: number } {
+function indexToCoord(index: number): { row: number; col: number } {
return {
row: Math.floor(index / MATRIX_SIZE),
col: index % MATRIX_SIZE
};
}
-export function distanceFromCenter(index: number): number {
+function distanceFromCenter(index: number): number {
const { row, col } = indexToCoord(index);
return Math.hypot(row - CENTER, col - CENTER);
}
-export function rowDistance(index: number): number {
+function rowDistance(index: number): number {
const { row } = indexToCoord(index);
return Math.abs(row - CENTER);
}
-export function polarAngle(index: number): number {
+function polarAngle(index: number): number {
const { row, col } = indexToCoord(index);
return Math.atan2(row - CENTER, col - CENTER);
}
-export function normalizedRadius(index: number): number {
+function normalizedRadius(index: number): number {
const { row, col } = indexToCoord(index);
return Math.hypot(row - CENTER, col - CENTER) / MAX_RADIUS;
}
-export function manhattanDistance(index: number): number {
+function manhattanDistance(index: number): number {
const { row, col } = indexToCoord(index);
return Math.abs(row - CENTER) + Math.abs(col - CENTER);
}
-export function harmonicPhase(row: number, col: number, a: number, b: number): number {
+function harmonicPhase(row: number, col: number, a: number, b: number): number {
return Math.sin((row + 1) * a + (col + 1) * b);
}
-export function lissajousOffset(
+function lissajousOffset(
row: number,
col: number,
amplitude = 2.25
@@ -154,7 +154,7 @@ export function lissajousOffset(
return { x, y, phase };
}
-export function spiralOffset(
+function spiralOffset(
angle: number,
radiusNormalizedValue: number,
amplitude = 2.8
@@ -167,7 +167,7 @@ export function spiralOffset(
return { x, y, phase };
}
-export function isPrime(value: number): boolean {
+function isPrime(value: number): boolean {
if (value <= 1) {
return false;
}
@@ -220,11 +220,11 @@ function buildSnakeOrderToIndexMap(): number[] {
const SNAKE_ORDER: readonly number[] = buildSnakeOrderToIndexMap();
-export function snakePathNormFromIndex(index: number): number {
+function snakePathNormFromIndex(index: number): number {
return SNAKE_ORDER[index]! / (CELLS - 1);
}
-export function snakePathOrderValue(index: number): number {
+function snakePathOrderValue(index: number): number {
return SNAKE_ORDER[index]!;
}
@@ -419,20 +419,20 @@ function buildRowWaveSnakeOrderToIndexMap(): number[] {
const ROW_WAVE_SNAKE_ORDER: readonly number[] = buildRowWaveSnakeOrderToIndexMap();
const ROW_WAVE_SNAKE_MAX_ORDER = Math.max(...ROW_WAVE_SNAKE_ORDER);
-export function rowWaveOrderValue(index: number): number {
+function rowWaveOrderValue(index: number): number {
return ROW_WAVE_SNAKE_ORDER[index]!;
}
-export function rowWaveNormFromIndex(index: number): number {
+function rowWaveNormFromIndex(index: number): number {
return ROW_WAVE_SNAKE_MAX_ORDER > 0 ? rowWaveOrderValue(index) / ROW_WAVE_SNAKE_MAX_ORDER : 0;
}
-export function colWaveNormFromIndex(index: number): number {
+function colWaveNormFromIndex(index: number): number {
const { col } = indexToCoord(index);
return N > 1 ? col / (N - 1) : 0;
}
-export function concentricRingNormFromIndex(index: number): number {
+function concentricRingNormFromIndex(index: number): number {
const { row, col } = indexToCoord(index);
return Math.max(Math.abs(row - C), Math.abs(col - C)) / C;
}
@@ -752,7 +752,7 @@ export function DotMatrixBase({
type NormFn = (ctx: Pick) => number;
-export function createPathWaveResolver(getPathNorm: NormFn): DotAnimationResolver {
+function createPathWaveResolver(getPathNorm: NormFn): DotAnimationResolver {
return ({ isActive, row, col, index, reducedMotion, phase }) => {
if (!isActive) {
return { className: "dmx-inactive" };
@@ -776,7 +776,7 @@ export function createPathWaveResolver(getPathNorm: NormFn): DotAnimationResolve
type PathWaveComponentProps = DotMatrixCommonProps;
-export function createPathWaveComponent(displayName: string, getPathNorm: NormFn) {
+function createPathWaveComponent(displayName: string, getPathNorm: NormFn) {
const resolve = createPathWaveResolver(getPathNorm);
function PathWaveComponent({
diff --git a/apps/dashboard/components/ui/dotmatrix/index.ts b/apps/dashboard/components/ui/dotmatrix/index.ts
index bac705ca2d..9bd60770bc 100644
--- a/apps/dashboard/components/ui/dotmatrix/index.ts
+++ b/apps/dashboard/components/ui/dotmatrix/index.ts
@@ -1,21 +1,4 @@
-export { DotMatrixBase } from "./core";
-export type {
- DotAnimationContext,
- DotAnimationResolver,
- DotAnimationState,
- DotMatrixCommonProps,
-} from "./core";
-
-export {
- useCyclePhase,
- useDotMatrixPhases,
- usePrefersReducedMotion,
- useSteppedCycle,
-} from "./hooks";
-
export {
DotMatrixLoader,
useRandomDotMatrixLoader,
- DOT_MATRIX_LOADER_NAMES,
} from "./loader";
-export type { DotMatrixLoaderName, DotMatrixLoaderProps } from "./loader";
diff --git a/apps/dashboard/components/ui/dotmatrix/loader.tsx b/apps/dashboard/components/ui/dotmatrix/loader.tsx
index 82f1c22041..41360d7390 100644
--- a/apps/dashboard/components/ui/dotmatrix/loader.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/loader.tsx
@@ -27,7 +27,7 @@ function capitalize(s: string) {
return s[0]!.toUpperCase() + s.slice(1);
}
-export const DOT_MATRIX_LOADER_NAMES = SHAPES.flatMap((shape) =>
+const DOT_MATRIX_LOADER_NAMES = SHAPES.flatMap((shape) =>
Array.from({ length: VARIANT_COUNT }, (_, i) => buildLoaderName(shape, i + 1))
);
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/circular/1.tsx b/apps/dashboard/components/ui/dotmatrix/variants/circular/1.tsx
index 6f1b4864a8..1f087dfc88 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/circular/1.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/circular/1.tsx
@@ -9,7 +9,7 @@ import { useCyclePhase } from "../../hooks";
import { usePrefersReducedMotion } from "../../hooks";
import type { DotAnimationResolver, DotMatrixCommonProps } from "../../core";
-export type DotmCircular1Props = DotMatrixCommonProps;
+type DotmCircular1Props = DotMatrixCommonProps;
const BASE_OPACITY = 0.08;
const STRAND_OPACITY = 1;
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/circular/10.tsx b/apps/dashboard/components/ui/dotmatrix/variants/circular/10.tsx
index 1926213146..6725b2b664 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/circular/10.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/circular/10.tsx
@@ -9,7 +9,7 @@ import { useCyclePhase } from "../../hooks";
import { usePrefersReducedMotion } from "../../hooks";
import type { DotAnimationResolver, DotMatrixCommonProps } from "../../core";
-export type DotmCircular10Props = DotMatrixCommonProps;
+type DotmCircular10Props = DotMatrixCommonProps;
const STEP_COUNT = 30;
const BASE_OPACITY = 0.06;
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/circular/11.tsx b/apps/dashboard/components/ui/dotmatrix/variants/circular/11.tsx
index 31c3f3111e..bb5e019fa6 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/circular/11.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/circular/11.tsx
@@ -9,7 +9,7 @@ import { useCyclePhase } from "../../hooks";
import { usePrefersReducedMotion } from "../../hooks";
import type { DotAnimationResolver, DotMatrixCommonProps } from "../../core";
-export type DotmCircular11Props = DotMatrixCommonProps;
+type DotmCircular11Props = DotMatrixCommonProps;
const BASE_OPACITY = 0.07;
const MID_OPACITY = 0.3;
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/circular/12.tsx b/apps/dashboard/components/ui/dotmatrix/variants/circular/12.tsx
index ec009a3b43..70732789b4 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/circular/12.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/circular/12.tsx
@@ -9,7 +9,7 @@ import { usePrefersReducedMotion } from "../../hooks";
import { useSteppedCycle } from "../../hooks";
import type { DotAnimationResolver, DotMatrixCommonProps } from "../../core";
-export type DotmCircular12Props = DotMatrixCommonProps;
+type DotmCircular12Props = DotMatrixCommonProps;
const STEP_COUNT = 36;
const BASE_OPACITY = 0.06;
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/circular/13.tsx b/apps/dashboard/components/ui/dotmatrix/variants/circular/13.tsx
index 1e9a47c97e..594cacc3de 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/circular/13.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/circular/13.tsx
@@ -9,7 +9,7 @@ import { usePrefersReducedMotion } from "../../hooks";
import { useSteppedCycle } from "../../hooks";
import type { DotAnimationResolver, DotMatrixCommonProps } from "../../core";
-export type DotmCircular13Props = DotMatrixCommonProps;
+type DotmCircular13Props = DotMatrixCommonProps;
const STEP_COUNT = 28;
const BASE_OPACITY = 0.07;
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/circular/14.tsx b/apps/dashboard/components/ui/dotmatrix/variants/circular/14.tsx
index ce53091c41..2996e62537 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/circular/14.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/circular/14.tsx
@@ -9,7 +9,7 @@ import { useCyclePhase } from "../../hooks";
import { usePrefersReducedMotion } from "../../hooks";
import type { DotAnimationResolver, DotMatrixCommonProps } from "../../core";
-export type DotmCircular14Props = DotMatrixCommonProps;
+type DotmCircular14Props = DotMatrixCommonProps;
const STEP_COUNT = 30;
const BASE_OPACITY = 0.07;
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/circular/15.tsx b/apps/dashboard/components/ui/dotmatrix/variants/circular/15.tsx
index 6725c73254..15f4e4ac8b 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/circular/15.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/circular/15.tsx
@@ -9,7 +9,7 @@ import { useCyclePhase } from "../../hooks";
import { usePrefersReducedMotion } from "../../hooks";
import type { DotAnimationResolver, DotMatrixCommonProps } from "../../core";
-export type DotmCircular15Props = DotMatrixCommonProps;
+type DotmCircular15Props = DotMatrixCommonProps;
const STEP_COUNT = 24;
const BASE_OPACITY = 0.07;
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/circular/16.tsx b/apps/dashboard/components/ui/dotmatrix/variants/circular/16.tsx
index 425e74354d..fd9c913c29 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/circular/16.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/circular/16.tsx
@@ -9,7 +9,7 @@ import { useCyclePhase } from "../../hooks";
import { usePrefersReducedMotion } from "../../hooks";
import type { DotAnimationResolver, DotMatrixCommonProps } from "../../core";
-export type DotmCircular16Props = DotMatrixCommonProps;
+type DotmCircular16Props = DotMatrixCommonProps;
const STEP_COUNT = 25;
const BASE_OPACITY = 0.07;
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/circular/17.tsx b/apps/dashboard/components/ui/dotmatrix/variants/circular/17.tsx
index 5920fc676a..e91bc7c079 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/circular/17.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/circular/17.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useMemo, useRef } from "react";
+import { useEffect, useMemo, useRef } from "react";
import { DotMatrixBase } from "../../core";
import { useDotMatrixPhases } from "../../hooks";
@@ -9,12 +9,11 @@ import { useCyclePhase } from "../../hooks";
import { usePrefersReducedMotion } from "../../hooks";
import type { DotAnimationResolver, DotMatrixCommonProps } from "../../core";
-export type DotmCircular17Props = DotMatrixCommonProps;
+type DotmCircular17Props = DotMatrixCommonProps;
const BASE_OPACITY = 0.07;
const MID_OPACITY = 0.34;
const HIGH_OPACITY = 0.95;
-/** Discrete checker frames per loop (must stay integer for `(row + col + t) % 2`). */
const CHECKER_STEPS = 4;
export function DotmCircular17({
@@ -36,7 +35,9 @@ export function DotmCircular17({
});
const animPhaseRef = useRef(animPhase);
- animPhaseRef.current = animPhase;
+ useEffect(() => {
+ animPhaseRef.current = animPhase;
+ }, [animPhase]);
const resolver = useMemo(() => {
return ({ row, col, phase: dmxPhase }) => {
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/circular/18.tsx b/apps/dashboard/components/ui/dotmatrix/variants/circular/18.tsx
index 7f6c76ac7a..d5f7645a35 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/circular/18.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/circular/18.tsx
@@ -9,7 +9,7 @@ import { useCyclePhase } from "../../hooks";
import { usePrefersReducedMotion } from "../../hooks";
import type { DotAnimationResolver, DotMatrixCommonProps } from "../../core";
-export type DotmCircular18Props = DotMatrixCommonProps;
+type DotmCircular18Props = DotMatrixCommonProps;
const BASE_OPACITY = 0.07;
const MID_OPACITY = 0.33;
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/circular/19.tsx b/apps/dashboard/components/ui/dotmatrix/variants/circular/19.tsx
index e77d9fb269..280e52ea64 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/circular/19.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/circular/19.tsx
@@ -9,7 +9,7 @@ import { useCyclePhase } from "../../hooks";
import { usePrefersReducedMotion } from "../../hooks";
import type { DotAnimationResolver, DotMatrixCommonProps } from "../../core";
-export type DotmCircular19Props = DotMatrixCommonProps;
+type DotmCircular19Props = DotMatrixCommonProps;
const STEP_COUNT = 24;
const BASE_OPACITY = 0.07;
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/circular/2.tsx b/apps/dashboard/components/ui/dotmatrix/variants/circular/2.tsx
index 5dd57543ed..1885ec497c 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/circular/2.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/circular/2.tsx
@@ -9,7 +9,7 @@ import { rowMajorIndex } from "../../core";
import { usePrefersReducedMotion } from "../../hooks";
import type { DotAnimationResolver, DotMatrixCommonProps } from "../../core";
-export type DotmCircular2Props = DotMatrixCommonProps;
+type DotmCircular2Props = DotMatrixCommonProps;
const RING_PATH: readonly number[] = [
rowMajorIndex(0, 1),
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/circular/20.tsx b/apps/dashboard/components/ui/dotmatrix/variants/circular/20.tsx
index 5ccc364de8..cdda774ced 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/circular/20.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/circular/20.tsx
@@ -9,7 +9,7 @@ import { useCyclePhase } from "../../hooks";
import { usePrefersReducedMotion } from "../../hooks";
import type { DotAnimationResolver, DotMatrixCommonProps } from "../../core";
-export type DotmCircular20Props = DotMatrixCommonProps;
+type DotmCircular20Props = DotMatrixCommonProps;
const STEP_COUNT = 30;
const BASE_OPACITY = 0.07;
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/circular/3.tsx b/apps/dashboard/components/ui/dotmatrix/variants/circular/3.tsx
index 1968575457..7bd7f30e60 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/circular/3.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/circular/3.tsx
@@ -10,7 +10,7 @@ import { usePrefersReducedMotion } from "../../hooks";
import { useSteppedCycle } from "../../hooks";
import type { DotAnimationResolver, DotMatrixCommonProps } from "../../core";
-export type DotmCircular3Props = DotMatrixCommonProps;
+type DotmCircular3Props = DotMatrixCommonProps;
const STEP_COUNT = 24;
const BASE_OPACITY = 0.08;
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/circular/4.tsx b/apps/dashboard/components/ui/dotmatrix/variants/circular/4.tsx
index dafa92e4ff..562b90c30a 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/circular/4.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/circular/4.tsx
@@ -9,7 +9,7 @@ import { useCyclePhase } from "../../hooks";
import { usePrefersReducedMotion } from "../../hooks";
import type { DotAnimationResolver, DotMatrixCommonProps } from "../../core";
-export type DotmCircular4Props = DotMatrixCommonProps;
+type DotmCircular4Props = DotMatrixCommonProps;
const BASE_OPACITY = 0.08;
const SWEEP_OPACITY = 0.96;
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/circular/5.tsx b/apps/dashboard/components/ui/dotmatrix/variants/circular/5.tsx
index 092b943501..f18f959eaf 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/circular/5.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/circular/5.tsx
@@ -9,7 +9,7 @@ import { useCyclePhase } from "../../hooks";
import { usePrefersReducedMotion } from "../../hooks";
import type { DotAnimationResolver, DotMatrixCommonProps } from "../../core";
-export type DotmCircular5Props = DotMatrixCommonProps;
+type DotmCircular5Props = DotMatrixCommonProps;
const BASE_OPACITY = 0.08;
const BLADE_OPACITY = 0.94;
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/circular/6.tsx b/apps/dashboard/components/ui/dotmatrix/variants/circular/6.tsx
index 0cbc56bccd..702041e12d 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/circular/6.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/circular/6.tsx
@@ -9,7 +9,7 @@ import { useCyclePhase } from "../../hooks";
import { usePrefersReducedMotion } from "../../hooks";
import type { DotAnimationResolver, DotMatrixCommonProps } from "../../core";
-export type DotmCircular6Props = DotMatrixCommonProps;
+type DotmCircular6Props = DotMatrixCommonProps;
const BASE_OPACITY = 0.08;
const ORBIT_OPACITY = 0.96;
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/circular/7.tsx b/apps/dashboard/components/ui/dotmatrix/variants/circular/7.tsx
index 8575d1de39..921ffc166e 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/circular/7.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/circular/7.tsx
@@ -9,7 +9,7 @@ import { useCyclePhase } from "../../hooks";
import { usePrefersReducedMotion } from "../../hooks";
import type { DotAnimationResolver, DotMatrixCommonProps } from "../../core";
-export type DotmCircular7Props = DotMatrixCommonProps;
+type DotmCircular7Props = DotMatrixCommonProps;
const BASE_OPACITY = 0.08;
const GATE_OPACITY = 0.92;
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/circular/8.tsx b/apps/dashboard/components/ui/dotmatrix/variants/circular/8.tsx
index 6e01efc881..7948416c25 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/circular/8.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/circular/8.tsx
@@ -9,7 +9,7 @@ import { useCyclePhase } from "../../hooks";
import { usePrefersReducedMotion } from "../../hooks";
import type { DotAnimationResolver, DotMatrixCommonProps } from "../../core";
-export type DotmCircular8Props = DotMatrixCommonProps;
+type DotmCircular8Props = DotMatrixCommonProps;
const BASE_OPACITY = 0.08;
const PULSE_CORE = 0.95;
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/circular/9.tsx b/apps/dashboard/components/ui/dotmatrix/variants/circular/9.tsx
index 2b510b96f7..8a358f024b 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/circular/9.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/circular/9.tsx
@@ -9,7 +9,7 @@ import { usePrefersReducedMotion } from "../../hooks";
import { useSteppedCycle } from "../../hooks";
import type { DotAnimationResolver, DotMatrixCommonProps } from "../../core";
-export type DotmCircular9Props = DotMatrixCommonProps;
+type DotmCircular9Props = DotMatrixCommonProps;
const STEP_COUNT = 36;
const BASE_OPACITY = 0.07;
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/square/1.tsx b/apps/dashboard/components/ui/dotmatrix/variants/square/1.tsx
index cbed3f4dca..ac0d1c0e90 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/square/1.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/square/1.tsx
@@ -9,7 +9,7 @@ import { usePrefersReducedMotion } from "../../hooks";
import type { DotAnimationResolver } from "../../core";
import type { DotMatrixCommonProps } from "../../core";
-export type DotmSquare1Props = DotMatrixCommonProps;
+type DotmSquare1Props = DotMatrixCommonProps;
const animationResolver: DotAnimationResolver = ({ isActive, index, row, col, reducedMotion, phase }) => {
if (!isActive) {
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/square/10.tsx b/apps/dashboard/components/ui/dotmatrix/variants/square/10.tsx
index ff095367a8..6b3830b50d 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/square/10.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/square/10.tsx
@@ -9,7 +9,7 @@ import { usePrefersReducedMotion } from "../../hooks";
import { useSteppedCycle } from "../../hooks";
import type { DotAnimationResolver, DotMatrixCommonProps } from "../../core";
-export type DotmSquare10Props = DotMatrixCommonProps;
+type DotmSquare10Props = DotMatrixCommonProps;
const ROWS = MATRIX_SIZE;
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/square/11.tsx b/apps/dashboard/components/ui/dotmatrix/variants/square/11.tsx
index fa1b460b97..505279c17b 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/square/11.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/square/11.tsx
@@ -7,7 +7,7 @@ import { useDotMatrixPhases } from "../../hooks";
import { usePrefersReducedMotion } from "../../hooks";
import type { DotAnimationResolver, DotMatrixCommonProps } from "../../core";
-export type DotmSquare11Props = DotMatrixCommonProps;
+type DotmSquare11Props = DotMatrixCommonProps;
const animationResolver: DotAnimationResolver = ({ isActive, manhattanDistance, reducedMotion, phase }) => {
if (!isActive) {
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/square/12.tsx b/apps/dashboard/components/ui/dotmatrix/variants/square/12.tsx
index f102fcd118..874ecf48f0 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/square/12.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/square/12.tsx
@@ -7,7 +7,7 @@ import { useDotMatrixPhases } from "../../hooks";
import { usePrefersReducedMotion } from "../../hooks";
import type { DotAnimationResolver, DotMatrixCommonProps } from "../../core";
-export type DotmSquare12Props = DotMatrixCommonProps;
+type DotmSquare12Props = DotMatrixCommonProps;
// User-defined origin is cell (2,2) in a 1-based 5x5 grid => (row=1,col=1) in zero-based coords.
const ORIGIN_ROW = 1;
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/square/13.tsx b/apps/dashboard/components/ui/dotmatrix/variants/square/13.tsx
index 9343e7a5f7..17b4e2bc0e 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/square/13.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/square/13.tsx
@@ -9,7 +9,7 @@ import { usePrefersReducedMotion } from "../../hooks";
import { useSteppedCycle } from "../../hooks";
import type { DotAnimationResolver, DotMatrixCommonProps } from "../../core";
-export type DotmSquare13Props = DotMatrixCommonProps;
+type DotmSquare13Props = DotMatrixCommonProps;
type FrameCell = "." | "o" | "x";
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/square/14.tsx b/apps/dashboard/components/ui/dotmatrix/variants/square/14.tsx
index 7060be32f9..5ac542286d 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/square/14.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/square/14.tsx
@@ -9,7 +9,7 @@ import { usePrefersReducedMotion } from "../../hooks";
import { useSteppedCycle } from "../../hooks";
import type { DotAnimationResolver, DotMatrixCommonProps } from "../../core";
-export type DotmSquare14Props = DotMatrixCommonProps;
+type DotmSquare14Props = DotMatrixCommonProps;
type FrameCell = "." | "o" | "x";
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/square/15.tsx b/apps/dashboard/components/ui/dotmatrix/variants/square/15.tsx
index c25e1b6bc6..7eb3ab57f7 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/square/15.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/square/15.tsx
@@ -8,13 +8,12 @@ import { useCyclePhase } from "../../hooks";
import { usePrefersReducedMotion } from "../../hooks";
import type { DotAnimationResolver, DotMatrixCommonProps } from "../../core";
-export type DotmSquare15Props = DotMatrixCommonProps;
+type DotmSquare15Props = DotMatrixCommonProps;
const BASE_OPACITY = 0.08;
const STRAND_OPACITY = 1;
const BRIDGE_OPACITY = 0.58;
const NEAR_STRAND_OPACITY = 0.24;
-/** Integer full sin periods per matrix cycle so phase 0 ≡ phase 1 (no wrap glitch). */
const STRAND_LOOPS = 2;
export function DotmSquare15({
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/square/16.tsx b/apps/dashboard/components/ui/dotmatrix/variants/square/16.tsx
index 7791d7d9bf..ae50b94d02 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/square/16.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/square/16.tsx
@@ -8,7 +8,7 @@ import { useCyclePhase } from "../../hooks";
import { usePrefersReducedMotion } from "../../hooks";
import type { DotAnimationResolver, DotMatrixCommonProps } from "../../core";
-export type DotmSquare16Props = DotMatrixCommonProps;
+type DotmSquare16Props = DotMatrixCommonProps;
const BASE_OPACITY = 0.08;
const STRAND_OPACITY = 1;
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/square/17.tsx b/apps/dashboard/components/ui/dotmatrix/variants/square/17.tsx
index 95293ca76d..0d40a2934c 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/square/17.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/square/17.tsx
@@ -8,7 +8,7 @@ import { useCyclePhase } from "../../hooks";
import { usePrefersReducedMotion } from "../../hooks";
import type { DotAnimationResolver, DotMatrixCommonProps } from "../../core";
-export type DotmSquare17Props = DotMatrixCommonProps;
+type DotmSquare17Props = DotMatrixCommonProps;
const BASE_OPACITY = 0.08;
const STRAND_OPACITY = 1;
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/square/18.tsx b/apps/dashboard/components/ui/dotmatrix/variants/square/18.tsx
index eee1ebcf80..ab7e70a5fe 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/square/18.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/square/18.tsx
@@ -8,7 +8,7 @@ import { useCyclePhase } from "../../hooks";
import { usePrefersReducedMotion } from "../../hooks";
import type { DotAnimationResolver, DotMatrixCommonProps } from "../../core";
-export type DotmSquare18Props = DotMatrixCommonProps;
+type DotmSquare18Props = DotMatrixCommonProps;
const BASE_OPACITY = 0.08;
const LIT_OPACITY = 0.94;
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/square/19.tsx b/apps/dashboard/components/ui/dotmatrix/variants/square/19.tsx
index 35e4739750..b3a6e3d0c3 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/square/19.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/square/19.tsx
@@ -8,7 +8,7 @@ import { usePrefersReducedMotion } from "../../hooks";
import { useSteppedCycle } from "../../hooks";
import type { DotAnimationResolver, DotMatrixCommonProps } from "../../core";
-export type DotmSquare19Props = DotMatrixCommonProps;
+type DotmSquare19Props = DotMatrixCommonProps;
const STEP_COUNT = 48;
const BASE_OPACITY = 0.08;
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/square/2.tsx b/apps/dashboard/components/ui/dotmatrix/variants/square/2.tsx
index db70ef8ba7..1b4c4a6cab 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/square/2.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/square/2.tsx
@@ -9,7 +9,7 @@ import { usePrefersReducedMotion } from "../../hooks";
import { useSteppedCycle } from "../../hooks";
import type { DotAnimationResolver, DotMatrixCommonProps } from "../../core";
-export type DotmSquare2Props = DotMatrixCommonProps;
+type DotmSquare2Props = DotMatrixCommonProps;
const SNAKE_TAIL = [1, 0.82, 0.68, 0.54, 0.42, 0.31, 0.22, 0.14] as const;
const BASE_OPACITY = 0.08;
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/square/20.tsx b/apps/dashboard/components/ui/dotmatrix/variants/square/20.tsx
index a0741f2cd3..d860dd5c99 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/square/20.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/square/20.tsx
@@ -9,9 +9,7 @@ import { usePrefersReducedMotion } from "../../hooks";
import { useSteppedCycle } from "../../hooks";
import type { DotAnimationResolver, DotMatrixCommonProps } from "../../core";
-export type DotmSquare20Props = DotMatrixCommonProps;
-
-/** Clockwise perimeter: one closed loop you can trace with your eye. */
+type DotmSquare20Props = DotMatrixCommonProps;
const PERIMETER_PATH: readonly number[] = [
rowMajorIndex(0, 0),
rowMajorIndex(0, 1),
@@ -39,8 +37,6 @@ const BASE_OPACITY = 0.08;
const TWIST_INNER_OPACITY = 0.52;
const SEAM_PULSE_OPACITY = 0.55;
const IDLE_RING_OPACITY = 0.48;
-
-/** Corner steps on the loop → one cell “inside” the strip at the fold (half-twist cue). */
const TWIST_INNER_BY_HEAD_STEP: ReadonlyMap = new Map([
[0, rowMajorIndex(1, 1)],
[4, rowMajorIndex(1, 3)],
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/square/3.tsx b/apps/dashboard/components/ui/dotmatrix/variants/square/3.tsx
index 70b570eecd..656a5a2fe9 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/square/3.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/square/3.tsx
@@ -8,7 +8,7 @@ import { spiralInwardNormFromIndex, spiralInwardOrderValue } from "../../core";
import { usePrefersReducedMotion } from "../../hooks";
import type { DotAnimationResolver, DotMatrixCommonProps } from "../../core";
-export type DotmSquare3Props = DotMatrixCommonProps;
+type DotmSquare3Props = DotMatrixCommonProps;
const animationResolver: DotAnimationResolver = ({ isActive, index, reducedMotion, phase }) => {
if (!isActive) {
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/square/4.tsx b/apps/dashboard/components/ui/dotmatrix/variants/square/4.tsx
index 59c26df05e..adb34797e2 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/square/4.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/square/4.tsx
@@ -13,7 +13,7 @@ import {
import { usePrefersReducedMotion } from "../../hooks";
import type { DotAnimationResolver, DotMatrixCommonProps } from "../../core";
-export type DotmSquare4Props = DotMatrixCommonProps;
+type DotmSquare4Props = DotMatrixCommonProps;
const animationResolver: DotAnimationResolver = ({ isActive, index, row, col, reducedMotion, phase }) => {
if (!isActive) {
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/square/5.tsx b/apps/dashboard/components/ui/dotmatrix/variants/square/5.tsx
index b56b5bdae8..6cc7f792b7 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/square/5.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/square/5.tsx
@@ -8,7 +8,7 @@ import { diagonalSnakeNormFromIndex, diagonalSnakeOrderValue } from "../../core"
import { usePrefersReducedMotion } from "../../hooks";
import type { DotAnimationResolver, DotMatrixCommonProps } from "../../core";
-export type DotmSquare5Props = DotMatrixCommonProps;
+type DotmSquare5Props = DotMatrixCommonProps;
const animationResolver: DotAnimationResolver = ({ isActive, index, reducedMotion, phase }) => {
if (!isActive) {
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/square/6.tsx b/apps/dashboard/components/ui/dotmatrix/variants/square/6.tsx
index 34e219dae8..7d5d9cded8 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/square/6.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/square/6.tsx
@@ -7,7 +7,7 @@ import { useDotMatrixPhases } from "../../hooks";
import { usePrefersReducedMotion } from "../../hooks";
import type { DotAnimationResolver, DotMatrixCommonProps } from "../../core";
-export type DotmSquare6Props = DotMatrixCommonProps;
+type DotmSquare6Props = DotMatrixCommonProps;
const COLUMN_HEIGHT = 5;
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/square/7.tsx b/apps/dashboard/components/ui/dotmatrix/variants/square/7.tsx
index b3c756229a..a2d859f1a2 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/square/7.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/square/7.tsx
@@ -9,7 +9,7 @@ import { usePrefersReducedMotion } from "../../hooks";
import { useSteppedCycle } from "../../hooks";
import type { DotAnimationResolver, DotMatrixCommonProps } from "../../core";
-export type DotmSquare7Props = DotMatrixCommonProps;
+type DotmSquare7Props = DotMatrixCommonProps;
type FrameCell = "." | "o" | "x" | "c";
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/square/8.tsx b/apps/dashboard/components/ui/dotmatrix/variants/square/8.tsx
index 79298ba004..27717a2b5b 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/square/8.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/square/8.tsx
@@ -9,20 +9,16 @@ import { usePrefersReducedMotion } from "../../hooks";
import { useSteppedCycle } from "../../hooks";
import type { DotAnimationResolver, DotMatrixCommonProps } from "../../core";
-export type DotmSquare8Props = DotMatrixCommonProps;
+type DotmSquare8Props = DotMatrixCommonProps;
const ROWS = MATRIX_SIZE;
const COLS = MATRIX_SIZE;
-
-/** Steps 0..FILL_LAST: column `c` gains one row from the bottom each tick, delayed by `c` (col 0 full at `ROWS`, last col at `ROWS + COLS - 1`). */
const FILL_LAST = ROWS + COLS - 1;
const BLINK_STEPS = 4;
const BLINK_OPACITIES = [0.38, 1, 0.38, 1] as const;
const DRAIN_LAST = FILL_LAST;
-
-/** fillTick 0..FILL_LAST → drainTick 0..DRAIN_LAST → + blink in between */
const SEQUENCE_LEN = FILL_LAST + 1 + BLINK_STEPS + DRAIN_LAST + 1;
const BASE_OPACITY = 0.08;
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/square/9.tsx b/apps/dashboard/components/ui/dotmatrix/variants/square/9.tsx
index c423cf5508..8295d030fe 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/square/9.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/square/9.tsx
@@ -7,22 +7,13 @@ import { useDotMatrixPhases } from "../../hooks";
import { usePrefersReducedMotion } from "../../hooks";
import type { DotAnimationResolver, DotMatrixCommonProps } from "../../core";
-export type DotmSquare9Props = DotMatrixCommonProps;
-
-/**
- * Dots 1–6 in Unicode / ISO braille numbering (matches U+2800 + mask):
- * 1·4
- * 2·5
- * 3·6
- */
+type DotmSquare9Props = DotMatrixCommonProps;
const D1 = 0x01;
const D2 = 0x02;
const D3 = 0x04;
const D4 = 0x08;
const D5 = 0x10;
const D6 = 0x20;
-
-/** Left column “odd” / right column “even” — classic 2×3 checkerboard. */
const CHECK_A = D1 | D3 | D5;
const BASE_OPACITY = 0.08;
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/triangle/1.tsx b/apps/dashboard/components/ui/dotmatrix/variants/triangle/1.tsx
index beab9ed346..e7d296e642 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/triangle/1.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/triangle/1.tsx
@@ -10,7 +10,7 @@ import { remapOpacityToTriplet } from "../../core";
import { usePrefersReducedMotion } from "../../hooks";
import type { DotMatrixCommonProps } from "../../core";
-export type DotmTriangle1Props = DotMatrixCommonProps;
+type DotmTriangle1Props = DotMatrixCommonProps;
const MATRIX_SIZE = 7;
const STEP_COUNT = 30;
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/triangle/10.tsx b/apps/dashboard/components/ui/dotmatrix/variants/triangle/10.tsx
index 589f825897..cae018583d 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/triangle/10.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/triangle/10.tsx
@@ -10,7 +10,7 @@ import { usePrefersReducedMotion } from "../../hooks";
import { useSteppedCycle } from "../../hooks";
import type { DotMatrixCommonProps } from "../../core";
-export type DotmTriangle10Props = DotMatrixCommonProps;
+type DotmTriangle10Props = DotMatrixCommonProps;
const MATRIX_SIZE = 7;
const STEP_COUNT = 36;
@@ -29,8 +29,6 @@ const TRIANGLE_CELLS = new Set([
"4,4",
"4,6"
]);
-
-/** Bottom-to-top within each column, columns 0→6 — only triangle cells appear in the path. */
const COLUMN_RAKE_PATH: ReadonlyArray = [
[4, 0],
[3, 1],
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/triangle/11.tsx b/apps/dashboard/components/ui/dotmatrix/variants/triangle/11.tsx
index 22b213ebb2..7eb080a9d6 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/triangle/11.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/triangle/11.tsx
@@ -10,7 +10,7 @@ import { useCyclePhase } from "../../hooks";
import { usePrefersReducedMotion } from "../../hooks";
import type { DotMatrixCommonProps } from "../../core";
-export type DotmTriangle11Props = DotMatrixCommonProps;
+type DotmTriangle11Props = DotMatrixCommonProps;
const MATRIX_SIZE = 7;
@@ -53,11 +53,6 @@ function smoothstep01(edge0: number, edge1: number, x: number): number {
const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0)));
return t * t * (3 - 2 * t);
}
-
-/**
- * Bright bands move down the triangle by tier: phase keys on Manhattan distance from the apex,
- * not the heart cell — reads as stacked horizontal “shelves” lighting in sequence.
- */
function opacityForCell(row: number, col: number, phase: number): number {
const tier = manhattanFromApex(row, col);
const maxTier = 6;
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/triangle/12.tsx b/apps/dashboard/components/ui/dotmatrix/variants/triangle/12.tsx
index 1cb997846f..a25b838755 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/triangle/12.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/triangle/12.tsx
@@ -10,7 +10,7 @@ import { useCyclePhase } from "../../hooks";
import { usePrefersReducedMotion } from "../../hooks";
import type { DotMatrixCommonProps } from "../../core";
-export type DotmTriangle12Props = DotMatrixCommonProps;
+type DotmTriangle12Props = DotMatrixCommonProps;
const MATRIX_SIZE = 7;
@@ -46,11 +46,6 @@ function smoothstep01(edge0: number, edge1: number, x: number): number {
const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0)));
return t * t * (3 - 2 * t);
}
-
-/**
- * Anti-diagonal harmonics on `row - col`: bands glide along NE–SW lines through the mask,
- * opposite oblique motion to loaders keyed on `row + col`.
- */
function opacityForCell(row: number, col: number, phase: number): number {
const skew = row - col;
const t = phase * Math.PI * 2;
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/triangle/13.tsx b/apps/dashboard/components/ui/dotmatrix/variants/triangle/13.tsx
index a72c74541e..001912bc0b 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/triangle/13.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/triangle/13.tsx
@@ -10,7 +10,7 @@ import { useCyclePhase } from "../../hooks";
import { usePrefersReducedMotion } from "../../hooks";
import type { DotMatrixCommonProps } from "../../core";
-export type DotmTriangle13Props = DotMatrixCommonProps;
+type DotmTriangle13Props = DotMatrixCommonProps;
const MATRIX_SIZE = 7;
const BASE_OPACITY = 0.13;
@@ -28,8 +28,6 @@ const TRIANGLE_CELLS = new Set([
"4,4",
"4,6"
]);
-
-/** Row serpent: base row left→right, row 3 right→left, mid rows alternate — reads as a zigzag zip. */
const SERPENT_PATH: ReadonlyArray = [
[4, 0],
[4, 2],
@@ -44,7 +42,6 @@ const SERPENT_PATH: ReadonlyArray = [
];
const PATH_LEN = SERPENT_PATH.length;
-/** Soft tail length in path units (Braille-style ramp, not discrete steps). */
const TRAIL_SPAN = 4.25;
function isWithinTriangleMask(row: number, col: number): boolean {
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/triangle/14.tsx b/apps/dashboard/components/ui/dotmatrix/variants/triangle/14.tsx
index ef7e34e5ee..8ba6b5d596 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/triangle/14.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/triangle/14.tsx
@@ -10,7 +10,7 @@ import { useCyclePhase } from "../../hooks";
import { usePrefersReducedMotion } from "../../hooks";
import type { DotMatrixCommonProps } from "../../core";
-export type DotmTriangle14Props = DotMatrixCommonProps;
+type DotmTriangle14Props = DotMatrixCommonProps;
const MATRIX_SIZE = 7;
@@ -46,11 +46,6 @@ function smoothstep01(edge0: number, edge1: number, x: number): number {
const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0)));
return t * t * (3 - 2 * t);
}
-
-/**
- * A soft vertical “pillar” of brightness sweeps column 0→6; only masked dots respond,
- * so the triangle appears to light one vertical slice at a time (not a cell path).
- */
function opacityForCell(row: number, col: number, phase: number): number {
const beamCenter = phase * 7.2 - 0.35;
const dist = Math.abs(col - beamCenter);
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/triangle/15.tsx b/apps/dashboard/components/ui/dotmatrix/variants/triangle/15.tsx
index fcd3478e9a..1d861fcb7a 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/triangle/15.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/triangle/15.tsx
@@ -10,7 +10,7 @@ import { useCyclePhase } from "../../hooks";
import { usePrefersReducedMotion } from "../../hooks";
import type { DotMatrixCommonProps } from "../../core";
-export type DotmTriangle15Props = DotMatrixCommonProps;
+type DotmTriangle15Props = DotMatrixCommonProps;
const MATRIX_SIZE = 7;
@@ -30,8 +30,6 @@ const TRIANGLE_CELLS = new Set([
"4,4",
"4,6"
]);
-
-/** Apex and the two base corners — the three natural vertices of the silhouette. */
const HUBS: ReadonlyArray = [
[1, 3],
[4, 0],
@@ -62,11 +60,6 @@ function falloffFromHub(row: number, col: number, hub: readonly [number, number]
const d = manhattan(row, col, hub[0], hub[1]);
return 1 - smoothstep01(0, 5.4, d);
}
-
-/**
- * Energy orbits the three triangle vertices (apex → left base → right base) on a continuous phase,
- * with soft Manhattan falloff — no lattice mod groups.
- */
function opacityForCell(row: number, col: number, phase: number): number {
const t = phase * Math.PI * 2;
const sharp = 4;
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/triangle/16.tsx b/apps/dashboard/components/ui/dotmatrix/variants/triangle/16.tsx
index 009e1c8eab..ace236a1b2 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/triangle/16.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/triangle/16.tsx
@@ -10,17 +10,13 @@ import { useCyclePhase } from "../../hooks";
import { usePrefersReducedMotion } from "../../hooks";
import type { DotMatrixCommonProps } from "../../core";
-export type DotmTriangle16Props = DotMatrixCommonProps;
+type DotmTriangle16Props = DotMatrixCommonProps;
const MATRIX_SIZE = 7;
const BASE_OPACITY = 0.1;
const MID_OPACITY = 0.36;
const HIGH_OPACITY = 0.96;
-/**
- * Inverted-V coordinate: same row is lower on the left/right flanks than in the center column,
- * so a moving front forms a V rising toward the apex (not a flat row band like Row Sweep).
- */
const WING = 0.52;
const FRONT_SIGMA = 0.88;
@@ -52,12 +48,6 @@ function smoothstep01(edge0: number, edge1: number, x: number): number {
const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0)));
return t * t * (3 - 2 * t);
}
-
-/**
- * Brightness peaks along a V-shaped isopleth: `row - wing * |col - 3|`.
- * The "front" oscillates in that space, so the highlight rides up the two lower legs
- * and meets at the top — convective lift, not a horizontal scanline.
- */
function opacityForCell(row: number, col: number, phase: number): number {
const t = phase * Math.PI * 2;
const v = row - WING * Math.abs(col - 3);
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/triangle/17.tsx b/apps/dashboard/components/ui/dotmatrix/variants/triangle/17.tsx
index 448da15cbc..75b5c292e3 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/triangle/17.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/triangle/17.tsx
@@ -10,7 +10,7 @@ import { useCyclePhase } from "../../hooks";
import { usePrefersReducedMotion } from "../../hooks";
import type { DotMatrixCommonProps } from "../../core";
-export type DotmTriangle17Props = DotMatrixCommonProps;
+type DotmTriangle17Props = DotMatrixCommonProps;
const MATRIX_SIZE = 7;
const BASE_OPACITY = 0.06;
@@ -28,11 +28,6 @@ const TRIANGLE_CELLS = new Set([
"4,4",
"4,6"
]);
-
-/**
- * Visits every triangle cell once per lap: up the left rim to the apex, down the right rim,
- * then cuts through (4,4) → center → (4,2) — reads as a crossing “∞” on the silhouette.
- */
const INFINITY_PATH: ReadonlyArray = [
[4, 0],
[3, 1],
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/triangle/18.tsx b/apps/dashboard/components/ui/dotmatrix/variants/triangle/18.tsx
index a58d65fddf..a02f1f9eb6 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/triangle/18.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/triangle/18.tsx
@@ -10,7 +10,7 @@ import { useCyclePhase } from "../../hooks";
import { usePrefersReducedMotion } from "../../hooks";
import type { DotMatrixCommonProps } from "../../core";
-export type DotmTriangle18Props = DotMatrixCommonProps;
+type DotmTriangle18Props = DotMatrixCommonProps;
const MATRIX_SIZE = 7;
@@ -46,11 +46,6 @@ function smoothstep01(edge0: number, edge1: number, x: number): number {
const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0)));
return t * t * (3 - 2 * t);
}
-
-/**
- * Heart cell stays dim while the outer shell breathes in sync — inverted emphasis vs center-led
- * corona loaders.
- */
function opacityForCell(row: number, col: number, phase: number): number {
if (row === 3 && col === 3) {
return CORE_DIM;
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/triangle/19.tsx b/apps/dashboard/components/ui/dotmatrix/variants/triangle/19.tsx
index 9ba123dffa..a277df6067 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/triangle/19.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/triangle/19.tsx
@@ -10,7 +10,7 @@ import { useCyclePhase } from "../../hooks";
import { usePrefersReducedMotion } from "../../hooks";
import type { DotMatrixCommonProps } from "../../core";
-export type DotmTriangle19Props = DotMatrixCommonProps;
+type DotmTriangle19Props = DotMatrixCommonProps;
const MATRIX_SIZE = 7;
@@ -33,8 +33,6 @@ const TRIANGLE_CELLS = new Set([
"4,4",
"4,6"
]);
-
-/** Wider wedge core (radians) for smoother rotation like Braille ramps. */
const BEAM_SIGMA = 0.58;
function isWithinTriangleMask(row: number, col: number): boolean {
@@ -63,11 +61,6 @@ function smoothstep01(edge0: number, edge1: number, x: number): number {
const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0)));
return t * t * (3 - 2 * t);
}
-
-/**
- * A soft **rotating wedge** from the heart cell: brightness peaks where polar angle matches the
- * spinning phase — reads as a searchlight pivot, not a cosine product field.
- */
function opacityForCell(row: number, col: number, phase: number): number {
if (row === CENTER_ROW && col === CENTER_COL) {
const hub = 0.5 + 0.5 * Math.sin(phase * Math.PI * 2);
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/triangle/2.tsx b/apps/dashboard/components/ui/dotmatrix/variants/triangle/2.tsx
index 50e1e3546b..0fc2b702f8 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/triangle/2.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/triangle/2.tsx
@@ -10,7 +10,7 @@ import { usePrefersReducedMotion } from "../../hooks";
import { useSteppedCycle } from "../../hooks";
import type { DotMatrixCommonProps } from "../../core";
-export type DotmTriangle2Props = DotMatrixCommonProps;
+type DotmTriangle2Props = DotMatrixCommonProps;
const MATRIX_SIZE = 7;
const STEP_COUNT = 36;
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/triangle/20.tsx b/apps/dashboard/components/ui/dotmatrix/variants/triangle/20.tsx
index 2879dfcb76..13b05b37dc 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/triangle/20.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/triangle/20.tsx
@@ -10,7 +10,7 @@ import { useCyclePhase } from "../../hooks";
import { usePrefersReducedMotion } from "../../hooks";
import type { DotMatrixCommonProps } from "../../core";
-export type DotmTriangle20Props = DotMatrixCommonProps;
+type DotmTriangle20Props = DotMatrixCommonProps;
const MATRIX_SIZE = 7;
const BASE_OPACITY = 0.08;
@@ -29,8 +29,6 @@ const TRIANGLE_CELLS = new Set([
"4,4",
"4,6"
]);
-
-/** Same perimeter ring as DotmTriangle1 — center is not on this loop. */
const PERIMETER_PATH: ReadonlyArray = [
[1, 3],
[2, 2],
@@ -89,10 +87,6 @@ function glowAlongPath(s: number, idx: number | null, L: number): number {
const g = 1 - smoothstep01(0, TRAIL_SPAN, d);
return BASE_OPACITY + g * (HIGH_OPACITY - BASE_OPACITY);
}
-
-/**
- * Two heads chase the perimeter **half a lap apart**, each with its own soft tail — center stays dim.
- */
function opacityForCell(row: number, col: number, phase: number): number {
if (row === 3 && col === 3) {
return CENTER_DIM;
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/triangle/3.tsx b/apps/dashboard/components/ui/dotmatrix/variants/triangle/3.tsx
index 51b678d735..6155614513 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/triangle/3.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/triangle/3.tsx
@@ -10,7 +10,7 @@ import { usePrefersReducedMotion } from "../../hooks";
import { useSteppedCycle } from "../../hooks";
import type { DotMatrixCommonProps } from "../../core";
-export type DotmTriangle3Props = DotMatrixCommonProps;
+type DotmTriangle3Props = DotMatrixCommonProps;
const MATRIX_SIZE = 7;
const STEP_COUNT = 36;
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/triangle/4.tsx b/apps/dashboard/components/ui/dotmatrix/variants/triangle/4.tsx
index 4819f0328c..048d2125a4 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/triangle/4.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/triangle/4.tsx
@@ -10,7 +10,7 @@ import { usePrefersReducedMotion } from "../../hooks";
import { useSteppedCycle } from "../../hooks";
import type { DotMatrixCommonProps } from "../../core";
-export type DotmTriangle4Props = DotMatrixCommonProps;
+type DotmTriangle4Props = DotMatrixCommonProps;
const MATRIX_SIZE = 7;
const STEP_COUNT = 28;
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/triangle/5.tsx b/apps/dashboard/components/ui/dotmatrix/variants/triangle/5.tsx
index a66547b584..04abe83e6b 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/triangle/5.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/triangle/5.tsx
@@ -10,7 +10,7 @@ import { usePrefersReducedMotion } from "../../hooks";
import { useSteppedCycle } from "../../hooks";
import type { DotMatrixCommonProps } from "../../core";
-export type DotmTriangle5Props = DotMatrixCommonProps;
+type DotmTriangle5Props = DotMatrixCommonProps;
const MATRIX_SIZE = 7;
const STEP_COUNT = 42;
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/triangle/6.tsx b/apps/dashboard/components/ui/dotmatrix/variants/triangle/6.tsx
index 809d7b278c..6e44cc41ff 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/triangle/6.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/triangle/6.tsx
@@ -10,11 +10,9 @@ import { useCyclePhase } from "../../hooks";
import { usePrefersReducedMotion } from "../../hooks";
import type { DotMatrixCommonProps } from "../../core";
-export type DotmTriangle6Props = DotMatrixCommonProps;
+type DotmTriangle6Props = DotMatrixCommonProps;
const MATRIX_SIZE = 7;
-
-/** Unicode / ISO braille dot numbering (same as `DotmSquare9`). */
const D1 = 0x01;
const D2 = 0x02;
const D3 = 0x04;
@@ -25,11 +23,7 @@ const D6 = 0x20;
const LOW_OPACITY = 0.07;
const MID_OPACITY = 0.36;
const HIGH_OPACITY = 0.96;
-
-/** Half-width of the traveling ramp (larger = softer, more “gradient” overlap). */
const WAVE_HALF = 0.82;
-
-/** Phase splits (must sum to 1): smooth intro wave, blink, fade reset. */
const INTRO_PHASE = 0.52;
const BLINK_PHASE = 0.36;
const RESET_PHASE = 0.12;
@@ -54,8 +48,6 @@ function smoothstep01(edge0: number, edge1: number, x: number): number {
const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0)));
return t * t * (3 - 2 * t);
}
-
-/** Six fills (D1..D6 order) from a single traveling wave front. */
function waveFills(introT: number): number[] {
const waveCenter = -WAVE_HALF + introT * (5 + 2 * WAVE_HALF);
return [0, 1, 2, 3, 4, 5].map((i) =>
@@ -70,8 +62,6 @@ function isWithinTriangleMask(row: number, col: number): boolean {
return TRIANGLE_CELLS.has(`${row},${col}`);
}
-
-/** Map triangle cell → braille bit (ISO 2×3), or null for accent cells. */
function brailleBitForTriangle(row: number, col: number): number | null {
if (row === 2 && col === 2) {
return D1;
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/triangle/7.tsx b/apps/dashboard/components/ui/dotmatrix/variants/triangle/7.tsx
index 4c3600e353..5da47621e9 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/triangle/7.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/triangle/7.tsx
@@ -10,7 +10,7 @@ import { useCyclePhase } from "../../hooks";
import { usePrefersReducedMotion } from "../../hooks";
import type { DotMatrixCommonProps } from "../../core";
-export type DotmTriangle7Props = DotMatrixCommonProps;
+type DotmTriangle7Props = DotMatrixCommonProps;
const MATRIX_SIZE = 7;
@@ -38,8 +38,6 @@ function isWithinTriangleMask(row: number, col: number): boolean {
return TRIANGLE_CELLS.has(`${row},${col}`);
}
-
-/** Sliding diagonal bands: same `row + col` share a phase so stripes read as continuous diagonals. */
function opacityForCell(row: number, col: number, phase: number): number {
const diag = row + col;
const t = phase * Math.PI * 2;
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/triangle/8.tsx b/apps/dashboard/components/ui/dotmatrix/variants/triangle/8.tsx
index 2350acd6ff..bc65ee9d9e 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/triangle/8.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/triangle/8.tsx
@@ -10,7 +10,7 @@ import { useCyclePhase } from "../../hooks";
import { usePrefersReducedMotion } from "../../hooks";
import type { DotMatrixCommonProps } from "../../core";
-export type DotmTriangle8Props = DotMatrixCommonProps;
+type DotmTriangle8Props = DotMatrixCommonProps;
const MATRIX_SIZE = 7;
@@ -60,11 +60,6 @@ function sectorForCell(row: number, col: number): Sector {
}
return "none";
}
-
-/**
- * Alternating emphasis on the two lower wings (split by the apex column), with the apex and
- * heart dot brightest when energy crosses the middle (both sides briefly equal).
- */
function opacityForCell(row: number, col: number, phase: number): number {
const p = 0.5 - 0.5 * Math.cos(phase * Math.PI * 2);
const leftLift = p * p;
diff --git a/apps/dashboard/components/ui/dotmatrix/variants/triangle/9.tsx b/apps/dashboard/components/ui/dotmatrix/variants/triangle/9.tsx
index 2d5da38f16..62590cebc1 100644
--- a/apps/dashboard/components/ui/dotmatrix/variants/triangle/9.tsx
+++ b/apps/dashboard/components/ui/dotmatrix/variants/triangle/9.tsx
@@ -10,7 +10,7 @@ import { useCyclePhase } from "../../hooks";
import { usePrefersReducedMotion } from "../../hooks";
import type { DotMatrixCommonProps } from "../../core";
-export type DotmTriangle9Props = DotMatrixCommonProps;
+type DotmTriangle9Props = DotMatrixCommonProps;
const MATRIX_SIZE = 7;
@@ -89,11 +89,6 @@ function smoothstep01(edge0: number, edge1: number, x: number): number {
const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0)));
return t * t * (3 - 2 * t);
}
-
-/**
- * Concentric tiers from the heart (8-connected). One soft bright band travels outward/inward;
- * smoothstep softens the cosine so ring-to-ring steps do not read as harsh pops between discrete phase steps.
- */
function opacityForCell(row: number, col: number, phase: number): number {
const ring = BFS_RING.get(`${row},${col}`) ?? 0;
const span = Math.max(1, MAX_RING);
diff --git a/apps/dashboard/components/ui/drawer.tsx b/apps/dashboard/components/ui/drawer.tsx
index 2f245eac00..68c5986ae1 100644
--- a/apps/dashboard/components/ui/drawer.tsx
+++ b/apps/dashboard/components/ui/drawer.tsx
@@ -126,13 +126,13 @@ function DrawerDescription({
export {
Drawer,
- DrawerClose,
+
DrawerContent,
- DrawerDescription,
- DrawerFooter,
- DrawerHeader,
- DrawerOverlay,
- DrawerPortal,
+
+
+
+
+
DrawerTitle,
- DrawerTrigger,
+
};
diff --git a/apps/dashboard/components/ui/elastic-slider.tsx b/apps/dashboard/components/ui/elastic-slider.tsx
deleted file mode 100644
index 0e06f221b4..0000000000
--- a/apps/dashboard/components/ui/elastic-slider.tsx
+++ /dev/null
@@ -1,224 +0,0 @@
-"use client";
-
-import {
- motion,
- useMotionValue,
- useMotionValueEvent,
- useTransform,
-} from "motion/react";
-import { useCallback, useRef, useState } from "react";
-import { cn } from "@/lib/utils";
-import {
- MinusIcon,
- PlusIcon,
-} from "@databuddy/ui/icons";
-
-const MAX_OVERFLOW = 30;
-
-interface SliderProps {
- className?: string;
- disabled?: boolean;
- leftIcon?: React.ReactNode;
- max?: number;
- min?: number;
- onValueChange?: (value: number) => void;
- rightIcon?: React.ReactNode;
- showValue?: boolean;
- step?: number;
- value?: number;
-}
-
-function decay(value: number, maxValue: number): number {
- if (maxValue === 0) {
- return 0;
- }
- const entry = value / maxValue;
- const sigmoid = 2 * (1 / (1 + Math.exp(-entry)) - 0.5);
- return sigmoid * maxValue;
-}
-
-export function Slider({
- value = 0,
- onValueChange,
- min = 0,
- max = 100,
- step = 1,
- className,
- leftIcon = ,
- rightIcon = ,
- showValue = true,
- disabled = false,
-}: SliderProps) {
- const [internalValue, setInternalValue] = useState(value);
- const sliderRef = useRef(null);
- const [region, setRegion] = useState<"left" | "middle" | "right">("middle");
- const [isDragging, setIsDragging] = useState(false);
-
- const clientX = useMotionValue(0);
- const overflow = useMotionValue(0);
-
- const percentage = ((internalValue - min) / (max - min || 1)) * 100;
-
- useMotionValueEvent(clientX, "change", (latest: number) => {
- if (!(sliderRef.current && isDragging)) {
- return;
- }
-
- const { left, right } = sliderRef.current.getBoundingClientRect();
- let newOverflow = 0;
-
- if (latest < left) {
- setRegion("left");
- newOverflow = left - latest;
- } else if (latest > right) {
- setRegion("right");
- newOverflow = latest - right;
- } else {
- setRegion("middle");
- }
-
- overflow.jump(decay(newOverflow, MAX_OVERFLOW));
- });
-
- const updateValue = useCallback(
- (clientXPos: number) => {
- if (!sliderRef.current) {
- return;
- }
-
- const { left, width } = sliderRef.current.getBoundingClientRect();
- let newValue = min + ((clientXPos - left) / width) * (max - min);
-
- if (step > 0) {
- newValue = Math.round(newValue / step) * step;
- }
-
- newValue = Math.min(Math.max(newValue, min), max);
- setInternalValue(newValue);
- onValueChange?.(newValue);
- clientX.jump(clientXPos);
- },
- [min, max, step, onValueChange, clientX]
- );
-
- const handlePointerDown = (e: React.PointerEvent) => {
- if (disabled) {
- return;
- }
-
- setIsDragging(true);
- updateValue(e.clientX);
- e.currentTarget.setPointerCapture(e.pointerId);
- document.body.style.cursor = "grabbing";
- };
-
- const handlePointerMove = (e: React.PointerEvent) => {
- if (!isDragging || disabled) {
- return;
- }
- updateValue(e.clientX);
- };
-
- const handlePointerUp = () => {
- setIsDragging(false);
- setRegion("middle");
- overflow.jump(0);
- document.body.style.cursor = "";
- };
-
- return (
-
-
-
- region === "left" ? -overflow.get() / 2 : 0
- ),
- scale: region === "left" ? 1.3 : 1,
- }}
- >
- {leftIcon}
-
-
-
-
{
- if (!sliderRef.current) {
- return 1;
- }
- const { width } = sliderRef.current.getBoundingClientRect();
- return 1 + overflow.get() / width;
- }),
- scaleY: useTransform(overflow, [0, MAX_OVERFLOW], [1, 0.7]),
- transformOrigin: useTransform(() => {
- if (!sliderRef.current) {
- return "center";
- }
- const { left, width } =
- sliderRef.current.getBoundingClientRect();
- return clientX.get() < left + width / 2 ? "right" : "left";
- }),
- }}
- >
-
-
-
-
-
-
-
- region === "right" ? overflow.get() / 2 : 0
- ),
- scale: region === "right" ? 1.3 : 1,
- }}
- >
- {rightIcon}
-
-
-
- {showValue && (
-
-
- {Math.round(internalValue)}
- {max === 100 && "%"}
-
-
- )}
-
- );
-}
diff --git a/apps/dashboard/components/ui/fluid-orb.tsx b/apps/dashboard/components/ui/fluid-orb.tsx
index ff043c3239..b6b0648a9f 100644
--- a/apps/dashboard/components/ui/fluid-orb.tsx
+++ b/apps/dashboard/components/ui/fluid-orb.tsx
@@ -4,7 +4,7 @@ import React, { useEffect, useRef } from 'react'
import { cn } from '@/lib/utils'
-export type FluidOrbProps = React.ComponentProps<'div'> & {
+type FluidOrbProps = React.ComponentProps<'div'> & {
size?: number
color?: string
}
diff --git a/apps/dashboard/components/ui/form-dialog.tsx b/apps/dashboard/components/ui/form-dialog.tsx
deleted file mode 100644
index b60a7fd759..0000000000
--- a/apps/dashboard/components/ui/form-dialog.tsx
+++ /dev/null
@@ -1,147 +0,0 @@
-"use client";
-
-import {
- Drawer,
- DrawerContent,
- DrawerDescription,
- DrawerFooter,
- DrawerHeader,
- DrawerTitle,
-} from "@/components/ui/drawer";
-import { useIsMobile } from "@/hooks/use-mobile";
-import { Button } from "@databuddy/ui";
-import { Dialog } from "@databuddy/ui/client";
-
-interface FormDialogProps {
- cancelLabel?: string;
- children: React.ReactNode;
- description?: string;
- icon?: React.ReactNode;
- isSubmitting?: boolean;
- onOpenChange: (open: boolean) => void;
- onSubmit: () => void;
- open: boolean;
- size?: "sm" | "md" | "lg";
- submitDisabled?: boolean;
- submitLabel?: string;
- title: string;
-}
-
-export function FormDialog({
- open,
- onOpenChange,
- title,
- description,
- children,
- onSubmit,
- submitLabel = "Save",
- cancelLabel = "Cancel",
- isSubmitting = false,
- submitDisabled = false,
- icon,
- size = "md",
-}: FormDialogProps) {
- const isMobile = useIsMobile();
-
- const sizeClasses = {
- sm: "w-[95vw] max-w-sm sm:w-full",
- md: "w-[95vw] max-w-md sm:w-full",
- lg: "w-[95vw] max-w-lg sm:w-full",
- };
-
- const drawerHeaderContent = icon ? (
-
-
- {icon}
-
-
- {title}
- {description && (
- {description}
- )}
-
-
- ) : null;
-
- const formContent = (
-
- {children}
-
- );
-
- const footerContent = (
- <>
- onOpenChange(false)}
- variant="secondary"
- >
- {cancelLabel}
-
-
- {submitLabel}
-
- >
- );
-
- if (isMobile) {
- return (
-
-
- {icon ? (
- {drawerHeaderContent}
- ) : (
-
- {title}
- {description && (
- {description}
- )}
-
- )}
- {formContent}
-
- {footerContent}
-
-
-
- );
- }
-
- return (
-
-
-
- {icon ? (
-
-
- {icon}
-
-
- {title}
- {description && (
- {description}
- )}
-
-
- ) : (
- <>
- {title}
- {description && (
- {description}
- )}
- >
- )}
-
- {formContent}
- {footerContent}
-
-
-
- );
-}
diff --git a/apps/dashboard/components/ui/form.tsx b/apps/dashboard/components/ui/form.tsx
deleted file mode 100644
index f1dd097369..0000000000
--- a/apps/dashboard/components/ui/form.tsx
+++ /dev/null
@@ -1,167 +0,0 @@
-"use client";
-
-import { type Label as LabelPrimitive, Slot as SlotPrimitive } from "radix-ui";
-import * as React from "react";
-
-import {
- Controller,
- type ControllerProps,
- type FieldPath,
- type FieldValues,
- FormProvider,
- useFormContext,
- useFormState,
-} from "react-hook-form";
-import { cn } from "@/lib/utils";
-import { Field } from "@databuddy/ui";
-
-const Form = FormProvider;
-
-type FormFieldContextValue<
- TFieldValues extends FieldValues = FieldValues,
- TName extends FieldPath = FieldPath,
-> = {
- name: TName;
-};
-
-const FormFieldContext = React.createContext(
- {} as FormFieldContextValue
-);
-
-const FormField = <
- TFieldValues extends FieldValues = FieldValues,
- TName extends FieldPath = FieldPath,
->({
- ...props
-}: ControllerProps) => {
- return (
-
-
-
- );
-};
-
-const useFormField = () => {
- const fieldContext = React.useContext(FormFieldContext);
- const itemContext = React.useContext(FormItemContext);
- const { getFieldState } = useFormContext();
- const formState = useFormState({ name: fieldContext.name });
- const fieldState = getFieldState(fieldContext.name, formState);
-
- if (!fieldContext) {
- throw new Error("useFormField should be used within ");
- }
-
- const { id } = itemContext;
-
- return {
- id,
- name: fieldContext.name,
- formItemId: `${id}-form-item`,
- formDescriptionId: `${id}-form-item-description`,
- formMessageId: `${id}-form-item-message`,
- ...fieldState,
- };
-};
-
-type FormItemContextValue = {
- id: string;
-};
-
-const FormItemContext = React.createContext(
- {} as FormItemContextValue
-);
-
-function FormItem({ className, ...props }: React.ComponentProps<"div">) {
- const id = React.useId();
-
- return (
-
-
-
- );
-}
-
-function FormLabel({
- className,
- ...props
-}: React.ComponentProps) {
- const { error, formItemId } = useFormField();
-
- return (
-
- );
-}
-
-function FormControl({
- ...props
-}: React.ComponentProps) {
- const { error, formItemId, formDescriptionId, formMessageId } =
- useFormField();
-
- return (
-
- );
-}
-
-function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
- const { formDescriptionId } = useFormField();
-
- return (
-
- );
-}
-
-function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
- const { error, formMessageId } = useFormField();
- const body = error ? String(error?.message ?? "") : props.children;
-
- if (!body) {
- return null;
- }
-
- return (
-
- {body}
-
- );
-}
-
-export {
- Form,
- FormControl,
- FormDescription,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
- useFormField,
-};
diff --git a/apps/dashboard/components/ui/inline-toggle.tsx b/apps/dashboard/components/ui/inline-toggle.tsx
deleted file mode 100644
index b8941f6eaa..0000000000
--- a/apps/dashboard/components/ui/inline-toggle.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-"use client";
-
-import type { ReactNode } from "react";
-import { cn } from "@/lib/utils";
-
-type InlineToggleOption = {
- value: T;
- label: ReactNode;
- ariaLabel?: string;
-};
-
-type InlineToggleProps = {
- options: InlineToggleOption[];
- value: T;
- onValueChangeAction: (value: T) => void;
- className?: string;
- disabled?: boolean;
-};
-
-export function InlineToggle({
- options,
- value,
- onValueChangeAction,
- className,
- disabled = false,
-}: InlineToggleProps) {
- return (
-
- {options.map((option) => {
- const isSelected = option.value === value;
- return (
- onValueChangeAction(option.value)}
- role="radio"
- type="button"
- >
- {option.label}
-
- );
- })}
-
- );
-}
diff --git a/apps/dashboard/components/ui/input-group.tsx b/apps/dashboard/components/ui/input-group.tsx
deleted file mode 100644
index 8ee6478bd6..0000000000
--- a/apps/dashboard/components/ui/input-group.tsx
+++ /dev/null
@@ -1,169 +0,0 @@
-"use client";
-
-import { cva, type VariantProps } from "class-variance-authority";
-import type * as React from "react";
-import { Input } from "@/components/ui/input";
-import { Textarea, type TextareaProps } from "@/components/ui/textarea";
-import { cn } from "@/lib/utils";
-import { Button } from "@databuddy/ui";
-
-function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
- return (
- textarea]:h-auto",
-
- // Variants based on alignment.
- "has-[>[data-align=inline-start]]:[&>input]:pl-2",
- "has-[>[data-align=inline-end]]:[&>input]:pr-2",
- "has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
- "has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
-
- // Focus state.
- "has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50",
-
- // Error state.
- "has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-destructive/20 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
-
- className
- )}
- data-slot="input-group"
- role="group"
- {...props}
- />
- );
-}
-
-const inputGroupAddonVariants = cva(
- "flex h-auto cursor-text select-none items-center justify-center gap-2 py-1.5 font-medium text-muted-foreground text-sm group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
- {
- variants: {
- align: {
- "inline-start":
- "order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
- "inline-end":
- "order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
- "block-start":
- "order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5 [.border-b]:pb-3",
- "block-end":
- "order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5 [.border-t]:pt-3",
- },
- },
- defaultVariants: {
- align: "inline-start",
- },
- }
-);
-
-function InputGroupAddon({
- className,
- align = "inline-start",
- ...props
-}: React.ComponentProps<"div"> & VariantProps
) {
- return (
- {
- if ((e.target as HTMLElement).closest("button")) {
- return;
- }
- e.currentTarget.parentElement?.querySelector("input")?.focus();
- }}
- role="group"
- {...props}
- />
- );
-}
-
-const inputGroupButtonVariants = cva(
- "flex items-center gap-2 text-sm shadow-none",
- {
- variants: {
- size: {
- xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
- sm: "h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5",
- "icon-xs":
- "size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
- "icon-sm": "size-8 p-0 has-[>svg]:p-0",
- },
- },
- defaultVariants: {
- size: "xs",
- },
- }
-);
-
-function InputGroupButton({
- className,
- type = "button",
- variant = "ghost",
- size = "xs",
- ...props
-}: Omit
, "size"> &
- VariantProps) {
- return (
-
- );
-}
-
-function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
- return (
-
- );
-}
-
-function InputGroupInput({
- className,
- ...props
-}: React.ComponentProps<"input">) {
- return (
-
- );
-}
-
-function InputGroupTextarea({
- className,
- ...props
-}: TextareaProps) {
- return (
-
- );
-}
-
-export {
- InputGroup,
- InputGroupAddon,
- InputGroupButton,
- InputGroupInput,
- InputGroupText,
- InputGroupTextarea,
-};
diff --git a/apps/dashboard/components/ui/input.tsx b/apps/dashboard/components/ui/input.tsx
deleted file mode 100644
index 54af6e32c3..0000000000
--- a/apps/dashboard/components/ui/input.tsx
+++ /dev/null
@@ -1,132 +0,0 @@
-"use client";
-
-import { forwardRef } from "react";
-import { cn } from "@/lib/utils";
-import { useFieldContext } from "@databuddy/ui";
-
-type InputProps = Omit, "prefix" | "suffix"> & {
- variant?: "default" | "ghost";
- showFocusIndicator?: boolean;
- wrapperClassName?: string;
- prefix?: React.ReactNode;
- suffix?: React.ReactNode;
-};
-
-const Input = forwardRef(
- (
- {
- className,
- type,
- variant = "default",
- showFocusIndicator = true,
- wrapperClassName,
- prefix,
- suffix,
- id,
- ...props
- },
- ref
- ) => {
- const field = useFieldContext();
- const hasError =
- field?.error ||
- props["aria-invalid"] === true ||
- props["aria-invalid"] === "true";
- const ariaDescribedBy =
- props["aria-describedby"] ??
- (field
- ? [field.error && field.errorId, field.descriptionId]
- .filter(Boolean)
- .join(" ") || undefined
- : undefined);
- const resolvedId = id ?? field?.id;
-
- const hasPrefix = !!prefix;
- const hasSuffix = !!suffix;
-
- const isSmallHeight = className?.includes("h-8");
- const heightClass = isSmallHeight ? "h-8" : "h-9";
-
- if (hasPrefix || hasSuffix) {
- return (
-
- {hasPrefix && (
-
- {prefix}
-
- )}
-
- {hasSuffix && (
-
- {suffix}
-
- )}
-
- );
- }
-
- return (
-
-
- {showFocusIndicator ? null : null}
-
- );
- }
-);
-
-Input.displayName = "Input";
-
-export type { InputProps };
-export { Input };
diff --git a/apps/dashboard/components/ui/keyboard-shortcuts.tsx b/apps/dashboard/components/ui/keyboard-shortcuts.tsx
deleted file mode 100644
index 71fb470f3f..0000000000
--- a/apps/dashboard/components/ui/keyboard-shortcuts.tsx
+++ /dev/null
@@ -1,104 +0,0 @@
-"use client";
-
-import { useMemo } from "react";
-import { cn } from "@/lib/utils";
-
-type ShortcutGroup = {
- title: string;
- shortcuts: {
- label: string;
- keys: string;
- macKeys?: string;
- }[];
-};
-
-const SHORTCUT_GROUPS: ShortcutGroup[] = [
- {
- title: "General",
- shortcuts: [{ label: "Search", keys: "Ctrl+K", macKeys: "⌘K" }],
- },
- {
- title: "Forms & Dialogs",
- shortcuts: [
- { label: "Submit form", keys: "Enter" },
- { label: "Close dialog", keys: "Esc" },
- ],
- },
- {
- title: "Date Ranges",
- shortcuts: [
- { label: "Last 24 hours", keys: "1" },
- { label: "Last 7 days", keys: "2" },
- { label: "Last 30 days", keys: "3" },
- { label: "Last 90 days", keys: "4" },
- { label: "Last 180 days", keys: "5" },
- { label: "Last 365 days", keys: "6" },
- ],
- },
- {
- title: "Chart Selection",
- shortcuts: [
- { label: "Zoom to range", keys: "Z" },
- { label: "Add annotation", keys: "A" },
- ],
- },
-];
-
-function isMac() {
- if (typeof window === "undefined") {
- return false;
- }
- return navigator.platform.toUpperCase().indexOf("MAC") >= 0;
-}
-
-type KeyboardShortcutsProps = {
- groups?: ShortcutGroup[];
- compact?: boolean;
-};
-
-export function KeyboardShortcuts({
- groups = SHORTCUT_GROUPS,
- compact = false,
-}: KeyboardShortcutsProps) {
- const isMacOS = useMemo(() => isMac(), []);
-
- return (
-
- {groups.map((group) => (
-
- {!compact && (
-
- {group.title}
-
- )}
-
- {group.shortcuts.map((shortcut) => {
- const displayKeys =
- isMacOS && shortcut.macKeys ? shortcut.macKeys : shortcut.keys;
- return (
-
-
- {shortcut.label}
-
-
- {displayKeys}
-
-
- );
- })}
-
-
- ))}
-
- );
-}
diff --git a/apps/dashboard/components/ui/label.tsx b/apps/dashboard/components/ui/label.tsx
deleted file mode 100644
index 5eeb8e3976..0000000000
--- a/apps/dashboard/components/ui/label.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-"use client";
-
-import { Label as LabelPrimitive } from "radix-ui";
-import type * as React from "react";
-
-import { cn } from "@/lib/utils";
-
-function Label({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- );
-}
-
-export { Label };
diff --git a/apps/dashboard/components/ui/percentage-badge.tsx b/apps/dashboard/components/ui/percentage-badge.tsx
deleted file mode 100644
index e6f2ffff03..0000000000
--- a/apps/dashboard/components/ui/percentage-badge.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-import { cn } from "@/lib/utils";
-
-interface PercentageBadgeProps {
- className?: string;
- percentage: number;
-}
-
-export function PercentageBadge({
- percentage,
- className,
-}: PercentageBadgeProps) {
- const getColorClass = (pct: number) => {
- if (pct >= 50) {
- return "bg-green-100 border border-green-800/50 green-angled-rectangle-gradient text-green-800 dark:bg-green-900/30 dark:text-green-400";
- }
- if (pct >= 25) {
- return "bg-brand-purple/10 border border-brand-purple/30 blue-angled-rectangle-gradient text-brand-purple dark:bg-brand-purple/20 dark:text-[#8B80BF]";
- }
- if (pct >= 10) {
- return "bg-amber-100 border border-amber-800/40 amber-angled-rectangle-gradient text-amber-800 dark:bg-amber-900/30 dark:text-amber-400";
- }
- return "bg-accent-brighter border border-accent-foreground/30 badge-angled-rectangle-gradient text-accent-foreground";
- };
-
- const safePercentage =
- percentage == null || Number.isNaN(percentage) ? 0 : percentage;
-
- return (
-
- {safePercentage.toFixed(1)}%
-
- );
-}
diff --git a/apps/dashboard/components/ui/popover.tsx b/apps/dashboard/components/ui/popover.tsx
index e51054d839..843d3352db 100644
--- a/apps/dashboard/components/ui/popover.tsx
+++ b/apps/dashboard/components/ui/popover.tsx
@@ -48,4 +48,4 @@ function PopoverAnchor({
return ;
}
-export { Popover, PopoverAnchor, PopoverContent, PopoverTrigger };
+export { Popover, PopoverContent, PopoverTrigger };
diff --git a/apps/dashboard/components/ui/progress.tsx b/apps/dashboard/components/ui/progress.tsx
deleted file mode 100644
index 3375039378..0000000000
--- a/apps/dashboard/components/ui/progress.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-"use client";
-
-import * as ProgressPrimitive from "@radix-ui/react-progress";
-import type * as React from "react";
-
-import { cn } from "@/lib/utils";
-
-function Progress({
- className,
- value,
- ...props
-}: React.ComponentProps) {
- const clamped = Math.min(Math.max(value || 0, 0), 100);
-
- return (
-
-
-
- );
-}
-
-export { Progress };
diff --git a/apps/dashboard/components/ui/scroll-area.tsx b/apps/dashboard/components/ui/scroll-area.tsx
index 256c8a9388..039d5bce49 100644
--- a/apps/dashboard/components/ui/scroll-area.tsx
+++ b/apps/dashboard/components/ui/scroll-area.tsx
@@ -55,4 +55,4 @@ function ScrollBar({
);
}
-export { ScrollArea, ScrollBar };
+export { ScrollArea, };
diff --git a/apps/dashboard/components/ui/segmented-control.tsx b/apps/dashboard/components/ui/segmented-control.tsx
deleted file mode 100644
index 86c906dcff..0000000000
--- a/apps/dashboard/components/ui/segmented-control.tsx
+++ /dev/null
@@ -1,76 +0,0 @@
-"use client";
-
-import { motion } from "motion/react";
-import { useId } from "react";
-import { cn } from "@/lib/utils";
-
-type SegmentedControlOption = {
- value: T;
- label: string;
-};
-
-type SegmentedControlProps = {
- options: SegmentedControlOption[];
- value: T;
- onValueChangeAction: (value: T) => void;
- className?: string;
- size?: "sm" | "default";
-};
-
-export function SegmentedControl({
- options,
- value,
- onValueChangeAction,
- className,
- size = "default",
-}: SegmentedControlProps) {
- const layoutId = useId();
-
- return (
-
- {options.map((option) => {
- const isSelected = option.value === value;
-
- return (
- onValueChangeAction(option.value)}
- role="radio"
- type="button"
- >
- {isSelected && (
-
- )}
- {option.label}
-
- );
- })}
-
- );
-}
diff --git a/apps/dashboard/components/ui/select.tsx b/apps/dashboard/components/ui/select.tsx
index 1f0d9c5267..fd65832233 100644
--- a/apps/dashboard/components/ui/select.tsx
+++ b/apps/dashboard/components/ui/select.tsx
@@ -208,12 +208,12 @@ function SelectScrollDownButton({
export {
Select,
SelectContent,
- SelectGroup,
+
SelectItem,
- SelectLabel,
- SelectScrollDownButton,
- SelectScrollUpButton,
- SelectSeparator,
+
+
+
+
SelectTrigger,
SelectValue,
};
diff --git a/apps/dashboard/components/ui/separator.tsx b/apps/dashboard/components/ui/separator.tsx
deleted file mode 100644
index db7059bc62..0000000000
--- a/apps/dashboard/components/ui/separator.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-"use client";
-
-import { Separator as SeparatorPrimitive } from "radix-ui";
-import type * as React from "react";
-
-import { cn } from "@/lib/utils";
-
-function Separator({
- className,
- orientation = "horizontal",
- decorative = true,
- ...props
-}: React.ComponentProps) {
- return (
-
- );
-}
-
-export { Separator };
diff --git a/apps/dashboard/components/ui/sheet.tsx b/apps/dashboard/components/ui/sheet.tsx
deleted file mode 100644
index 99e802e891..0000000000
--- a/apps/dashboard/components/ui/sheet.tsx
+++ /dev/null
@@ -1,153 +0,0 @@
-"use client";
-
-import { XMarkIcon as X } from "@databuddy/ui/icons";
-import { Dialog as SheetPrimitive } from "radix-ui";
-import type * as React from "react";
-
-import { cn } from "@/lib/utils";
-
-function Sheet({ ...props }: React.ComponentProps) {
- return ;
-}
-
-function SheetTrigger({
- ...props
-}: React.ComponentProps) {
- return ;
-}
-
-function SheetClose({
- ...props
-}: React.ComponentProps) {
- return ;
-}
-
-function SheetPortal({
- ...props
-}: React.ComponentProps) {
- return ;
-}
-
-function SheetOverlay({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- );
-}
-
-function SheetContent({
- className,
- children,
- side = "right",
- ...props
-}: React.ComponentProps & {
- side?: "top" | "right" | "bottom" | "left";
-}) {
- return (
-
-
-
- {children}
-
-
- Close
-
-
-
- );
-}
-
-function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- );
-}
-
-function SheetBody({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- );
-}
-
-function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- );
-}
-
-function SheetTitle({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- );
-}
-
-function SheetDescription({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- );
-}
-
-export {
- Sheet,
- SheetBody,
- SheetClose,
- SheetContent,
- SheetDescription,
- SheetFooter,
- SheetHeader,
- SheetTitle,
- SheetTrigger,
-};
diff --git a/apps/dashboard/components/ui/skeleton.tsx b/apps/dashboard/components/ui/skeleton.tsx
deleted file mode 100644
index 5b322564d8..0000000000
--- a/apps/dashboard/components/ui/skeleton.tsx
+++ /dev/null
@@ -1,13 +0,0 @@
-import { cn } from "@/lib/utils";
-
-function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- );
-}
-
-export { Skeleton };
diff --git a/apps/dashboard/components/ui/slider.tsx b/apps/dashboard/components/ui/slider.tsx
deleted file mode 100644
index f7e6bc9d4c..0000000000
--- a/apps/dashboard/components/ui/slider.tsx
+++ /dev/null
@@ -1,63 +0,0 @@
-"use client";
-
-import { Slider as SliderPrimitive } from "radix-ui";
-import { useMemo } from "react";
-
-import { cn } from "@/lib/utils";
-
-function Slider({
- className,
- defaultValue,
- value,
- min = 0,
- max = 100,
- ...props
-}: React.ComponentProps) {
- const _values = useMemo(() => {
- if (Array.isArray(value)) {
- return value;
- }
- if (Array.isArray(defaultValue)) {
- return defaultValue;
- }
- return [min, max];
- }, [value, defaultValue, min, max]);
-
- return (
-
-
-
-
- {_values.map((value, index) => (
-
- ))}
-
- );
-}
-
-export { Slider };
diff --git a/apps/dashboard/components/ui/spinner.tsx b/apps/dashboard/components/ui/spinner.tsx
deleted file mode 100644
index 5b7b63e02c..0000000000
--- a/apps/dashboard/components/ui/spinner.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-"use client";
-
-import { CircleNotchIcon } from "@databuddy/ui/icons";
-import { cn } from "../../lib/utils";
-
-export const Spinner = ({ className }: { className?: string }) => {
- return (
-
- );
-};
diff --git a/apps/dashboard/components/ui/switch.tsx b/apps/dashboard/components/ui/switch.tsx
deleted file mode 100644
index 6d407e73e6..0000000000
--- a/apps/dashboard/components/ui/switch.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-"use client";
-
-import { Switch as SwitchPrimitive } from "radix-ui";
-import type * as React from "react";
-
-import { cn } from "@/lib/utils";
-
-function Switch({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
-
-
- );
-}
-
-export { Switch };
diff --git a/apps/dashboard/components/ui/table.tsx b/apps/dashboard/components/ui/table.tsx
index 6e85f8be8b..9319afffa2 100644
--- a/apps/dashboard/components/ui/table.tsx
+++ b/apps/dashboard/components/ui/table.tsx
@@ -101,9 +101,9 @@ function TableCaption({
export {
Table,
TableBody,
- TableCaption,
+
TableCell,
- TableFooter,
+
TableHead,
TableHeader,
TableRow,
diff --git a/apps/dashboard/components/ui/tabs.tsx b/apps/dashboard/components/ui/tabs.tsx
deleted file mode 100644
index 52c810fb80..0000000000
--- a/apps/dashboard/components/ui/tabs.tsx
+++ /dev/null
@@ -1,311 +0,0 @@
-"use client";
-
-import { Tabs as TabsPrimitive } from "radix-ui";
-import type * as React from "react";
-import {
- createContext,
- useContext,
- useEffect,
- useLayoutEffect,
- useRef,
- useState,
-} from "react";
-
-import { cn } from "@/lib/utils";
-
-type TabsVariant = "default" | "underline" | "pills" | "navigation";
-
-const TabsContext = createContext<{
- variant: TabsVariant;
- registerTrigger: (value: string, element: HTMLButtonElement | null) => void;
- activeValue: string | undefined;
-}>({
- variant: "default",
- registerTrigger: () => {},
- activeValue: undefined,
-});
-
-function Tabs({
- className,
- variant = "default",
- defaultValue,
- value,
- onValueChange,
- ...props
-}: React.ComponentProps & {
- variant?: TabsVariant;
-}) {
- const [activeValue, setActiveValue] = useState(value ?? defaultValue);
- const triggersRef = useRef>(new Map());
-
- // Sync internal state when parent updates the controlled value prop.
- // Without this, the indicator/context lags behind URL-driven tab changes.
- useEffect(() => {
- if (value !== undefined) {
- setActiveValue(value);
- }
- }, [value]);
-
- const registerTrigger = (val: string, element: HTMLButtonElement | null) => {
- if (element) {
- triggersRef.current.set(val, element);
- } else {
- triggersRef.current.delete(val);
- }
- };
-
- const handleValueChange = (newValue: string) => {
- setActiveValue(newValue);
- onValueChange?.(newValue);
- };
-
- return (
-
-
-
- );
-}
-
-function TabsList({
- className,
- ...props
-}: React.ComponentProps) {
- const { variant, activeValue } = useContext(TabsContext);
- const listRef = useRef(null);
- const [indicatorStyle, setIndicatorStyle] = useState({});
-
- useLayoutEffect(() => {
- if (
- (variant !== "underline" && variant !== "navigation") ||
- !listRef.current ||
- !activeValue
- ) {
- return;
- }
-
- const activeTab = listRef.current.querySelector(
- `[data-state="active"]`
- ) as HTMLElement;
-
- if (activeTab) {
- const listRect = listRef.current.getBoundingClientRect();
- const tabRect = activeTab.getBoundingClientRect();
-
- setIndicatorStyle({
- width: tabRect.width,
- transform: `translateX(${tabRect.left - listRect.left}px)`,
- });
- }
- }, [activeValue, variant]);
-
- if (variant === "navigation") {
- return (
-
- );
- }
-
- if (variant === "underline") {
- return (
-
- );
- }
-
- if (variant === "pills") {
- return (
-
- );
- }
-
- // Default variant
- return (
-
- );
-}
-
-function TabsTrigger({
- className,
- value,
- ...props
-}: React.ComponentProps) {
- const { variant, registerTrigger } = useContext(TabsContext);
- const triggerRef = useRef(null);
-
- useLayoutEffect(() => {
- if (value) {
- registerTrigger(value, triggerRef.current);
- }
- return () => {
- if (value) {
- registerTrigger(value, null);
- }
- };
- }, [value, registerTrigger]);
-
- if (variant === "navigation") {
- return (
-
- );
- }
-
- if (variant === "underline") {
- return (
-
- );
- }
-
- if (variant === "pills") {
- return (
-
- );
- }
-
- // Default variant
- return (
-
- );
-}
-
-function TabsContent({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- );
-}
-
-function TabsBadge({
- children,
- forValue,
- className,
-}: {
- children: React.ReactNode;
- forValue: string;
- className?: string;
-}) {
- const { activeValue } = useContext(TabsContext);
- const isActive = activeValue === forValue;
-
- return (
-
- {children}
-
- );
-}
-
-export { Tabs, TabsBadge, TabsContent, TabsList, TabsTrigger };
diff --git a/apps/dashboard/components/ui/tags.tsx b/apps/dashboard/components/ui/tags.tsx
deleted file mode 100644
index a8a1c05fde..0000000000
--- a/apps/dashboard/components/ui/tags.tsx
+++ /dev/null
@@ -1,164 +0,0 @@
-"use client";
-
-import { useCallback, useMemo, useRef, useState } from "react";
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { cn } from "@/lib/utils";
-import {
- PlusIcon,
- XCircleIcon,
-} from "@databuddy/ui/icons";
-
-export interface TagsChatProps {
- allowDuplicates?: boolean;
- className?: string;
- maxTags?: number;
- onChange: (next: string[]) => void;
- placeholder?: string;
- suggestions?: string[];
- values: string[];
-}
-
-function normalizeTag(raw: string): string {
- return raw.trim();
-}
-
-export function TagsChat({
- values,
- onChange,
- placeholder = "Type a tag and press Enter…",
- suggestions,
- maxTags,
- allowDuplicates = false,
- className,
-}: TagsChatProps) {
- const [draft, setDraft] = useState("");
- const areaRef = useRef(null);
-
- const canAddMore =
- typeof maxTags === "number" ? values.length < maxTags : true;
-
- const addTag = useCallback(
- (tag: string) => {
- const normalized = normalizeTag(tag);
- if (!normalized) {
- return;
- }
- if (!allowDuplicates && values.some((t) => t === normalized)) {
- setDraft("");
- return;
- }
- if (!canAddMore) {
- return;
- }
- onChange([...values, normalized]);
- setDraft("");
- // Scroll to bottom like chat
- queueMicrotask(() => {
- areaRef.current?.scrollTo({
- top: areaRef.current.scrollHeight,
- behavior: "smooth",
- });
- });
- },
- [allowDuplicates, canAddMore, onChange, values]
- );
-
- const removeTag = useCallback(
- (index: number) => {
- const next = values.slice();
- next.splice(index, 1);
- onChange(next);
- },
- [onChange, values]
- );
-
- const visibleSuggestions = useMemo(() => {
- if (!suggestions || suggestions.length === 0) {
- return [] as string[];
- }
- const needle = draft.toLowerCase();
- const pool = suggestions.filter((s) => s.toLowerCase().includes(needle));
- return pool.slice(0, 6);
- }, [draft, suggestions]);
-
- const handleKeyDown = (e: React.KeyboardEvent) => {
- if (e.key === "Enter" || e.key === ",") {
- e.preventDefault();
- addTag(draft);
- }
- if (e.key === "Backspace" && draft.length === 0 && values.length > 0) {
- e.preventDefault();
- removeTag(values.length - 1);
- }
- };
-
- return (
-
-
- {values.length === 0 ? (
-
- No tags yet. Start typing below.
-
- ) : (
-
- {values.map((tag, index) => (
-
- {tag}
- removeTag(index)}
- size="icon"
- type="button"
- variant="ghost"
- >
-
-
-
- ))}
-
- )}
-
-
-
-
-
setDraft(e.target.value)}
- onKeyDown={handleKeyDown}
- placeholder={placeholder}
- value={draft}
- />
-
addTag(draft)}
- type="button"
- >
- Add
-
-
-
- {visibleSuggestions.length > 0 && (
-
- {visibleSuggestions.map((s) => (
- {
- e.preventDefault();
- addTag(s);
- }}
- type="button"
- >
- {s}
-
- ))}
-
- )}
-
-
- );
-}
diff --git a/apps/dashboard/components/ui/textarea.tsx b/apps/dashboard/components/ui/textarea.tsx
deleted file mode 100644
index 0433d3e7dd..0000000000
--- a/apps/dashboard/components/ui/textarea.tsx
+++ /dev/null
@@ -1,66 +0,0 @@
-"use client";
-
-import type { ComponentProps } from "react";
-import { forwardRef } from "react";
-import TextareaAutosize from "react-textarea-autosize";
-import { cn } from "@/lib/utils";
-import { useFieldContext } from "@databuddy/ui";
-
-type TextareaProps = ComponentProps & {
- showFocusIndicator?: boolean;
- wrapperClassName?: string;
-};
-
-const Textarea = forwardRef(
- (
- {
- className,
- showFocusIndicator = true,
- wrapperClassName,
- id,
- ...props
- },
- ref
- ) => {
- const field = useFieldContext();
- const hasError =
- field?.error ||
- props["aria-invalid"] === true ||
- props["aria-invalid"] === "true";
- const ariaDescribedBy =
- props["aria-describedby"] ??
- (field
- ? [field.error && field.errorId, field.descriptionId]
- .filter(Boolean)
- .join(" ") || undefined
- : undefined);
- const resolvedId = id ?? field?.id;
-
- return (
-
-
- {showFocusIndicator ? null : null}
-
- );
- }
-);
-
-Textarea.displayName = "Textarea";
-
-export type { TextareaProps };
-export { Textarea };
diff --git a/apps/dashboard/components/ui/toggle-group.tsx b/apps/dashboard/components/ui/toggle-group.tsx
deleted file mode 100644
index d427abe815..0000000000
--- a/apps/dashboard/components/ui/toggle-group.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-"use client";
-
-import type { VariantProps } from "class-variance-authority";
-import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui";
-import * as React from "react";
-import { toggleVariants } from "@/components/ui/toggle";
-import { cn } from "@/lib/utils";
-
-const ToggleGroupContext = React.createContext<
- VariantProps
->({
- size: "default",
- variant: "default",
-});
-
-function ToggleGroup({
- className,
- variant,
- size,
- children,
- ...props
-}: React.ComponentProps &
- VariantProps) {
- return (
-
-
- {children}
-
-
- );
-}
-
-function ToggleGroupItem({
- className,
- children,
- variant,
- size,
- ...props
-}: React.ComponentProps &
- VariantProps) {
- const context = React.useContext(ToggleGroupContext);
-
- return (
-
- {children}
-
- );
-}
-
-export { ToggleGroup, ToggleGroupItem };
diff --git a/apps/dashboard/components/ui/toggle.tsx b/apps/dashboard/components/ui/toggle.tsx
deleted file mode 100644
index 679f8c9d92..0000000000
--- a/apps/dashboard/components/ui/toggle.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-"use client";
-
-import { cva, type VariantProps } from "class-variance-authority";
-import { Toggle as TogglePrimitive } from "radix-ui";
-import type * as React from "react";
-
-import { cn } from "@/lib/utils";
-
-const toggleVariants = cva(
- "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded font-medium text-foreground text-sm outline-none transition-[color,box-shadow] hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=on]:bg-primary data-[state=on]:text-primary-foreground data-[state=on]:hover:bg-primary/90 data-[state=on]:hover:text-primary-foreground dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
- {
- variants: {
- variant: {
- default: "bg-transparent",
- outline:
- "border border-input bg-transparent shadow-xs hover:bg-muted hover:text-foreground data-[state=on]:border-primary",
- },
- size: {
- default: "h-9 min-w-9 px-2",
- sm: "h-8 min-w-8 px-1.5",
- lg: "h-10 min-w-10 px-2.5",
- },
- },
- defaultVariants: {
- variant: "default",
- size: "default",
- },
- }
-);
-
-function Toggle({
- className,
- variant,
- size,
- ...props
-}: React.ComponentProps &
- VariantProps) {
- return (
-
- );
-}
-
-export { Toggle, toggleVariants };
diff --git a/apps/dashboard/components/ui/tooltip-bubble.tsx b/apps/dashboard/components/ui/tooltip-bubble.tsx
deleted file mode 100644
index 26068a3f43..0000000000
--- a/apps/dashboard/components/ui/tooltip-bubble.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import type React from "react";
-import { cn } from "@/lib/utils";
-
-interface TooltipBubbleProps {
- children: React.ReactNode;
- className?: string;
-}
-
-export const TooltipBubble: React.FC = ({
- children,
- className,
-}) => {
- return (
-
- {children}
-
- );
-};
diff --git a/apps/dashboard/components/ui/tooltip.tsx b/apps/dashboard/components/ui/tooltip.tsx
deleted file mode 100644
index 555447f7f6..0000000000
--- a/apps/dashboard/components/ui/tooltip.tsx
+++ /dev/null
@@ -1,66 +0,0 @@
-"use client";
-
-import * as TooltipPrimitive from "@radix-ui/react-tooltip";
-import type * as React from "react";
-
-import { cn } from "@/lib/utils";
-
-function TooltipProvider({
- delayDuration = 0,
- ...props
-}: React.ComponentProps) {
- return (
-
- );
-}
-
-function Tooltip({
- skipProvider = false,
- ...props
-}: React.ComponentProps & {
- skipProvider?: boolean;
-}) {
- const content = ;
-
- if (skipProvider) {
- return content;
- }
-
- return {content} ;
-}
-
-function TooltipTrigger({
- ...props
-}: React.ComponentProps) {
- return ;
-}
-
-function TooltipContent({
- className,
- sideOffset = 0,
- children,
- ...props
-}: React.ComponentProps) {
- return (
-
-
- {children}
-
-
-
- );
-}
-
-export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
diff --git a/apps/dashboard/components/user-avatar.tsx b/apps/dashboard/components/user-avatar.tsx
deleted file mode 100644
index b043949aa4..0000000000
--- a/apps/dashboard/components/user-avatar.tsx
+++ /dev/null
@@ -1,88 +0,0 @@
-"use client";
-
-interface UserAvatarProps {
- className?: string;
- size?: "sm" | "md" | "lg";
- visitorId: string;
-}
-
-function hashCode(str: string): number {
- let hash = 5381;
- for (const char of str) {
- hash = Math.imul(hash, 33) + char.charCodeAt(0);
- }
- return Math.abs(hash);
-}
-
-function hslToHex(h: number, s: number, l: number): string {
- const sNorm = s / 100;
- const lNorm = l / 100;
- const a = sNorm * Math.min(lNorm, 1 - lNorm);
-
- const f = (n: number) => {
- const k = (n + h / 30) % 12;
- const color = lNorm - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
- return Math.round(255 * color)
- .toString(16)
- .padStart(2, "0");
- };
-
- return `#${f(0)}${f(8)}${f(4)}`;
-}
-
-function getGradient(visitorId: string): {
- from: string;
- to: string;
- angle: number;
-} {
- const hash1 = hashCode(visitorId);
- const hash2 = hashCode(visitorId.split("").reverse().join(""));
- const hash3 = hashCode(`${visitorId}salt`);
-
- // Generate base hue from hash (0-360)
- const baseHue = hash1 % 360;
-
- // Hue shift strategies for nice color combinations
- const hueShifts = [30, 45, 60, 90, 120, 150, 180];
- const hueShift = hueShifts[hash2 % hueShifts.length] ?? 60;
-
- // Saturation and lightness variations
- const saturations = [65, 70, 75, 80, 85];
- const lightnesses = [55, 60, 65, 70];
-
- const sat1 = saturations[hash1 % saturations.length] ?? 75;
- const sat2 = saturations[hash2 % saturations.length] ?? 70;
- const light1 = lightnesses[hash2 % lightnesses.length] ?? 60;
- const light2 = lightnesses[hash3 % lightnesses.length] ?? 65;
-
- const from = hslToHex(baseHue, sat1, light1);
- const to = hslToHex((baseHue + hueShift) % 360, sat2, light2);
-
- // Angle variation
- const angle = 120 + (hash3 % 60);
-
- return { from, to, angle };
-}
-
-export function UserAvatar({
- visitorId,
- size = "md",
- className,
-}: UserAvatarProps) {
- const { from, to, angle } = getGradient(visitorId);
-
- const sizeClasses = {
- sm: "size-6",
- md: "size-8",
- lg: "size-10",
- };
-
- const sizeClass = sizeClasses[size];
-
- return (
-
- );
-}
diff --git a/apps/dashboard/components/website-dialog.tsx b/apps/dashboard/components/website-dialog.tsx
index cb32b7760b..0a3c801c76 100644
--- a/apps/dashboard/components/website-dialog.tsx
+++ b/apps/dashboard/components/website-dialog.tsx
@@ -5,6 +5,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react";
import { type SubmitHandler, useForm } from "react-hook-form";
import { toast } from "sonner";
+import { showErrorToast } from "@/lib/user-facing-error";
import { z } from "zod";
import { useOrganizationsContext } from "@/components/providers/organizations-provider";
import {
@@ -85,37 +86,6 @@ export function WebsiteDialog({
}
}, [website, form]);
- const getErrorMessage = (error: unknown, isEditingMode: boolean): string => {
- const defaultMessage = `Failed to ${isEditingMode ? "update" : "create"} website.`;
-
- const rpcError = error as {
- data?: { code?: string };
- message?: string;
- };
-
- if (rpcError?.data?.code) {
- switch (rpcError.data.code) {
- case "CONFLICT":
- return "A website with this domain already exists.";
- case "FORBIDDEN":
- return (
- rpcError.message ||
- "You do not have permission to perform this action."
- );
- case "UNAUTHORIZED":
- return "You must be logged in to perform this action.";
- case "BAD_REQUEST":
- return (
- rpcError.message || "Invalid request. Please check your input."
- );
- default:
- return rpcError.message || defaultMessage;
- }
- }
-
- return rpcError?.message || defaultMessage;
- };
-
const handleSubmit: SubmitHandler = async (formData) => {
const submissionData: CreateWebsiteData = {
name: formData.name,
@@ -148,8 +118,10 @@ export function WebsiteDialog({
}
onOpenChange(false);
} catch (error: unknown) {
- const message = getErrorMessage(error, !!website?.id);
- toast.error(message);
+ showErrorToast(
+ error,
+ `Failed to ${website?.id ? "update" : "create"} website.`
+ );
}
};
diff --git a/apps/dashboard/contexts/chat-context.tsx b/apps/dashboard/contexts/chat-context.tsx
index 9a839ee832..e0b73c3ce0 100644
--- a/apps/dashboard/contexts/chat-context.tsx
+++ b/apps/dashboard/contexts/chat-context.tsx
@@ -96,7 +96,9 @@ export function ChatProvider({
});
const chatRef = useRef(chat);
- chatRef.current = chat;
+ useEffect(() => {
+ chatRef.current = chat;
+ }, [chat]);
const [hasRestored, setHasRestored] = useState(false);
const [persistedUserMessageIds, setPersistedUserMessageIds] = useState<
diff --git a/apps/dashboard/error.tsx b/apps/dashboard/error.tsx
deleted file mode 100644
index b01052760f..0000000000
--- a/apps/dashboard/error.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-"use client";
-
-import { useEffect } from "react";
-import { ArrowClockwiseIcon, WarningIcon } from "@databuddy/ui/icons";
-import { Button, Card } from "@databuddy/ui";
-
-interface ErrorPageProps {
- error: Error & { digest?: string };
- reset: () => void;
-}
-
-export default function ErrorPage({ error, reset }: ErrorPageProps) {
- useEffect(() => {
- console.error(error);
- }, [error]);
-
- return (
-
-
-
-
-
- Something went wrong
-
-
-
-
- We encountered an unexpected error. Please try again. If the problem
- persists, please contact support.
-
- {error.digest && (
-
- Reference: {error.digest}
-
- )}
- reset()} size="sm">
-
- Try again
-
-
-
-
- );
-}
diff --git a/apps/dashboard/hooks/use-chart-preferences.ts b/apps/dashboard/hooks/use-chart-preferences.ts
index 2e30575f68..5dd6f326e1 100644
--- a/apps/dashboard/hooks/use-chart-preferences.ts
+++ b/apps/dashboard/hooks/use-chart-preferences.ts
@@ -5,10 +5,6 @@ import type {
import { usePersistentState } from "@databuddy/ui";
const CHART_PREFERENCES_STORAGE_KEY = "databuddy-chart-preferences";
-
-/**
- * Chart location identifiers - where charts appear in the app
- */
export type ChartLocation =
| "overview-stats" // Small stat cards on the overview tab (visitors, pageviews, etc.)
| "overview-main" // Large main chart on the overview tab
@@ -32,14 +28,6 @@ export const CHART_LOCATION_LABELS: Record = {
events: "Events Stats",
};
-export const CHART_LOCATION_DESCRIPTIONS: Record = {
- "overview-stats": "Small stat cards showing visitors, pageviews, etc.",
- "overview-main": "Large main chart on the overview tab",
- funnels: "Stat cards in the funnel analytics section",
- "website-list": "Mini charts on the websites list page",
- events: "Stat cards in the events analytics section",
-};
-
function isValidChartSeriesKind(value: unknown): value is ChartSeriesKind {
return (
typeof value === "string" &&
@@ -93,10 +81,6 @@ const getDefaultPreferences = (): AllPreferences => {
}
return defaults;
};
-
-/**
- * Hook to get chart preferences for a specific location
- */
export function useChartPreferences(location: ChartLocation) {
const [storedPreferences] = usePersistentState(
CHART_PREFERENCES_STORAGE_KEY,
@@ -117,10 +101,6 @@ export function useChartPreferences(location: ChartLocation) {
chartStepType: locationPrefs.chartStepType,
};
}
-
-/**
- * Hook to get and update all chart preferences (for settings page)
- */
export function useAllChartPreferences() {
const [storedPreferences, setStoredPreferences] =
usePersistentState(
diff --git a/apps/dashboard/hooks/use-date-filters.ts b/apps/dashboard/hooks/use-date-filters.ts
index 625493c891..52c6357af3 100644
--- a/apps/dashboard/hooks/use-date-filters.ts
+++ b/apps/dashboard/hooks/use-date-filters.ts
@@ -5,10 +5,13 @@ import {
getDefaultDatesFromPreset,
} from "@/hooks/use-default-date-range";
import { dayjs } from "@databuddy/ui";
-import type {
- DateRangeState,
- TimeGranularity,
-} from "@/stores/jotai/filterAtoms";
+
+export interface DateRangeState {
+ endDate: Date;
+ startDate: Date;
+}
+
+export type TimeGranularity = "daily" | "hourly";
const MAX_HOURLY_DAYS = 7;
const AUTO_HOURLY_DAYS = 2;
diff --git a/apps/dashboard/hooks/use-default-date-range.ts b/apps/dashboard/hooks/use-default-date-range.ts
index e2fbcf3067..2cf1e36d89 100644
--- a/apps/dashboard/hooks/use-default-date-range.ts
+++ b/apps/dashboard/hooks/use-default-date-range.ts
@@ -3,7 +3,7 @@ import { dayjs } from "@databuddy/ui";
const DEFAULT_DATE_RANGE_STORAGE_KEY = "databuddy-default-date-range";
-export const DEFAULT_DATE_RANGE_PRESETS = [
+const DEFAULT_DATE_RANGE_PRESETS = [
"24h",
"7d",
"30d",
@@ -21,12 +21,6 @@ function isValidPreset(value: unknown): value is DefaultDateRangePreset {
DEFAULT_DATE_RANGE_PRESETS.includes(value as DefaultDateRangePreset)
);
}
-
-/**
- * Reads the default date range preset from localStorage synchronously.
- * Used by use-date-filters for the initial default when URL has no params.
- * Returns "30d" during SSR or when storage is unavailable.
- */
export function getDefaultDateRangePresetSync(): DefaultDateRangePreset {
if (typeof window === "undefined") {
return "30d";
diff --git a/apps/dashboard/hooks/use-filters.ts b/apps/dashboard/hooks/use-filters.ts
index 77914c91bc..ecb6a0126c 100644
--- a/apps/dashboard/hooks/use-filters.ts
+++ b/apps/dashboard/hooks/use-filters.ts
@@ -16,7 +16,7 @@ export const goalFunnelOperatorOptions = [
{ value: "contains", label: "contains" },
] as const;
-export const operatorLabels: Record = {
+const operatorLabels: Record = {
eq: "=",
ne: "≠",
contains: "contains",
diff --git a/apps/dashboard/hooks/use-funnels.ts b/apps/dashboard/hooks/use-funnels.ts
index 6b62e72f04..dc204f5e89 100644
--- a/apps/dashboard/hooks/use-funnels.ts
+++ b/apps/dashboard/hooks/use-funnels.ts
@@ -10,6 +10,7 @@ import { toast } from "sonner";
import { insightQueries } from "@/lib/insight-api";
import { listQueryOutcome } from "@/lib/list-query-outcome";
import { orpc } from "@/lib/orpc";
+import { mutationErrorToast } from "@/lib/user-facing-error";
import type {
CreateFunnelData,
FunnelAnalyticsData,
@@ -41,6 +42,7 @@ export function useFunnelActions(websiteId: string) {
const createMutation = useMutation({
...orpc.funnels.create.mutationOptions(),
+ ...mutationErrorToast,
onSuccess: () => {
invalidateAll();
toast.success("Funnel created successfully");
@@ -49,6 +51,7 @@ export function useFunnelActions(websiteId: string) {
const updateMutation = useMutation({
...orpc.funnels.update.mutationOptions(),
+ ...mutationErrorToast,
onSuccess: () => {
invalidateAll();
toast.success("Funnel updated successfully");
@@ -57,6 +60,7 @@ export function useFunnelActions(websiteId: string) {
const deleteMutation = useMutation({
...orpc.funnels.delete.mutationOptions(),
+ ...mutationErrorToast,
onSuccess: () => {
invalidateAll();
toast.success("Funnel deleted successfully");
diff --git a/apps/dashboard/hooks/use-goals.ts b/apps/dashboard/hooks/use-goals.ts
index 3aa60b0f12..f0776957de 100644
--- a/apps/dashboard/hooks/use-goals.ts
+++ b/apps/dashboard/hooks/use-goals.ts
@@ -8,10 +8,11 @@ import { toast } from "sonner";
import { insightQueries } from "@/lib/insight-api";
import { listQueryOutcome } from "@/lib/list-query-outcome";
import { orpc } from "@/lib/orpc";
+import { mutationErrorToast } from "@/lib/user-facing-error";
export type Goal = InferSelectModel;
-export interface GoalAnalyticsData {
+interface GoalAnalyticsData {
avg_completion_time: number;
avg_completion_time_formatted: string;
biggest_dropoff_rate: number;
@@ -108,6 +109,7 @@ export function useGoalActions(websiteId: string) {
const createMutation = useMutation({
...orpc.goals.create.mutationOptions(),
+ ...mutationErrorToast,
onSuccess: () => {
invalidateAll();
toast.success("Goal created successfully");
@@ -116,6 +118,7 @@ export function useGoalActions(websiteId: string) {
const updateMutation = useMutation({
...orpc.goals.update.mutationOptions(),
+ ...mutationErrorToast,
onSuccess: () => {
invalidateAll();
toast.success("Goal updated successfully");
@@ -124,6 +127,7 @@ export function useGoalActions(websiteId: string) {
const deleteMutation = useMutation({
...orpc.goals.delete.mutationOptions(),
+ ...mutationErrorToast,
onSuccess: () => {
invalidateAll();
toast.success("Goal deleted successfully");
@@ -184,9 +188,6 @@ export function useGoalActions(websiteId: string) {
isCreating: createMutation.isPending,
isUpdating: updateMutation.isPending,
isDeleting: deleteMutation.isPending,
- createError: createMutation.error,
- updateError: updateMutation.error,
- deleteError: deleteMutation.error,
};
}
@@ -236,27 +237,6 @@ export function useGoal(goalId: string, enabled = true) {
});
}
-export function useGoalAnalytics(
- websiteId: string,
- goalId: string,
- dateRange: { start_date: string; end_date: string },
- filters: GoalFilter[] = [],
- options: { enabled: boolean } = { enabled: true }
-) {
- return useQuery({
- ...orpc.goals.getAnalytics.queryOptions({
- input: {
- goalId,
- websiteId,
- startDate: dateRange?.start_date,
- endDate: dateRange?.end_date,
- filters,
- },
- }),
- enabled: options.enabled && !!websiteId && !!goalId,
- });
-}
-
export function useBulkGoalAnalytics(
websiteId: string,
goalIds: string[],
diff --git a/apps/dashboard/hooks/use-links.ts b/apps/dashboard/hooks/use-links.ts
index 9313d41ed5..226f67e298 100644
--- a/apps/dashboard/hooks/use-links.ts
+++ b/apps/dashboard/hooks/use-links.ts
@@ -62,7 +62,7 @@ const EMPTY_LINK_FOLDERS: LinkFolder[] = [];
export type LinkSortOption = "newest" | "oldest" | "name-asc" | "name-desc";
export type LinkTypeFilter = "all" | "short" | "deep";
-export const LINKS_PAGE_SIZE = 50;
+const LINKS_PAGE_SIZE = 50;
export interface LinksPageParams {
folderId?: string | null;
@@ -402,30 +402,3 @@ export function useCreateLinkFolder() {
},
});
}
-
-export function useUpdateLinkFolder() {
- const queryClient = useQueryClient();
-
- return useMutation({
- ...orpc.linkFolders.update.mutationOptions(),
- onSuccess: () => {
- queryClient.invalidateQueries({
- queryKey: foldersRootKey,
- });
- },
- });
-}
-
-export function useDeleteLinkFolder() {
- const queryClient = useQueryClient();
-
- return useMutation({
- ...orpc.linkFolders.delete.mutationOptions(),
- onSuccess: () => {
- queryClient.invalidateQueries({
- queryKey: foldersRootKey,
- });
- queryClient.invalidateQueries({ queryKey: linksPaginatedRootKey });
- },
- });
-}
diff --git a/apps/dashboard/hooks/use-websites.ts b/apps/dashboard/hooks/use-websites.ts
index 7194c33a0f..a588324119 100644
--- a/apps/dashboard/hooks/use-websites.ts
+++ b/apps/dashboard/hooks/use-websites.ts
@@ -26,7 +26,7 @@ export const getWebsitesListKey = (): QueryKey =>
const EMPTY_WEBSITES: Website[] = [];
-export const updateWebsiteInList = (
+const updateWebsiteInList = (
old: WebsitesListData | undefined,
updatedWebsite: Website
): WebsitesListData | undefined => {
diff --git a/apps/dashboard/lib/ai-components/index.ts b/apps/dashboard/lib/ai-components/index.ts
index c1a200d6e7..cbfa105ea9 100644
--- a/apps/dashboard/lib/ai-components/index.ts
+++ b/apps/dashboard/lib/ai-components/index.ts
@@ -2,33 +2,6 @@
export { parseContentSegments } from "./parser";
-export { componentRegistry, getComponent, hasComponent } from "./registry";
+export { getComponent, hasComponent } from "./registry";
-export {
- AI_COMPONENT_DATA_PART_NAME,
- AI_COMPONENT_DATA_PART_TYPE,
- getAIComponentInputFromPart,
- getAIComponentInputFromToolOutput,
- normalizeAIComponentMessageParts,
- normalizeAIComponentMessages,
-} from "./message-parts";
-export type { AIComponentDataPart } from "./message-parts";
-
-export type {
- BaseComponentProps,
- ChartComponentProps,
- ComponentDefinition,
- ComponentRegistry,
- ContentSegment,
- CountryItem,
- DashboardActionsInput,
- DataTableInput,
- DistributionInput,
- LinksListInput,
- MiniMapInput,
- ParsedSegments,
- RawComponentInput,
- ReferrerItem,
- ReferrersListInput,
- TimeSeriesInput,
-} from "./types";
+export type { RawComponentInput } from "./types";
diff --git a/apps/dashboard/lib/ai-components/message-parts.ts b/apps/dashboard/lib/ai-components/message-parts.ts
index 8ffa267921..4c4ad3b28b 100644
--- a/apps/dashboard/lib/ai-components/message-parts.ts
+++ b/apps/dashboard/lib/ai-components/message-parts.ts
@@ -2,7 +2,7 @@ import { parseContentSegments } from "./parser";
import { validateComponentJSON } from "./schemas";
import type { RawComponentInput } from "./types";
-export const AI_COMPONENT_DATA_PART_NAME = "aiComponent";
+const AI_COMPONENT_DATA_PART_NAME = "aiComponent";
export const AI_COMPONENT_DATA_PART_TYPE = `data-${AI_COMPONENT_DATA_PART_NAME}`;
interface MessageLike {
@@ -16,7 +16,7 @@ interface TextPartLike {
[key: string]: unknown;
}
-export interface AIComponentDataPart {
+interface AIComponentDataPart {
data: RawComponentInput;
id?: string;
type: typeof AI_COMPONENT_DATA_PART_TYPE;
@@ -107,7 +107,7 @@ function expandTextPart(part: TextPartLike): unknown[] | null {
return expanded.length > 0 ? expanded : null;
}
-export function normalizeAIComponentMessageParts(
+function normalizeAIComponentMessageParts(
message: TMessage
): TMessage {
if (message.role !== "assistant") {
diff --git a/apps/dashboard/lib/ai-components/parser.ts b/apps/dashboard/lib/ai-components/parser.ts
index 32bcdf86c8..ff46fdbd8f 100644
--- a/apps/dashboard/lib/ai-components/parser.ts
+++ b/apps/dashboard/lib/ai-components/parser.ts
@@ -25,7 +25,7 @@ function isRawComponentInput(obj: unknown): obj is RawComponentInput {
return valid;
}
-export function repairPartialJSON(input: string): string | null {
+function repairPartialJSON(input: string): string | null {
if (input.length < 10) {
return null;
}
diff --git a/apps/dashboard/lib/ai-components/registry.tsx b/apps/dashboard/lib/ai-components/registry.tsx
index c41d4bea7d..357e56b6ee 100644
--- a/apps/dashboard/lib/ai-components/registry.tsx
+++ b/apps/dashboard/lib/ai-components/registry.tsx
@@ -492,7 +492,7 @@ function toMiniMapProps(input: MiniMapInput): MiniMapProps {
};
}
-export const componentRegistry: ComponentRegistry = {
+const componentRegistry: ComponentRegistry = {
"line-chart": {
validate: isTimeSeriesInput,
transform: toTimeSeriesProps,
diff --git a/apps/dashboard/lib/ai-components/renderers/config.ts b/apps/dashboard/lib/ai-components/renderers/config.ts
deleted file mode 100644
index 8b56acc8a8..0000000000
--- a/apps/dashboard/lib/ai-components/renderers/config.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import { chartSeriesColorAtIndex } from "@/lib/chart-presentation";
-
-/**
- * Get a theme-aware chart color by index.
- * Uses the dashboard's CSS variable palette for consistency.
- */
-export const getChartColor = chartSeriesColorAtIndex;
diff --git a/apps/dashboard/lib/ai-components/renderers/data-table.tsx b/apps/dashboard/lib/ai-components/renderers/data-table.tsx
index 8355c70adb..56e0348bd6 100644
--- a/apps/dashboard/lib/ai-components/renderers/data-table.tsx
+++ b/apps/dashboard/lib/ai-components/renderers/data-table.tsx
@@ -5,7 +5,7 @@ import type { BaseComponentProps } from "../types";
import { TableIcon } from "@databuddy/ui/icons";
import { Badge, Card } from "@databuddy/ui";
-export interface DataTableColumn {
+interface DataTableColumn {
align?: "left" | "center" | "right";
header: string;
key: string;
diff --git a/apps/dashboard/lib/ai-components/renderers/funnels/list.tsx b/apps/dashboard/lib/ai-components/renderers/funnels/list.tsx
index 20c84d40cb..7bd51fd9f5 100644
--- a/apps/dashboard/lib/ai-components/renderers/funnels/list.tsx
+++ b/apps/dashboard/lib/ai-components/renderers/funnels/list.tsx
@@ -3,7 +3,6 @@
import { useQuery } from "@tanstack/react-query";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useState } from "react";
-import { toast } from "sonner";
import { EditFunnelDialog } from "@/app/(main)/websites/[id]/funnels/_components/edit-funnel-dialog";
import { useFunnels } from "@/hooks/use-funnels";
import { orpc } from "@/lib/orpc";
@@ -195,9 +194,7 @@ export function FunnelsListRenderer({
try {
await createAction(data);
closeDialog();
- } catch {
- toast.error("Failed to create funnel");
- }
+ } catch {}
},
[createAction, closeDialog]
);
@@ -216,9 +213,7 @@ export function FunnelsListRenderer({
ignoreHistoricData: funnel.ignoreHistoricData,
});
closeDialog();
- } catch {
- toast.error("Failed to update funnel");
- }
+ } catch {}
},
[editingFunnelId, updateAction, closeDialog]
);
@@ -230,9 +225,7 @@ export function FunnelsListRenderer({
try {
await deleteAction(deletingId);
setDeletingId(null);
- } catch {
- toast.error("Failed to delete funnel");
- }
+ } catch {}
}, [deletingId, deleteAction]);
const fetchedFunnel = editingFunnelQuery.data as
diff --git a/apps/dashboard/lib/ai-components/renderers/funnels/preview.tsx b/apps/dashboard/lib/ai-components/renderers/funnels/preview.tsx
index dc05a851b3..235cfa36ae 100644
--- a/apps/dashboard/lib/ai-components/renderers/funnels/preview.tsx
+++ b/apps/dashboard/lib/ai-components/renderers/funnels/preview.tsx
@@ -7,7 +7,6 @@ import {
useCallback,
useState,
} from "react";
-import { toast } from "sonner";
import { EditFunnelDialog } from "@/app/(main)/websites/[id]/funnels/_components/edit-funnel-dialog";
import { useChat } from "@/contexts/chat-context";
import { useFunnels } from "@/hooks/use-funnels";
@@ -101,9 +100,7 @@ export function FunnelPreviewRenderer({
try {
await createAction(data);
setIsDialogOpen(false);
- } catch {
- toast.error("Failed to create funnel");
- }
+ } catch {}
},
[createAction]
);
diff --git a/apps/dashboard/lib/ai-components/renderers/goals/list.tsx b/apps/dashboard/lib/ai-components/renderers/goals/list.tsx
index b74f12bced..5916d4b26a 100644
--- a/apps/dashboard/lib/ai-components/renderers/goals/list.tsx
+++ b/apps/dashboard/lib/ai-components/renderers/goals/list.tsx
@@ -231,11 +231,7 @@ export function GoalsListRenderer({ title, goals, className }: GoalsListProps) {
} as CreateGoalData);
}
closeDialog();
- } catch {
- toast.error(
- editingGoalId ? "Failed to update goal" : "Failed to create goal"
- );
- }
+ } catch {}
},
[editingGoalId, createGoal, updateGoal, websiteId, closeDialog]
);
@@ -247,9 +243,7 @@ export function GoalsListRenderer({ title, goals, className }: GoalsListProps) {
try {
await deleteGoal(deletingId);
setDeletingId(null);
- } catch {
- toast.error("Failed to delete goal");
- }
+ } catch {}
}, [deletingId, deleteGoal]);
const goalForDialog: Goal | null = (editingGoalQuery.data as Goal) ?? null;
diff --git a/apps/dashboard/lib/ai-components/renderers/goals/preview.tsx b/apps/dashboard/lib/ai-components/renderers/goals/preview.tsx
index 0f9ab3b2f4..b88a06df16 100644
--- a/apps/dashboard/lib/ai-components/renderers/goals/preview.tsx
+++ b/apps/dashboard/lib/ai-components/renderers/goals/preview.tsx
@@ -7,7 +7,6 @@ import {
useCallback,
useState,
} from "react";
-import { toast } from "sonner";
import { EditGoalDialog } from "@/app/(main)/websites/[id]/goals/_components/edit-goal-dialog";
import { useChat } from "@/contexts/chat-context";
import { type CreateGoalData, type Goal, useGoals } from "@/hooks/use-goals";
@@ -111,9 +110,7 @@ export function GoalPreviewRenderer({
...data,
} as CreateGoalData);
setIsDialogOpen(false);
- } catch {
- toast.error("Failed to create goal");
- }
+ } catch {}
},
[createGoal, websiteId]
);
diff --git a/apps/dashboard/lib/ai-components/renderers/mini-map.tsx b/apps/dashboard/lib/ai-components/renderers/mini-map.tsx
index b42189cabf..bb5b4b707a 100644
--- a/apps/dashboard/lib/ai-components/renderers/mini-map.tsx
+++ b/apps/dashboard/lib/ai-components/renderers/mini-map.tsx
@@ -2,7 +2,6 @@
import type { LocationData } from "@/types/website";
import dynamic from "next/dynamic";
-import { motion } from "motion/react";
import { useId, useMemo, useState } from "react";
import { CountryFlag } from "@/components/icon";
import { formatNumber } from "@/lib/formatters";
@@ -34,7 +33,7 @@ const MapComponent = dynamic(
}
);
-export interface CountryItem {
+interface CountryItem {
country_code?: string;
name: string;
pageviews?: number;
@@ -174,74 +173,75 @@ export function MiniMapRenderer({ title, countries, className }: MiniMapProps) {
/>
-
-
- {topCountries.length > 0 ? (
- topCountries.map((country) => {
- const safeVisitors =
- country.visitors == null ||
- Number.isNaN(country.visitors)
- ? 0
- : country.visitors;
- const safeTotalVisitors =
- totalVisitors == null || Number.isNaN(totalVisitors)
- ? 0
- : totalVisitors;
- const percentage =
- safeTotalVisitors > 0 &&
- !Number.isNaN(safeVisitors) &&
- !Number.isNaN(safeTotalVisitors)
- ? (safeVisitors / safeTotalVisitors) * 100
- : 0;
- const countryCode =
- country.country_code?.toUpperCase() ||
- country.country.toUpperCase();
+
+
+ {topCountries.length > 0 ? (
+ topCountries.map((country) => {
+ const safeVisitors =
+ country.visitors == null ||
+ Number.isNaN(country.visitors)
+ ? 0
+ : country.visitors;
+ const safeTotalVisitors =
+ totalVisitors == null ||
+ Number.isNaN(totalVisitors)
+ ? 0
+ : totalVisitors;
+ const percentage =
+ safeTotalVisitors > 0 &&
+ !Number.isNaN(safeVisitors) &&
+ !Number.isNaN(safeTotalVisitors)
+ ? (safeVisitors / safeTotalVisitors) * 100
+ : 0;
+ const countryCode =
+ country.country_code?.toUpperCase() ||
+ country.country.toUpperCase();
- return (
-
-
-
- {country.country}
-
-
-
- {formatNumber(country.visitors)}
-
-
- {percentage.toFixed(0)}%
+ return (
+
+
+
+ {country.country}
+
+
+ {formatNumber(country.visitors)}
+
+
+ {percentage.toFixed(0)}%
+
+
-
- );
- })
- ) : (
-
-
-
- No location data
-
-
- )}
-
-
+ );
+ })
+ ) : (
+
+
+
+ No location data
+
+
+ )}
+
+
+
diff --git a/apps/dashboard/lib/ai-components/renderers/referrers-list.tsx b/apps/dashboard/lib/ai-components/renderers/referrers-list.tsx
index 70a3897960..e36166907a 100644
--- a/apps/dashboard/lib/ai-components/renderers/referrers-list.tsx
+++ b/apps/dashboard/lib/ai-components/renderers/referrers-list.tsx
@@ -7,7 +7,7 @@ import type { BaseComponentProps } from "../types";
import { GlobeIcon } from "@databuddy/ui/icons";
import { Card } from "@databuddy/ui";
-export interface ReferrerItem {
+interface ReferrerItem {
domain?: string;
name: string;
pageviews?: number;
diff --git a/apps/dashboard/lib/ai-components/schemas.ts b/apps/dashboard/lib/ai-components/schemas.ts
index 19f73f34c8..fc601cca8e 100644
--- a/apps/dashboard/lib/ai-components/schemas.ts
+++ b/apps/dashboard/lib/ai-components/schemas.ts
@@ -6,7 +6,7 @@ const linkSlugSchema = z.string().refine(isPublicLinkSlug, {
"Slug must be 3-50 characters and use only letters, numbers, hyphens, or underscores",
});
-export const timeSeriesSchema = z
+const timeSeriesSchema = z
.object({
type: z.string(),
title: z.string().optional(),
@@ -15,7 +15,7 @@ export const timeSeriesSchema = z
})
.passthrough();
-export const distributionSchema = z
+const distributionSchema = z
.object({
type: z.string(),
title: z.string().optional(),
@@ -23,7 +23,7 @@ export const distributionSchema = z
})
.passthrough();
-export const dataTableSchema = z
+const dataTableSchema = z
.object({
type: z.literal("data-table"),
title: z.string().optional(),
@@ -48,7 +48,7 @@ const referrerItemSchema = z
})
.passthrough();
-export const referrersListSchema = z
+const referrersListSchema = z
.object({
type: z.literal("referrers-list"),
title: z.string().optional(),
@@ -66,7 +66,7 @@ const countryItemSchema = z
})
.passthrough();
-export const miniMapSchema = z
+const miniMapSchema = z
.object({
type: z.literal("mini-map"),
title: z.string().optional(),
@@ -102,7 +102,7 @@ export const linksListSchema = z
})
.passthrough();
-export const linkPreviewSchema = z
+const linkPreviewSchema = z
.object({
type: z.literal("link-preview"),
mode: z.enum(["create", "update", "delete"]),
@@ -122,7 +122,7 @@ export const linkPreviewSchema = z
})
.passthrough();
-export const feedbackPreviewSchema = z
+const feedbackPreviewSchema = z
.object({
type: z.literal("feedback-preview"),
mode: z.enum(["offer", "sent"]),
@@ -156,7 +156,7 @@ const funnelItemSchema = z
})
.passthrough();
-export const funnelsListSchema = z
+const funnelsListSchema = z
.object({
type: z.literal("funnels-list"),
title: z.string().optional(),
@@ -164,7 +164,7 @@ export const funnelsListSchema = z
})
.passthrough();
-export const funnelPreviewSchema = z
+const funnelPreviewSchema = z
.object({
type: z.literal("funnel-preview"),
mode: z.enum(["create", "update", "delete"]),
@@ -247,7 +247,7 @@ const goalItemSchema = z
})
.passthrough();
-export const goalsListSchema = z
+const goalsListSchema = z
.object({
type: z.literal("goals-list"),
title: z.string().optional(),
@@ -255,7 +255,7 @@ export const goalsListSchema = z
})
.passthrough();
-export const goalPreviewSchema = z
+const goalPreviewSchema = z
.object({
type: z.literal("goal-preview"),
mode: z.enum(["create", "update", "delete"]),
@@ -285,7 +285,7 @@ const annotationItemSchema = z
})
.passthrough();
-export const annotationsListSchema = z
+const annotationsListSchema = z
.object({
type: z.literal("annotations-list"),
title: z.string().optional(),
@@ -293,7 +293,7 @@ export const annotationsListSchema = z
})
.passthrough();
-export const annotationPreviewSchema = z
+const annotationPreviewSchema = z
.object({
type: z.literal("annotation-preview"),
mode: z.enum(["create", "update", "delete"]),
@@ -311,7 +311,7 @@ export const annotationPreviewSchema = z
})
.passthrough();
-export const componentSchemaMap: Record = {
+const componentSchemaMap: Record = {
"line-chart": timeSeriesSchema,
"bar-chart": timeSeriesSchema,
"area-chart": timeSeriesSchema,
diff --git a/apps/dashboard/lib/ai-components/types.ts b/apps/dashboard/lib/ai-components/types.ts
index cd0bfe46f5..77fc3a6335 100644
--- a/apps/dashboard/lib/ai-components/types.ts
+++ b/apps/dashboard/lib/ai-components/types.ts
@@ -22,11 +22,6 @@ export interface ComponentDefinition<
export type ComponentRegistry = Record>;
-export interface ParsedContent {
- components: RawComponentInput[];
- text: string;
-}
-
export type ContentSegment =
| { type: "text"; content: string }
| { type: "component"; content: RawComponentInput }
@@ -228,7 +223,7 @@ export interface DataTableInput {
type: "data-table";
}
-export interface ReferrerItem {
+interface ReferrerItem {
domain?: string;
name: string;
pageviews?: number;
@@ -243,7 +238,7 @@ export interface ReferrersListInput {
type: "referrers-list";
}
-export interface CountryItem {
+interface CountryItem {
country_code?: string;
name: string;
pageviews?: number;
diff --git a/apps/dashboard/lib/annotation-constants.ts b/apps/dashboard/lib/annotation-constants.ts
index c02756d3bb..3d930d4130 100644
--- a/apps/dashboard/lib/annotation-constants.ts
+++ b/apps/dashboard/lib/annotation-constants.ts
@@ -1,8 +1,4 @@
import type { AnnotationColor, AnnotationTag } from "@/types/annotations";
-
-/**
- * Available colors for annotations
- */
export const ANNOTATION_COLORS: AnnotationColor[] = [
{ value: "#3B82F6", label: "Blue" },
{ value: "#EF4444", label: "Red" },
@@ -11,10 +7,6 @@ export const ANNOTATION_COLORS: AnnotationColor[] = [
{ value: "#8B5CF6", label: "Purple" },
{ value: "#EC4899", label: "Pink" },
];
-
-/**
- * Common tags for quick selection
- */
export const COMMON_ANNOTATION_TAGS: AnnotationTag[] = [
{ label: "Campaign", value: "campaign", color: "#3B82F6" },
{ label: "Launch", value: "launch", color: "#10B981" },
@@ -25,33 +17,12 @@ export const COMMON_ANNOTATION_TAGS: AnnotationTag[] = [
{ label: "Marketing", value: "marketing", color: "#06B6D4" },
{ label: "Update", value: "update", color: "#84CC16" },
];
-
-/**
- * Default annotation values
- */
export const DEFAULT_ANNOTATION_VALUES = {
color: "#3B82F6",
isPublic: false,
maxTextLength: 500,
tags: [] as string[],
} as const;
-
-/**
- * Chart annotation styling constants
- */
-export const CHART_ANNOTATION_STYLES = {
- strokeWidth: 3,
- strokeDasharray: "5 5",
- fillOpacity: 0.08,
- strokeOpacity: 0.6,
- fontSize: 11,
- fontWeight: 600,
- offset: 10,
-} as const;
-
-/**
- * Local storage keys for annotation preferences
- */
export const ANNOTATION_STORAGE_KEYS = {
visibility: (websiteId: string) => `chart-annotations-visible-${websiteId}`,
tipDismissed: (websiteId: string) =>
diff --git a/apps/dashboard/lib/annotation-utils.ts b/apps/dashboard/lib/annotation-utils.ts
index 91690fc777..5d29b72d79 100644
--- a/apps/dashboard/lib/annotation-utils.ts
+++ b/apps/dashboard/lib/annotation-utils.ts
@@ -2,26 +2,13 @@ import { dayjs } from "@databuddy/ui";
import type { Annotation } from "@/types/annotations";
type Granularity = "hourly" | "daily" | "weekly" | "monthly";
-
-/**
- * Formats a date to a readable string
- * Shows time if the date is within a 24-hour period or spans less than a day
- */
-export function formatAnnotationDate(
- date: Date | string,
- showTime = false
-): string {
+function formatAnnotationDate(date: Date | string, showTime = false): string {
const dateObj = dayjs(date);
if (showTime) {
return dateObj.format("MMM D, h:mm A");
}
return dateObj.format("MMM D, YYYY");
}
-
-/**
- * Formats a date range for annotations
- * Automatically detects if hourly format is needed based on granularity or date range
- */
export function formatAnnotationDateRange(
start: Date | string,
end: Date | string | null,
@@ -49,10 +36,6 @@ export function formatAnnotationDateRange(
return `${formatAnnotationDate(start, showTime)} - ${formatAnnotationDate(end as Date | string, showTime)}`;
}
-
-/**
- * Checks if an annotation is a single-day range
- */
export function isSingleDayAnnotation(annotation: Annotation): boolean {
if (annotation.annotationType !== "range" || !annotation.xEndValue) {
return false;
@@ -63,24 +46,6 @@ export function isSingleDayAnnotation(annotation: Annotation): boolean {
return startTime === endTime;
}
-
-/**
- * Gets the display date for chart rendering
- * Matches the format used by formatDateByGranularity
- */
-export function getChartDisplayDate(
- date: Date | string,
- granularity: Granularity = "daily"
-): string {
- const dateObj = dayjs(date);
- return granularity === "hourly"
- ? dateObj.format("MMM D, h:mm A")
- : dateObj.format("MMM D");
-}
-
-/**
- * Validates annotation form data
- */
export function validateAnnotationForm(data: {
text: string;
tags: string[];
@@ -105,27 +70,6 @@ export function validateAnnotationForm(data: {
errors,
};
}
-
-/**
- * Generates a unique annotation ID
- */
-export function generateAnnotationId(): string {
- return `annotation_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
-}
-
-/**
- * Sanitizes annotation text
- */
export function sanitizeAnnotationText(text: string): string {
return text.trim().slice(0, 500);
}
-
-/**
- * Formats annotation tags for display
- */
-export function formatAnnotationTags(tags: string[] | null): string[] {
- if (!tags || tags.length === 0) {
- return [];
- }
- return tags.filter((tag) => tag.trim().length > 0);
-}
diff --git a/apps/dashboard/lib/app-events.ts b/apps/dashboard/lib/app-events.ts
index 16be651a0e..7edfa6c7a2 100644
--- a/apps/dashboard/lib/app-events.ts
+++ b/apps/dashboard/lib/app-events.ts
@@ -19,7 +19,6 @@ import {
export {
APP_EVENTS,
readMarketingProperties,
- readUtmProperties,
} from "@databuddy/shared/custom-events";
export type {
OnboardingAttributionProperties,
diff --git a/apps/dashboard/lib/app-url.ts b/apps/dashboard/lib/app-url.ts
index a351f09dd7..0d8255c494 100644
--- a/apps/dashboard/lib/app-url.ts
+++ b/apps/dashboard/lib/app-url.ts
@@ -2,7 +2,7 @@ import { publicConfig } from "@databuddy/env/public";
export const APP_URL = publicConfig.urls.dashboard;
-export const STATUS_URL = publicConfig.urls.status;
+const STATUS_URL = publicConfig.urls.status;
export function getStatusPageUrl(slug: string): string {
return `${STATUS_URL}/${slug}`;
diff --git a/apps/dashboard/lib/autumn/attach-content.tsx b/apps/dashboard/lib/autumn/attach-content.tsx
deleted file mode 100644
index 937888d076..0000000000
--- a/apps/dashboard/lib/autumn/attach-content.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-import type { PreviewAttachResponse } from "autumn-js";
-
-export const getAttachContent = (_preview: PreviewAttachResponse) => {
- const planName = "this plan";
-
- return {
- title: Confirm Subscription
,
- message: (
-
- By clicking confirm, you will be subscribed to {planName} and your
- payment method will be charged.
-
- ),
- };
-};
diff --git a/apps/dashboard/lib/chart-presentation.ts b/apps/dashboard/lib/chart-presentation.ts
index 705d50f669..ad63ce04d9 100644
--- a/apps/dashboard/lib/chart-presentation.ts
+++ b/apps/dashboard/lib/chart-presentation.ts
@@ -1,27 +1,13 @@
import type { CSSProperties } from "react";
import { cn } from "@/lib/utils";
-
-/** Root shell for chart cards (matches `Chart` default root). */
export const chartSurfaceClassName =
"flex w-full min-w-0 flex-col gap-0 overflow-hidden rounded border border-border bg-card";
-
-/** Same as `chartSurfaceClassName` but no border (inset charts, e.g. LLM panel). */
export const chartSurfaceBorderlessClassName =
"w-full min-w-0 flex flex-col gap-0 overflow-hidden rounded border-0 bg-card";
-
-/** Plot background behind Recharts (matches `Chart.Plot`). */
export const chartPlotRegionClassName = "dotted-bg bg-accent";
-
-/** Default `YAxis` width when labels need room (most dashboards). */
export const chartAxisYWidthDefault = 45;
-
-/** Narrow charts (trends with tight left margin). */
export const chartAxisYWidthCompact = 32;
-
-/** Recharts `Legend` swatch size — use for every `Legend` `iconSize`. */
export const chartRechartsLegendIconSize = 8;
-
-/** Interactive legend (click to toggle series): centered row, matches pill typography rhythm. */
export const chartRechartsLegendInteractiveWrapperStyle = {
cursor: "pointer",
display: "flex",
@@ -31,8 +17,6 @@ export const chartRechartsLegendInteractiveWrapperStyle = {
lineHeight: 1.2,
paddingTop: "20px",
} as const satisfies CSSProperties;
-
-/** Read-only Recharts legend (no toggle). */
export const chartRechartsLegendStaticWrapperStyle = {
display: "flex",
fontSize: "12px",
@@ -55,8 +39,6 @@ export function chartRechartsInteractiveLegendLabelClassName(
export const chartRechartsLegendStaticLabelClassName =
"text-pretty text-muted-foreground text-xs";
-
-/** `Chart.Legend` metric pills (footer) — keep in sync with Recharts legend labels. */
export const chartLegendPillRowClassName =
"flex shrink-0 flex-wrap justify-end gap-1.5";
@@ -67,28 +49,20 @@ export const chartLegendPillDotClassName = "size-2 shrink-0 rounded-full";
export const chartLegendPillLabelClassName =
"text-muted-foreground text-xs leading-none";
-
-/** Single-row legend above a chart (e.g. retention) — same dot + label scale as pills. */
export const chartLegendInlineRowClassName =
"flex flex-wrap items-center gap-x-4 gap-y-1";
export const chartLegendInlineItemClassName = "flex items-center gap-2";
-
-/** Recharts `tick` prop for X/Y axes — use for every dashboard chart. */
export const chartAxisTickDefault = {
fontSize: 11,
fill: "var(--muted-foreground)",
} as const;
-
-/** Horizontal `CartesianGrid` — single token for all Cartesian charts. */
export const chartCartesianGridDefault = {
stroke: "var(--border)",
strokeDasharray: "2 4",
strokeOpacity: 0.35,
vertical: false,
} as const;
-
-/** Theme series colors (rotate for N metrics without semantic hex). */
export const chartSeriesPalette = [
"var(--color-chart-1)",
"var(--color-chart-2)",
@@ -100,25 +74,15 @@ export const chartSeriesPalette = [
export function chartSeriesColorAtIndex(index: number): string {
return chartSeriesPalette[index % chartSeriesPalette.length];
}
-
-/** Outer shell for `Chart.Tooltip` multi-series layout (matches composable defaults). */
export const chartTooltipMultiShellClassName =
"min-w-[180px] rounded border border-border bg-popover p-2.5 shadow-lg";
-
-/** Outer shell for single-value tooltip. */
export const chartTooltipSingleShellClassName =
"rounded border border-border bg-popover px-2.5 py-1.5 shadow-lg";
-
-/** Date/label row inside multi-series tooltip. */
export const chartTooltipHeaderRowClassName =
"mb-2 flex items-center gap-2 border-b border-border pb-2";
-
-/** Custom Recharts `Tooltip` content wrappers (when not using `Chart.Tooltip`). */
export function chartTooltipCustomSurfaceClassName(className?: string) {
return cn(chartTooltipMultiShellClassName, "p-3", className);
}
-
-/** Merges static legend placement tweaks (e.g. pie `bottom`, scrollable X bottom offset). */
export function chartRechartsLegendStaticWrapperStyleMerge(
extra: CSSProperties
): CSSProperties {
diff --git a/apps/dashboard/lib/chart-query-outcome.ts b/apps/dashboard/lib/chart-query-outcome.ts
index 7248f6203e..448cde1f28 100644
--- a/apps/dashboard/lib/chart-query-outcome.ts
+++ b/apps/dashboard/lib/chart-query-outcome.ts
@@ -1,9 +1,4 @@
import type { UseQueryResult } from "@tanstack/react-query";
-
-/**
- * Derives chart UI state from TanStack Query (or manual props). Use with
- * `Chart.Content` so pages don’t hand-roll loading → flash → chart.
- */
export type ChartQueryOutcome =
| { status: "empty" }
| { status: "error" }
@@ -51,8 +46,6 @@ export type ChartQuerySlice = Pick<
UseQueryResult,
"data" | "isPending" | "isError" | "isSuccess"
>;
-
-/** Infers chart state from a TanStack query — pass to `Chart.Content` as `query`. */
export function chartQueryOutcomeFromQuery(
query: ChartQuerySlice,
options?: { gatePending?: boolean; isEmpty?: (data: T) => boolean }
diff --git a/apps/dashboard/lib/dashboard-navigation-actions.ts b/apps/dashboard/lib/dashboard-navigation-actions.ts
index 4249401287..54701c9097 100644
--- a/apps/dashboard/lib/dashboard-navigation-actions.ts
+++ b/apps/dashboard/lib/dashboard-navigation-actions.ts
@@ -2,7 +2,7 @@ import type { DynamicQueryFilter } from "@/types/api";
export const DASHBOARD_FILTERS_QUERY_PARAM = "filters";
-export type DashboardActionParamValue =
+type DashboardActionParamValue =
| (boolean | number | string)[]
| boolean
| null
@@ -37,7 +37,7 @@ export const DASHBOARD_ACTION_TARGETS = [
"website.vitals",
] as const;
-export type DashboardActionTarget = (typeof DASHBOARD_ACTION_TARGETS)[number];
+type DashboardActionTarget = (typeof DASHBOARD_ACTION_TARGETS)[number];
const DASHBOARD_ACTION_TARGET_SET = new Set(DASHBOARD_ACTION_TARGETS);
@@ -193,7 +193,7 @@ function isSafeDashboardPath(pathname: string) {
return topLevel ? ALLOWED_TOP_LEVEL_SEGMENTS.has(topLevel) : false;
}
-export function normalizeDashboardHref(
+function normalizeDashboardHref(
href: string,
websiteId?: string | null
): string | null {
diff --git a/apps/dashboard/lib/flags/get-examples-strategy.ts b/apps/dashboard/lib/flags/get-examples-strategy.ts
deleted file mode 100644
index 14f55b033a..0000000000
--- a/apps/dashboard/lib/flags/get-examples-strategy.ts
+++ /dev/null
@@ -1,83 +0,0 @@
-"use server";
-
-import { publicConfig } from "@databuddy/env/public";
-import { createServerFlagsManager } from "@databuddy/sdk/node";
-
-export interface ExamplesDisplayStrategy {
- dependencies?: {
- prerequisiteFlag: string;
- prerequisiteEnabled: boolean;
- };
- environment?: string;
- exampleCount: number; // 0, 3, or 6
- schedule?: {
- hasSchedule: boolean;
- nextChange?: string;
- };
- testCondition?: string; // Optional human-readable test condition
- variant: string; // Variant key (for debugging)
- variantValue: any; // The actual variant value
-}
-
-export async function getExamplesDisplayStrategy(
- websiteId: string,
- userId?: string,
- environment: string = process.env.NODE_ENV || "development"
-): Promise {
- const flagsManager = createServerFlagsManager({
- clientId: websiteId,
- apiUrl: publicConfig.urls.api,
- user: { userId },
- debug: process.env.NODE_ENV === "development",
- environment,
- });
-
- // Wait for initialization (important in serverless)
- await flagsManager.waitForInit();
-
- try {
- const result = await flagsManager.getFlag("flag-examples-display-strategy");
-
- console.log("🚀 Flag result:", result);
-
- const variantKey = (result.payload?.variantKey as string) || "unknown";
- const variantValue = result.value;
- const exampleCount = typeof variantValue === "number" ? variantValue : 0;
-
- return {
- exampleCount,
- variant: variantKey,
- variantValue,
- testCondition: "multi-variant-sticky-assignment",
- environment,
- };
- } catch (error) {
- console.error("❌ Error fetching examples display flag:", error);
-
- // Graceful fallback
- return {
- exampleCount: 6,
- variant: "error-fallback",
- variantValue: 6,
- testCondition: "error",
- environment,
- };
- }
-}
-
-export const getShouldShowExamples = async (
- websiteId: string,
- userId: string,
- environment: string
-) => {
- const flagsManager = createServerFlagsManager({
- clientId: websiteId,
- apiUrl: publicConfig.urls.api,
- user: { userId },
- debug: process.env.NODE_ENV === "development",
- environment,
- });
- await flagsManager.waitForInit();
- const flag = await flagsManager.getFlag("enable-flag-examples");
- return flag.value;
-};
diff --git a/apps/dashboard/lib/format-locale-number.ts b/apps/dashboard/lib/format-locale-number.ts
index 205235690a..e9c739eceb 100644
--- a/apps/dashboard/lib/format-locale-number.ts
+++ b/apps/dashboard/lib/format-locale-number.ts
@@ -1,6 +1,4 @@
const LOCALE = "en-US" as const;
-
-/** Stable number formatting for SSR + client (avoids locale mismatch hydration). */
export function formatLocaleNumber(value: number): string {
return value.toLocaleString(LOCALE);
}
diff --git a/apps/dashboard/lib/formatters.ts b/apps/dashboard/lib/formatters.ts
index bb88c00c01..3a1325ea3d 100644
--- a/apps/dashboard/lib/formatters.ts
+++ b/apps/dashboard/lib/formatters.ts
@@ -1,5 +1,3 @@
-import { dayjs } from "@databuddy/ui";
-
export const formatNumber = (value: number | null | undefined): string => {
if (value == null || Number.isNaN(value)) {
return "0";
@@ -24,56 +22,3 @@ export const formatCurrency = (
currency,
}).format(amount);
};
-
-// Predefined date formats for consistency across the app
-export const DATE_FORMATS = {
- DATE_ONLY: "MMM D, YYYY", // Jul 6, 2025
- DATE_TIME: "MMM D, YYYY HH:mm", // Jul 6, 2025 14:30
- DATE_TIME_SECONDS: "MMM D, YYYY HH:mm:ss", // Jul 6, 2025 14:30:25
- DATE_MONTH_DAY: "MMM D", // Jul 6
- ISO_DATE: "YYYY-MM-DD", // 2025-07-06
- TIME_ONLY: "HH:mm", // 14:30
- DATE_TIME_NO_YEAR: "MMM D, HH:mm", // Jul 6, 14:30
- DATE_TIME_12H: "MMM D, YYYY h:mm A", // Jul 6, 2025 2:30 PM
-} as const;
-
-// Global date formatting functions
-export const formatDate = (
- dateString: string | Date | undefined | null,
- format: string = DATE_FORMATS.DATE_ONLY
-): string => {
- if (!dateString) {
- return "";
- }
-
- try {
- const date = dayjs(dateString);
- if (!date.isValid()) {
- console.warn("Invalid date:", dateString);
- return "";
- }
- return date.format(format);
- } catch (error) {
- console.warn("Failed to format date:", dateString, error);
- return "";
- }
-};
-
-// Helper function for date ranges
-export const formatDateRange = (
- startDate: string | Date | undefined | null,
- endDate: string | Date | undefined | null,
- format: string = DATE_FORMATS.DATE_ONLY
-): string => {
- const start = formatDate(startDate, format);
- const end = formatDate(endDate, format);
-
- if (!(start && end)) {
- return "";
- }
- if (start === end) {
- return start;
- }
-
- return `${start} - ${end}`;
-};
diff --git a/apps/dashboard/lib/geo.ts b/apps/dashboard/lib/geo.ts
index b838c85184..01d1af27f6 100644
--- a/apps/dashboard/lib/geo.ts
+++ b/apps/dashboard/lib/geo.ts
@@ -1,24 +1,6 @@
import { useQuery } from "@tanstack/react-query";
const countriesGeoUrl = "https://cdn.databuddy.cc/geojson/countries.geojson";
-const subdivisionsGeoUrl = "https://cdn.databuddy.cc/geojson/subdivisions.json";
-
-export interface Subdivisions {
- features: Array<{
- type: string;
- properties: {
- name: string;
- iso_3166_2: string;
- admin: string;
- border: number;
- };
- geometry: {
- type: string;
- coordinates: number[][][];
- };
- }>;
- type: string;
-}
export interface Country {
features: Array<{
@@ -37,25 +19,8 @@ export interface Country {
type: string;
}
-export const useSubdivisions = () =>
- useQuery({
- queryKey: ["subdivisions"],
- queryFn: () => fetch(subdivisionsGeoUrl).then((res) => res.json()),
- });
-
export const useCountries = () =>
useQuery({
queryKey: ["countries"],
queryFn: () => fetch(countriesGeoUrl).then((res) => res.json()),
});
-
-export const useGetRegionName = () => {
- const { data: subdivisions } = useSubdivisions();
-
- return {
- getRegionName: (region: string) =>
- subdivisions?.features.find(
- (feature) => feature.properties.iso_3166_2 === region
- )?.properties.name,
- };
-};
diff --git a/apps/dashboard/lib/insight-api.ts b/apps/dashboard/lib/insight-api.ts
index af8b05ff04..0c467a5e21 100644
--- a/apps/dashboard/lib/insight-api.ts
+++ b/apps/dashboard/lib/insight-api.ts
@@ -9,7 +9,6 @@ const INSIGHT_CACHE = {
const INSIGHTS_ROOT = ["insights"] as const;
const BRIEF_PAGE_SIZE = 5;
const HISTORY_PAGE_SIZE = 50;
-const RECOMMENDATIONS_PAGE_SIZE = 20;
export const insightQueries = {
all: () => INSIGHTS_ROOT,
@@ -64,42 +63,6 @@ export const insightQueries = {
retry: 2,
retryDelay: (attempt: number) => Math.min(2000 * 2 ** attempt, 15_000),
}),
- recommendationsInfinite: (orgId: string | undefined) =>
- infiniteQueryOptions({
- queryKey: [...INSIGHTS_ROOT, "recommendations-infinite", orgId] as const,
- queryFn: ({ pageParam }) =>
- fetchInsightRecommendationsPage(
- orgId ?? "",
- pageParam,
- RECOMMENDATIONS_PAGE_SIZE,
- pageParam === 0
- ),
- initialPageParam: 0,
- getNextPageParam: (lastPage, _allPages, lastPageParam) =>
- lastPage.hasMore
- ? lastPageParam + RECOMMENDATIONS_PAGE_SIZE
- : undefined,
- enabled: !!orgId,
- staleTime: INSIGHT_CACHE.historyStaleTime,
- gcTime: INSIGHT_CACHE.gcTime,
- refetchOnWindowFocus: false,
- retry: 2,
- retryDelay: (attempt: number) => Math.min(2000 * 2 ** attempt, 15_000),
- }),
- recommendationTotal: (orgId: string | undefined) =>
- queryOptions({
- queryKey: [...INSIGHTS_ROOT, "recommendation-total", orgId] as const,
- queryFn: async () =>
- (await fetchInsightRecommendationsPage(orgId ?? "", 0, 1, false)).total,
- enabled: !!orgId,
- staleTime: INSIGHT_CACHE.historyStaleTime,
- gcTime: INSIGHT_CACHE.gcTime,
- meta: { suppressGlobalErrorToast: true },
- refetchInterval: INSIGHT_CACHE.historyStaleTime,
- refetchOnWindowFocus: true,
- retry: 2,
- retryDelay: (attempt: number) => Math.min(2000 * 2 ** attempt, 15_000),
- }),
byId: (insightId: string | undefined) =>
queryOptions({
queryKey: [...INSIGHTS_ROOT, "by-id", insightId] as const,
@@ -148,26 +111,6 @@ function fetchInsightsHistoryPage(
type InsightsHistoryPage = Awaited>;
export type Insight = InsightsHistoryPage["insights"][number];
-function fetchInsightRecommendationsPage(
- organizationId: string,
- offset: number,
- limit = 50,
- includeCompleted = true
-) {
- return orpc.insights.recommendations.call({
- includeCompleted,
- organizationId,
- limit,
- offset,
- });
-}
-
-type InsightRecommendationsPage = Awaited<
- ReturnType
->;
-export type InsightRecommendation =
- InsightRecommendationsPage["recommendations"][number];
-
function fetchInsightById(insightId: string) {
return orpc.insights.getById.call({ insightId });
}
diff --git a/apps/dashboard/lib/is-abort-error.ts b/apps/dashboard/lib/is-abort-error.ts
index 910dafae97..9048c5d119 100644
--- a/apps/dashboard/lib/is-abort-error.ts
+++ b/apps/dashboard/lib/is-abort-error.ts
@@ -1,4 +1,3 @@
-/** True when a fetch or query was cancelled (Strict Mode remount, navigation, etc.). */
export function isAbortError(error: unknown): boolean {
if (error instanceof Error) {
return (
diff --git a/apps/dashboard/lib/list-query-outcome.ts b/apps/dashboard/lib/list-query-outcome.ts
index 49986f1547..5f50c2c24b 100644
--- a/apps/dashboard/lib/list-query-outcome.ts
+++ b/apps/dashboard/lib/list-query-outcome.ts
@@ -1,9 +1,4 @@
import type { UseQueryResult } from "@tanstack/react-query";
-
-/**
- * Single place to derive list UI state from TanStack Query + optional gate loading.
- * Use with List.Content so pages don’t hand-roll loading → null → content flashes.
- */
export type ListQueryOutcome =
| { status: "empty" }
| { status: "error" }
@@ -40,8 +35,6 @@ export type ListQuerySlice = Pick<
UseQueryResult,
"data" | "isPending" | "isError" | "isSuccess"
>;
-
-/** Infers list state from a TanStack array query — pass to List.Content as `query` instead of calling listQueryOutcome yourself. */
export function listQueryOutcomeFromQuery(
query: ListQuerySlice,
options?: { gatePending?: boolean }
diff --git a/apps/dashboard/lib/orpc-public.ts b/apps/dashboard/lib/orpc-public.ts
deleted file mode 100644
index 00c891484a..0000000000
--- a/apps/dashboard/lib/orpc-public.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { publicConfig } from "@databuddy/env/public";
-import type { AppRouter } from "@databuddy/rpc";
-import { createORPCClient } from "@orpc/client";
-import { RPCLink } from "@orpc/client/fetch";
-import type { RouterClient } from "@orpc/server";
-
-const link = new RPCLink({
- url: `${publicConfig.urls.api}/rpc`,
-});
-
-export const publicRPCClient = createORPCClient(
- link
-) as RouterClient;
diff --git a/apps/dashboard/lib/query-client.ts b/apps/dashboard/lib/query-client.ts
index 4539de6ab5..fa28482694 100644
--- a/apps/dashboard/lib/query-client.ts
+++ b/apps/dashboard/lib/query-client.ts
@@ -65,7 +65,7 @@ function reportError(error: unknown, showToast = true) {
});
}
-export function makeQueryClient() {
+function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
diff --git a/apps/dashboard/lib/topup-math.ts b/apps/dashboard/lib/topup-math.ts
index 6c345c8b79..5b1c1d62b4 100644
--- a/apps/dashboard/lib/topup-math.ts
+++ b/apps/dashboard/lib/topup-math.ts
@@ -11,7 +11,7 @@ export const TOPUP_TIERS: readonly TopupTier[] = [
];
export const TOPUP_MIN_QUANTITY = 10;
-export const TOPUP_MAX_PURCHASE_USD = 5000;
+const TOPUP_MAX_PURCHASE_USD = 5000;
export const TOPUP_MAX_QUANTITY = maxTopupQuantityForAmount(
TOPUP_MAX_PURCHASE_USD
);
@@ -23,7 +23,7 @@ function tierTop(tier: TopupTier): number {
return tier.to === "inf" ? Number.POSITIVE_INFINITY : tier.to;
}
-export function maxTopupQuantityForAmount(
+function maxTopupQuantityForAmount(
maxAmount: number,
tiers: readonly TopupTier[] = TOPUP_TIERS
): number {
@@ -144,7 +144,7 @@ export function nextTierNudge(
return { unitsUntilNextTier, nextRate: info.nextRate };
}
-export const TOPUP_SLIDER_BOUNDARIES: readonly number[] = [
+const TOPUP_SLIDER_BOUNDARIES: readonly number[] = [
TOPUP_MIN_QUANTITY,
100,
1000,
diff --git a/apps/dashboard/lib/user-facing-error.test.ts b/apps/dashboard/lib/user-facing-error.test.ts
index 8ace93de84..068f30dfed 100644
--- a/apps/dashboard/lib/user-facing-error.test.ts
+++ b/apps/dashboard/lib/user-facing-error.test.ts
@@ -25,4 +25,57 @@ describe("getUserFacingErrorMessage", () => {
getUserFacingErrorMessage(new Error("postgres relation users failed"))
).toBe(DEFAULT_USER_ERROR_MESSAGE);
});
+
+ it("shows the server-authored message for plan limit errors", () => {
+ expect(
+ getUserFacingErrorMessage({
+ code: "PLAN_LIMIT_EXCEEDED",
+ status: 402,
+ message:
+ "Your Free plan includes 1 funnel. Delete one or upgrade to Hobby to create more.",
+ })
+ ).toBe(
+ "Your Free plan includes 1 funnel. Delete one or upgrade to Hobby to create more."
+ );
+ });
+
+ it("falls back to plan limit copy when no message is present", () => {
+ expect(
+ getUserFacingErrorMessage({ code: "PLAN_LIMIT_EXCEEDED", status: 402 })
+ ).toBe("You have reached your plan's limit. Upgrade to create more.");
+ });
+
+ it("does not mistranslate feature gating as a permissions error", () => {
+ expect(
+ getUserFacingErrorMessage({
+ code: "FEATURE_UNAVAILABLE",
+ status: 403,
+ message:
+ "Error Tracking is not available on your plan. Upgrade to Hobby to unlock it.",
+ })
+ ).toBe(
+ "Error Tracking is not available on your plan. Upgrade to Hobby to unlock it."
+ );
+ });
+
+ it("maps a bare 402 status to plan limit copy", () => {
+ expect(getUserFacingErrorMessage({ status: 402 })).toBe(
+ "You have reached your plan's limit. Upgrade to create more."
+ );
+ });
+
+ it("shows server-authored conflict messages", () => {
+ expect(
+ getUserFacingErrorMessage({
+ data: { code: "CONFLICT" },
+ message: 'A website with the domain "stowlink.app" already exists.',
+ })
+ ).toBe('A website with the domain "stowlink.app" already exists.');
+ });
+
+ it("ignores non-sentence server messages for authored codes", () => {
+ expect(
+ getUserFacingErrorMessage({ data: { code: "FORBIDDEN" }, message: "Forbidden" })
+ ).toBe("You do not have permission to do that.");
+ });
});
diff --git a/apps/dashboard/lib/user-facing-error.ts b/apps/dashboard/lib/user-facing-error.ts
index 6e73fc7724..42112a35d1 100644
--- a/apps/dashboard/lib/user-facing-error.ts
+++ b/apps/dashboard/lib/user-facing-error.ts
@@ -1,3 +1,5 @@
+import { toast } from "sonner";
+
interface ErrorDetails {
code?: string;
data?: {
@@ -13,19 +15,36 @@ const CODE_MESSAGES: Record = {
BAD_REQUEST:
"That request could not be completed. Check the details and try again.",
CONFLICT: "That change conflicts with newer data. Refresh and try again.",
+ FEATURE_UNAVAILABLE:
+ "This feature is not available on your current plan. Upgrade to unlock it.",
FORBIDDEN: "You do not have permission to do that.",
INTERNAL_SERVER_ERROR: "Something went wrong on our side. Try again.",
NOT_FOUND: "That item could not be found. It may have been removed.",
+ PLAN_LIMIT_EXCEEDED:
+ "You have reached your plan's limit. Upgrade to create more.",
RATE_LIMITED: "Too many requests. Wait a moment and try again.",
+ SERVICE_UNAVAILABLE:
+ "The service is temporarily unavailable. Try again shortly.",
TIMEOUT: "The request took too long. Try again.",
TOO_MANY_REQUESTS: "Too many requests. Wait a moment and try again.",
UNAUTHORIZED: "Your session has expired. Sign in and try again.",
VALIDATION_ERROR: "Some details are invalid. Check them and try again.",
};
+const SERVER_AUTHORED_CODES = new Set([
+ "BAD_REQUEST",
+ "CONFLICT",
+ "FEATURE_UNAVAILABLE",
+ "FORBIDDEN",
+ "PLAN_LIMIT_EXCEEDED",
+ "SERVICE_UNAVAILABLE",
+ "VALIDATION_ERROR",
+]);
+
const STATUS_MESSAGES: Record = {
400: CODE_MESSAGES.BAD_REQUEST,
401: CODE_MESSAGES.UNAUTHORIZED,
+ 402: CODE_MESSAGES.PLAN_LIMIT_EXCEEDED,
403: CODE_MESSAGES.FORBIDDEN,
404: CODE_MESSAGES.NOT_FOUND,
409: CODE_MESSAGES.CONFLICT,
@@ -47,9 +66,16 @@ export function getUserFacingErrorMessage(
const details = error as ErrorDetails;
const code = details.data?.code ?? details.code;
if (typeof code === "string") {
- const message = CODE_MESSAGES[code.toUpperCase()];
- if (message) {
- return message;
+ const normalized = code.toUpperCase();
+ if (SERVER_AUTHORED_CODES.has(normalized)) {
+ const authored = details.message?.trim();
+ if (authored?.includes(" ")) {
+ return authored;
+ }
+ }
+ const mapped = CODE_MESSAGES[normalized];
+ if (mapped) {
+ return mapped;
}
}
@@ -66,3 +92,12 @@ export function getUserFacingErrorMessage(
return fallback;
}
+
+export function showErrorToast(error: unknown, fallback?: string) {
+ toast.error(getUserFacingErrorMessage(error, fallback));
+}
+
+export const mutationErrorToast = {
+ onError: (error: unknown) => showErrorToast(error),
+ meta: { suppressGlobalErrorToast: true },
+};
diff --git a/apps/dashboard/lib/utils.ts b/apps/dashboard/lib/utils.ts
index c6feaa63ab..836d55c08a 100644
--- a/apps/dashboard/lib/utils.ts
+++ b/apps/dashboard/lib/utils.ts
@@ -28,14 +28,3 @@ export function formatDuration(seconds: number): string {
return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
}
-
-export function getDefaultDateRange() {
- const today = new Date();
- const thirtyDaysAgo = new Date(today);
- thirtyDaysAgo.setDate(today.getDate() - 30);
- return {
- start_date: thirtyDaysAgo.toISOString().split("T")[0],
- end_date: today.toISOString().split("T")[0],
- granularity: "daily" as "hourly" | "daily",
- };
-}
diff --git a/apps/dashboard/lib/vitals-scoring.ts b/apps/dashboard/lib/vitals-scoring.ts
index ebb4171a64..ddf4f3b038 100644
--- a/apps/dashboard/lib/vitals-scoring.ts
+++ b/apps/dashboard/lib/vitals-scoring.ts
@@ -1,12 +1,12 @@
// RES uses Lighthouse 10 mobile weights: FCP 15%, LCP 30%, INP 30%, CLS 25%.
-export const RES_WEIGHTS = {
+const RES_WEIGHTS = {
FCP: 0.15,
LCP: 0.3,
INP: 0.3,
CLS: 0.25,
} as const;
-export type RESMetric = keyof typeof RES_WEIGHTS;
+type RESMetric = keyof typeof RES_WEIGHTS;
// Google Core Web Vitals good/poor thresholds.
const METRIC_THRESHOLDS = {
@@ -24,7 +24,7 @@ const SCORE_CURVES = {
CLS: { median: 0.1, p10: 0.25 },
} as const;
-export function calculateMetricScore(
+function calculateMetricScore(
value: number | null | undefined,
metric: RESMetric
): number | null {
diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json
index 3e297c7508..6a05f5c287 100644
--- a/apps/dashboard/package.json
+++ b/apps/dashboard/package.json
@@ -33,34 +33,24 @@
"@databuddy/ui": "workspace:*",
"@hello-pangea/dnd": "^18.0.1",
"@hookform/resolvers": "^5.2.2",
- "@json-render/react": "0.19.0",
"@orpc/client": "^1.14.0",
"@orpc/tanstack-query": "^1.14.0",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-collapsible": "^1.1.12",
- "@radix-ui/react-dialog": "^1.1.15",
- "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
- "@radix-ui/react-popover": "^1.1.15",
- "@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
- "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
- "@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
- "@tanstack/query-async-storage-persister": "^5.99.2",
"@tanstack/query-sync-storage-persister": "5.100.10",
"@tanstack/react-pacer": "0.22.1",
"@tanstack/react-query": "^5.99.2",
"@tanstack/react-query-persist-client": "5.100.10",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.14.5",
- "@tokenlens/models": "catalog:",
"@types/d3-scale": "^4.0.9",
"@types/geojson": "^7946.0.16",
"@types/leaflet": "^1.9.21",
- "@xyflow/react": "^12.10.1",
"ai": "^6.0.188",
"atmn": "^1.1.8",
"autumn-js": "catalog:",
@@ -71,14 +61,10 @@
"cmdk": "^1.1.1",
"cnfast": "^0.0.8",
"d3-scale": "^4.0.2",
- "dayjs": "^1.11.20",
- "embla-carousel-react": "^8.6.0",
"flag-icons": "^7.5.0",
"geist": "^1.7.0",
- "input-otp": "^1.4.2",
"jotai": "^2.18.1",
"leaflet": "^1.9.4",
- "maplibre-gl": "^5.20.2",
"motion": "^12.38.0",
"nanoid": "^5.1.7",
"next": "^16.2.6",
@@ -95,35 +81,30 @@
"react-hotkeys-hook": "^5.2.4",
"react-leaflet": "^5.0.0",
"react-qrcode-logo": "^4.0.0",
- "react-textarea-autosize": "^8.5.9",
"recharts": "^3.9.0",
"shiki": "^4.3.0",
"sonner": "^2.0.7",
"streamdown": "^2.5.0",
- "tokenlens": "catalog:",
"tw-animate-css": "^1.4.0",
"use-stick-to-bottom": "^1.1.3",
"vaul": "^1.1.2",
"zod": "catalog:"
},
"devDependencies": {
- "@biomejs/biome": "2.5.1",
"@orpc/server": "^1.14.0",
+ "@playwright/test": "^1.58.2",
"@tailwindcss/postcss": "^4.2.2",
- "@tanstack/react-query-devtools": "^5.99.2",
"@types/d3-geo": "^3.1.0",
"@types/node": "^26.0.0",
"@types/pg": "^8.18.0",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
- "@types/react-simple-maps": "^3.0.6",
"@types/topojson-client": "^3.1.5",
"husky": "^9.1.7",
"lint-staged": "^16.4.0",
"tailwindcss": "^4.2.2",
"typescript": "^5.9.3",
- "ultracite": "catalog:",
- "@playwright/test": "^1.58.2"
+ "ultracite": "catalog:"
},
"lint-staged": {
"*.{js,jsx,ts,tsx,json,jsonc,css,scss}": [
diff --git a/apps/dashboard/stores/jotai/chartAtoms.ts b/apps/dashboard/stores/jotai/chartAtoms.ts
index e85e164286..3ba6be77a8 100644
--- a/apps/dashboard/stores/jotai/chartAtoms.ts
+++ b/apps/dashboard/stores/jotai/chartAtoms.ts
@@ -32,13 +32,6 @@ export const toggleMetricAtom = atom(
}
);
-export const visibleMetricsAtom = atom((get) => {
- const visibility = get(metricVisibilityAtom);
- return Object.entries(visibility)
- .filter(([, isVisible]) => isVisible)
- .map(([metric]) => metric);
-});
-
export interface RevenueMetricVisibilityState {
avg_transaction: boolean;
customers: boolean;
@@ -70,5 +63,3 @@ export const toggleRevenueMetricAtom = atom(
}));
}
);
-
-export const isRefreshingAtom = atom(false);
diff --git a/apps/dashboard/stores/jotai/filterAtoms.ts b/apps/dashboard/stores/jotai/filterAtoms.ts
index 8a440cc1ae..46fe8ef274 100644
--- a/apps/dashboard/stores/jotai/filterAtoms.ts
+++ b/apps/dashboard/stores/jotai/filterAtoms.ts
@@ -1,204 +1,9 @@
-import { dayjs, guessTimezone } from "@databuddy/ui";
import { atom } from "jotai";
import type { DynamicQueryFilter } from "./filter-types";
export type { DynamicQueryFilter } from "./filter-types";
import { RECOMMENDED_DEFAULTS } from "../../app/(main)/websites/[id]/_components/utils/tracking-defaults";
-import {
- enableAllAdvancedTracking,
- enableAllBasicTracking,
- enableAllOptimization,
-} from "../../app/(main)/websites/[id]/_components/utils/tracking-helpers";
import type { TrackingOptions } from "../../app/(main)/websites/[id]/_components/utils/types";
-export interface DateRangeState {
- endDate: Date;
- startDate: Date;
-}
-
-const initialStartDate = dayjs().subtract(30, "day").toDate();
-const initialEndDate = new Date();
-
-export const dateRangeAtom = atom({
- startDate: initialStartDate,
- endDate: initialEndDate,
-});
-
-export const formattedDateRangeAtom = atom((get) => {
- const { startDate, endDate } = get(dateRangeAtom);
- return {
- startDate: dayjs(startDate).isValid()
- ? dayjs(startDate).format("YYYY-MM-DD")
- : "",
- endDate: dayjs(endDate).isValid()
- ? dayjs(endDate).format("YYYY-MM-DD")
- : "",
- };
-});
-
-export type TimeGranularity = "daily" | "hourly";
-
-const MAX_HOURLY_DAYS = 7;
-const AUTO_HOURLY_DAYS = 2;
-
-export const timeGranularityAtom = atom("daily");
-
-export const setDateRangeAndAdjustGranularityAtom = atom(
- null,
- (_get, set, newRange: DateRangeState) => {
- set(dateRangeAtom, newRange);
-
- const rangeDays = dayjs(newRange.endDate).diff(newRange.startDate, "day");
-
- if (rangeDays > MAX_HOURLY_DAYS) {
- set(timeGranularityAtom, "daily");
- } else if (rangeDays <= AUTO_HOURLY_DAYS) {
- set(timeGranularityAtom, "hourly");
- }
- }
-);
-
-export const timezoneAtom = atom(guessTimezone());
-
-export type BasicFilterValue =
- | string[]
- | number[]
- | string
- | number
- | boolean
- | undefined;
-export interface BasicFilters {
- [key: string]: BasicFilterValue;
-}
-
-export const basicFiltersAtom = atom({});
-
-export type FilterOperator =
- | "is"
- | "isNot"
- | "contains"
- | "doesNotContain"
- | "startsWith"
- | "endsWith"
- | "greaterThan"
- | "lessThan"
- | "in"
- | "notIn"
- | "isSet"
- | "isNotSet";
-
-export interface ComplexFilter {
- field: string;
- id: string;
- operator: FilterOperator;
- value?: string | number | boolean | Array;
-}
-
-export const complexFiltersAtom = atom([]);
-
-export const setBasicFilterAtom = atom(
- null,
- (_get, set, { key, value }: { key: string; value: BasicFilterValue }) => {
- set(basicFiltersAtom, (prev) => {
- if (value === undefined) {
- const { [key]: _, ...rest } = prev;
- return rest;
- }
- return { ...prev, [key]: value };
- });
- }
-);
-
-export const clearBasicFilterAtom = atom(null, (_get, set, key?: string) => {
- if (key) {
- set(basicFiltersAtom, (prev) => {
- const { [key]: _, ...rest } = prev;
- return rest;
- });
- } else {
- set(basicFiltersAtom, {});
- }
-});
-
-export const upsertComplexFilterAtom = atom(
- null,
- (_get, set, filter: ComplexFilter) => {
- set(complexFiltersAtom, (prev) => {
- const existingIndex = prev.findIndex((f) => f.id === filter.id);
- if (existingIndex > -1) {
- const updatedFilters = [...prev];
- updatedFilters[existingIndex] = filter;
- return updatedFilters;
- }
- return [...prev, filter];
- });
- }
-);
-
-export const removeComplexFilterAtom = atom(
- null,
- (_get, set, filterId: string) => {
- set(complexFiltersAtom, (prev) => prev.filter((f) => f.id !== filterId));
- }
-);
-
-export const clearComplexFiltersAtom = atom(null, (_get, set) => {
- set(complexFiltersAtom, []);
-});
-
-export const clearAllFiltersAtom = atom(null, (_get, set) => {
- set(dateRangeAtom, { startDate: initialStartDate, endDate: initialEndDate });
- set(timeGranularityAtom, "daily");
- set(basicFiltersAtom, {});
- set(complexFiltersAtom, []);
-});
-
-export const activeFiltersForApiAtom = atom((get) => {
- const { startDate: fmtStartDate, endDate: fmtEndDate } = get(
- formattedDateRangeAtom
- );
- const granularityValue = get(timeGranularityAtom);
- const basicFiltersValue = get(basicFiltersAtom);
- const complexFiltersValue = get(complexFiltersAtom);
- const timezoneValue = get(timezoneAtom);
-
- const apiReadyBasicFilters: Record<
- string,
- string | number | boolean | undefined
- > = {};
- for (const key in basicFiltersValue) {
- if (Object.hasOwn(basicFiltersValue, key)) {
- const value = basicFiltersValue[key];
- if (Array.isArray(value)) {
- apiReadyBasicFilters[key] = value.join(",");
- } else {
- apiReadyBasicFilters[key] = value;
- }
- }
- }
-
- return {
- dateRange: { startDate: fmtStartDate, endDate: fmtEndDate },
- granularity: granularityValue,
- timezone: timezoneValue,
- basicFilters: apiReadyBasicFilters,
- complexFilters: complexFiltersValue,
- };
-});
-
-export const selectBasicFilterValueAtom = (key: string) =>
- atom((get) => get(basicFiltersAtom)[key]);
-
-export const selectComplexFilterByIdAtom = (id: string) =>
- atom((get) =>
- get(complexFiltersAtom).find((filter) => filter.id === id)
- );
-
-export const hasActiveSubFiltersAtom = atom((get) => {
- const basic = get(basicFiltersAtom);
- const complex = get(complexFiltersAtom);
- return Object.keys(basic).length > 0 || complex.length > 0;
-});
-
export const isAnalyticsRefreshingAtom = atom(false);
const dynamicQueryFiltersBaseAtom = atom<{
@@ -261,10 +66,6 @@ export const removeDynamicFilterAtom = atom(
}
);
-export const clearDynamicFiltersAtom = atom(null, (_get, set) => {
- set(dynamicQueryFiltersAtom, []);
-});
-
export type EditingSavedFilter = {
id: string;
name: string;
@@ -303,13 +104,6 @@ export const savedFiltersAtom = atom(
export const trackingOptionsAtom = atom(RECOMMENDED_DEFAULTS);
-export const setTrackingOptionsAtom = atom(
- null,
- (_get, set, newOptions: TrackingOptions) => {
- set(trackingOptionsAtom, newOptions);
- }
-);
-
export const toggleTrackingOptionAtom = atom(
null,
(get, set, option: keyof TrackingOptions) => {
@@ -320,22 +114,3 @@ export const toggleTrackingOptionAtom = atom(
});
}
);
-
-export const resetTrackingOptionsAtom = atom(null, (_get, set) => {
- set(trackingOptionsAtom, RECOMMENDED_DEFAULTS);
-});
-
-export const enableAllBasicTrackingAtom = atom(null, (get, set) => {
- const current = get(trackingOptionsAtom);
- set(trackingOptionsAtom, enableAllBasicTracking(current));
-});
-
-export const enableAllAdvancedTrackingAtom = atom(null, (get, set) => {
- const current = get(trackingOptionsAtom);
- set(trackingOptionsAtom, enableAllAdvancedTracking(current));
-});
-
-export const enableAllOptimizationAtom = atom(null, (get, set) => {
- const current = get(trackingOptionsAtom);
- set(trackingOptionsAtom, enableAllOptimization(current));
-});
diff --git a/apps/dashboard/test/e2e/specs/regressions/measurement-recommendations.spec.ts b/apps/dashboard/test/e2e/specs/regressions/measurement-recommendations.spec.ts
deleted file mode 100644
index af493982ee..0000000000
--- a/apps/dashboard/test/e2e/specs/regressions/measurement-recommendations.spec.ts
+++ /dev/null
@@ -1,151 +0,0 @@
-import { randomUUID } from "node:crypto";
-import { db } from "@databuddy/db";
-import { insightObservations } from "@databuddy/db/schema";
-import type {
- InvestigationOutcome,
- InvestigationSignal,
-} from "@databuddy/shared/insights";
-import { expect, test } from "@/test/e2e/fixtures";
-
-test(
- "opens an editable goal draft from an insight recommendation",
- { tag: ["@regression"] },
- async ({ authenticatedPage, e2eSession }) => {
- expect(e2eSession.websiteId).toBeTruthy();
- if (!e2eSession.websiteId) {
- throw new Error("Expected the E2E session to include a website");
- }
-
- const signalKey = "measurement:conversion-coverage";
- const createdAt = new Date();
- const signal: InvestigationSignal = {
- signalKey,
- entity: {
- type: "website",
- id: e2eSession.websiteId,
- label: "E2E Website",
- },
- metric: {
- label: "Conversion measurement coverage",
- current: 0,
- format: "number",
- },
- changePercent: null,
- severity: "info",
- sentiment: "neutral",
- period: {
- current: { from: "2026-07-25", to: "2026-07-31" },
- previous: { from: "2026-07-18", to: "2026-07-24" },
- },
- };
- const outcome: InvestigationOutcome = {
- title: "Checkout completion needs a measurable goal",
- summary:
- "The site has active traffic but no configured conversion measurement.",
- impact:
- "The team cannot see whether people complete checkout from the current analytics setup.",
- rootCause: null,
- evidence: [
- "The completed period recorded 68 sessions and 142 pageviews without an active goal or funnel.",
- ],
- publish: true,
- recommendation: {
- kind: "goal_draft",
- action: "Review a goal for completed checkout.",
- draft: {
- name: "Checkout completed",
- description: "Measure completed checkout events.",
- type: "EVENT",
- target: "checkout_completed",
- filters: [],
- ignoreHistoricData: false,
- },
- },
- next: {
- type: "resolve",
- reason: "This proposed goal is ready for teammate review.",
- },
- };
-
- await db.insert(insightObservations).values({
- id: randomUUID(),
- organizationId: e2eSession.organizationId,
- websiteId: e2eSession.websiteId,
- insightId: null,
- signalKey,
- asOf: createdAt,
- signal,
- evidence: outcome.evidence,
- outcome,
- recheckAt: createdAt,
- createdAt,
- });
-
- await authenticatedPage.goto("/insights");
- await expect(
- authenticatedPage.getByText(outcome.title, { exact: true })
- ).toBeVisible();
- await expect(
- authenticatedPage.getByRole("button", { name: "Review goal draft" })
- ).toHaveCount(0);
-
- await authenticatedPage.goto("/insights/investigations");
- await expect(
- authenticatedPage.getByText(outcome.title, { exact: true })
- ).toHaveCount(0);
-
- await authenticatedPage.goto("/insights/recommendations");
- await expect(
- authenticatedPage.getByRole("link", {
- exact: true,
- name: "Recommendations 1 current recommendation",
- })
- ).toHaveAttribute("aria-current", "page");
- await expect(
- authenticatedPage.getByRole("link", {
- exact: true,
- name: "Insights 1 current recommendation",
- })
- ).toBeVisible();
-
- await expect(
- authenticatedPage.getByText("Create goal", { exact: true })
- ).toBeVisible();
- await expect(
- authenticatedPage.getByText(outcome.recommendation.action, {
- exact: true,
- })
- ).toBeVisible();
- await authenticatedPage
- .getByRole("button", { name: "Review goal draft" })
- .click();
- const dialog = authenticatedPage.getByRole("dialog", {
- name: "Review Goal Draft",
- });
- await expect(dialog).toBeVisible();
- await expect(dialog.getByRole("textbox", { name: "Name" })).toHaveValue(
- "Checkout completed"
- );
- await expect(dialog.locator('input[placeholder="event_name"]')).toHaveValue(
- "checkout_completed"
- );
- await dialog.getByRole("button", { name: "Create Goal" }).click();
- await expect(dialog).toHaveCount(0);
- await expect(
- authenticatedPage.getByText("Nothing needs attention right now.")
- ).toBeVisible();
- await expect(
- authenticatedPage.getByRole("link", {
- exact: true,
- name: "Recommendations",
- })
- ).toBeVisible();
-
- await authenticatedPage
- .getByRole("button", { name: /Completed 1 Latest verified/ })
- .click();
- await expect(
- authenticatedPage.getByText("Goal is now set up.")
- ).toBeVisible();
- }
-);
diff --git a/apps/dashboard/test/e2e/utils/dashboard.ts b/apps/dashboard/test/e2e/utils/dashboard.ts
index cc0a63df3b..43f6ae7a54 100644
--- a/apps/dashboard/test/e2e/utils/dashboard.ts
+++ b/apps/dashboard/test/e2e/utils/dashboard.ts
@@ -39,7 +39,7 @@ export function idFromPath(url: string, segment: "links" | "websites"): string {
return match[1];
}
-export function organizationSelector(page: Page): Locator {
+function organizationSelector(page: Page): Locator {
return page.getByRole("button", { name: ORGANIZATION_TRIGGER_RE });
}
diff --git a/apps/dashboard/types/annotations.ts b/apps/dashboard/types/annotations.ts
index 7e5b0a2464..8be20e970d 100644
--- a/apps/dashboard/types/annotations.ts
+++ b/apps/dashboard/types/annotations.ts
@@ -1,10 +1,6 @@
-/**
- * Annotation types and interfaces for the chart annotations system
- */
+type AnnotationType = "point" | "line" | "range";
-export type AnnotationType = "point" | "line" | "range";
-
-export type ChartType = "metrics";
+type ChartType = "metrics";
export interface Annotation {
annotationType: AnnotationType;
@@ -54,14 +50,6 @@ export interface CreateAnnotationData {
yValue?: number;
}
-export interface UpdateAnnotationData {
- color?: string;
- id: string;
- isPublic?: boolean;
- tags?: string[];
- text?: string;
-}
-
export interface AnnotationColor {
label: string;
value: string;
@@ -73,12 +61,6 @@ export interface AnnotationTag {
value: string;
}
-export interface ListAnnotationsInput {
- chartContext: ChartContext;
- chartType: ChartType;
- websiteId: string;
-}
-
export interface AnnotationFormData {
color: string;
isPublic: boolean;
diff --git a/apps/dashboard/types/api.ts b/apps/dashboard/types/api.ts
index 7d3427183a..0b3942d022 100644
--- a/apps/dashboard/types/api.ts
+++ b/apps/dashboard/types/api.ts
@@ -1,6 +1,6 @@
import type { DataFilter } from "@databuddy/db/schema";
-export interface ParameterWithDates {
+interface ParameterWithDates {
end_date?: string;
granularity?: "hourly" | "daily";
id?: string;
diff --git a/apps/dashboard/types/billing.ts b/apps/dashboard/types/billing.ts
index c79088d9fe..0a94f29305 100644
--- a/apps/dashboard/types/billing.ts
+++ b/apps/dashboard/types/billing.ts
@@ -1,4 +1,4 @@
-export interface DailyUsageRow {
+interface DailyUsageRow {
date: string;
event_count: number;
}
@@ -9,7 +9,7 @@ export interface DailyUsageByTypeRow {
event_count: number;
}
-export interface EventTypeBreakdown {
+interface EventTypeBreakdown {
event_category: string;
event_count: number;
}
diff --git a/apps/dashboard/types/funnels.ts b/apps/dashboard/types/funnels.ts
index eea0aee80e..5ada1e94b2 100644
--- a/apps/dashboard/types/funnels.ts
+++ b/apps/dashboard/types/funnels.ts
@@ -32,7 +32,7 @@ export interface CreateFunnelData {
steps: FunnelStep[];
}
-export interface StepErrorInsight {
+interface StepErrorInsight {
count: number;
error_type: string;
message: string;
@@ -53,7 +53,7 @@ export interface FunnelStepAnalytics {
users: number;
}
-export interface FunnelErrorInsights {
+interface FunnelErrorInsights {
available: boolean;
dropoffs_with_errors: number;
error_correlation_rate: number;
diff --git a/apps/dashboard/types/invitations.ts b/apps/dashboard/types/invitations.ts
deleted file mode 100644
index 85b4cc8a49..0000000000
--- a/apps/dashboard/types/invitations.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-export type InvitationStatus = "pending" | "accepted" | "rejected" | "canceled";
-
-export type InvitationPageStatus =
- | "loading"
- | "ready"
- | "accepting"
- | "success"
- | "error"
- | "expired"
- | "already-accepted";
-
-export interface InvitationData {
- email: string;
- expiresAt: Date;
- id: string;
- inviterEmail: string;
- inviterId: string;
- organizationId: string;
- organizationName: string;
- organizationSlug: string;
- role: string;
- status: InvitationStatus;
- teamId?: string;
-}
diff --git a/apps/dashboard/types/outbound-links.ts b/apps/dashboard/types/outbound-links.ts
index 61dbed6a37..2af4cf6c6c 100644
--- a/apps/dashboard/types/outbound-links.ts
+++ b/apps/dashboard/types/outbound-links.ts
@@ -17,7 +17,7 @@ export interface OutboundDomainRow {
unique_users: number;
}
-export interface OutboundLinksSectionData {
+interface OutboundLinksSectionData {
outbound_domains: unknown[];
outbound_links: unknown[];
}
diff --git a/apps/dashboard/types/performance.ts b/apps/dashboard/types/performance.ts
deleted file mode 100644
index c1264b5fa4..0000000000
--- a/apps/dashboard/types/performance.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-export interface PerformanceEntry {
- _uniqueKey?: string;
- avg_cls?: number;
- avg_dom_ready_time?: number;
- avg_fcp?: number;
- avg_fid?: number;
- avg_inp?: number;
- avg_lcp?: number;
- avg_load_time: number;
- avg_render_time?: number;
- avg_ttfb?: number;
- country_code?: string;
- country_name?: string;
- measurements?: number;
- name: string;
- p50_cls?: number;
- p50_fcp?: number;
- p50_lcp?: number;
- p50_load_time?: number;
- pageviews?: number;
- visitors: number;
-}
-
-export interface PerformanceSummary {
- avgCLS?: number;
- avgFCP?: number;
- avgFID?: number;
- avgINP?: number;
- avgLCP?: number;
- avgLoadTime: number;
- fastPages: number;
- performanceScore: number;
- slowPages: number;
- totalPages: number;
-}
diff --git a/apps/dashboard/types/pulse-features.ts b/apps/dashboard/types/pulse-features.ts
deleted file mode 100644
index 63bc6ba4c1..0000000000
--- a/apps/dashboard/types/pulse-features.ts
+++ /dev/null
@@ -1,325 +0,0 @@
-import { PLAN_IDS, type PlanId } from "@databuddy/shared/types/features";
-
-/**
- * Pulse plan tiers - ordered from lowest to highest
- */
-export const PULSE_PLAN_IDS = {
- FREE: "pulse_free",
- PRO: "pulse_pro",
- BUSINESS: "pulse_business",
-} as const;
-
-export type PulsePlanId = (typeof PULSE_PLAN_IDS)[keyof typeof PULSE_PLAN_IDS];
-
-/** Plan tier hierarchy (index = tier level, higher = more features) */
-export const PULSE_PLAN_HIERARCHY: PulsePlanId[] = [
- PULSE_PLAN_IDS.FREE,
- PULSE_PLAN_IDS.PRO,
- PULSE_PLAN_IDS.BUSINESS,
-];
-
-/** Gated features - locked behind specific plans */
-export const PULSE_GATED_FEATURES = {
- // Basic checks
- BASIC_UPTIME_CHECKS: "basic_uptime_checks",
- EMAIL_ALERTS: "email_alerts",
- WEBHOOKS: "webhooks",
- PUBLIC_STATUS_PAGE: "public_status_page",
- DASHBOARD_INTEGRATION: "dashboard_integration",
- // Advanced checks
- SSL_CERTIFICATE_CHECKS: "ssl_certificate_checks",
- KEYWORD_CONTENT_MATCH: "keyword_content_match",
- MULTI_LOCATION_CHECKS: "multi_location_checks",
- ONE_MINUTE_FREQUENCY: "one_minute_frequency",
- // Enterprise features
- THIRTY_SECOND_FREQUENCY: "thirty_second_frequency",
- SYNTHETIC_TRANSACTIONS: "synthetic_transactions",
- ALERT_ESCALATION: "alert_escalation",
- SMS_VOICE_ALERTS: "sms_voice_alerts",
- HEARTBEAT_MONITORING: "heartbeat_monitoring",
- N_OUT_OF_M_FALSE_POSITIVE_REDUCTION: "n_out_of_m_false_positive_reduction",
-} as const;
-
-export type PulseGatedFeatureId =
- (typeof PULSE_GATED_FEATURES)[keyof typeof PULSE_GATED_FEATURES];
-
-/**
- * Plan feature matrix - edit this to control which features are enabled per plan
- */
-export const PULSE_PLAN_FEATURES: Record<
- PulsePlanId,
- Record
-> = {
- [PULSE_PLAN_IDS.FREE]: {
- [PULSE_GATED_FEATURES.BASIC_UPTIME_CHECKS]: true,
- [PULSE_GATED_FEATURES.EMAIL_ALERTS]: true,
- [PULSE_GATED_FEATURES.WEBHOOKS]: true,
- [PULSE_GATED_FEATURES.PUBLIC_STATUS_PAGE]: true,
- [PULSE_GATED_FEATURES.DASHBOARD_INTEGRATION]: true,
- [PULSE_GATED_FEATURES.SSL_CERTIFICATE_CHECKS]: false,
- [PULSE_GATED_FEATURES.KEYWORD_CONTENT_MATCH]: false,
- [PULSE_GATED_FEATURES.MULTI_LOCATION_CHECKS]: false,
- [PULSE_GATED_FEATURES.ONE_MINUTE_FREQUENCY]: false,
- [PULSE_GATED_FEATURES.THIRTY_SECOND_FREQUENCY]: false,
- [PULSE_GATED_FEATURES.SYNTHETIC_TRANSACTIONS]: false,
- [PULSE_GATED_FEATURES.ALERT_ESCALATION]: false,
- [PULSE_GATED_FEATURES.SMS_VOICE_ALERTS]: false,
- [PULSE_GATED_FEATURES.HEARTBEAT_MONITORING]: false,
- [PULSE_GATED_FEATURES.N_OUT_OF_M_FALSE_POSITIVE_REDUCTION]: false,
- },
- [PULSE_PLAN_IDS.PRO]: {
- [PULSE_GATED_FEATURES.BASIC_UPTIME_CHECKS]: true,
- [PULSE_GATED_FEATURES.EMAIL_ALERTS]: true,
- [PULSE_GATED_FEATURES.WEBHOOKS]: true,
- [PULSE_GATED_FEATURES.PUBLIC_STATUS_PAGE]: true,
- [PULSE_GATED_FEATURES.DASHBOARD_INTEGRATION]: true,
- [PULSE_GATED_FEATURES.SSL_CERTIFICATE_CHECKS]: true,
- [PULSE_GATED_FEATURES.KEYWORD_CONTENT_MATCH]: true,
- [PULSE_GATED_FEATURES.MULTI_LOCATION_CHECKS]: true,
- [PULSE_GATED_FEATURES.ONE_MINUTE_FREQUENCY]: true,
- [PULSE_GATED_FEATURES.THIRTY_SECOND_FREQUENCY]: false,
- [PULSE_GATED_FEATURES.SYNTHETIC_TRANSACTIONS]: false,
- [PULSE_GATED_FEATURES.ALERT_ESCALATION]: false,
- [PULSE_GATED_FEATURES.SMS_VOICE_ALERTS]: false,
- [PULSE_GATED_FEATURES.HEARTBEAT_MONITORING]: false,
- [PULSE_GATED_FEATURES.N_OUT_OF_M_FALSE_POSITIVE_REDUCTION]: false,
- },
- [PULSE_PLAN_IDS.BUSINESS]: {
- [PULSE_GATED_FEATURES.BASIC_UPTIME_CHECKS]: true,
- [PULSE_GATED_FEATURES.EMAIL_ALERTS]: true,
- [PULSE_GATED_FEATURES.WEBHOOKS]: true,
- [PULSE_GATED_FEATURES.PUBLIC_STATUS_PAGE]: true,
- [PULSE_GATED_FEATURES.DASHBOARD_INTEGRATION]: true,
- [PULSE_GATED_FEATURES.SSL_CERTIFICATE_CHECKS]: true,
- [PULSE_GATED_FEATURES.KEYWORD_CONTENT_MATCH]: true,
- [PULSE_GATED_FEATURES.MULTI_LOCATION_CHECKS]: true,
- [PULSE_GATED_FEATURES.ONE_MINUTE_FREQUENCY]: true,
- [PULSE_GATED_FEATURES.THIRTY_SECOND_FREQUENCY]: true,
- [PULSE_GATED_FEATURES.SYNTHETIC_TRANSACTIONS]: true,
- [PULSE_GATED_FEATURES.ALERT_ESCALATION]: true,
- [PULSE_GATED_FEATURES.SMS_VOICE_ALERTS]: true,
- [PULSE_GATED_FEATURES.HEARTBEAT_MONITORING]: true,
- [PULSE_GATED_FEATURES.N_OUT_OF_M_FALSE_POSITIVE_REDUCTION]: true,
- },
-};
-
-/** Plan limits and configuration */
-export interface PulsePlanLimits {
- /** Check frequency in minutes (or seconds for Business) */
- checkFrequencyMinutes?: number;
- checkFrequencySeconds?: number;
- /** Number of check locations for multi-location checks */
- checkLocations?: number;
- /** Data retention period */
- dataRetentionDays?: number;
- dataRetentionMonths?: number;
- /** Number of monitors included */
- includedMonitors: number;
-}
-
-/** Plan metadata including pricing and target audience */
-export interface PulsePlanMetadata {
- limits: PulsePlanLimits;
- name: string;
- priceUsdMonthly: number;
- targetUser: string;
-}
-
-/**
- * Plan metadata - pricing, limits, and target audience
- */
-export const PULSE_PLAN_METADATA: Record = {
- [PULSE_PLAN_IDS.FREE]: {
- name: "Pulse Free",
- priceUsdMonthly: 0,
- targetUser: "Hobbyists, Personal Projects",
- limits: {
- includedMonitors: 5,
- checkFrequencyMinutes: 5,
- dataRetentionDays: 30,
- },
- },
- [PULSE_PLAN_IDS.PRO]: {
- name: "Pulse Pro",
- priceUsdMonthly: 15,
- targetUser: "SMBs, Small Agencies",
- limits: {
- includedMonitors: 50,
- checkFrequencyMinutes: 1,
- dataRetentionMonths: 12,
- checkLocations: 3,
- },
- },
- [PULSE_PLAN_IDS.BUSINESS]: {
- name: "Pulse Business",
- priceUsdMonthly: 49,
- targetUser: "Growing SaaS, Dev Teams",
- limits: {
- includedMonitors: 200,
- checkFrequencySeconds: 30,
- dataRetentionMonths: 24,
- },
- },
-};
-
-interface PulseFeatureMeta {
- description: string;
- minPlan?: PulsePlanId;
- name: string;
- upgradeMessage: string;
-}
-
-export const PULSE_FEATURE_METADATA: Record<
- PulseGatedFeatureId,
- PulseFeatureMeta
-> = {
- [PULSE_GATED_FEATURES.BASIC_UPTIME_CHECKS]: {
- name: "Basic Uptime Checks",
- description: "HTTP/S, Ping, and Port monitoring",
- upgradeMessage: "Basic uptime checks are available on all plans",
- },
- [PULSE_GATED_FEATURES.EMAIL_ALERTS]: {
- name: "Email Alerts",
- description: "Receive email notifications when monitors go down",
- upgradeMessage: "Email alerts are available on all plans",
- },
- [PULSE_GATED_FEATURES.WEBHOOKS]: {
- name: "Webhooks",
- description: "Integrate with external services via webhooks",
- upgradeMessage: "Webhooks are available on all plans",
- },
- [PULSE_GATED_FEATURES.PUBLIC_STATUS_PAGE]: {
- name: "Public Status Page",
- description: "Share your service status publicly",
- upgradeMessage: "Public status pages are available on all plans",
- },
- [PULSE_GATED_FEATURES.DASHBOARD_INTEGRATION]: {
- name: "Dashboard Integration",
- description: "View monitor status in your dashboard",
- upgradeMessage: "Dashboard integration is available on all plans",
- },
- [PULSE_GATED_FEATURES.SSL_CERTIFICATE_CHECKS]: {
- name: "SSL Certificate Expiry Checks",
- description: "Monitor SSL certificate expiration dates",
- upgradeMessage: "Upgrade to Pro for SSL certificate checks",
- minPlan: PULSE_PLAN_IDS.PRO,
- },
- [PULSE_GATED_FEATURES.KEYWORD_CONTENT_MATCH]: {
- name: "Keyword/Content Match",
- description: "Verify specific content appears on monitored pages",
- upgradeMessage: "Upgrade to Pro for keyword/content matching",
- minPlan: PULSE_PLAN_IDS.PRO,
- },
- [PULSE_GATED_FEATURES.MULTI_LOCATION_CHECKS]: {
- name: "Multi-Location Checks",
- description: "Monitor from multiple geographic locations",
- upgradeMessage: "Upgrade to Pro for multi-location checks",
- minPlan: PULSE_PLAN_IDS.PRO,
- },
- [PULSE_GATED_FEATURES.ONE_MINUTE_FREQUENCY]: {
- name: "1-Minute Check Frequency",
- description: "Check your monitors every minute",
- upgradeMessage: "Upgrade to Pro for 1-minute check frequency",
- minPlan: PULSE_PLAN_IDS.PRO,
- },
- [PULSE_GATED_FEATURES.THIRTY_SECOND_FREQUENCY]: {
- name: "30-Second Check Frequency",
- description: "Check your monitors every 30 seconds",
- upgradeMessage: "Upgrade to Business for 30-second check frequency",
- minPlan: PULSE_PLAN_IDS.BUSINESS,
- },
- [PULSE_GATED_FEATURES.SYNTHETIC_TRANSACTIONS]: {
- name: "Synthetic Transactions",
- description: "Multi-step checks that simulate user workflows",
- upgradeMessage: "Upgrade to Business for synthetic transactions",
- minPlan: PULSE_PLAN_IDS.BUSINESS,
- },
- [PULSE_GATED_FEATURES.ALERT_ESCALATION]: {
- name: "Alert Escalation Policies",
- description: "Configure alert escalation rules",
- upgradeMessage: "Upgrade to Business for alert escalation",
- minPlan: PULSE_PLAN_IDS.BUSINESS,
- },
- [PULSE_GATED_FEATURES.SMS_VOICE_ALERTS]: {
- name: "SMS/Voice Call Alerts",
- description: "Receive alerts via SMS or voice calls via Twilio",
- upgradeMessage: "Upgrade to Business for SMS/voice alerts",
- minPlan: PULSE_PLAN_IDS.BUSINESS,
- },
- [PULSE_GATED_FEATURES.HEARTBEAT_MONITORING]: {
- name: "Heartbeat Monitoring",
- description: "Monitor applications that send heartbeat signals",
- upgradeMessage: "Upgrade to Business for heartbeat monitoring",
- minPlan: PULSE_PLAN_IDS.BUSINESS,
- },
- [PULSE_GATED_FEATURES.N_OUT_OF_M_FALSE_POSITIVE_REDUCTION]: {
- name: "N-out-of-M False Positive Reduction",
- description:
- "Reduce false positives by requiring N failures out of M checks",
- upgradeMessage:
- "Upgrade to Business for N-out-of-M false positive reduction",
- minPlan: PULSE_PLAN_IDS.BUSINESS,
- },
-};
-
-/**
- * Map Pulse plan to equivalent regular plan for feature checks
- * Pulse Free maps to regular Free plan
- */
-export function getRegularPlanForPulsePlan(
- pulsePlanId: PulsePlanId | string | null
-): PlanId {
- const pulsePlan = (pulsePlanId ?? PULSE_PLAN_IDS.FREE) as PulsePlanId;
-
- if (pulsePlan === PULSE_PLAN_IDS.FREE) {
- return PLAN_IDS.FREE;
- }
-
- // For other Pulse plans, return free as default
- // You can extend this mapping if needed
- return PLAN_IDS.FREE;
-}
-
-/** Check if a plan has access to a gated feature */
-export function isPulsePlanFeatureEnabled(
- planId: PulsePlanId | string | null,
- feature: PulseGatedFeatureId
-): boolean {
- const plan = (planId ?? PULSE_PLAN_IDS.FREE) as PulsePlanId;
- return PULSE_PLAN_FEATURES[plan]?.[feature] ?? false;
-}
-
-/** Get the minimum plan required for a feature */
-export function getMinimumPulsePlanForFeature(
- feature: PulseGatedFeatureId
-): PulsePlanId | null {
- for (const plan of PULSE_PLAN_HIERARCHY) {
- if (PULSE_PLAN_FEATURES[plan][feature]) {
- return plan;
- }
- }
- return null;
-}
-
-/** Get plan metadata */
-export function getPulsePlanMetadata(
- planId: PulsePlanId | string | null
-): PulsePlanMetadata {
- const plan = (planId ?? PULSE_PLAN_IDS.FREE) as PulsePlanId;
- return PULSE_PLAN_METADATA[plan] ?? PULSE_PLAN_METADATA[PULSE_PLAN_IDS.FREE];
-}
-
-/** Get plan limits */
-export function getPulsePlanLimits(
- planId: PulsePlanId | string | null
-): PulsePlanLimits {
- const metadata = getPulsePlanMetadata(planId);
- return metadata.limits;
-}
-
-/** Product information */
-export const PULSE_PRODUCT_INFO = {
- productName: "Databuddy Pulse",
- coreValueProposition:
- "Integrated, Privacy-First Uptime Monitoring: Know when your site is down, and why, without compromising user privacy.",
-} as const;
diff --git a/apps/dashboard/types/sessions.ts b/apps/dashboard/types/sessions.ts
index e3eab1b3f0..4261b6cf0f 100644
--- a/apps/dashboard/types/sessions.ts
+++ b/apps/dashboard/types/sessions.ts
@@ -1,8 +1,4 @@
-export type SessionEventSource =
- | "analytics"
- | "custom"
- | "error"
- | "outgoing_link";
+type SessionEventSource = "analytics" | "custom" | "error" | "outgoing_link";
export interface SessionEvent {
event_id: string;
@@ -13,7 +9,7 @@ export interface SessionEvent {
time: string;
}
-export interface SessionWebVital {
+interface SessionWebVital {
metric_name: string;
metric_value: number;
path: string;
@@ -100,4 +96,4 @@ export type RawSessionEventTuple = [
SessionEventSource?,
];
-export type RawSessionWebVitalTuple = [string, number, string, string];
+type RawSessionWebVitalTuple = [string, number, string, string];
diff --git a/apps/dashboard/types/website.ts b/apps/dashboard/types/website.ts
index 5c9825df32..c8fb34369c 100644
--- a/apps/dashboard/types/website.ts
+++ b/apps/dashboard/types/website.ts
@@ -1,4 +1,4 @@
-export interface MiniChartDataPoint {
+interface MiniChartDataPoint {
date: string;
value: number;
}
@@ -31,9 +31,3 @@ export interface LocationData {
countries: CountryData[];
regions: RegionData[];
}
-
-export interface WebsiteBasic {
- domain: string;
- id: string;
- name?: string | null;
-}
diff --git a/apps/docs/app/(home)/alternatives/page.tsx b/apps/docs/app/(home)/alternatives/page.tsx
index fcb517dcf0..9d60aeb52a 100644
--- a/apps/docs/app/(home)/alternatives/page.tsx
+++ b/apps/docs/app/(home)/alternatives/page.tsx
@@ -102,7 +102,7 @@ export default function AlternativesHubPage() {
Ready to try Databuddy?
- Start free with 10K monthly pageviews - no credit card.
+ Start free with 10K monthly events - no credit card.
diff --git a/apps/docs/app/(home)/ambassadors/ambassador-rewards.tsx b/apps/docs/app/(home)/ambassadors/ambassador-rewards.tsx
index ca35a9e53b..5a7db825cb 100644
--- a/apps/docs/app/(home)/ambassadors/ambassador-rewards.tsx
+++ b/apps/docs/app/(home)/ambassadors/ambassador-rewards.tsx
@@ -27,7 +27,7 @@ const rewards: Reward[] = [
icon: DiamondIcon,
title: "Free Premium Access",
description: "Lifetime access to all premium features and analytics",
- value: "$99/month value",
+ value: "Pro plan access",
highlight: true,
},
{
diff --git a/apps/docs/app/(home)/api/actions.ts b/apps/docs/app/(home)/api/actions.ts
index f6640b21a3..4cb319e023 100644
--- a/apps/docs/app/(home)/api/actions.ts
+++ b/apps/docs/app/(home)/api/actions.ts
@@ -8,7 +8,7 @@ interface QueryConfig {
defaultLimit: number;
}
-export interface QueryConfigWithMeta extends QueryConfig {
+interface QueryConfigWithMeta extends QueryConfig {
meta?: QueryBuilderMeta;
}
diff --git a/apps/docs/app/(home)/api/query-builder.ts b/apps/docs/app/(home)/api/query-builder.ts
index 472fdb92e3..3a9209e8ce 100644
--- a/apps/docs/app/(home)/api/query-builder.ts
+++ b/apps/docs/app/(home)/api/query-builder.ts
@@ -136,41 +136,6 @@ async function executeDynamicQuery(
}
}
-export async function executeQuery(
- startDate: string,
- endDate: string,
- queryRequest: DynamicQueryRequest,
- timezone = "UTC"
-): Promise {
- try {
- const result = await executeDynamicQuery(
- startDate,
- endDate,
- queryRequest,
- timezone
- );
-
- if ("batch" in result) {
- throw new Error("Unexpected batch response for single query");
- }
-
- return result;
- } catch {
- return {
- success: false,
- queryId: queryRequest.id,
- data: [],
- meta: {
- parameters: queryRequest.parameters,
- total_parameters: queryRequest.parameters.length,
- page: queryRequest.page || 1,
- limit: queryRequest.limit || 100,
- filters_applied: queryRequest.filters?.length || 0,
- },
- };
- }
-}
-
export async function executeBatchQueries(
startDate: string,
endDate: string,
diff --git a/apps/docs/app/(home)/api/types-query-builder.ts b/apps/docs/app/(home)/api/types-query-builder.ts
index efa77fe342..558e26271c 100644
--- a/apps/docs/app/(home)/api/types-query-builder.ts
+++ b/apps/docs/app/(home)/api/types-query-builder.ts
@@ -1,4 +1,4 @@
-export type QueryFieldType =
+type QueryFieldType =
| "string"
| "number"
| "boolean"
@@ -6,7 +6,7 @@ export type QueryFieldType =
| "datetime"
| "json";
-export interface QueryOutputField {
+interface QueryOutputField {
description?: string;
example?: string | number | boolean | null;
label?: string;
@@ -15,7 +15,7 @@ export interface QueryOutputField {
unit?: string;
}
-export type VisualizationType =
+type VisualizationType =
| "table"
| "timeseries"
| "bar"
diff --git a/apps/docs/app/(home)/api/types.ts b/apps/docs/app/(home)/api/types.ts
index ae56299b21..88ea2d6942 100644
--- a/apps/docs/app/(home)/api/types.ts
+++ b/apps/docs/app/(home)/api/types.ts
@@ -1,4 +1,4 @@
-export interface DynamicQueryFilter {
+interface DynamicQueryFilter {
field: string;
operator: string;
value: string | number | boolean;
@@ -16,7 +16,7 @@ export interface DynamicQueryRequest {
timeZone?: string;
}
-export interface ParameterResult {
+interface ParameterResult {
data: unknown[];
error?: string;
parameter: string;
diff --git a/apps/docs/app/(home)/calculator/_components/calculator-engine.ts b/apps/docs/app/(home)/calculator/_components/calculator-engine.ts
index 3d51b93d9c..94999e223b 100644
--- a/apps/docs/app/(home)/calculator/_components/calculator-engine.ts
+++ b/apps/docs/app/(home)/calculator/_components/calculator-engine.ts
@@ -4,8 +4,6 @@ import type { NormalizedPlan } from "@/app/(home)/pricing/_pricing/types";
import { RAW_PLANS } from "@/app/(home)/pricing/data";
const PLANS: NormalizedPlan[] = normalizePlans(RAW_PLANS);
-
-/** Literature-aligned band: share of visits without measurable consent / analytics visibility */
export const VISITOR_DATA_LOSS_RANGE_LOW = 0.4;
export const VISITOR_DATA_LOSS_RANGE_HIGH = 0.7;
@@ -50,9 +48,7 @@ export interface CalculatorOutputs {
lostConversions: number;
lostRevenueMonthly: number;
lostRevenueYearly: number;
- /** Same inputs, upper bound of literature band (visitor data loss) */
lostRevenueYearlyRangeHigh: number;
- /** Same inputs, lower bound of literature band (visitor data loss) */
lostRevenueYearlyRangeLow: number;
lostVisitors: number;
}
@@ -179,16 +175,6 @@ export const SCENARIOS: Scenario[] = SCENARIO_CONFIGS.map((config) => ({
outputs: calculateCookieBannerCost(config.inputs),
}));
-export function formatCurrency(value: number): string {
- if (value >= 1_000_000) {
- return `$${(value / 1_000_000).toFixed(1)}M`;
- }
- if (value >= 1000) {
- return `$${(value / 1000).toFixed(1)}K`;
- }
- return `$${Math.round(value).toLocaleString()}`;
-}
-
export function formatCurrencyFull(value: number): string {
return `$${Math.round(value).toLocaleString()}`;
}
diff --git a/apps/docs/app/(home)/calculator/page.tsx b/apps/docs/app/(home)/calculator/page.tsx
index 8afb03d754..3de41b7435 100644
--- a/apps/docs/app/(home)/calculator/page.tsx
+++ b/apps/docs/app/(home)/calculator/page.tsx
@@ -9,8 +9,6 @@ import { ScenariosSection } from "./_components/scenarios-section";
const TITLE = "Cookie Banner Cost Calculator";
const DESCRIPTION =
"Model unattributed revenue from the cookie-consent measurement gap: traffic, visitor-to-paid, revenue per conversion, and a 40–70% band. Not P&L impact.";
-
-/** Matches defaults: 50k visitors, 55% data loss, 1.5% visitor-to-paid, $50 - ~$248k/yr; ~$11/mo Databuddy at this volume */
const DEFAULT_OG_PARAMS = "revenue=247500&visitors=50000&cost=11";
interface PageProps {
diff --git a/apps/docs/app/(home)/databunny/page.tsx b/apps/docs/app/(home)/databunny/page.tsx
index 5a8fc3f33f..ed2c7a5002 100644
--- a/apps/docs/app/(home)/databunny/page.tsx
+++ b/apps/docs/app/(home)/databunny/page.tsx
@@ -11,8 +11,8 @@ import { FaqSection } from "@/components/landing/faq-section";
import {
AgentChatDemo,
AnomalyDetectionDemo,
+ CaseFollowUpDemo,
InsightCardsDemo,
- NarrativeSummaryDemo,
ProactiveAlertsDemo,
SuggestedPromptsDemo,
} from "@/components/landing/databunny-demo-visuals";
@@ -22,14 +22,14 @@ import { StructuredData } from "@/components/structured-data";
export const metadata: Metadata = {
title: "AI Analytics Agent - Ask Your Data Questions in Plain English",
description:
- "Ask analytics questions in plain English. Run automatic investigations daily or weekly and continue them from Slack.",
+ "Databunny investigates your analytics on its own, delivers evidence-backed findings to Slack, and answers questions in plain English.",
alternates: {
canonical: "https://www.databuddy.cc/databunny",
},
openGraph: {
title: "AI Analytics Agent - Ask Your Data Questions in Plain English",
description:
- "Ask analytics questions in plain English. Run automatic investigations daily or weekly and continue them from Slack.",
+ "Databunny investigates your analytics on its own, delivers evidence-backed findings to Slack, and answers questions in plain English.",
url: "https://www.databuddy.cc/databunny",
images: ["/og-image.png"],
},
@@ -81,77 +81,77 @@ export default function DatabunnyPage() {
-
+
- Have a conversation with your analytics data.
+ Surface trends, spikes, and drops automatically.
-
+
- Start from a suggestion or ask your own question.
+ Every case is rechecked until the fix is verified.
-
+
-
+
- Surface trends, spikes, and drops automatically.
+ Detect spikes and drops across pageviews, errors, and events.
-
+
- Get a weekly summary across all your sites.
+ Actions, questions, and resolutions, delivered to Slack.
-
+
-
+
- Detect spikes and drops across pageviews, errors, and events.
+ Have a conversation with your analytics data.
-
+
- Get notified where you already work.
+ Start from a suggestion or ask your own question.
-
+
diff --git a/apps/docs/app/(home)/errors/page.tsx b/apps/docs/app/(home)/errors/page.tsx
index b26fe05424..3463d212cc 100644
--- a/apps/docs/app/(home)/errors/page.tsx
+++ b/apps/docs/app/(home)/errors/page.tsx
@@ -13,20 +13,21 @@ import { ErrorFrequencyChartDemo } from "@/components/landing/error-frequency-ch
import { ErrorPerPageBreakdownDemo } from "@/components/landing/error-per-page-breakdown-demo";
import { ErrorImpactTableArtifact } from "@/components/landing/error-who-it-affects-artifacts";
import { FaqSection } from "@/components/landing/faq-section";
+import { MidPageCta } from "@/components/landing/mid-page-cta";
import Section from "@/components/landing/section";
import { StructuredData } from "@/components/structured-data";
export const metadata: Metadata = {
title: "JavaScript Error Tracking - Built Into Your Analytics",
description:
- "Catch, group, and fix JavaScript errors before your users notice. Stack traces, session context, user impact, and instant alerts. No second tool, no extra script.",
+ "Catch and group JavaScript errors with stack traces, session context, and affected-user impact. No second tool, no extra script.",
alternates: {
canonical: "https://www.databuddy.cc/errors",
},
openGraph: {
title: "JavaScript Error Tracking - Built Into Your Analytics",
description:
- "Catch, group, and fix JavaScript errors before your users notice. Stack traces, session context, user impact, and instant alerts. No second tool, no extra script.",
+ "Catch and group JavaScript errors with stack traces, session context, and affected-user impact. No second tool, no extra script.",
url: "https://www.databuddy.cc/errors",
images: ["/og-image.png"],
},
@@ -41,22 +42,22 @@ const FAQ_ITEMS = [
{
question: "How does Databuddy group errors?",
answer:
- "Errors are grouped by their stack trace fingerprint, so the same bug hitting thousands of users shows up as one issue - not thousands. You can also manually merge or split groups.",
+ "Errors are grouped by message, so the same bug hitting thousands of users shows up as one issue ranked by how many people it affects - not thousands of duplicate rows.",
},
{
- question: "Can I see which users were affected by an error?",
+ question: "Can I see how many users an error affected?",
answer:
- "Yes. If you identify users in your analytics setup, every error is linked to the affected user sessions. You can see exactly who hit the bug and replay the context.",
+ "Yes. Every error shows affected users, occurrences, and errors per affected user. Impact analysis stays privacy-safe: aggregate counts, never a list of identified individuals.",
},
{
- question: "Does it work with server-side errors too?",
+ question: "Does it capture noise from browser extensions?",
answer:
- "Yes. The Node.js SDK captures unhandled exceptions and rejections on the server side. Both client and server errors appear in the same dashboard.",
+ "No. Extension errors, cross-origin script noise, and known browser runtime junk are filtered before they reach your dashboard, so you only see errors from your own code.",
},
{
question: "Is error tracking included in all plans?",
answer:
- "Error tracking is available on every plan. The free plan gives you 1,000 error events per month - paid plans scale from there with higher limits and longer retention.",
+ "Error tracking starts on the Hobby plan. Every paid plan includes it with no separate error quota.",
},
] as const;
@@ -74,24 +75,25 @@ export default function ErrorsPage() {
page={{
title: "JavaScript Error Tracking - Built Into Your Analytics",
description:
- "Catch, group, and fix JavaScript errors before your users notice. Stack traces, session context, user impact, and instant alerts.",
+ "Catch and group JavaScript errors with stack traces, session context, and affected-user impact.",
url: "https://www.databuddy.cc/errors",
}}
/>
@@ -103,7 +105,7 @@ export default function ErrorsPage() {
- Know about errors before your users report them.
+ Exceptions and rejections, captured automatically.
@@ -133,6 +135,12 @@ export default function ErrorsPage() {
+
+
>
diff --git a/apps/docs/app/(home)/feature-flags/page.tsx b/apps/docs/app/(home)/feature-flags/page.tsx
index 426edb4f24..3256acfa56 100644
--- a/apps/docs/app/(home)/feature-flags/page.tsx
+++ b/apps/docs/app/(home)/feature-flags/page.tsx
@@ -15,6 +15,7 @@ import { FFInstantRolloutsDemo } from "@/components/landing/ff-instant-rollouts-
import { FFPercentageRolloutsDemo } from "@/components/landing/ff-percentage-rollouts-demo";
import { FFTemplatesMiniGridDemo } from "@/components/landing/ff-templates-mini-grid-demo";
import { FFUserTargetingDemo } from "@/components/landing/ff-user-targeting-demo";
+import { MidPageCta } from "@/components/landing/mid-page-cta";
import Section from "@/components/landing/section";
import { StructuredData } from "@/components/structured-data";
@@ -38,27 +39,27 @@ const FAQ_ITEMS = [
{
question: "Will feature flags slow down my app?",
answer:
- "No. Flags load once and are cached locally, so your users never see a delay. Pages render just as fast with flags as without - there's no visible performance impact.",
+ "No. Flags are cached locally with request batching, so after the first load your users never see a delay. There is no separate SDK to ship either; flags ride in the same script as your analytics.",
},
{
question: "Can I roll out a feature to just one team or customer first?",
answer:
- "Yes. You can release to specific users, entire organizations, or teams before opening it up to everyone. This means you can validate with your biggest customer before a wider launch.",
+ "Yes. Target specific users by ID, email, or any property you pass, and bucket percentage rollouts by user, organization, or team so a whole workspace flips together.",
},
{
question: "What happens if something goes wrong after a release?",
answer:
- "One click and the feature is off - no deploy, no rollback, no downtime. Your users see the previous experience immediately while you fix the issue.",
+ "One click and the feature is off - no deploy, no rollback, no downtime. Server caches are purged instantly and clients pick up the change within about a minute.",
},
{
question: "Can I run A/B tests to see which version performs better?",
answer:
- "Yes. Create multiple variants, split traffic by percentage, and each user consistently sees the same version across sessions. You can measure which variant drives better outcomes and scale the winner.",
+ "Yes. Create multiple variants, split traffic by weight, and each user consistently sees the same variant across sessions. Every evaluation is tracked with its variant, so you can segment any metric by variant in your analytics.",
},
{
question: "Are feature flags included in all plans?",
answer:
- "Every plan includes feature flags - the free plan gives you 3 flags to start, and paid plans scale from there. No add-ons or hidden costs.",
+ "Every plan includes feature flags - the free plan gives you 3 flags to start, and paid plans scale from there. Flag evaluations never count toward your event quota.",
},
] as const;
@@ -83,21 +84,24 @@ export default function FeatureFlagsPage() {
- Every flag, one place.
+
+ Every flag, with a full audit trail of changes.
+
@@ -113,9 +117,9 @@ export default function FeatureFlagsPage() {
@@ -136,13 +140,13 @@ export default function FeatureFlagsPage() {
- Run experiments with consistent assignments.
+ Same user, same variant, every session.
- Target by user, team, plan, or any property.
+ Target by user ID, email, or any property.
@@ -156,6 +160,12 @@ export default function FeatureFlagsPage() {
+
+
>
diff --git a/apps/docs/app/(home)/links/page.tsx b/apps/docs/app/(home)/links/page.tsx
index f67e5c7b1a..3201435506 100644
--- a/apps/docs/app/(home)/links/page.tsx
+++ b/apps/docs/app/(home)/links/page.tsx
@@ -9,6 +9,7 @@ import {
TwoColumnGrid,
} from "@/components/landing/demo-primitives";
import { FaqSection } from "@/components/landing/faq-section";
+import { MidPageCta } from "@/components/landing/mid-page-cta";
import {
DeepLinkDemo,
LinkFunnelDemo,
@@ -22,14 +23,14 @@ import { StructuredData } from "@/components/structured-data";
export const metadata: Metadata = {
title: "Short Links & Click Analytics",
description:
- "Create branded short links with built-in click analytics, UTM tagging, QR codes, deep linking, and referrer tracking. Every click tracked inside your analytics dashboard.",
+ "Short links with built-in click analytics, UTM tagging, QR codes, deep linking, and referrer tracking. Every click tracked inside your analytics dashboard.",
alternates: {
canonical: "https://www.databuddy.cc/links",
},
openGraph: {
title: "Short Links & Click Analytics",
description:
- "Create branded short links with built-in click analytics, UTM tagging, QR codes, deep linking, and referrer tracking. Every click tracked inside your analytics dashboard.",
+ "Short links with built-in click analytics, UTM tagging, QR codes, deep linking, and referrer tracking. Every click tracked inside your analytics dashboard.",
url: "https://www.databuddy.cc/links",
images: ["/og-image.png"],
},
@@ -37,14 +38,14 @@ export const metadata: Metadata = {
const FAQ_ITEMS = [
{
- question: "Can I use my own domain for short links?",
+ question: "How is this different from Bitly or Dub?",
answer:
- "Yes. You can connect any custom domain and use it as your link base. All analytics still flow into your Databuddy dashboard.",
+ "Databuddy links live inside your analytics stack. Every click is connected to the same dashboard where you track pageviews, errors, and conversions. No separate tool, no data silos.",
},
{
- question: "How is this different from Bitly or Dub?",
+ question: "Are click counts accurate?",
answer:
- "Databuddy links live inside your analytics stack. Every click is connected to the same dashboard where you track pageviews, errors, and conversions. No separate tool, no data silos.",
+ "Yes. Bots and crawlers are detected and redirected without counting, so your numbers reflect real people. Visitor IPs are hashed with a rotating daily salt and never stored raw.",
},
{
question: "Do links expire?",
@@ -57,9 +58,9 @@ const FAQ_ITEMS = [
"Set an iOS URL and an Android URL on any link. When someone clicks on mobile, they go to the right app store or deep into your native app. Desktop users get the web fallback automatically.",
},
{
- question: "Are short links included in all plans?",
+ question: "Can I manage links from the API?",
answer:
- "Every plan includes short links. The free plan gives you 50 links. Paid plans scale from there with unlimited links and custom domains.",
+ "Yes. Create, update, and search links with a scoped API key, or let your AI agent do it over MCP. Folders keep campaigns organized either way.",
},
] as const;
@@ -77,15 +78,16 @@ export default function LinksPage() {
page={{
title: "Short Links & Click Analytics",
description:
- "Create branded short links with built-in click analytics, UTM tagging, QR codes, and deep linking.",
+ "Short links with built-in click analytics, UTM tagging, QR codes, and deep linking.",
url: "https://www.databuddy.cc/links",
}}
/>
@@ -98,13 +100,13 @@ export default function LinksPage() {
- See which links drive traffic and which ones don't.
+ See which links drive traffic.
- Know exactly where your clicks come from.
+ Know where your clicks come from.
@@ -112,7 +114,7 @@ export default function LinksPage() {
- Track the full journey from click to conversion.
+ Connect link clicks to your funnels.
@@ -129,14 +131,14 @@ export default function LinksPage() {
+
+
>
diff --git a/apps/docs/app/(home)/manifesto/manifesto-data.ts b/apps/docs/app/(home)/manifesto/manifesto-data.ts
index 4d6d2da8ed..2f8d1d2cff 100644
--- a/apps/docs/app/(home)/manifesto/manifesto-data.ts
+++ b/apps/docs/app/(home)/manifesto/manifesto-data.ts
@@ -3,7 +3,7 @@ export type ManifestoBlock =
| { type: "callout"; text: string }
| { type: "prompts"; items: readonly string[] };
-export type ManifestoChapterId =
+type ManifestoChapterId =
| "analytics-is-broken"
| "context-is-everything"
| "privacy-is-the-default"
@@ -95,7 +95,7 @@ export const manifestoSections: readonly ManifestoChapter[] = [
},
{
type: "paragraph",
- text: "Databuddy\u2019s tracking script is 30KB. No cookies. No fingerprints. No personal identifiers. No consent banners scaring away 30\u201340% of your visitors before they even see your product. You install it, and it works. Your users never know it\u2019s there, because it never asks them for anything.",
+ text: "Databuddy\u2019s tracking script is about 12 KB gzipped. No cookies. No fingerprints. No personal identifiers. No consent banners scaring away 30\u201340% of your visitors before they even see your product. You install it, and it works. Your users never know it\u2019s there, because it never asks them for anything.",
},
{
type: "paragraph",
diff --git a/apps/docs/app/(home)/page.tsx b/apps/docs/app/(home)/page.tsx
index 9c61a984df..1fb40b66fa 100644
--- a/apps/docs/app/(home)/page.tsx
+++ b/apps/docs/app/(home)/page.tsx
@@ -2,12 +2,14 @@ import type { Metadata } from "next";
import { headers } from "next/headers";
import Bento from "@/components/bento";
import { Footer } from "@/components/footer";
+import { AiSection } from "@/components/landing/ai-section";
import { DemoPreconnectLinks } from "@/components/landing/demo-preconnect-links";
import { Description } from "@/components/landing/description";
import FAQ from "@/components/landing/faq";
import { GridCards } from "@/components/landing/grid-cards";
import Hero from "@/components/landing/hero";
import { MidPageCta } from "@/components/landing/mid-page-cta";
+import { PricingPreview } from "@/components/landing/pricing-preview";
import Section from "@/components/landing/section";
import Testimonials from "@/components/landing/testimonials";
import { TrustedBy } from "@/components/landing/trusted-by";
@@ -41,6 +43,31 @@ function firstValue(value: string | string[] | undefined) {
return Array.isArray(value) ? value[0] : value;
}
+async function getGithubStars(): Promise {
+ try {
+ const response = await fetch(
+ "https://api.github.com/repos/databuddy-analytics/databuddy",
+ {
+ headers: {
+ Accept: "application/vnd.github+json",
+ },
+ next: { revalidate: 3600 },
+ }
+ );
+
+ if (!response.ok) {
+ return null;
+ }
+
+ const data = (await response.json()) as { stargazers_count?: number };
+ return typeof data.stargazers_count === "number"
+ ? data.stargazers_count
+ : null;
+ } catch {
+ return null;
+ }
+}
+
function AgentModeView() {
const agent = createAgentJson();
@@ -111,6 +138,7 @@ export default async function HomePage({ searchParams }: HomePageProps) {
const headerList = await headers();
const demoEmbedBaseUrl = getDemoEmbedBaseUrl(hostFromNextHeaders(headerList));
+ const stars = await getGithubStars();
return (
<>
@@ -148,7 +176,7 @@ export default async function HomePage({ searchParams }: HomePageProps) {
/>
+
+
@@ -179,6 +213,12 @@ export default async function HomePage({ searchParams }: HomePageProps) {
+
+
a.includedEventsMonthly - b.includedEventsMonthly
);
diff --git a/apps/docs/app/(home)/pricing/_pricing/estimator-scale.ts b/apps/docs/app/(home)/pricing/_pricing/estimator-scale.ts
index 804bdc2381..d3c214c27b 100644
--- a/apps/docs/app/(home)/pricing/_pricing/estimator-scale.ts
+++ b/apps/docs/app/(home)/pricing/_pricing/estimator-scale.ts
@@ -1,10 +1,10 @@
-export const SLIDER_THRESHOLDS: number[] = [
+const SLIDER_THRESHOLDS: number[] = [
0, 10_000, 100_000, 1_000_000, 10_000_000, 100_000_000, 250_000_000,
];
const SLIDER_SEGMENT_PCT = 100 / (SLIDER_THRESHOLDS.length - 1);
-export const clamp = (value: number, min: number, max: number): number =>
+const clamp = (value: number, min: number, max: number): number =>
Math.min(Math.max(value, min), max);
export function eventsToSliderValue(events: number): number {
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 3c28940e5f..5d4afff7af 100644
--- a/apps/docs/app/(home)/pricing/_pricing/gated-feature-rows.tsx
+++ b/apps/docs/app/(home)/pricing/_pricing/gated-feature-rows.tsx
@@ -12,8 +12,6 @@ import {
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). */
const TABLE_PLAN_TO_SHARED: Record = {
free: PLAN_IDS.FREE,
hobby: PLAN_IDS.HOBBY,
diff --git a/apps/docs/app/(home)/pricing/_pricing/normalize.ts b/apps/docs/app/(home)/pricing/_pricing/normalize.ts
index f3543882d8..2027977729 100644
--- a/apps/docs/app/(home)/pricing/_pricing/normalize.ts
+++ b/apps/docs/app/(home)/pricing/_pricing/normalize.ts
@@ -1,7 +1,7 @@
import type { RawItem, RawPlan } from "../data";
import type { NormalizedPlan } from "./types";
-export function getPriceMonthly(items: RawItem[]): number {
+function getPriceMonthly(items: RawItem[]): number {
for (const item of items) {
if (item.type === "price") {
return item.price;
@@ -10,7 +10,7 @@ export function getPriceMonthly(items: RawItem[]): number {
return 0;
}
-export function getEventsInfo(items: RawItem[]): {
+function getEventsInfo(items: RawItem[]): {
included: number;
tiers: Array<{ to: number | "inf"; amount: number }> | null;
} {
@@ -33,7 +33,7 @@ export function getEventsInfo(items: RawItem[]): {
return { included, tiers };
}
-export function getAgentCreditsByInterval(
+function getAgentCreditsByInterval(
items: RawItem[],
interval: "day" | "month"
): number | null {
diff --git a/apps/docs/app/(home)/pricing/data.ts b/apps/docs/app/(home)/pricing/data.ts
index a8b46b0f7e..f25507fdaa 100644
--- a/apps/docs/app/(home)/pricing/data.ts
+++ b/apps/docs/app/(home)/pricing/data.ts
@@ -1,10 +1,10 @@
import { DATABUNNY_USAGE } from "@databuddy/shared/billing";
-export interface FeatureDisplay {
+interface FeatureDisplay {
plural: string;
singular: string;
}
-export interface RawFeature {
+interface RawFeature {
display: FeatureDisplay;
id: string;
name: string;
diff --git a/apps/docs/app/(home)/roadmap/roadmap-data.ts b/apps/docs/app/(home)/roadmap/roadmap-data.ts
index 0f74f5e94a..a1d0341228 100644
--- a/apps/docs/app/(home)/roadmap/roadmap-data.ts
+++ b/apps/docs/app/(home)/roadmap/roadmap-data.ts
@@ -1,7 +1,6 @@
import type {
RoadmapItem,
RoadmapMilestone,
- RoadmapQuarter,
RoadmapStats,
} from "./roadmap-types";
@@ -351,118 +350,7 @@ export const roadmapItems: RoadmapItem[] = [
},
];
-export const roadmapQuarters: RoadmapQuarter[] = [
- {
- id: "q4-2024",
- name: "Q4 2024",
- startDate: "2024-10-01",
- endDate: "2024-12-31",
- items: roadmapItems.filter(
- (item) =>
- item.targetDate &&
- item.targetDate >= "2024-10-01" &&
- item.targetDate <= "2024-12-31"
- ),
- },
- {
- id: "q1-2025",
- name: "Q1 2025",
- startDate: "2025-01-01",
- endDate: "2025-03-31",
- items: roadmapItems.filter(
- (item) =>
- item.targetDate &&
- item.targetDate >= "2025-01-01" &&
- item.targetDate <= "2025-03-31"
- ),
- },
- {
- id: "q2-2025",
- name: "Q2 2025",
- startDate: "2025-04-01",
- endDate: "2025-06-30",
- items: roadmapItems.filter(
- (item) =>
- item.targetDate &&
- item.targetDate >= "2025-04-01" &&
- item.targetDate <= "2025-06-30"
- ),
- },
- {
- id: "q3-2025",
- name: "Q3 2025",
- startDate: "2025-07-01",
- endDate: "2025-09-30",
- items: roadmapItems.filter(
- (item) =>
- item.targetDate &&
- item.targetDate >= "2025-07-01" &&
- item.targetDate <= "2025-09-30"
- ),
- },
- {
- id: "q4-2025",
- name: "Q4 2025",
- startDate: "2025-10-01",
- endDate: "2025-12-31",
- items: roadmapItems.filter(
- (item) =>
- item.targetDate &&
- item.targetDate >= "2025-10-01" &&
- item.targetDate <= "2025-12-31"
- ),
- },
- {
- id: "q1-2026",
- name: "Q1 2026",
- startDate: "2026-01-01",
- endDate: "2026-03-31",
- items: roadmapItems.filter(
- (item) =>
- item.targetDate &&
- item.targetDate >= "2026-01-01" &&
- item.targetDate <= "2026-03-31"
- ),
- },
- {
- id: "q2-2026",
- name: "Q2 2026",
- startDate: "2026-04-01",
- endDate: "2026-06-30",
- items: roadmapItems.filter(
- (item) =>
- item.targetDate &&
- item.targetDate >= "2026-04-01" &&
- item.targetDate <= "2026-06-30"
- ),
- },
- {
- id: "q3-2026",
- name: "Q3 2026",
- startDate: "2026-07-01",
- endDate: "2026-09-30",
- items: roadmapItems.filter(
- (item) =>
- item.targetDate &&
- item.targetDate >= "2026-07-01" &&
- item.targetDate <= "2026-09-30"
- ),
- },
- {
- id: "q4-2026",
- name: "Q4 2026",
- startDate: "2026-10-01",
- endDate: "2026-12-31",
- items: roadmapItems.filter(
- (item) =>
- item.targetDate &&
- item.targetDate >= "2026-10-01" &&
- item.targetDate <= "2026-12-31"
- ),
- },
-];
-
-export const roadmapMilestones: RoadmapMilestone[] = [
+const roadmapMilestones: RoadmapMilestone[] = [
{
id: "core-platform",
title: "Core Platform",
diff --git a/apps/docs/app/(home)/roadmap/roadmap-types.ts b/apps/docs/app/(home)/roadmap/roadmap-types.ts
index f2600ebd0b..74a3ddc75e 100644
--- a/apps/docs/app/(home)/roadmap/roadmap-types.ts
+++ b/apps/docs/app/(home)/roadmap/roadmap-types.ts
@@ -7,7 +7,7 @@ export type RoadmapStatus =
export type RoadmapPriority = "critical" | "high" | "medium" | "low";
-export type RoadmapCategory =
+type RoadmapCategory =
| "analytics"
| "AI"
| "integrations"
@@ -31,14 +31,6 @@ export interface RoadmapItem {
title: string;
}
-export interface RoadmapQuarter {
- endDate: string;
- id: string;
- items: RoadmapItem[];
- name: string; // e.g., "Q1 2024"
- startDate: string;
-}
-
export interface RoadmapStats {
cancelledItems: number;
completedItems: number;
diff --git a/apps/docs/app/(home)/sponsors/page.tsx b/apps/docs/app/(home)/sponsors/page.tsx
index 7ab2e32f67..7a6c698644 100644
--- a/apps/docs/app/(home)/sponsors/page.tsx
+++ b/apps/docs/app/(home)/sponsors/page.tsx
@@ -40,10 +40,7 @@ export default function SponsorsPage() {
}}
/>
{/* Current Sponsors Section */}
diff --git a/apps/docs/app/(home)/sponsors/sponsors-data.ts b/apps/docs/app/(home)/sponsors/sponsors-data.ts
index ae725e9868..266f8fdefd 100644
--- a/apps/docs/app/(home)/sponsors/sponsors-data.ts
+++ b/apps/docs/app/(home)/sponsors/sponsors-data.ts
@@ -55,7 +55,4 @@ const activeSponsors = sponsors.filter((s) => !s.disabled);
export const sponsorStats = {
totalSponsors: activeSponsors.length,
- featuredSponsors: activeSponsors.filter(
- (s) => s.tier === "platinum" || s.tier === "gold"
- ).length,
};
diff --git a/apps/docs/app/(home)/sponsors/sponsors-hero.tsx b/apps/docs/app/(home)/sponsors/sponsors-hero.tsx
index d5d83b983b..1216a24a06 100644
--- a/apps/docs/app/(home)/sponsors/sponsors-hero.tsx
+++ b/apps/docs/app/(home)/sponsors/sponsors-hero.tsx
@@ -2,11 +2,10 @@
type IconWeight = "regular" | "bold" | "fill" | "duotone";
-import { StarIcon as HeartIcon, StarIcon } from "@databuddy/ui/icons";
+import { StarIcon as HeartIcon } from "@databuddy/ui/icons";
import { SciFiCard } from "@/components/scifi-card";
interface SponsorsHeroProps {
- featuredSponsors: number;
totalSponsors: number;
}
@@ -46,10 +45,7 @@ function StatCard({
);
}
-export default function SponsorsHero({
- totalSponsors,
- featuredSponsors,
-}: SponsorsHeroProps) {
+export default function SponsorsHero({ totalSponsors }: SponsorsHeroProps) {
return (
@@ -72,19 +68,13 @@ export default function SponsorsHero({
{/* Stats Grid */}
-
diff --git a/apps/docs/app/(home)/uptime/page.tsx b/apps/docs/app/(home)/uptime/page.tsx
index c58f05b14f..f85e87c613 100644
--- a/apps/docs/app/(home)/uptime/page.tsx
+++ b/apps/docs/app/(home)/uptime/page.tsx
@@ -9,6 +9,7 @@ import {
TwoColumnGrid,
} from "@/components/landing/demo-primitives";
import { FaqSection } from "@/components/landing/faq-section";
+import { MidPageCta } from "@/components/landing/mid-page-cta";
import Section from "@/components/landing/section";
import {
UptimeAlertsStackVisual,
@@ -19,16 +20,16 @@ import {
import { StructuredData } from "@/components/structured-data";
export const metadata: Metadata = {
- title: "Uptime Monitoring (coming Q3 2026)",
+ title: "Uptime Monitoring - Status Pages & 1-Minute Checks",
description:
- "1-minute checks from six regions, instant alerts, and public status pages - included with Databuddy. Launching Q3 2026. Join the waitlist for early access.",
+ "1-minute HTTP checks, one alert per status change to Slack, email, or webhook, and public branded status pages. Included with Databuddy on every plan.",
alternates: {
canonical: "https://www.databuddy.cc/uptime",
},
openGraph: {
- title: "Uptime Monitoring (coming Q3 2026)",
+ title: "Uptime Monitoring - Status Pages & 1-Minute Checks",
description:
- "1-minute checks from six regions, instant alerts, and public status pages - included with Databuddy. Launching Q3 2026.",
+ "1-minute HTTP checks, one alert per status change to Slack, email, or webhook, and public branded status pages. Included with Databuddy on every plan.",
url: "https://www.databuddy.cc/uptime",
images: ["/og-image.png"],
},
@@ -38,7 +39,7 @@ const FAQ_ITEMS = [
{
question: "How quickly will I know if my site goes down?",
answer:
- "Within a minute. Checks run as frequently as every 60 seconds, and you get notified the moment something goes wrong - before your customers notice.",
+ "Checks run as frequently as every 60 seconds, and an alert fires as soon as a check sees the status change from up to down.",
},
{
question: "Can my customers see the status page?",
@@ -53,12 +54,12 @@ const FAQ_ITEMS = [
{
question: "What kind of services can I monitor?",
answer:
- "Any public website, API, or web service. You can also parse JSON health endpoints to monitor specific fields - useful for services that report their own status.",
+ "Any public website, API, or web service. Point a monitor at a page or health endpoint and the check passes or fails on the HTTP response.",
},
{
question: "Is uptime monitoring included in all plans?",
answer:
- "Uptime monitoring is available on every paid plan. The number of monitors and how frequently they run depends on your tier - check the pricing page for the full breakdown.",
+ "Yes, including the free plan. Uptime checks never count toward your event quota, so monitoring stays free no matter how often your sites are checked.",
},
] as const;
@@ -76,33 +77,23 @@ export default function UptimePage() {
page={{
title: "Uptime Monitoring",
description:
- "1-minute checks, status pages, and instant alerts - coming to Databuddy Q3 2026.",
+ "1-minute checks, public status pages, and one alert per status change. Included with Databuddy.",
url: "https://www.databuddy.cc/uptime",
}}
/>
-
-
- Coming Soon
-
-
- }
docsHref="/docs"
- primaryLabel="Join Waitlist"
- subtitle="Status pages, 1-minute checks, and instant alerts so you find out before your users tweet about it."
- title="Be the first to know when your site goes down."
+ footnote="Included on every plan. Checks never count toward your event quota."
+ primaryLabel="Start Monitoring"
+ subtitle="1-minute HTTP checks, one alert per status change, and a public status page your customers can check themselves. In the same dashboard as your analytics."
+ title="Know the minute your site goes down."
/>
@@ -110,13 +101,13 @@ export default function UptimePage() {
- HTTP monitoring every 60 seconds, from 6 regions.
+ HTTP checks on your sites, as often as every 60 seconds.
- Slack, email, or webhook in under 30 seconds.
+ One alert per incident to Slack, email, or webhook.
@@ -125,13 +116,13 @@ export default function UptimePage() {
- Share a status page transparently with your users.
+ Publish a status page without leaking internals.
- Every incident, documented automatically.
+ Post incident updates your users can follow.
@@ -145,6 +136,12 @@ export default function UptimePage() {
+
+
>
diff --git a/apps/docs/app/(home)/web-vitals/page.tsx b/apps/docs/app/(home)/web-vitals/page.tsx
index 9ed35408c6..59a32421b9 100644
--- a/apps/docs/app/(home)/web-vitals/page.tsx
+++ b/apps/docs/app/(home)/web-vitals/page.tsx
@@ -9,6 +9,7 @@ import {
TwoColumnGrid,
} from "@/components/landing/demo-primitives";
import { FaqSection } from "@/components/landing/faq-section";
+import { MidPageCta } from "@/components/landing/mid-page-cta";
import Section from "@/components/landing/section";
import { WebVitalsAlertCycleDemo } from "@/components/landing/web-vitals-alert-cycle-demo";
import { WebVitalsBreakdownDemo } from "@/components/landing/web-vitals-breakdown-demo";
@@ -20,14 +21,14 @@ import { StructuredData } from "@/components/structured-data";
export const metadata: Metadata = {
title: "Core Web Vitals Monitoring - Real User Performance Analytics",
description:
- "Monitor LCP, CLS, INP, FCP, and TTFB from real users in production. Percentile breakdowns by page, device, and country. Field data, not lab simulations. Built into your analytics.",
+ "Monitor LCP, CLS, INP, FCP, and TTFB from real users in production. Percentile breakdowns by page, browser, and country. Field data, not lab simulations. Built into your analytics.",
alternates: {
canonical: "https://www.databuddy.cc/web-vitals",
},
openGraph: {
title: "Core Web Vitals Monitoring - Real User Performance Analytics",
description:
- "Monitor LCP, CLS, INP, FCP, and TTFB from real users in production. Percentile breakdowns by page, device, and country. Field data, not lab simulations. Built into your analytics.",
+ "Monitor LCP, CLS, INP, FCP, and TTFB from real users in production. Percentile breakdowns by page, browser, and country. Field data, not lab simulations. Built into your analytics.",
url: "https://www.databuddy.cc/web-vitals",
images: ["/og-image.png"],
},
@@ -47,7 +48,7 @@ const FAQ_ITEMS = [
{
question: "How does Databuddy measure INP?",
answer:
- "Interaction to Next Paint is captured using the PerformanceObserver API, the same method used by Chrome. It measures responsiveness for all interactions - clicks, taps, and keyboard input.",
+ "Interaction to Next Paint is captured with Google's own web-vitals library, the same measurement Chrome uses. It covers all interactions - clicks, taps, and keyboard input.",
},
{
question: "Can I see which specific pages are failing?",
@@ -57,7 +58,7 @@ const FAQ_ITEMS = [
{
question: "Is web vitals monitoring included in all plans?",
answer:
- "Web vitals are collected automatically on every plan - there's nothing to turn on. The free plan includes 7 days of history; paid plans extend that to 30 or 90 days.",
+ "Yes, web vitals are included on every plan. Enable them with a single prop on the tracker (trackWebVitals) and data starts flowing from your next visitor.",
},
] as const;
@@ -75,35 +76,35 @@ export default function WebVitalsPage() {
page={{
title: "Core Web Vitals Monitoring - Real User Performance Analytics",
description:
- "Monitor LCP, CLS, INP, FCP, and TTFB from real users in production. Percentile breakdowns by page, device, and country.",
+ "Monitor LCP, CLS, INP, FCP, and TTFB from real users in production. Percentile breakdowns by page, browser, and country.",
url: "https://www.databuddy.cc/web-vitals",
}}
/>
- Every vital, measured from real users.
+ Re-measured on every SPA route change.
- Find out which environments are making users wait.
+ See which browsers are making users wait.
@@ -129,15 +130,15 @@ export default function WebVitalsPage() {
- Know the moment something breaks.
+ Get a finding when a route regresses.
@@ -157,6 +158,12 @@ export default function WebVitalsPage() {
+
+
>
diff --git a/apps/docs/app/api/pricing/accept-markdown.ts b/apps/docs/app/api/pricing/accept-markdown.ts
index d2015462a7..eca367b501 100644
--- a/apps/docs/app/api/pricing/accept-markdown.ts
+++ b/apps/docs/app/api/pricing/accept-markdown.ts
@@ -1,4 +1,3 @@
-/** True when `text/markdown` beats `text/html` on the Accept header. */
export function acceptMarkdownOverHtml(accept: string): boolean {
const q = new Map();
for (const part of accept.split(",")) {
diff --git a/apps/docs/app/api/pricing/build-response.ts b/apps/docs/app/api/pricing/build-response.ts
index d3a69aed02..b3f9e9120b 100644
--- a/apps/docs/app/api/pricing/build-response.ts
+++ b/apps/docs/app/api/pricing/build-response.ts
@@ -119,5 +119,3 @@ export function buildPricingApiPayload(request: Request) {
currency: "USD" as const,
};
}
-
-export type PricingApiPayload = ReturnType;
diff --git a/apps/docs/app/demo/page.tsx b/apps/docs/app/demo/page.tsx
index 78186d2005..08c5d11cfa 100644
--- a/apps/docs/app/demo/page.tsx
+++ b/apps/docs/app/demo/page.tsx
@@ -56,14 +56,14 @@ export default async function DemoPage() {
- Live AI analytics demo
+ Live analytics demo
- Ask why your metrics changed
+ Explore a real Databuddy dashboard
- Explore real traffic, funnels, errors, vitals, and feature flags.
- Databuddy turns the dashboard into answers your team can act on.
+ Real traffic from a live site: audience, events, funnels, goals,
+ errors, Web Vitals, and a world map of your visitors.
diff --git a/apps/docs/app/llms-full.txt/route.ts b/apps/docs/app/llms-full.txt/route.ts
index 948eefeaaf..31e65c37b7 100644
--- a/apps/docs/app/llms-full.txt/route.ts
+++ b/apps/docs/app/llms-full.txt/route.ts
@@ -10,7 +10,10 @@ export const revalidate = false;
const HEADER = `# Databuddy Documentation (Full)
> Lightweight web analytics with an asynchronous tracker, GDPR compliant, no cookies required.
-> This file contains the complete documentation corpus for long-context agents.
+> This file contains the highest-priority documentation sections for long-context agents,
+> truncated at a size limit. For the rest, use the scoped indexes at
+> https://www.databuddy.cc/docs/llms.txt, https://www.databuddy.cc/api/llms.txt, and
+> https://www.databuddy.cc/developers/llms.txt.
`;
const MAX_LLMS_FULL_CHARS = 190_000;
@@ -21,7 +24,6 @@ const SECTION_ORDER = [
"api",
"Integrations",
"hooks",
- "features",
"performance",
"privacy",
"compliance",
@@ -32,7 +34,6 @@ const SECTION_LABELS: Record
= {
api: "API Reference",
Integrations: "Integrations",
hooks: "React Hooks",
- features: "Features",
performance: "Performance",
privacy: "Privacy",
compliance: "Compliance",
diff --git a/apps/docs/app/llms.txt/route.ts b/apps/docs/app/llms.txt/route.ts
index 348e8b2abb..e87b1d6895 100644
--- a/apps/docs/app/llms.txt/route.ts
+++ b/apps/docs/app/llms.txt/route.ts
@@ -22,17 +22,16 @@ const SECTION_ORDER = [
"api",
"Integrations",
"hooks",
- "features",
"performance",
"privacy",
"compliance",
];
const SECTION_LABELS: Record = {
root: "Core",
+ sdk: "SDK",
api: "API Reference",
Integrations: "Integrations",
hooks: "React Hooks",
- features: "Features",
performance: "Performance",
privacy: "Privacy",
compliance: "Compliance",
diff --git a/apps/docs/app/robots.txt/route.ts b/apps/docs/app/robots.txt/route.ts
index c020346a12..a7ae2176c0 100644
--- a/apps/docs/app/robots.txt/route.ts
+++ b/apps/docs/app/robots.txt/route.ts
@@ -2,6 +2,8 @@ import { SITE_URL } from "@/app/util/constants";
export const revalidate = 3600;
+const CRAWL_ALLOW = ["/api/llms.txt", "/api/pricing"];
+
const CRAWL_DISALLOW = [
"/api/",
"/_next/",
@@ -26,6 +28,7 @@ function allowBlock(userAgent: string) {
return [
`User-agent: ${userAgent}`,
"Allow: /",
+ ...CRAWL_ALLOW.map((path) => `Allow: ${path}`),
...CRAWL_DISALLOW.map((path) => `Disallow: ${path}`),
].join("\n");
}
diff --git a/apps/docs/app/skill.md/route.ts b/apps/docs/app/skill.md/route.ts
index 868f040534..fd4646c378 100644
--- a/apps/docs/app/skill.md/route.ts
+++ b/apps/docs/app/skill.md/route.ts
@@ -3,10 +3,10 @@ export const revalidate = false;
const SKILL = `---
name: databuddy
description: Privacy-first analytics SDK. Browser tracking, server-side events, feature flags, and REST API.
-version: 2.4.0
+version: 2.6.0
---
-# Databuddy SDK (v2.4)
+# Databuddy SDK (v2.6)
Privacy-first analytics SDK. Covers browser tracking, server-side events, feature flags, and a REST API.
@@ -131,13 +131,24 @@ Requires \`trackAttributes: true\`:
|--------|------|---------|-------------|
| \`clientId\` | \`string\` | Auto-detect | Project client ID |
| \`disabled\` | \`boolean\` | \`false\` | Disable all tracking |
-| \`trackWebVitals\` | \`boolean\` | \`false\` | Core Web Vitals (LCP, INP, CLS) |
+| \`anonymizeVisitorIds\` | \`boolean \\| "auto"\` | \`true\` | Anonymize visitor IDs before storage |
+| \`apiUrl\` | \`string\` | \`https://basket.databuddy.cc\` | Event ingestion endpoint |
+| \`scriptUrl\` | \`string\` | \`https://cdn.databuddy.cc/databuddy.js\` | Custom browser bundle URL |
+| \`trackWebVitals\` | \`boolean\` | \`false\` | Core Web Vitals (FCP, LCP, INP, CLS, TTFB) plus FPS |
| \`trackErrors\` | \`boolean\` | \`false\` | JavaScript error tracking |
| \`trackInteractions\` | \`boolean\` | \`false\` | User interactions |
| \`trackOutgoingLinks\` | \`boolean\` | \`false\` | External link clicks |
| \`trackHashChanges\` | \`boolean\` | \`false\` | URL hash changes |
| \`trackAttributes\` | \`boolean\` | \`false\` | \`data-*\` attributes |
| \`enableBatching\` | \`boolean\` | \`true\` | Batch events before sending |
+| \`batchSize\` | \`number\` | \`10\` | Events per batch (max 50) |
+| \`batchTimeout\` | \`number\` | \`5000\` | Batch flush interval in ms |
+| \`enableRetries\` | \`boolean\` | \`true\` | Retry failed requests |
+| \`maxRetries\` | \`number\` | \`3\` | Max retries for failed requests |
+| \`initialRetryDelay\` | \`number\` | \`500\` | Initial retry delay in ms |
+| \`ignoreBotDetection\` | \`boolean\` | \`false\` | Track bots when true |
+| \`usePixel\` | \`boolean\` | \`false\` | Use 1x1 pixel image instead of script |
+| \`filter\` | \`(event) => boolean\` | -- | Return \`false\` to skip an event |
| \`samplingRate\` | \`number\` | \`1.0\` | Sampling rate (0.0-1.0) |
| \`skipPatterns\` | \`string[]\` | -- | Glob patterns to skip tracking |
| \`maskPatterns\` | \`string[]\` | -- | Glob patterns to mask paths |
@@ -188,7 +199,7 @@ curl -X POST https://basket.databuddy.cc/track \\
-d '{"name": "purchase", "properties": {"amount": 99.99}}'
\`\`\`
-Accepts single event or array (max 100). Max payload: 1MB single, 5MB batch.
+Accepts single event or array (max 100). Max payload: 1MB (2MB request body hard cap); batch arrays up to 100 events.
### Feature Flags
@@ -226,7 +237,9 @@ Accepts single event or array (max 100). Max payload: 1MB single, 5MB batch.
## Supported Frameworks
-React, Next.js, Vue, Svelte, SvelteKit, Angular, Shopify, WordPress, Webflow, Wix, Squarespace, Hugo, Jekyll, Framer, Bubble, Mintlify, Laravel, Google Tag Manager.
+React, Next.js, Vue, Nuxt, Svelte, SvelteKit, Angular, Shopify, WordPress, Webflow, Wix, Squarespace, Hugo, Jekyll, Framer, Bubble, Mintlify, Laravel, Google Tag Manager.
+
+Dedicated integration guides also cover Cal.com bookings and payments (Stripe and Paddle).
`;
export async function GET() {
diff --git a/apps/docs/app/util/constants.ts b/apps/docs/app/util/constants.ts
index 0ac2c60d8e..6b22f4d11e 100644
--- a/apps/docs/app/util/constants.ts
+++ b/apps/docs/app/util/constants.ts
@@ -7,6 +7,3 @@ export const OPENAPI_SPEC_URL = `${SITE_URL}/openapi.json`;
export const API_OPENAPI_SPEC_URL = `${API_URL}/openapi.json`;
export const MCP_SERVER_URL = `${API_URL}/v1/mcp/`;
export const MCP_MANIFEST_URL = `${SITE_URL}/.well-known/mcp.json`;
-
-export const CHATGPT_PROMPT_URL =
- "https://chatgpt.com/?hints=search&prompt=Read+these+3+pages%3A%0A%0A-+https%3A%2F%2Fwww.databuddy.cc%0A-+https%3A%2F%2Fwww.databuddy.cc%2Fdocs%0A-+https%3A%2F%2Fwww.databuddy.cc%2Fpricing%0A%0AThen+explain+in+simple+terms+what+this+app+does%2C+and+why+i+should+care";
diff --git a/apps/docs/components/bento.tsx b/apps/docs/components/bento.tsx
index c84dbe2f9e..921284fca4 100644
--- a/apps/docs/components/bento.tsx
+++ b/apps/docs/components/bento.tsx
@@ -476,10 +476,10 @@ const ErrorTrackingFeature = () => (
const FeatureFlagsFeature = () => {
const flags = [
- { name: "New Dashboard", enabled: true, env: "prod" },
- { name: "AI Assistant", enabled: false, env: "beta" },
- { name: "Dark Mode V2", enabled: true, env: "dev" },
- { name: "Analytics V3", enabled: true, env: "prod" },
+ { name: "New Dashboard", enabled: true, env: "100% rollout" },
+ { name: "AI Assistant", enabled: false, env: "off" },
+ { name: "Dark Mode V2", enabled: true, env: "25% rollout" },
+ { name: "Analytics V3", enabled: true, env: "3 variants" },
];
return (
diff --git a/apps/docs/components/bits/liquid.tsx b/apps/docs/components/bits/liquid.tsx
deleted file mode 100644
index 13ade1826a..0000000000
--- a/apps/docs/components/bits/liquid.tsx
+++ /dev/null
@@ -1,199 +0,0 @@
-import { Mesh, Program, Renderer, Triangle } from "ogl";
-import type React from "react";
-import { useEffect, useRef } from "react";
-
-interface LiquidChromeProps extends React.HTMLAttributes {
- /** Amplitude of the distortion. Default is 0.6. */
- amplitude?: number;
- /** Base color as an RGB array. Default is [0.1, 0.1, 0.1]. */
- baseColor?: [number, number, number];
- /** Frequency modifier for the x distortion. Default is 2.5. */
- frequencyX?: number;
- /** Frequency modifier for the y distortion. Default is 1.5. */
- frequencyY?: number;
- /** Enable mouse/touch interaction. Default is true. */
- interactive?: boolean;
- /** Animation speed multiplier. Default is 1.0. */
- speed?: number;
-}
-
-export const LiquidChrome: React.FC = ({
- baseColor = [0.1, 0.1, 0.1],
- speed = 0.2,
- amplitude = 0.5,
- frequencyX = 3,
- frequencyY = 2,
- interactive = true,
- ...props
-}) => {
- const containerRef = useRef(null);
-
- useEffect(() => {
- if (!containerRef.current) {
- return;
- }
-
- const container = containerRef.current;
- // Enable built-in antialiasing.
- const renderer = new Renderer({ antialias: true });
- const gl = renderer.gl;
- gl.clearColor(1, 1, 1, 1);
-
- // Vertex shader: passes along position and uv.
- const vertexShader = `
- attribute vec2 position;
- attribute vec2 uv;
- varying vec2 vUv;
- void main() {
- vUv = uv;
- gl_Position = vec4(position, 0.0, 1.0);
- }
- `;
-
- // Fragment shader with the original vibrant color calculation.
- const fragmentShader = `
- precision highp float;
- uniform float uTime;
- uniform vec3 uResolution;
- uniform vec3 uBaseColor;
- uniform float uAmplitude;
- uniform float uFrequencyX;
- uniform float uFrequencyY;
- uniform vec2 uMouse;
- varying vec2 vUv;
-
- // Render function for a given uv coordinate.
- vec4 renderImage(vec2 uvCoord) {
- // Convert uvCoord (in [0,1]) to a fragment coordinate.
- vec2 fragCoord = uvCoord * uResolution.xy;
- // Map fragCoord to a normalized space.
- vec2 uv = (2.0 * fragCoord - uResolution.xy) / min(uResolution.x, uResolution.y);
-
- // Iterative cosine-based distortions.
- for (float i = 1.0; i < 10.0; i++){
- uv.x += uAmplitude / i * cos(i * uFrequencyX * uv.y + uTime + uMouse.x * 3.14159);
- uv.y += uAmplitude / i * cos(i * uFrequencyY * uv.x + uTime + uMouse.y * 3.14159);
- }
-
- // Add a liquid ripple effect based on the mouse position.
- vec2 diff = (uvCoord - uMouse);
- float dist = length(diff);
- float falloff = exp(-dist * 20.0);
- float ripple = sin(10.0 * dist - uTime * 2.0) * 0.03;
- uv += (diff / (dist + 0.0001)) * ripple * falloff;
-
- // Original vibrant color computation.
- vec3 color = uBaseColor / abs(sin(uTime - uv.y - uv.x));
- return vec4(color, 1.0);
- }
-
- void main() {
- // 3x3 supersampling for anti-aliasing.
- vec4 col = vec4(0.0);
- int samples = 0;
- for (int i = -1; i <= 1; i++){
- for (int j = -1; j <= 1; j++){
- vec2 offset = vec2(float(i), float(j)) * (1.0 / min(uResolution.x, uResolution.y));
- col += renderImage(vUv + offset);
- samples++;
- }
- }
- gl_FragColor = col / float(samples);
- }
- `;
-
- // Create geometry and program with uniforms.
- const geometry = new Triangle(gl);
- const program = new Program(gl, {
- vertex: vertexShader,
- fragment: fragmentShader,
- uniforms: {
- uTime: { value: 0 },
- uResolution: {
- value: new Float32Array([
- gl.canvas.width,
- gl.canvas.height,
- gl.canvas.width / gl.canvas.height,
- ]),
- },
- uBaseColor: { value: new Float32Array(baseColor) },
- uAmplitude: { value: amplitude },
- uFrequencyX: { value: frequencyX },
- uFrequencyY: { value: frequencyY },
- uMouse: { value: new Float32Array([0, 0]) },
- },
- });
- const mesh = new Mesh(gl, { geometry, program });
-
- // Resize handler.
- function resize() {
- const scale = 1;
- renderer.setSize(
- container.offsetWidth * scale,
- container.offsetHeight * scale
- );
- const resUniform = program.uniforms.uResolution.value as Float32Array;
- resUniform[0] = gl.canvas.width;
- resUniform[1] = gl.canvas.height;
- resUniform[2] = gl.canvas.width / gl.canvas.height;
- }
- window.addEventListener("resize", resize);
- resize();
-
- // Mouse and touch move handlers for interactivity.
- function handleMouseMove(event: MouseEvent) {
- const rect = container.getBoundingClientRect();
- const x = (event.clientX - rect.left) / rect.width;
- const y = 1 - (event.clientY - rect.top) / rect.height;
- const mouseUniform = program.uniforms.uMouse.value as Float32Array;
- mouseUniform[0] = x;
- mouseUniform[1] = y;
- }
-
- function handleTouchMove(event: TouchEvent) {
- if (event.touches.length > 0) {
- const touch = event.touches[0];
- const rect = container.getBoundingClientRect();
- const x = (touch.clientX - rect.left) / rect.width;
- const y = 1 - (touch.clientY - rect.top) / rect.height;
- const mouseUniform = program.uniforms.uMouse.value as Float32Array;
- mouseUniform[0] = x;
- mouseUniform[1] = y;
- }
- }
-
- if (interactive) {
- container.addEventListener("mousemove", handleMouseMove);
- container.addEventListener("touchmove", handleTouchMove, {
- passive: true,
- });
- }
-
- // Animation loop.
- let animationId: number;
- function update(t: number) {
- animationId = requestAnimationFrame(update);
- // Multiply time by speed to adjust the animation rate.
- program.uniforms.uTime.value = t * 0.001 * speed;
- renderer.render({ scene: mesh });
- }
- animationId = requestAnimationFrame(update);
-
- container.appendChild(gl.canvas);
-
- return () => {
- cancelAnimationFrame(animationId);
- window.removeEventListener("resize", resize);
- if (interactive) {
- container.removeEventListener("mousemove", handleMouseMove);
- container.removeEventListener("touchmove", handleTouchMove);
- }
- if (gl.canvas.parentElement) {
- gl.canvas.parentElement.removeChild(gl.canvas);
- }
- gl.getExtension("WEBGL_lose_context")?.loseContext();
- };
- }, [baseColor, speed, amplitude, frequencyX, frequencyY, interactive]);
-
- return
;
-};
diff --git a/apps/docs/components/bits/squares.tsx b/apps/docs/components/bits/squares.tsx
deleted file mode 100644
index 4d661d3ea6..0000000000
--- a/apps/docs/components/bits/squares.tsx
+++ /dev/null
@@ -1,197 +0,0 @@
-import type React from "react";
-import { useEffect, useRef } from "react";
-
-type CanvasStrokeStyle = string | CanvasGradient | CanvasPattern;
-
-interface GridOffset {
- x: number;
- y: number;
-}
-
-interface SquaresProps {
- borderColor?: CanvasStrokeStyle;
- direction?: "diagonal" | "up" | "right" | "down" | "left";
- hoverFillColor?: CanvasStrokeStyle;
- speed?: number;
- squareSize?: number;
-}
-
-const Squares: React.FC = ({
- direction = "right",
- speed = 1,
- borderColor = "#999",
- squareSize = 40,
- hoverFillColor = "#222",
-}) => {
- const canvasRef = useRef(null);
- const requestRef = useRef(null);
- const numSquaresX = useRef(0);
- const numSquaresY = useRef(0);
- const gridOffset = useRef({ x: 0, y: 0 });
- const hoveredSquareRef = useRef(null);
-
- useEffect(() => {
- const canvas = canvasRef.current;
- if (!canvas) {
- return;
- }
- const ctx = canvas.getContext("2d");
-
- const resizeCanvas = () => {
- canvas.width = canvas.offsetWidth;
- canvas.height = canvas.offsetHeight;
- numSquaresX.current = Math.ceil(canvas.width / squareSize) + 1;
- numSquaresY.current = Math.ceil(canvas.height / squareSize) + 1;
- };
-
- const drawSquare = (
- x: number,
- y: number,
- squareX: number,
- squareY: number
- ) => {
- if (!ctx) {
- return;
- }
-
- const startX = Math.floor(gridOffset.current.x / squareSize) * squareSize;
- const startY = Math.floor(gridOffset.current.y / squareSize) * squareSize;
-
- const isHovered =
- hoveredSquareRef.current &&
- Math.floor((x - startX) / squareSize) === hoveredSquareRef.current.x &&
- Math.floor((y - startY) / squareSize) === hoveredSquareRef.current.y;
-
- if (isHovered) {
- ctx.fillStyle = hoverFillColor;
- ctx.fillRect(squareX, squareY, squareSize, squareSize);
- }
-
- ctx.strokeStyle = borderColor;
- ctx.strokeRect(squareX, squareY, squareSize, squareSize);
- };
-
- const drawGradient = () => {
- if (!ctx) {
- return;
- }
-
- const gradient = ctx.createRadialGradient(
- canvas.width / 2,
- canvas.height / 2,
- 0,
- canvas.width / 2,
- canvas.height / 2,
- Math.sqrt(canvas.width ** 2 + canvas.height ** 2) / 2
- );
- gradient.addColorStop(0, "rgba(0, 0, 0, 0)");
- gradient.addColorStop(1, "#060606");
- gradient.addColorStop(0.5, "rgba(0, 0, 0, 0.5)");
-
- ctx.fillStyle = gradient;
- ctx.fillRect(0, 0, canvas.width, canvas.height);
- };
-
- const drawGrid = () => {
- if (!ctx) {
- return;
- }
-
- ctx.clearRect(0, 0, canvas.width, canvas.height);
-
- const startX = Math.floor(gridOffset.current.x / squareSize) * squareSize;
- const startY = Math.floor(gridOffset.current.y / squareSize) * squareSize;
-
- for (let x = startX; x < canvas.width + squareSize; x += squareSize) {
- for (let y = startY; y < canvas.height + squareSize; y += squareSize) {
- const squareX = x - (gridOffset.current.x % squareSize);
- const squareY = y - (gridOffset.current.y % squareSize);
- drawSquare(x, y, squareX, squareY);
- }
- }
-
- drawGradient();
- };
-
- const updateAnimation = () => {
- const effectiveSpeed = Math.max(speed, 0.1);
- switch (direction) {
- case "right":
- gridOffset.current.x =
- (gridOffset.current.x - effectiveSpeed + squareSize) % squareSize;
- break;
- case "left":
- gridOffset.current.x =
- (gridOffset.current.x + effectiveSpeed + squareSize) % squareSize;
- break;
- case "up":
- gridOffset.current.y =
- (gridOffset.current.y + effectiveSpeed + squareSize) % squareSize;
- break;
- case "down":
- gridOffset.current.y =
- (gridOffset.current.y - effectiveSpeed + squareSize) % squareSize;
- break;
- case "diagonal":
- gridOffset.current.x =
- (gridOffset.current.x - effectiveSpeed + squareSize) % squareSize;
- gridOffset.current.y =
- (gridOffset.current.y - effectiveSpeed + squareSize) % squareSize;
- break;
- default:
- break;
- }
-
- drawGrid();
- requestRef.current = requestAnimationFrame(updateAnimation);
- };
-
- const handleMouseMove = (event: MouseEvent) => {
- const rect = canvas.getBoundingClientRect();
- const mouseX = event.clientX - rect.left;
- const mouseY = event.clientY - rect.top;
-
- const startX = Math.floor(gridOffset.current.x / squareSize) * squareSize;
- const startY = Math.floor(gridOffset.current.y / squareSize) * squareSize;
-
- const hoveredSquareX = Math.floor(
- (mouseX + gridOffset.current.x - startX) / squareSize
- );
- const hoveredSquareY = Math.floor(
- (mouseY + gridOffset.current.y - startY) / squareSize
- );
-
- if (
- !hoveredSquareRef.current ||
- hoveredSquareRef.current.x !== hoveredSquareX ||
- hoveredSquareRef.current.y !== hoveredSquareY
- ) {
- hoveredSquareRef.current = { x: hoveredSquareX, y: hoveredSquareY };
- }
- };
-
- const handleMouseLeave = () => {
- hoveredSquareRef.current = null;
- };
-
- window.addEventListener("resize", resizeCanvas);
- resizeCanvas();
-
- canvas.addEventListener("mousemove", handleMouseMove);
- canvas.addEventListener("mouseleave", handleMouseLeave);
- requestRef.current = requestAnimationFrame(updateAnimation);
-
- return () => {
- window.removeEventListener("resize", resizeCanvas);
- if (requestRef.current) {
- cancelAnimationFrame(requestRef.current);
- }
- canvas.removeEventListener("mousemove", handleMouseMove);
- canvas.removeEventListener("mouseleave", handleMouseLeave);
- };
- }, [direction, speed, borderColor, hoverFillColor, squareSize]);
-
- return ;
-};
-
-export default Squares;
diff --git a/apps/docs/components/compare/amplitude-mark-icon.tsx b/apps/docs/components/compare/amplitude-mark-icon.tsx
index 67a8c66fbd..a61120b2cd 100644
--- a/apps/docs/components/compare/amplitude-mark-icon.tsx
+++ b/apps/docs/components/compare/amplitude-mark-icon.tsx
@@ -1,5 +1,3 @@
-/** Amplitude mark vector (Iconify `logos:amplitude-icon`), fill uses `currentColor`. */
-
const AMPLITUDE_LOGO_PATH_D =
"M128 0c70.683 0 128 57.317 128 128s-57.317 128-128 128S0 198.734 0 128S57.317 0 128 0m-16.912 39.12c-15.782.051-30.073 25.445-42.359 75.412c-8.687-.103-16.655-.257-24.109-.36h-1.13c-.926-.052-1.851 0-2.777.103c-4.215.77-7.248 4.472-7.248 8.739c0 4.37 3.239 8.122 7.557 8.79l.102.103H64.72a578 578 0 0 0-5.706 29.404l-.72 4.164v.205a5.84 5.84 0 0 0 2.724 4.935c2.725 1.748 6.375.926 8.123-1.799l.154.154l11.566-37.063h55.724c4.266 16.141 8.687 32.745 14.548 48.373c3.135 8.379 10.435 27.913 22.67 28.016h.154c18.917 0 26.32-30.587 31.203-50.84c1.08-4.37 1.953-8.123 2.827-10.899l.36-1.13l.052-.167c.4-1.445-.416-2.988-1.851-3.483c-1.491-.514-3.187.257-3.701 1.799l-.412 1.13c-1.593 4.473-3.084 8.637-4.42 12.39l-.103.308c-8.225 23.184-11.926 33.774-19.277 33.774h-.463c-9.407 0-18.198-38.143-21.54-52.486c-.565-2.467-1.079-4.78-1.593-6.785h60.659c1.08 0 2.159-.257 3.136-.771l.047-.039a2 2 0 0 1 .21-.115l.308-.206l.154-.103c.155-.103.309-.206.463-.36l.227-.188c1.114-.969 1.903-2.321 2.24-3.719c.772-3.65-1.644-7.248-5.294-7.967h-.309c-.36-.052-.668-.103-1.028-.103l-.925-.103c-21.436-1.542-43.49-2.16-64.206-2.57l-.051-.155c-10.024-37.783-22.62-76.388-39.582-76.388m-.669 17.015c.874 0 1.697.514 2.416 1.44c1.748 2.775 4.832 8.995 9.408 22.772c3.135 9.459 6.528 21.23 10.126 34.904c-13.673-.205-27.45-.36-40.816-.514l-6.785-.051c7.66-29.918 16.964-52.588 23.8-57.934c.566-.36 1.183-.617 1.851-.617";
diff --git a/apps/docs/components/compare/comparison-page-view.tsx b/apps/docs/components/compare/comparison-page-view.tsx
index acbbf49a4a..73def7a772 100644
--- a/apps/docs/components/compare/comparison-page-view.tsx
+++ b/apps/docs/components/compare/comparison-page-view.tsx
@@ -16,7 +16,7 @@ import type {
PricingTier,
} from "@/lib/comparison-config";
-export type ComparisonPageType = "compare" | "switch_from" | "alternatives";
+type ComparisonPageType = "compare" | "switch_from" | "alternatives";
interface ComparisonPageViewProps {
competitor: CompetitorInfo;
diff --git a/apps/docs/components/docs-navbar.tsx b/apps/docs/components/docs-navbar.tsx
deleted file mode 100644
index 47469c2f93..0000000000
--- a/apps/docs/components/docs-navbar.tsx
+++ /dev/null
@@ -1,264 +0,0 @@
-"use client";
-
-import { Branding } from "@/components/logo";
-import { cn } from "@/lib/utils";
-import { CaretDownIcon } from "@databuddy/ui/icons";
-import { AnimatePresence, motion } from "motion/react";
-import Link from "next/link";
-import { useState } from "react";
-import { navMenu } from "./navbar";
-import { NavbarGithubDesktopLink } from "./navbar-github-desktop-link";
-import { NavbarGithubMobileLink } from "./navbar-github-mobile-link";
-import { NavbarMobileMenuButton } from "./navbar-mobile-menu-button";
-import { contents, type SidebarItem } from "./sidebar-content";
-
-export interface DocsNavbarProps {
- stars?: number | null;
-}
-
-export const DocsNavbar = ({ stars }: DocsNavbarProps) => {
- const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
- const [openSection, setOpenSection] = useState(0);
-
- const docsSecondaryNav = navMenu.filter((menu) => menu.name !== "Docs");
-
- const toggleSection = (index: number) => {
- setOpenSection(openSection === index ? -1 : index);
- };
-
- const githubDelayMs = (contents.length * 5 + docsSecondaryNav.length) * 30;
-
- return (
-
-
-
-
-
-
-
-
-
-
- {navMenu.map((menu) => (
-
- {menu.name}
-
- ))}
-
-
-
-
-
setIsMobileMenuOpen((open) => !open)}
- />
-
-
-
-
-
-
-
-
- {contents.map((section, sectionIndex) => (
-
-
toggleSection(sectionIndex)}
- type="button"
- >
-
-
-
- {section.title}
-
-
-
-
-
-
-
- {openSection === sectionIndex && (
-
-
- {section.list.map((item, itemIndex) => (
-
- setIsMobileMenuOpen(false)
- }
- transitionDelayMs={
- (sectionIndex * section.list.length +
- itemIndex) *
- 30
- }
- />
- ))}
-
-
- )}
-
-
- ))}
-
-
-
-
-
- {docsSecondaryNav.map((menu, index) => (
- setIsMobileMenuOpen(false)}
- style={{
- transitionDelay: isMobileMenuOpen
- ? `${(contents.length * 5 + index) * 30}ms`
- : "0ms",
- }}
- >
- {menu.name}
-
- ))}
- setIsMobileMenuOpen(false)}
- stars={stars}
- transitionDelayMs={githubDelayMs}
- />
-
-
-
-
-
- );
-};
-
-function MobileSidebarItem({
- isMobileMenuOpen,
- item,
- level = 0,
- onNavigateAction,
- transitionDelayMs,
-}: {
- isMobileMenuOpen: boolean;
- item: SidebarItem;
- level?: number;
- onNavigateAction: () => void;
- transitionDelayMs: number;
-}) {
- if (item.group) {
- return (
-
- );
- }
-
- if (item.children) {
- return (
- 0 && "ml-2")}>
-
0 && "py-1.5 text-xs"
- )}
- >
- {item.icon ? (
-
- ) : null}
- {item.title}
- {item.isNew ? : null}
-
-
- {item.children.map((child, childIndex) => (
-
- ))}
-
-
- );
- }
-
- const isExternal = item.href?.startsWith("http");
-
- return (
- 0 && "py-1.5 text-xs",
- isMobileMenuOpen
- ? "translate-x-0 opacity-100"
- : "-translate-x-4 opacity-0"
- )}
- href={item.href || "#"}
- onClick={onNavigateAction}
- rel={isExternal ? "noopener noreferrer" : undefined}
- style={{
- transitionDelay: isMobileMenuOpen ? `${transitionDelayMs}ms` : "0ms",
- }}
- target={isExternal ? "_blank" : undefined}
- >
-
- {item.icon ? (
-
- ) : null}
- {item.title}
- {item.isNew ? : null}
-
-
- );
-}
-
-function MobileNewBadge() {
- return (
-
- New
-
- );
-}
diff --git a/apps/docs/components/docs/accordion.tsx b/apps/docs/components/docs/accordion.tsx
index 433ecde8ff..2799bb969a 100644
--- a/apps/docs/components/docs/accordion.tsx
+++ b/apps/docs/components/docs/accordion.tsx
@@ -2,7 +2,6 @@
import type * as React from "react";
import { cn } from "@databuddy/ui";
-import { Accordion as DSAccordion } from "@databuddy/ui/client";
import { docsSurface } from "@/components/docs/docs-styles";
function Accordion({
@@ -17,58 +16,6 @@ function Accordion({
);
}
-function AccordionItem({
- className,
- children,
- ...props
-}: React.ComponentProps<"div"> & { value: string }) {
- return (
-
- {children}
-
- );
-}
-
-function AccordionTrigger({
- className,
- children,
- ...props
-}: React.ComponentProps<"button">) {
- return (
-
- {children}
-
- );
-}
-
-function AccordionContent({
- className,
- children,
-}: React.ComponentProps<"div">) {
- return (
- *:first-child]:mt-0 [&>*:last-child]:mb-0",
- className
- )}
- >
- {children}
-
- );
-}
-
interface AccordionsProps extends React.ComponentProps<"div"> {
collapsible?: boolean;
type?: "single" | "multiple";
@@ -82,10 +29,4 @@ function Accordions({ className, children, ...props }: AccordionsProps) {
);
}
-export {
- Accordion,
- AccordionContent,
- AccordionItem,
- AccordionTrigger,
- Accordions,
-};
+export { Accordion, Accordions };
diff --git a/apps/docs/components/docs/index.ts b/apps/docs/components/docs/index.ts
index c22e09fa29..8891b77cc0 100644
--- a/apps/docs/components/docs/index.ts
+++ b/apps/docs/components/docs/index.ts
@@ -1,13 +1,10 @@
export {
Accordion,
- AccordionContent,
- AccordionItem,
Accordions,
- AccordionTrigger,
} from "./accordion";
export { Callout } from "./callout";
export { Card, Cards } from "./card";
-export { CodeBlock, InlineCode, PreWrapper } from "./code-block";
+export { CodeBlock } from "./code-block";
export { LeapComponent } from "./leap-component";
-export { CompletedStep, Step, Steps } from "./steps";
-export { Tab, Tabs, TabsContent, TabsList, TabsTrigger } from "./tabs";
+export { Step, Steps } from "./steps";
+export { Tab, Tabs } from "./tabs";
diff --git a/apps/docs/components/docs/leap-component.tsx b/apps/docs/components/docs/leap-component.tsx
index c3dd0e9f99..2397b1e60d 100644
--- a/apps/docs/components/docs/leap-component.tsx
+++ b/apps/docs/components/docs/leap-component.tsx
@@ -79,5 +79,3 @@ export const LeapComponent = () => {
);
};
-
-export default LeapComponent;
diff --git a/apps/docs/components/docs/steps.tsx b/apps/docs/components/docs/steps.tsx
index 945cd36ce9..7398e40ba2 100644
--- a/apps/docs/components/docs/steps.tsx
+++ b/apps/docs/components/docs/steps.tsx
@@ -1,4 +1,3 @@
-import { CheckIcon } from "@databuddy/ui/icons";
import { cn } from "@databuddy/ui";
import React from "react";
@@ -67,43 +66,4 @@ function Step({
);
}
-interface CompletedStepProps extends Omit {
- stepNumber?: number;
-}
-
-function CompletedStep({
- className,
- title,
- stepNumber,
- total,
- children,
- ...props
-}: CompletedStepProps) {
- const isLast = stepNumber === total;
-
- return (
-
-
-
-
-
-
- {title && (
-
{title}
- )}
-
- {children}
-
-
-
- );
-}
-
-export { CompletedStep, Step, Steps };
+export { Step, Steps };
diff --git a/apps/docs/components/docs/tabs.tsx b/apps/docs/components/docs/tabs.tsx
index df11cd9d5a..b563463ecc 100644
--- a/apps/docs/components/docs/tabs.tsx
+++ b/apps/docs/components/docs/tabs.tsx
@@ -112,4 +112,4 @@ function Tab({ children }: TabProps) {
return <>{children}>;
}
-export { Tab, Tabs, TabsContent, TabsList, TabsTrigger };
+export { Tab, Tabs };
diff --git a/apps/docs/components/features.tsx b/apps/docs/components/features.tsx
deleted file mode 100644
index cc48796b45..0000000000
--- a/apps/docs/components/features.tsx
+++ /dev/null
@@ -1,240 +0,0 @@
-"use client";
-
-// Credits to better-auth for the inspiration
-
-import {
- TriangleWarningIcon as AlertTriangle,
- ChartBarIcon as BarChart3,
- CodeIcon as Code,
- GlobeIcon as Globe2Icon,
- PackageIcon as Package,
- PlusIcon as Plus,
- ShieldCheckIcon as Shield,
- TrendUpIcon as TrendingUp,
- UsersIcon as Users,
-} from "@databuddy/ui/icons";
-
-import { cn } from "@/lib/utils";
-import Testimonials from "./landing/testimonials";
-
-const whyWeExist = [
- {
- id: 1,
- label: "Bloated and creepy",
- title:
- "Most analytics tools are either bloated and creepy (hi Google)",
- description:
- "Google Analytics tracks everything, slows down your site, and requires cookie banners that hurt conversion rates.",
- icon: AlertTriangle,
- },
- {
- id: 2,
- label: "Minimal but useless",
- title: "Or minimal but useless (hi SimpleAnalytics)",
- description:
- "Simple tools give you basic pageviews but lack the depth developers need to make informed decisions about their products.",
- icon: BarChart3,
- },
- {
- id: 3,
- label: "Complex product analytics",
- title:
- 'Or "product analytics" platforms that need a data team to set up (hi PostHog)',
- description:
- "Enterprise tools are powerful but require dedicated data engineers and complex setup processes that small teams can't handle.",
- icon: Users,
- },
-];
-
-const whatYouGet = [
- {
- id: 4,
- label: "Privacy-First Approach",
- title:
- "Build trust & reduce legal risk with built-in GDPR/CCPA compliance .",
- description:
- "No cookies required, complete data anonymization, and full GDPR/CCPA compliance out of the box. Build user trust while staying compliant.",
- icon: Shield,
- },
- {
- id: 5,
- label: "Real-time Analytics",
- title:
- "Make data-driven decisions instantly with live dashboards .",
- description:
- "See your data update in real-time with beautiful dashboards. No data sampling means 100% accurate data for confident decision making.",
- icon: TrendingUp,
- },
- {
- id: 6,
- label: "Data Ownership",
- title: "Full control of your valuable business data .",
- description:
- "Your data stays yours. Export raw data, integrate with existing tools, and maintain complete control over your analytics.",
- icon: Users,
- },
- {
- id: 7,
- label: "Energy Efficient",
- title:
- "Up to 10x more eco-friendly with lower carbon footprint .",
- description:
- "Reduce your environmental impact with our energy-efficient analytics platform while maintaining powerful insights.",
- icon: Globe2Icon,
- },
- {
- id: 8,
- label: "100% Transparency",
- title: "Fully transparent, no hidden fees or data games .",
- description:
- "Clear pricing, open about what data we collect, and honest about our limitations. No vendor lock-in, export your data anytime, and only pay for what you actually use.",
- icon: Code,
- },
- {
- id: 9,
- label: "Lightweight",
- title:
- "Lightweight, no cookies, no fingerprinting, no consent needed .",
- description:
- "Databuddy is lightweight, no cookies, no fingerprinting, no consent needed. It's GDPR compliant out of the box.",
- icon: Code,
- },
-];
-
-export default function Features() {
- return (
-
-
- {/* Why We Exist Section */}
-
-
-
-
-
-
- Most analytics tools are either:
-
-
-
-
-
-
-
-
- {whyWeExist.map((item) => (
-
-
-
-
-
- {item.description}
-
-
-
- ))}
-
-
- {/* What You Get Section */}
-
-
-
-
-
-
- Everything you need to understand your users:
-
-
-
-
-
-
-
-
- {whatYouGet.map((item) => (
-
-
-
-
-
- {item.description}
-
-
-
- ))}
-
-
- {/* For Who Section */}
-
-
-
-
-
-
- If you're a developer, indie hacker, or small team who wants
- to:
-
-
-
-
- • Stop blindly shipping features
- • Stay GDPR-compliant without paying a lawyer
- • Avoid tracking your users like it's 2010
-
-
- Then Databuddy is for you.
-
-
-
-
-
-
-
-
-
- );
-}
diff --git a/apps/docs/components/github-nav-mark.tsx b/apps/docs/components/github-nav-mark.tsx
index cdb1742870..7491182f61 100644
--- a/apps/docs/components/github-nav-mark.tsx
+++ b/apps/docs/components/github-nav-mark.tsx
@@ -21,15 +21,3 @@ export function GithubNavMark({ className }: GithubNavMarkProps) {
);
}
-
-export interface GithubStarsBadgeProps {
- stars: number;
-}
-
-export function GithubStarsBadge({ stars }: GithubStarsBadgeProps) {
- return (
-
- {stars.toLocaleString()} ★
-
- );
-}
diff --git a/apps/docs/components/landing/ai-section.tsx b/apps/docs/components/landing/ai-section.tsx
new file mode 100644
index 0000000000..85ecf89c0f
--- /dev/null
+++ b/apps/docs/components/landing/ai-section.tsx
@@ -0,0 +1,416 @@
+"use client";
+
+import {
+ ArrowRightIcon,
+ CheckCircleIcon,
+ CheckIcon,
+ PlugIcon,
+ RobotIcon,
+} from "@databuddy/ui/icons";
+import Link from "next/link";
+import { useEffect, useState } from "react";
+import { SectionBullet } from "../icons/section-bullet";
+import { EASE } from "./demo-constants";
+import { CardChrome, useRevealOnScroll } from "./demo-primitives";
+import { SciFiButton } from "./scifi-btn";
+import { cn } from "@/lib/utils";
+
+const MCP_CLIENTS = [
+ "Claude Code",
+ "Claude Desktop",
+ "Cursor",
+ "Windsurf",
+ "VS Code",
+ "Zed",
+] as const;
+
+function revealStyle(visible: boolean, delayMs: number) {
+ return {
+ transitionDelay: visible ? `${delayMs}ms` : "0ms",
+ transitionTimingFunction: EASE,
+ };
+}
+
+function InvestigationSlackDemo() {
+ const { ref, visible } = useRevealOnScroll();
+ const reveal = cn(
+ "transition-all duration-500",
+ visible ? "translate-y-0 opacity-100" : "translate-y-3 opacity-0"
+ );
+
+ return (
+
+
+
+
+ #eng-alerts
+
+
+ Slack
+
+
+
+
+
+
+
+
+
+ Databuddy
+
+
+ App
+
+
+ 9:12 AM
+
+
+
+
+ Action
+
+
+ Checkout funnel · Mar 3 to Mar 9
+
+
+ Open
+
+
+
+ Checkout exceptions triple after Tuesday's deploy
+
+
+ Step-two errors rose 2.8x, concentrated on iOS Safari.
+
+
+
+ Impact: 847 sessions
+ hit the error; exposed sessions continued 19 points less often
+ than matched controls.
+
+
+ Next: Roll back
+ address autocomplete, then verify step-two completion recovers.
+
+
+
+
+
+
+ alex · Rolled back in
+ v2.14.1
+
+
+
+
+ Resolved · Verified on
+ recheck: step-two completion is back at baseline.
+
+
+
+
+
+
+ );
+}
+
+const TERMINAL_SCENARIOS = [
+ {
+ question: "How did the launch land? Set up tracking for the new checkout.",
+ calls: [
+ { tool: "get_data", detail: "traffic and conversions · launch week" },
+ { tool: "create_funnel", detail: "/checkout → /pay → purchase" },
+ { tool: "create_goal", detail: "checkout_completed" },
+ ],
+ answer:
+ "Launch week traffic is up 64% and signup conversion held at 4.1%. I created the checkout funnel and a checkout_completed goal; both are live in your dashboard.",
+ },
+ {
+ question: "Anything I should know before we ship today?",
+ calls: [
+ {
+ tool: "get_investigation",
+ detail: "checkout errors +180% · opened 2h ago",
+ },
+ { tool: "get_data", detail: "errors by device · past 24h" },
+ { tool: "reply_to_investigation", detail: "rolled back in v2.14.1" },
+ ],
+ answer:
+ "Yes. Databunny opened a case two hours ago: checkout exceptions are up 2.8x since yesterday's deploy, concentrated on iOS Safari. I rolled back address autocomplete and replied to the case.",
+ },
+] as const;
+
+const TYPE_CHARS_PER_TICK = 2;
+const TYPE_TICK_MS = 24;
+const HOLD_MS = 5200;
+const FADE_MS = 350;
+
+function useTypewriter(text: string, active: boolean) {
+ const [typed, setTyped] = useState(0);
+
+ useEffect(() => {
+ setTyped(0);
+ }, [text]);
+
+ useEffect(() => {
+ if (!active) {
+ return;
+ }
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
+ setTyped(text.length);
+ return;
+ }
+ const id = window.setInterval(() => {
+ setTyped((count) => {
+ if (count >= text.length) {
+ window.clearInterval(id);
+ return count;
+ }
+ return count + TYPE_CHARS_PER_TICK;
+ });
+ }, TYPE_TICK_MS);
+ return () => window.clearInterval(id);
+ }, [active, text.length]);
+
+ return { text: text.slice(0, typed), done: typed >= text.length };
+}
+
+function useOnScreen(ref: React.RefObject) {
+ const [onScreen, setOnScreen] = useState(false);
+
+ useEffect(() => {
+ const el = ref.current;
+ if (!el) {
+ return;
+ }
+ const observer = new IntersectionObserver(
+ (entries) => setOnScreen(entries[0]?.isIntersecting ?? false),
+ { threshold: 0.2 }
+ );
+ observer.observe(el);
+ return () => observer.disconnect();
+ }, [ref]);
+
+ return onScreen;
+}
+
+function McpTerminalDemo() {
+ const { ref, visible } = useRevealOnScroll();
+ const onScreen = useOnScreen(ref);
+ const [scenarioIndex, setScenarioIndex] = useState(0);
+ const [fading, setFading] = useState(false);
+ const scenario = TERMINAL_SCENARIOS[scenarioIndex];
+ const question = useTypewriter(scenario.question, visible && onScreen);
+ const showing = visible && question.done && !fading;
+
+ useEffect(() => {
+ if (!(question.done && onScreen) || fading) {
+ return;
+ }
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
+ return;
+ }
+ const id = window.setTimeout(() => setFading(true), HOLD_MS);
+ return () => window.clearTimeout(id);
+ }, [question.done, onScreen, fading]);
+
+ useEffect(() => {
+ if (!fading) {
+ return;
+ }
+ const id = window.setTimeout(() => {
+ setScenarioIndex((index) => (index + 1) % TERMINAL_SCENARIOS.length);
+ setFading(false);
+ }, FADE_MS);
+ return () => window.clearTimeout(id);
+ }, [fading]);
+
+ return (
+
+
+
+
+
+
+
+
+
+ your agent
+
+
+
+ databuddy · scoped key
+
+
+
+
+ ›
+ {question.text}
+
+
+ {scenario.calls.map((call, i) => (
+
+
+
+ {call.tool}
+
+
+ {call.detail}
+
+
+
+ ))}
+
+ {scenario.answer}
+
+
+
+
+ );
+}
+
+export function AiSection() {
+ return (
+
+
+
+
+
+
+
+ Finds problems before you ask.
+
+
+
+ Databunny investigates your traffic, errors, funnels, and vitals on
+ its own, and only interrupts you when there is a decision to make.
+ Your own agents get the same access over MCP.
+
+
+
+
+
+
+
+ Proactive
+
+
+ Databunny investigates on its own
+
+
+ When errors spike or a funnel leaks, Databunny builds the case: what
+ happened, why it matters, what to do next. Findings land in Slack,
+ replies continue the investigation, and it rechecks until the fix is
+ verified.
+
+
+
+
+
+
+
+
+ MCP
+
+
+ Analytics run by your agent
+
+
+ Connect Claude Code, Cursor, or any MCP client with a scoped key.
+ Your agent can query live traffic, triage errors, create goals,
+ funnels, and flags, and pick up any investigation Databunny opened.
+ It gets exactly the permissions you grant.
+
+
+
+ {MCP_CLIENTS.map((client) => (
+
+ {client}
+
+ ))}
+
+
+
+ Set up MCP
+
+
+ API reference
+
+
+
+
+
+
+ );
+}
diff --git a/apps/docs/components/landing/databunny-demo-visuals.tsx b/apps/docs/components/landing/databunny-demo-visuals.tsx
index 8c66c35a74..59467c0a97 100644
--- a/apps/docs/components/landing/databunny-demo-visuals.tsx
+++ b/apps/docs/components/landing/databunny-demo-visuals.tsx
@@ -301,27 +301,30 @@ export function InsightCardsDemo() {
const PROACTIVE_ALERTS = [
{
icon: TriangleWarningIcon,
- title: "Anomaly detected: /api/auth error spike",
- description: "Error rate jumped from 0.2% to 4.1% in the last 30 minutes",
- time: "2m ago",
+ title: "Action: /api/auth errors spike after deploy",
+ description:
+ "Exceptions rose 4.2x versus baseline. Next: roll back the session refactor, then verify recovery",
+ time: "Today",
tone: "danger" as const,
channel: "Slack #alerts",
},
{
icon: LightbulbIcon,
- title: "Weekly investigation: your best-performing page",
- description: "/blog/launch-post drove 42% of new signups this week",
- time: "6h ago",
+ title: "Question: did the pricing experiment ship?",
+ description:
+ "Signup conversion moved with no matching deploy or annotation. One answer unblocks the case",
+ time: "Today",
tone: "info" as const,
channel: "Slack #insights",
},
{
icon: TrendUpIcon,
- title: "Goal reached: 1,000 daily active users",
- description: "You hit your DAU target for the first time today",
- time: "1d ago",
+ title: "Resolved: signup funnel recovered",
+ description:
+ "Verified on recheck: completion is back at baseline after the copy fix",
+ time: "Yesterday",
tone: "success" as const,
- channel: "Slack #growth",
+ channel: "Slack #alerts",
},
] as const;
@@ -439,7 +442,7 @@ const ANOMALY_ITEMS: AnomalyItem[] = [
current: "847",
baseline: "92",
change: "+820%",
- period: "Apr 28, 2:00 – 3:30 PM",
+ period: "Apr 28 vs weekday baseline",
},
{
metric: "Pageviews",
@@ -450,7 +453,7 @@ const ANOMALY_ITEMS: AnomalyItem[] = [
current: "1,204",
baseline: "4,820",
change: "-75%",
- period: "Apr 27, 8:00 – 11:00 PM",
+ period: "Apr 27 vs prior Sundays",
},
{
metric: "Custom events",
@@ -461,7 +464,7 @@ const ANOMALY_ITEMS: AnomalyItem[] = [
current: "3,412",
baseline: "890",
change: "+283%",
- period: "Apr 27, 1:00 – 4:00 PM",
+ period: "Apr 27 vs prior week",
},
];
@@ -559,40 +562,73 @@ export function AnomalyDetectionDemo() {
);
}
-export function NarrativeSummaryDemo() {
+const CASE_TIMELINE = [
+ {
+ label: "Opened",
+ time: "Mon",
+ text: "Checkout exceptions rose 2.8x after Tuesday's deploy, concentrated on iOS Safari.",
+ tone: "danger" as const,
+ },
+ {
+ label: "Reply",
+ time: "Mon",
+ text: "alex: Rolled back address autocomplete in v2.14.1.",
+ tone: "info" as const,
+ },
+ {
+ label: "Verified",
+ time: "Tue",
+ text: "Recheck passed: step-two completion is back at baseline. Case resolved.",
+ tone: "success" as const,
+ },
+] as const;
+
+const CASE_TONE = {
+ danger: "bg-red-500/15 text-red-400",
+ info: "bg-blue-500/15 text-blue-400",
+ success: "bg-emerald-500/15 text-emerald-400",
+} as const;
+
+export function CaseFollowUpDemo() {
const { ref, visible } = useRevealOnScroll();
return (
-
-
-
-
-
-
-
-
- This week across your sites
-
-
- Updated 2h ago
-
+
+ {CASE_TIMELINE.map((step, i) => (
+
+
+
+
+ {step.label}
+
+
+ {step.text}
+
+
+ {step.time}
+
+
-
- Traffic is up 23% week-over-week, led by organic search. Your
- /pricing page is converting 2.4x better than last month after the
- copy change. Error rates are stable except for a brief spike on
- /checkout Tuesday afternoon (resolved). Mobile bounce rate is
- trending down for the third week.
-
-
-
-
+
+ ))}
+
);
}
diff --git a/apps/docs/components/landing/demo-primitives.tsx b/apps/docs/components/landing/demo-primitives.tsx
index 64f0d05df2..82d4b2c916 100644
--- a/apps/docs/components/landing/demo-primitives.tsx
+++ b/apps/docs/components/landing/demo-primitives.tsx
@@ -8,7 +8,6 @@ import { Button } from "@databuddy/ui";
import { cn } from "@/lib/utils";
export {
- CELL_TITLE_CLASS,
EASE,
TH,
TH_RIGHT,
@@ -131,6 +130,7 @@ export function FeatureHero({
primaryHref = "https://app.databuddy.cc/register",
docsHref = "/docs",
badge,
+ footnote = "Free up to 10,000 events/mo. No credit card required.",
}: {
title: string;
subtitle: string;
@@ -138,6 +138,7 @@ export function FeatureHero({
primaryHref?: string;
docsHref?: string;
badge?: ReactNode;
+ footnote?: string | null;
}) {
return (
@@ -158,6 +159,9 @@ export function FeatureHero({
Read Docs
+ {footnote ? (
+ {footnote}
+ ) : null}
diff --git a/apps/docs/components/landing/error-auto-capture-alerts-stack-demo.tsx b/apps/docs/components/landing/error-auto-capture-alerts-stack-demo.tsx
index 68ef69e734..7af321b6d2 100644
--- a/apps/docs/components/landing/error-auto-capture-alerts-stack-demo.tsx
+++ b/apps/docs/components/landing/error-auto-capture-alerts-stack-demo.tsx
@@ -36,8 +36,8 @@ const COMPACT_ROWS: AlertCardRow[] = [
tone: "warning",
},
{
- id: "console",
- title: "console.error: API timeout",
+ id: "custom",
+ title: "trackError: payment declined",
meta: "8m ago",
tone: "success",
},
diff --git a/apps/docs/components/landing/error-frequency-chart-demo.tsx b/apps/docs/components/landing/error-frequency-chart-demo.tsx
index d5341a432d..aaf9d29a36 100644
--- a/apps/docs/components/landing/error-frequency-chart-demo.tsx
+++ b/apps/docs/components/landing/error-frequency-chart-demo.tsx
@@ -4,7 +4,6 @@ import { useId } from "react";
import { EASE, useRevealOnScroll } from "@/components/landing/demo-primitives";
const DEPLOY_X = 390;
-// Orange line — total occurrences. Peaks early, stays elevated, drops sharply after deploy.
const ORANGE_LINE =
"M 0 44" +
" C 28 37,52 35,75 38" +
@@ -18,7 +17,6 @@ const ORANGE_LINE =
" C 504 124,530 122,556 124" +
" C 570 125,580 127,600 128";
-// Red line — affected users. Always below orange. Near-zero after deploy.
const RED_LINE =
"M 0 76" +
" C 28 69,52 67,75 69" +
@@ -151,7 +149,7 @@ export function ErrorFrequencyChartDemo() {
x={DEPLOY_X}
y={12}
>
- v1.2.5 deployed
+ fix shipped
@@ -177,7 +175,7 @@ export function ErrorFrequencyChartDemo() {
- −98% after deploy
+ −98% after the fix
diff --git a/apps/docs/components/landing/error-per-page-breakdown-demo.tsx b/apps/docs/components/landing/error-per-page-breakdown-demo.tsx
index 4ddcf2741c..bcf76b83b1 100644
--- a/apps/docs/components/landing/error-per-page-breakdown-demo.tsx
+++ b/apps/docs/components/landing/error-per-page-breakdown-demo.tsx
@@ -3,33 +3,11 @@ import { cn } from "@/lib/utils";
type StatusTone = "critical" | "warning" | "good";
-const STATUS_STYLES: Record<
- StatusTone,
- { badge: string; text: string; border: string }
-> = {
- critical: {
- badge: "bg-red-500/10",
- text: "text-red-300",
- border: "border-red-500/35",
- },
- warning: {
- badge: "bg-amber-500/10",
- text: "text-amber-300",
- border: "border-amber-500/35",
- },
- good: {
- badge: "bg-emerald-500/10",
- text: "text-emerald-300",
- border: "border-emerald-500/35",
- },
-};
-
const ROWS: {
page: string;
errors: string;
users: string;
rate: string;
- status: string;
tone: StatusTone;
highlight?: boolean;
}[] = [
@@ -37,8 +15,7 @@ const ROWS: {
page: "/checkout",
errors: "2,431",
users: "159",
- rate: "18.4%",
- status: "Critical",
+ rate: "15.3",
tone: "critical",
highlight: true,
},
@@ -46,24 +23,21 @@ const ROWS: {
page: "/pricing",
errors: "412",
users: "69",
- rate: "4.1%",
- status: "Investigate",
+ rate: "6.0",
tone: "warning",
},
{
page: "/login",
errors: "188",
users: "47",
- rate: "2.6%",
- status: "Monitor",
+ rate: "4.0",
tone: "warning",
},
{
page: "/blog",
errors: "73",
users: "29",
- rate: "0.8%",
- status: "OK",
+ rate: "2.5",
tone: "good",
},
] as const;
@@ -96,63 +70,45 @@ export function ErrorPerPageBreakdownDemo() {
Users
- Rate
-
-
- Status
+ Per user
- {ROWS.map((row) => {
- const styles = STATUS_STYLES[row.tone];
- return (
- (
+
+
+ {row.page}
+
+
+ {row.errors}
+
+
+ {row.users}
+
+
-
- {row.page}
-
-
- {row.errors}
-
-
- {row.users}
-
-
- {row.rate}
-
-
-
- {row.status}
-
-
-
- );
- })}
+ {row.rate}
+
+
+ ))}
diff --git a/apps/docs/components/landing/error-who-it-affects-artifacts.tsx b/apps/docs/components/landing/error-who-it-affects-artifacts.tsx
index 4aff134d9c..076691f188 100644
--- a/apps/docs/components/landing/error-who-it-affects-artifacts.tsx
+++ b/apps/docs/components/landing/error-who-it-affects-artifacts.tsx
@@ -108,47 +108,3 @@ export function ErrorImpactTableArtifact() {
);
}
-
-const RELEASES: { version: string; status: string; tone: "good" | "bad" }[] = [
- { version: "v1.2.3", status: "stable", tone: "good" },
- { version: "v1.2.4", status: "14 new errors", tone: "bad" },
- { version: "v1.2.5", status: "resolved", tone: "good" },
-];
-
-export function ReleaseTimelineArtifact() {
- return (
-
-
- {RELEASES.map((rel, index) => (
-
- {index < RELEASES.length - 1 ? (
-
- ) : null}
-
-
-
- {rel.version}
- {rel.tone === "bad" ? (
- {rel.status}
- ) : (
-
- {rel.status}
-
- )}
-
-
-
- ))}
-
-
- );
-}
diff --git a/apps/docs/components/landing/faq-section.tsx b/apps/docs/components/landing/faq-section.tsx
index 0747ecc3fe..b7735d8dc3 100644
--- a/apps/docs/components/landing/faq-section.tsx
+++ b/apps/docs/components/landing/faq-section.tsx
@@ -9,7 +9,7 @@ import {
import { cn } from "@/lib/utils";
import { SectionBullet } from "../icons/section-bullet";
-export interface FaqItem {
+interface FaqItem {
answer: string;
question: string;
}
diff --git a/apps/docs/components/landing/ff-ab-testing-demo.tsx b/apps/docs/components/landing/ff-ab-testing-demo.tsx
index 6a210d17c2..6eb6e271e1 100644
--- a/apps/docs/components/landing/ff-ab-testing-demo.tsx
+++ b/apps/docs/components/landing/ff-ab-testing-demo.tsx
@@ -47,7 +47,7 @@ export function FFAbTestingDemo() {
multivariant
- prod
+ active
diff --git a/apps/docs/components/landing/ff-compact-flags-dashboard-demo.tsx b/apps/docs/components/landing/ff-compact-flags-dashboard-demo.tsx
index eb78cf6b7d..face4a0278 100644
--- a/apps/docs/components/landing/ff-compact-flags-dashboard-demo.tsx
+++ b/apps/docs/components/landing/ff-compact-flags-dashboard-demo.tsx
@@ -42,7 +42,7 @@ export function FFCompactFlagsDashboardDemo() {
- Production
+ 4 live
diff --git a/apps/docs/components/landing/ff-user-targeting-demo.tsx b/apps/docs/components/landing/ff-user-targeting-demo.tsx
index 9dff23ac62..ac9b3a6b12 100644
--- a/apps/docs/components/landing/ff-user-targeting-demo.tsx
+++ b/apps/docs/components/landing/ff-user-targeting-demo.tsx
@@ -5,7 +5,7 @@ import { EASE, useRevealOnScroll } from "@/components/landing/demo-primitives";
const RULES = [
{ property: "plan", operator: "is", value: "pro" },
{ property: "region", operator: "is one of", value: "US, EU" },
- { property: "signup_date", operator: "after", value: "2025-01-01" },
+ { property: "email", operator: "ends with", value: "@acme.com" },
] as const;
export function FFUserTargetingDemo() {
@@ -57,7 +57,7 @@ export function FFUserTargetingDemo() {
: "0ms",
}}
>
- AND
+ OR
) : null}
diff --git a/apps/docs/components/landing/grid-cards.tsx b/apps/docs/components/landing/grid-cards.tsx
index 00bb68bafc..8510579b85 100644
--- a/apps/docs/components/landing/grid-cards.tsx
+++ b/apps/docs/components/landing/grid-cards.tsx
@@ -28,7 +28,7 @@ const cards = [
},
{
id: 3,
- title: "About 10 KB gzip",
+ title: "About 12 KB gzip",
description:
"Your analytics script shouldn't slow your site. Ours is lighter than a single hero image.",
icon: LightningIcon,
diff --git a/apps/docs/components/landing/hero.tsx b/apps/docs/components/landing/hero.tsx
index e73ecc2b24..7110b98768 100644
--- a/apps/docs/components/landing/hero.tsx
+++ b/apps/docs/components/landing/hero.tsx
@@ -11,6 +11,9 @@ import {
GaugeIcon,
LightbulbFilamentIcon,
LightningIcon,
+ LockSimpleIcon,
+ ShieldCheckIcon,
+ StarIcon,
TrendUpIcon,
} from "@databuddy/ui/icons";
import { motion } from "motion/react";
@@ -150,6 +153,12 @@ const tabLabels = new Map(
tabs.map((tab) => [tab.id, tab.label])
);
+const proofChips = [
+ { icon: LightningIcon, label: "~12 KB script" },
+ { icon: LockSimpleIcon, label: "Cookieless, no banners" },
+ { icon: ShieldCheckIcon, label: "GDPR by default" },
+] as const;
+
type FullscreenElement = HTMLIFrameElement & {
webkitRequestFullscreen?: () => Promise;
mozRequestFullScreen?: () => Promise;
@@ -299,8 +308,10 @@ function HeroInsightOverlay({ activeTab }: { activeTab: HeroTabId }) {
export default function Hero({
demoEmbedBaseUrl,
+ stars,
}: {
demoEmbedBaseUrl: string;
+ stars?: number | null;
}) {
const [activeTab, setActiveTab] = useState(tabs[0].id);
const [loadedTabIds, setLoadedTabIds] = useState>(
@@ -370,6 +381,46 @@ export default function Hero({
and a next step attached.
+
+ {proofChips.map((chip, i) => (
+
+
+ {chip.label}
+
+ ))}
+
+
+ Open source
+ {typeof stars === "number" && (
+
+ · {stars.toLocaleString()}
+
+ )}
+
+
+
-
-
- Result
+
+
+
+ Short link
+
+
+ dby.sh/launch
+
-
- dby.sh/pricing
?{params}
+
+
+ Destination
+
+
+ yourapp.com/pricing
+ ?{params}
+
diff --git a/apps/docs/components/landing/pricing-preview.tsx b/apps/docs/components/landing/pricing-preview.tsx
new file mode 100644
index 0000000000..d827841a2f
--- /dev/null
+++ b/apps/docs/components/landing/pricing-preview.tsx
@@ -0,0 +1,47 @@
+"use client";
+
+import { ArrowRightIcon } from "@databuddy/ui/icons";
+import Link from "next/link";
+import { Estimator } from "@/app/(home)/pricing/_pricing/estimator";
+import { normalizePlans } from "@/app/(home)/pricing/_pricing/normalize";
+import type { NormalizedPlan } from "@/app/(home)/pricing/_pricing/types";
+import { RAW_PLANS } from "@/app/(home)/pricing/data";
+import { SectionBullet } from "../icons/section-bullet";
+
+const PLANS: NormalizedPlan[] = normalizePlans(RAW_PLANS);
+
+export function PricingPreview() {
+ return (
+
+
+
+
+
+
+
+ Know your bill before you sign up.
+
+
+
+ Every feature on every plan. Slide to your event volume and see the
+ number, no sales call and no feature gates.
+
+
+
+
+
+
+
+ Free up to 10,000 events/mo. No credit card required.
+
+
+ See the full plan comparison
+
+
+
+
+ );
+}
diff --git a/apps/docs/components/landing/trusted-by.tsx b/apps/docs/components/landing/trusted-by.tsx
index 330ee66251..2b8cdbc942 100644
--- a/apps/docs/components/landing/trusted-by.tsx
+++ b/apps/docs/components/landing/trusted-by.tsx
@@ -222,5 +222,3 @@ export function TrustedBy() {
);
}
-
-export default TrustedBy;
diff --git a/apps/docs/components/landing/uptime-landing-visuals.tsx b/apps/docs/components/landing/uptime-landing-visuals.tsx
index 40bfc5b3d1..9371272ad4 100644
--- a/apps/docs/components/landing/uptime-landing-visuals.tsx
+++ b/apps/docs/components/landing/uptime-landing-visuals.tsx
@@ -6,10 +6,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { feature } from "topojson-client";
import type { GeometryCollection, Topology } from "topojson-specification";
import worldTopo from "world-atlas/countries-110m.json";
-
-// ---------------------------------------------------------------------------
// UptimeRegionsHubDiagram — animated D3 canvas world map with zone dithering
-// ---------------------------------------------------------------------------
const ZONE_DEFS = [
{ ids: new Set([840, 124, 484, 304]) },
{ ids: new Set([826, 372, 250, 724, 620, 380, 56, 528, 300, 196]) },
@@ -224,10 +221,7 @@ export function UptimeRegionsHubDiagram() {
);
}
-
-// ---------------------------------------------------------------------------
// UptimeAlertsStackVisual — focus-aware carousel
-// ---------------------------------------------------------------------------
const ROW_H = 62;
const GAP = 8;
const STRIDE = ROW_H + GAP;
@@ -240,51 +234,36 @@ const ALERT_ITEMS = [
text: "api.acme.com returned 503",
time: "14:32 UTC",
},
- {
- kind: "warn" as const,
- text: "Response time > 2s on /api/checkout",
- time: "14:28 UTC",
- },
{
kind: "up" as const,
- text: "Uptime restored — 3 min total",
+ text: "api.acme.com recovered, down 3 min",
time: "14:35 UTC",
},
{
kind: "down" as const,
- text: "cdn.acme.com returned 503",
+ text: "cdn.acme.com timed out",
time: "09:14 UTC",
},
- {
- kind: "warn" as const,
- text: "Error rate spike on /api/auth",
- time: "11:47 UTC",
- },
{
kind: "up" as const,
- text: "cdn.acme.com — back online",
+ text: "cdn.acme.com back online",
time: "09:22 UTC",
},
+ {
+ kind: "warn" as const,
+ text: "Health check failed: status=degraded",
+ time: "11:47 UTC",
+ },
{
kind: "down" as const,
text: "app.acme.com returned 503",
time: "03:58 UTC",
},
- {
- kind: "warn" as const,
- text: "P95 latency > 3s on /checkout",
- time: "16:01 UTC",
- },
{
kind: "up" as const,
- text: "app.acme.com — back online",
+ text: "app.acme.com back online",
time: "04:06 UTC",
},
- {
- kind: "warn" as const,
- text: "SSL cert expires in 7 days",
- time: "08:00 UTC",
- },
];
const DOT: Record<"down" | "warn" | "up", string> = {
@@ -443,10 +422,7 @@ export function UptimeAlertsStackVisual() {
);
}
-
-// ---------------------------------------------------------------------------
// UptimeStatusPageMiniVisual
-// ---------------------------------------------------------------------------
const DEMO_MONITORS = [
{
name: "Dashboard",
@@ -556,10 +532,7 @@ export function UptimeStatusPageMiniVisual() {
);
}
-
-// ---------------------------------------------------------------------------
// UptimeIncidentTimelineVisual
-// ---------------------------------------------------------------------------
const TIMELINE = [
{ time: "14:32", label: "Downtime detected", tone: "red" as const },
{ time: "14:33", label: "Team alerted via Slack", tone: "amber" as const },
diff --git a/apps/docs/components/landing/web-vitals-alert-cycle-demo.tsx b/apps/docs/components/landing/web-vitals-alert-cycle-demo.tsx
index 4aca9177ef..9aff7ee0cf 100644
--- a/apps/docs/components/landing/web-vitals-alert-cycle-demo.tsx
+++ b/apps/docs/components/landing/web-vitals-alert-cycle-demo.tsx
@@ -4,7 +4,7 @@ import {
RightFade,
} from "@/components/landing/demo-primitives";
-const TAGS = ["/checkout", "Mobile Chrome", "EU"] as const;
+const TAGS = ["/checkout", "p75", "vs last week"] as const;
export function WebVitalsAlertCycleDemo() {
return (
@@ -25,11 +25,11 @@ export function WebVitalsAlertCycleDemo() {
LCP regression
- just now
+ Today
- exceeded 2.5s threshold on /checkout
+ p75 rose past the 2.5s threshold on /checkout
{TAGS.map((tag) => (
@@ -52,10 +52,10 @@ export function WebVitalsAlertCycleDemo() {
className="size-2 shrink-0 rounded-full bg-amber-500 shadow-[0_0_10px_rgba(245,158,11,0.55)]"
/>
- TTFB exceeding 800ms
+ Poor LCP persisting on /docs
- 18m ago
+ Today
@@ -67,10 +67,10 @@ export function WebVitalsAlertCycleDemo() {
className="size-2 shrink-0 rounded-full bg-amber-500 shadow-[0_0_10px_rgba(245,158,11,0.55)]"
/>
- INP exceeding 200ms
+ INP up 42% on /signup
- 18m ago
+ Yesterday
diff --git a/apps/docs/components/landing/web-vitals-breakdown-demo.tsx b/apps/docs/components/landing/web-vitals-breakdown-demo.tsx
index 8fb6aa64f1..08ee52f2d7 100644
--- a/apps/docs/components/landing/web-vitals-breakdown-demo.tsx
+++ b/apps/docs/components/landing/web-vitals-breakdown-demo.tsx
@@ -438,8 +438,6 @@ const breakdownShellClass =
const breakdownMaskClass =
"pointer-events-none absolute inset-0 z-0 rounded border border-border/50 [-webkit-mask-image:linear-gradient(to_bottom,black_0%,black_60%,transparent_100%)] [mask-image:linear-gradient(to_bottom,black_0%,black_60%,transparent_100%)]";
-
-/** Short bottom fade — matches browser + page breakdown + percentile demos. */
const breakdownFadeClass =
"pointer-events-none absolute inset-x-0 bottom-0 z-10 h-24 bg-linear-to-t from-background/100 via-background/50 to-transparent";
diff --git a/apps/docs/components/landing/web-vitals-graphs-demo.tsx b/apps/docs/components/landing/web-vitals-graphs-demo.tsx
index 62e2f8eb02..ac1b0af9dd 100644
--- a/apps/docs/components/landing/web-vitals-graphs-demo.tsx
+++ b/apps/docs/components/landing/web-vitals-graphs-demo.tsx
@@ -9,8 +9,6 @@ const STATUS_STROKE = {
"needs-improvement": "stroke-amber-400",
poor: "stroke-red-400",
} as const;
-
-/** Ring fill (0–100) and display values aligned with the marketing gauge reference. */
const GAUGES = [
{
label: "LCP",
diff --git a/apps/docs/components/logo.tsx b/apps/docs/components/logo.tsx
index 6be6c13cbd..c3ef51f07b 100644
--- a/apps/docs/components/logo.tsx
+++ b/apps/docs/components/logo.tsx
@@ -1,9 +1,6 @@
import Link from "next/link";
import { Branding } from "./logo/branding";
-export type { BrandingProps, BrandVariant } from "./logo/branding";
-export { Branding } from "./logo/branding";
-
export function LogoContent() {
return (
diff --git a/apps/docs/components/logo/branding.tsx b/apps/docs/components/logo/branding.tsx
index ebfb16ce83..348c4f2c8e 100644
--- a/apps/docs/components/logo/branding.tsx
+++ b/apps/docs/components/logo/branding.tsx
@@ -1,20 +1,14 @@
import Image from "next/image";
import { cn } from "@/lib/utils";
-export type BrandVariant =
- | "logomark"
- | "wordmark"
- | "primary-logo"
- | "secondary-logo";
+type BrandVariant = "logomark" | "wordmark" | "primary-logo" | "secondary-logo";
export interface BrandingProps {
className?: string;
- /** Height of the primary asset in pixels (width follows the SVG viewBox aspect ratio). */
heightPx?: number;
imageClassName?: string;
priority?: boolean;
variant: BrandVariant;
- /** When `variant` is `logomark`, also show the wordmark asset beside the icon. */
wordmark?: boolean;
}
@@ -33,7 +27,6 @@ const BRAND_PATH: Record
= {
};
interface ThemeBrandImageProps {
- /** Primary image alt; the dark-mode twin is decorative. */
alt: string;
basePath: string;
className?: string;
diff --git a/apps/docs/components/nav-link.tsx b/apps/docs/components/nav-link.tsx
index 3369375076..417251a85e 100644
--- a/apps/docs/components/nav-link.tsx
+++ b/apps/docs/components/nav-link.tsx
@@ -1,7 +1,7 @@
import Link from "next/link";
import type { CSSProperties, ReactNode } from "react";
-export type NavSection =
+type NavSection =
| "navbar"
| "navbar_mobile"
| "navbar_features"
diff --git a/apps/docs/components/navbar-github-desktop-link.tsx b/apps/docs/components/navbar-github-desktop-link.tsx
deleted file mode 100644
index 20631a3e66..0000000000
--- a/apps/docs/components/navbar-github-desktop-link.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import { Button } from "@databuddy/ui";
-import {
- GithubNavMark,
- GithubStarsBadge,
- githubRepoUrl,
-} from "./github-nav-mark";
-
-interface NavbarGithubDesktopLinkProps {
- stars?: number | null;
-}
-
-export function NavbarGithubDesktopLink({
- stars,
-}: NavbarGithubDesktopLinkProps) {
- return (
-
-
-
- {typeof stars === "number" && }
-
-
- );
-}
diff --git a/apps/docs/components/navbar-github-mobile-link.tsx b/apps/docs/components/navbar-github-mobile-link.tsx
deleted file mode 100644
index dabbdad537..0000000000
--- a/apps/docs/components/navbar-github-mobile-link.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-import Link from "next/link";
-import { docsNavMobileItem } from "@/components/docs-nav-styles";
-import { cn } from "@/lib/utils";
-import {
- GithubNavMark,
- GithubStarsBadge,
- githubRepoUrl,
-} from "./github-nav-mark";
-
-interface NavbarGithubMobileLinkProps {
- density?: "default" | "compact";
- isMenuOpen: boolean;
- onCloseAction: () => void;
- stars?: number | null;
- transitionDelayMs: number;
-}
-
-export function NavbarGithubMobileLink({
- stars,
- isMenuOpen,
- transitionDelayMs,
- onCloseAction,
- density = "default",
-}: NavbarGithubMobileLinkProps) {
- const densityClass =
- density === "compact" ? "px-3 py-2 text-sm" : "px-4 py-3 text-base";
-
- return (
-
-
-
- GitHub
- {typeof stars === "number" && }
-
-
- );
-}
diff --git a/apps/docs/components/navbar.tsx b/apps/docs/components/navbar.tsx
index f148dc96e4..3eafb5c7d2 100644
--- a/apps/docs/components/navbar.tsx
+++ b/apps/docs/components/navbar.tsx
@@ -191,15 +191,13 @@ export const Navbar = ({ stars, variant = "default" }: NavbarProps) => {
);
};
-export { iconBtn as navIconBtn };
-
-export interface NavMenuItem {
+interface NavMenuItem {
name: string;
path: string;
trackId: string;
}
-export const navMenu: NavMenuItem[] = [
+const navMenu: NavMenuItem[] = [
{ name: "Docs", path: "/docs", trackId: "docs" },
{ name: "Pricing", path: "/pricing", trackId: "pricing" },
{ name: "Compare", path: "/compare", trackId: "compare" },
diff --git a/apps/docs/components/structured-data.tsx b/apps/docs/components/structured-data.tsx
index 58b69d923e..4ed20d34af 100644
--- a/apps/docs/components/structured-data.tsx
+++ b/apps/docs/components/structured-data.tsx
@@ -50,8 +50,6 @@ type ElementItem =
interface StructuredDataProps {
baseUrl?: string; // default: https://www.databuddy.cc
-
- /** Mixed, repeatable elements */
elements?: ElementItem[];
logoUrl?: string; // default: {baseUrl}/logo.png
diff --git a/apps/docs/components/theme-toggle.tsx b/apps/docs/components/theme-toggle.tsx
deleted file mode 100644
index 4b073b31b8..0000000000
--- a/apps/docs/components/theme-toggle.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-"use client";
-
-import { MoonIcon, SunIcon } from "@databuddy/ui/icons";
-import { useTheme } from "next-themes";
-import { useEffect, useState } from "react";
-
-interface ThemeToggleProps {
- className?: string;
-}
-
-export function ThemeToggle({ className }: ThemeToggleProps) {
- const { resolvedTheme, setTheme } = useTheme();
- const [mounted, setMounted] = useState(false);
-
- useEffect(() => setMounted(true), []);
-
- const cycle = () => {
- const next = resolvedTheme === "light" ? "dark" : "light";
- if ("startViewTransition" in document) {
- document.startViewTransition(() => setTheme(next));
- } else {
- setTheme(next);
- }
- };
-
- const Icon = mounted && resolvedTheme === "dark" ? MoonIcon : SunIcon;
-
- return (
-
-
-
- );
-}
diff --git a/apps/docs/components/ui/alert-dialog.tsx b/apps/docs/components/ui/alert-dialog.tsx
deleted file mode 100644
index 59257878bc..0000000000
--- a/apps/docs/components/ui/alert-dialog.tsx
+++ /dev/null
@@ -1,156 +0,0 @@
-"use client";
-
-import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
-import type * as React from "react";
-import { buttonVariants } from "@/components/ui/button";
-import { cn } from "@/lib/utils";
-
-function AlertDialog({
- ...props
-}: React.ComponentProps) {
- return ;
-}
-
-function AlertDialogTrigger({
- ...props
-}: React.ComponentProps) {
- return (
-
- );
-}
-
-function AlertDialogPortal({
- ...props
-}: React.ComponentProps) {
- return (
-
- );
-}
-
-function AlertDialogOverlay({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- );
-}
-
-function AlertDialogContent({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
-
-
-
- );
-}
-
-function AlertDialogHeader({
- className,
- ...props
-}: React.ComponentProps<"div">) {
- return (
-
- );
-}
-
-function AlertDialogFooter({
- className,
- ...props
-}: React.ComponentProps<"div">) {
- return (
-
- );
-}
-
-function AlertDialogTitle({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- );
-}
-
-function AlertDialogDescription({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- );
-}
-
-function AlertDialogAction({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- );
-}
-
-function AlertDialogCancel({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- );
-}
-
-export {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogOverlay,
- AlertDialogPortal,
- AlertDialogTitle,
- AlertDialogTrigger,
-};
diff --git a/apps/docs/components/ui/alert.tsx b/apps/docs/components/ui/alert.tsx
deleted file mode 100644
index 068be82a2a..0000000000
--- a/apps/docs/components/ui/alert.tsx
+++ /dev/null
@@ -1,66 +0,0 @@
-import { cva, type VariantProps } from "class-variance-authority";
-import type * as React from "react";
-
-import { cn } from "@/lib/utils";
-
-const alertVariants = cva(
- "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
- {
- variants: {
- variant: {
- default: "bg-card text-card-foreground",
- destructive:
- "bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
- },
- },
- defaultVariants: {
- variant: "default",
- },
- }
-);
-
-function Alert({
- className,
- variant,
- ...props
-}: React.ComponentProps<"div"> & VariantProps) {
- return (
-
- );
-}
-
-function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- );
-}
-
-function AlertDescription({
- className,
- ...props
-}: React.ComponentProps<"div">) {
- return (
-
- );
-}
-
-export { Alert, AlertDescription, AlertTitle };
diff --git a/apps/docs/components/ui/aspect-ratio.tsx b/apps/docs/components/ui/aspect-ratio.tsx
deleted file mode 100644
index 51fa2c6285..0000000000
--- a/apps/docs/components/ui/aspect-ratio.tsx
+++ /dev/null
@@ -1,11 +0,0 @@
-"use client";
-
-import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
-
-function AspectRatio({
- ...props
-}: React.ComponentProps) {
- return ;
-}
-
-export { AspectRatio };
diff --git a/apps/docs/components/ui/badge.tsx b/apps/docs/components/ui/badge.tsx
index 35e81718a2..dd579b1266 100644
--- a/apps/docs/components/ui/badge.tsx
+++ b/apps/docs/components/ui/badge.tsx
@@ -46,4 +46,4 @@ function Badge({
);
}
-export { Badge, badgeVariants };
+export { Badge, };
diff --git a/apps/docs/components/ui/breadcrumb.tsx b/apps/docs/components/ui/breadcrumb.tsx
deleted file mode 100644
index 1146057a65..0000000000
--- a/apps/docs/components/ui/breadcrumb.tsx
+++ /dev/null
@@ -1,110 +0,0 @@
-import { Slot } from "@radix-ui/react-slot";
-import { CaretRightIcon as ChevronRight, DotsThreeIcon as MoreHorizontal } from "@databuddy/ui/icons";
-import type * as React from "react";
-
-import { cn } from "@/lib/utils";
-
-function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
- return ;
-}
-
-function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
- return (
-
- );
-}
-
-function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
- return (
-
- );
-}
-
-function BreadcrumbLink({
- asChild,
- className,
- ...props
-}: React.ComponentProps<"a"> & {
- asChild?: boolean;
-}) {
- const Comp = asChild ? Slot : "a";
-
- return (
-
- );
-}
-
-function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
- return (
-
- );
-}
-
-function BreadcrumbSeparator({
- children,
- className,
- ...props
-}: React.ComponentProps<"li">) {
- return (
- svg]:size-3.5", className)}
- data-slot="breadcrumb-separator"
- role="presentation"
- {...props}
- >
- {children ?? }
-
- );
-}
-
-function BreadcrumbEllipsis({
- className,
- ...props
-}: React.ComponentProps<"span">) {
- return (
-
-
- More
-
- );
-}
-
-export {
- Breadcrumb,
- BreadcrumbEllipsis,
- BreadcrumbItem,
- BreadcrumbLink,
- BreadcrumbList,
- BreadcrumbPage,
- BreadcrumbSeparator,
-};
diff --git a/apps/docs/components/ui/button.tsx b/apps/docs/components/ui/button.tsx
index e5f586a8f9..0e78bdb332 100644
--- a/apps/docs/components/ui/button.tsx
+++ b/apps/docs/components/ui/button.tsx
@@ -56,4 +56,4 @@ function Button({
);
}
-export { Button, buttonVariants };
+export { Button, };
diff --git a/apps/docs/components/ui/calendar.tsx b/apps/docs/components/ui/calendar.tsx
deleted file mode 100644
index 5f7e385697..0000000000
--- a/apps/docs/components/ui/calendar.tsx
+++ /dev/null
@@ -1,211 +0,0 @@
-"use client";
-
-import { CaretDownIcon as ChevronDownIcon, CaretLeftIcon as ChevronLeftIcon, CaretRightIcon as ChevronRightIcon } from "@databuddy/ui/icons";
-import { useEffect, useRef } from "react";
-import {
- type DayButton,
- DayPicker,
- getDefaultClassNames,
-} from "react-day-picker";
-import { Button, buttonVariants } from "@/components/ui/button";
-import { cn } from "@/lib/utils";
-
-function Calendar({
- className,
- classNames,
- showOutsideDays = true,
- captionLayout = "label",
- buttonVariant = "ghost",
- formatters,
- components,
- ...props
-}: React.ComponentProps & {
- buttonVariant?: React.ComponentProps["variant"];
-}) {
- const defaultClassNames = getDefaultClassNames();
-
- return (
- svg]:rotate-180`,
- String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
- className
- )}
- classNames={{
- root: cn("w-fit", defaultClassNames.root),
- months: cn(
- "relative flex flex-col gap-4 md:flex-row",
- defaultClassNames.months
- ),
- month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
- nav: cn(
- "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
- defaultClassNames.nav
- ),
- button_previous: cn(
- buttonVariants({ variant: buttonVariant }),
- "size-(--cell-size) select-none p-0 aria-disabled:opacity-50",
- defaultClassNames.button_previous
- ),
- button_next: cn(
- buttonVariants({ variant: buttonVariant }),
- "size-(--cell-size) select-none p-0 aria-disabled:opacity-50",
- defaultClassNames.button_next
- ),
- month_caption: cn(
- "flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
- defaultClassNames.month_caption
- ),
- dropdowns: cn(
- "flex h-(--cell-size) w-full items-center justify-center gap-1.5 font-medium text-sm",
- defaultClassNames.dropdowns
- ),
- dropdown_root: cn(
- "relative rounded-md border border-input shadow-xs has-focus:border-ring has-focus:ring-[3px] has-focus:ring-ring/50",
- defaultClassNames.dropdown_root
- ),
- dropdown: cn("absolute inset-0 opacity-0", defaultClassNames.dropdown),
- caption_label: cn(
- "select-none font-medium",
- captionLayout === "label"
- ? "text-sm"
- : "flex h-8 items-center gap-1 rounded-md pr-1 pl-2 text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
- defaultClassNames.caption_label
- ),
- month_grid: cn("w-full border-collapse", defaultClassNames.month_grid),
- weekdays: cn("flex", defaultClassNames.weekdays),
- weekday: cn(
- "flex-1 select-none rounded-md font-normal text-[0.8rem] text-muted-foreground",
- defaultClassNames.weekday
- ),
- week: cn("mt-2 flex w-full", defaultClassNames.week),
- week_number_header: cn(
- "w-(--cell-size) select-none",
- defaultClassNames.week_number_header
- ),
- week_number: cn(
- "select-none text-[0.8rem] text-muted-foreground",
- defaultClassNames.week_number
- ),
- day: cn(
- "group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md",
- defaultClassNames.day
- ),
- range_start: cn(
- "rounded-l-md bg-accent",
- defaultClassNames.range_start
- ),
- range_middle: cn("rounded-none", defaultClassNames.range_middle),
- range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
- today: cn(
- "rounded-md bg-accent text-accent-foreground data-[selected=true]:rounded-none",
- defaultClassNames.today
- ),
- outside: cn(
- "text-muted-foreground aria-selected:text-muted-foreground",
- defaultClassNames.outside
- ),
- disabled: cn(
- "text-muted-foreground opacity-50",
- defaultClassNames.disabled
- ),
- hidden: cn("invisible", defaultClassNames.hidden),
- ...classNames,
- }}
- components={{
- Root: ({ className, rootRef, ...props }) => {
- return (
-
- );
- },
- Chevron: ({ className, orientation, ...props }) => {
- if (orientation === "left") {
- return (
-
- );
- }
-
- if (orientation === "right") {
- return (
-
- );
- }
-
- return (
-
- );
- },
- DayButton: CalendarDayButton,
- WeekNumber: ({ children, ...props }) => {
- return (
-
-
- {children}
-
-
- );
- },
- ...components,
- }}
- formatters={{
- formatMonthDropdown: (date) =>
- date.toLocaleString("default", { month: "short" }),
- ...formatters,
- }}
- showOutsideDays={showOutsideDays}
- {...props}
- />
- );
-}
-
-function CalendarDayButton({
- className,
- day,
- modifiers,
- ...props
-}: React.ComponentProps) {
- const defaultClassNames = getDefaultClassNames();
-
- const ref = useRef(null);
- useEffect(() => {
- if (modifiers.focused) {
- ref.current?.focus();
- }
- }, [modifiers.focused]);
-
- return (
- span]:text-xs [&>span]:opacity-70",
- defaultClassNames.day,
- className
- )}
- data-day={day.date.toLocaleDateString()}
- data-range-end={modifiers.range_end}
- data-range-middle={modifiers.range_middle}
- data-range-start={modifiers.range_start}
- data-selected-single={
- modifiers.selected &&
- !modifiers.range_start &&
- !modifiers.range_end &&
- !modifiers.range_middle
- }
- ref={ref}
- size="icon"
- variant="ghost"
- {...props}
- />
- );
-}
-
-export { Calendar, CalendarDayButton };
diff --git a/apps/docs/components/ui/card.tsx b/apps/docs/components/ui/card.tsx
index 1441de84ef..b21d56c533 100644
--- a/apps/docs/components/ui/card.tsx
+++ b/apps/docs/components/ui/card.tsx
@@ -83,10 +83,10 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
export {
Card,
- CardAction,
+
CardContent,
CardDescription,
- CardFooter,
+
CardHeader,
CardTitle,
};
diff --git a/apps/docs/components/ui/carousel.tsx b/apps/docs/components/ui/carousel.tsx
deleted file mode 100644
index 34e52c93b2..0000000000
--- a/apps/docs/components/ui/carousel.tsx
+++ /dev/null
@@ -1,251 +0,0 @@
-"use client";
-
-import useEmblaCarousel, {
- type UseEmblaCarouselType,
-} from "embla-carousel-react";
-import { ArrowLeftIcon as ArrowLeft, ArrowRightIcon as ArrowRight } from "@databuddy/ui/icons";
-import {
- type ComponentProps,
- createContext,
- useCallback,
- useContext,
- useEffect,
- useState,
-} from "react";
-import { Button } from "@/components/ui/button";
-import { cn } from "@/lib/utils";
-
-type CarouselApi = UseEmblaCarouselType[1];
-type UseCarouselParameters = Parameters;
-type CarouselOptions = UseCarouselParameters[0];
-type CarouselPlugin = UseCarouselParameters[1];
-
-type CarouselProps = {
- opts?: CarouselOptions;
- plugins?: CarouselPlugin;
- orientation?: "horizontal" | "vertical";
- setApi?: (api: CarouselApi) => void;
-};
-
-type CarouselContextProps = {
- carouselRef: ReturnType[0];
- api: ReturnType[1];
- scrollPrev: () => void;
- scrollNext: () => void;
- canScrollPrev: boolean;
- canScrollNext: boolean;
-} & CarouselProps;
-
-const CarouselContext = createContext(null);
-
-function useCarousel() {
- const context = useContext(CarouselContext);
-
- if (!context) {
- throw new Error("useCarousel must be used within a ");
- }
-
- return context;
-}
-
-function Carousel({
- orientation = "horizontal",
- opts,
- setApi,
- plugins,
- className,
- children,
- ...props
-}: ComponentProps<"div"> & CarouselProps) {
- const [carouselRef, api] = useEmblaCarousel(
- {
- ...opts,
- axis: orientation === "horizontal" ? "x" : "y",
- },
- plugins
- );
- const [canScrollPrev, setCanScrollPrev] = useState(false);
- const [canScrollNext, setCanScrollNext] = useState(false);
-
- const onSelect = useCallback((carouselApi: CarouselApi) => {
- if (!carouselApi) {
- return;
- }
- setCanScrollPrev(carouselApi.canScrollPrev());
- setCanScrollNext(carouselApi.canScrollNext());
- }, []);
-
- const scrollPrev = useCallback(() => {
- api?.scrollPrev();
- }, [api]);
-
- const scrollNext = useCallback(() => {
- api?.scrollNext();
- }, [api]);
-
- const handleKeyDown = useCallback(
- (event: React.KeyboardEvent) => {
- if (event.key === "ArrowLeft") {
- event.preventDefault();
- scrollPrev();
- } else if (event.key === "ArrowRight") {
- event.preventDefault();
- scrollNext();
- }
- },
- [scrollPrev, scrollNext]
- );
-
- useEffect(() => {
- if (!(api && setApi)) {
- return;
- }
- setApi(api);
- }, [api, setApi]);
-
- useEffect(() => {
- if (!api) {
- return;
- }
- onSelect(api);
- api.on("reInit", onSelect);
- api.on("select", onSelect);
-
- return () => {
- api?.off("select", onSelect);
- };
- }, [api, onSelect]);
-
- return (
-
-
-
- );
-}
-
-function CarouselContent({ className, ...props }: ComponentProps<"div">) {
- const { carouselRef, orientation } = useCarousel();
-
- return (
-
- );
-}
-
-function CarouselItem({ className, ...props }: ComponentProps<"div">) {
- const { orientation } = useCarousel();
-
- return (
-
- );
-}
-
-function CarouselPrevious({
- className,
- variant = "outline",
- size = "icon",
- ...props
-}: ComponentProps) {
- const { orientation, scrollPrev, canScrollPrev } = useCarousel();
-
- return (
-
-
- Previous slide
-
- );
-}
-
-function CarouselNext({
- className,
- variant = "outline",
- size = "icon",
- ...props
-}: ComponentProps) {
- const { orientation, scrollNext, canScrollNext } = useCarousel();
-
- return (
-
-
- Next slide
-
- );
-}
-
-export {
- Carousel,
- type CarouselApi,
- CarouselContent,
- CarouselItem,
- CarouselNext,
- CarouselPrevious,
-};
diff --git a/apps/docs/components/ui/chart.tsx b/apps/docs/components/ui/chart.tsx
deleted file mode 100644
index 874ec2f3e4..0000000000
--- a/apps/docs/components/ui/chart.tsx
+++ /dev/null
@@ -1,362 +0,0 @@
-"use client";
-
-import {
- type ComponentProps,
- type CSSProperties,
- createContext,
- useContext,
- useId,
- useMemo,
-} from "react";
-import * as RechartsPrimitive from "recharts";
-
-import { cn } from "@/lib/utils";
-
-// Format: { THEME_NAME: CSS_SELECTOR }
-const THEMES = { light: "", dark: ".dark" } as const;
-
-export type ChartConfig = {
- [k in string]: {
- label?: React.ReactNode;
- icon?: React.ComponentType;
- } & (
- | { color?: string; theme?: never }
- | { color?: never; theme: Record }
- );
-};
-
-type ChartContextProps = {
- config: ChartConfig;
-};
-
-const ChartContext = createContext(null);
-
-function useChart() {
- const context = useContext(ChartContext);
-
- if (!context) {
- throw new Error("useChart must be used within a ");
- }
-
- return context;
-}
-
-function ChartContainer({
- id,
- className,
- children,
- config,
- ...props
-}: ComponentProps<"div"> & {
- config: ChartConfig;
- children: ComponentProps<
- typeof RechartsPrimitive.ResponsiveContainer
- >["children"];
-}) {
- const uniqueId = useId();
- const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
-
- return (
-
-
-
-
- {children}
-
-
-
- );
-}
-
-const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
- const colorConfig = Object.entries(config).filter(
- ([, itemConfig]) => itemConfig.theme || itemConfig.color
- );
-
- if (!colorConfig.length) {
- return null;
- }
-
- return (
-