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/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..5d72f3d3ba 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(); 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..3e249e057c 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(); 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/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.ts b/apps/basket/src/utils/origin-ip-validation.ts index 22d4074455..9125560c76 100644 --- a/apps/basket/src/utils/origin-ip-validation.ts +++ b/apps/basket/src/utils/origin-ip-validation.ts @@ -82,12 +82,12 @@ 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(); 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(" { const long = longString(3000); const result = sanitizeString(long); @@ -58,7 +59,6 @@ describe("sanitizeString", () => { expect(result).toBe("abcde"); }); - // XSS payloads for (const payload of XSS_PAYLOADS) { test(`XSS: ${payload.slice(0, 30)}… → no angle brackets`, () => { const result = sanitizeString(payload); @@ -67,20 +67,19 @@ describe("sanitizeString", () => { }); } - test("100 random strings with injected control chars", () => { - for (let i = 0; i < 100; i++) { - const input = `test${String.fromCharCode(Math.floor(Math.random() * 32))}value${i}`; - const result = sanitizeString(input); - // Should never contain control chars (except \t=9, \n=10, \r=13 which are allowed) - for (let c = 0; c <= 8; c++) { - expect(result).not.toContain(String.fromCharCode(c)); + test("strips every disallowed control char while keeping tab/newline/return", () => { + for (let code = 0; code <= 31; code++) { + const char = String.fromCharCode(code); + const result = sanitizeString(`a${char}b`); + if (code === 9 || code === 10 || code === 13) { + expect(result).toBe("a b"); + } else { + expect(result).toBe("ab"); } } }); }); -// ── redactSensitiveQueryParams ── - describe("redactSensitiveQueryParams", () => { const table: [string, string, string][] = [ [ @@ -110,6 +109,11 @@ describe("redactSensitiveQueryParams", () => { "/cb#access_token=REDACTED&state=xyz", ], ["plain fragment untouched", "/docs?page=1#install", "/docs?page=1#install"], + [ + "query and fragment redacted independently", + "/cb?token=abc&page=2#access_token=xyz&state=ok", + "/cb?token=REDACTED&page=2#access_token=REDACTED&state=ok", + ], [ "relative path with otp", "/verify?otp=123456", @@ -124,8 +128,6 @@ describe("redactSensitiveQueryParams", () => { } }); -// ── sanitizeUrl ── - describe("sanitizeUrl", () => { test("non-string → ''", () => expect(sanitizeUrl(123)).toBe("")); @@ -145,8 +147,6 @@ describe("sanitizeUrl", () => { expect(sanitizeUrl("/abcdefghij", 5)).toBe("/abcd")); }); -// ── validateSessionId ── - cases( "validateSessionId", [ @@ -163,8 +163,6 @@ cases( (input) => validateSessionId(input) ); -// ── validateNumeric ── - describe("validateNumeric", () => { const table: [string, [unknown, number?, number?], number | null][] = [ ["integer", [42], 42], @@ -192,8 +190,6 @@ describe("validateNumeric", () => { } }); -// ── validatePayloadSize ── - describe("validatePayloadSize", () => { test("small object → true", () => expect(validatePayloadSize({ a: 1 })).toBe(true)); @@ -210,8 +206,7 @@ describe("validatePayloadSize", () => { expect(validatePayloadSize(obj)).toBe(false); }); - test("exactly at 1MB limit", () => { - // JSON.stringify adds quotes, so account for that + test("string whose serialized form is exactly at the 1MB limit", () => { const data = longString(VALIDATION_LIMITS.PAYLOAD_MAX_SIZE - 2); expect(validatePayloadSize(data)).toBe(true); }); @@ -222,8 +217,6 @@ describe("validatePayloadSize", () => { }); }); -// ── validatePerformanceMetric ── - cases( "validatePerformanceMetric", [ diff --git a/apps/cron/geo.ts b/apps/cron/geo.ts index 0db5eae9dc..4eab2ea19e 100644 --- a/apps/cron/geo.ts +++ b/apps/cron/geo.ts @@ -1,7 +1,3 @@ -/** - * Geo IP Generator & Accuracy Benchmark - * Generates IPs from regional CIDR blocks and validates against MaxMind - */ /** biome-ignore-all lint/suspicious/noBitwiseOperators: We need it */ import { AddressNotFoundError, Reader } from "@maxmind/geoip2-node"; diff --git a/apps/dashboard/app/(dby)/dby/og/brand.tsx b/apps/dashboard/app/(dby)/dby/og/brand.tsx index e8d546d9d3..ec87acf45e 100644 --- a/apps/dashboard/app/(dby)/dby/og/brand.tsx +++ b/apps/dashboard/app/(dby)/dby/og/brand.tsx @@ -57,7 +57,7 @@ async function readOgFonts() { const LOGOMARK_ASPECT = 997.25 / 1000; const WORDMARK_ASPECT = 3529.1 / 722.77; -export function OgLogomark({ +function OgLogomark({ height, fill = OG_COLORS.foreground, }: { @@ -83,7 +83,7 @@ export function OgLogomark({ ); } -export function OgWordmark({ +function OgWordmark({ height, fill = OG_COLORS.foreground, }: { diff --git a/apps/dashboard/app/(main)/billing/components/billing-controls-card.tsx b/apps/dashboard/app/(main)/billing/components/billing-controls-card.tsx index 14f44bfd1f..4815ce47dd 100644 --- a/apps/dashboard/app/(main)/billing/components/billing-controls-card.tsx +++ b/apps/dashboard/app/(main)/billing/components/billing-controls-card.tsx @@ -9,7 +9,6 @@ import { } from "@/lib/topup-math"; import { useMutation } from "@tanstack/react-query"; import { useCustomer } from "autumn-js/react"; -import { AnimatePresence, motion } from "motion/react"; import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { @@ -35,7 +34,7 @@ const ALERT_LIMITS = { threshold: [1, 99] } as const; const SPEND_DEFAULTS = { overageLimit: 50 }; const SPEND_LIMITS = { overageLimit: [1, 10_000] } as const; -const EXPAND_EASE: [number, number, number, number] = [0.32, 0.72, 0, 1]; +const EXPAND_EASE = "cubic-bezier(0.32, 0.72, 0, 1)"; export function BillingControlsCard() { const { data: customer, refetch } = useCustomer(); @@ -367,19 +366,19 @@ function Expand({ open: boolean; }) { return ( - - {open && ( - - {children} - +
+ inert={!open} + style={{ + transitionDuration: `${duration}s`, + transitionTimingFunction: EXPAND_EASE, + }} + > +
{children}
+
); } diff --git a/apps/dashboard/app/(main)/billing/components/empty-states.tsx b/apps/dashboard/app/(main)/billing/components/empty-states.tsx index bb40387e8c..d5cbbcd364 100644 --- a/apps/dashboard/app/(main)/billing/components/empty-states.tsx +++ b/apps/dashboard/app/(main)/billing/components/empty-states.tsx @@ -1,30 +1,8 @@ "use client"; -import { - ArrowClockwiseIcon, - TrendUpIcon, - WarningCircleIcon, -} from "@databuddy/ui/icons"; +import { ArrowClockwiseIcon, WarningCircleIcon } from "@databuddy/ui/icons"; import { Button } from "@databuddy/ui"; -export function EmptyUsageState() { - return ( -
-
- -
-

No usage data yet

-

- Start using features to see your consumption stats here -

-
- ); -} - interface ErrorStateProps { error: Error | unknown; onRetry: () => void; diff --git a/apps/dashboard/app/(main)/billing/hooks/use-billing.ts b/apps/dashboard/app/(main)/billing/hooks/use-billing.ts index 5c1f970918..0daa81302a 100644 --- a/apps/dashboard/app/(main)/billing/hooks/use-billing.ts +++ b/apps/dashboard/app/(main)/billing/hooks/use-billing.ts @@ -11,7 +11,7 @@ import { } from "../utils/feature-usage"; import { getStripeMetadata } from "../utils/stripe-metadata"; -export interface Usage { +interface Usage { features: FeatureUsage[]; } export interface CancelTarget { @@ -19,10 +19,7 @@ export interface CancelTarget { id: string; name: string; } - -export type { Customer, Invoice } from "autumn-js"; export type { CancelFeedback } from "../components/cancel-subscription-dialog"; -export type { CustomerWithPaymentMethod } from "../types/billing"; export function useBilling(refetch?: () => void) { const { attach, updateSubscription, check, openCustomerPortal } = diff --git a/apps/dashboard/app/(main)/billing/types/billing.ts b/apps/dashboard/app/(main)/billing/types/billing.ts index a801195c62..757ef4238e 100644 --- a/apps/dashboard/app/(main)/billing/types/billing.ts +++ b/apps/dashboard/app/(main)/billing/types/billing.ts @@ -1,11 +1,11 @@ -export interface PaymentMethodCard { +interface PaymentMethodCard { brand?: string; expMonth?: number; expYear?: number; last4?: string; } -export interface PaymentMethodBillingDetails { +interface PaymentMethodBillingDetails { address?: { city?: string; country?: string; @@ -18,7 +18,7 @@ export interface PaymentMethodBillingDetails { name?: string; } -export interface PaymentMethod { +interface PaymentMethod { billingDetails?: PaymentMethodBillingDetails; card?: PaymentMethodCard; id?: string; diff --git a/apps/dashboard/app/(main)/billing/utils/billing-utils.ts b/apps/dashboard/app/(main)/billing/utils/billing-utils.ts index 59198a884e..712db942b4 100644 --- a/apps/dashboard/app/(main)/billing/utils/billing-utils.ts +++ b/apps/dashboard/app/(main)/billing/utils/billing-utils.ts @@ -1,4 +1,4 @@ -export const EVENT_COST = 0.000_035; +const EVENT_COST = 0.000_035; export interface OverageInfo { hasOverage: boolean; diff --git a/apps/dashboard/app/(main)/events/_components/events-page-context.tsx b/apps/dashboard/app/(main)/events/_components/events-page-context.tsx index 741604d3ad..4e73bf7d1e 100644 --- a/apps/dashboard/app/(main)/events/_components/events-page-context.tsx +++ b/apps/dashboard/app/(main)/events/_components/events-page-context.tsx @@ -12,15 +12,9 @@ import { import { usePersistentState } from "@databuddy/ui"; import { useWebsitesLight } from "@/hooks/use-websites"; import { dayjs } from "@databuddy/ui"; +type WebsiteFilterMode = "no-website" | "all" | string; -/** - * "no-website" = events not tied to any website - * "all" = all events across the organization - * string = a specific websiteId - */ -export type WebsiteFilterMode = "no-website" | "all" | string; - -export interface WebsiteEntry { +interface WebsiteEntry { domain: string; id: string; name: string; @@ -46,7 +40,7 @@ interface EventsPageContextValue { const EventsPageContext = createContext(null); -export const DEFAULT_DATE_RANGE = { +const DEFAULT_DATE_RANGE = { start_date: dayjs().subtract(30, "day").format("YYYY-MM-DD"), end_date: dayjs().format("YYYY-MM-DD"), granularity: "daily" as const, diff --git a/apps/dashboard/app/(main)/links/_components/link-form-schema.ts b/apps/dashboard/app/(main)/links/_components/link-form-schema.ts index d93fae903b..091dd60801 100644 --- a/apps/dashboard/app/(main)/links/_components/link-form-schema.ts +++ b/apps/dashboard/app/(main)/links/_components/link-form-schema.ts @@ -86,10 +86,3 @@ export function createDeepLinkFormSchema(app: DeepLinkApp) { export type DeepLinkFormData = z.infer< ReturnType >; - -export type ExpandedSection = - | "expiration" - | "devices" - | "utm" - | "social" - | null; diff --git a/apps/dashboard/app/(main)/links/_components/link-item.tsx b/apps/dashboard/app/(main)/links/_components/link-item.tsx index cf7929559a..dd5805a8d1 100644 --- a/apps/dashboard/app/(main)/links/_components/link-item.tsx +++ b/apps/dashboard/app/(main)/links/_components/link-item.tsx @@ -273,5 +273,3 @@ export function LinksSearchBarSkeleton() { ); } - -export { LinkRow as LinkItem }; diff --git a/apps/dashboard/app/(main)/links/_components/link-utils.ts b/apps/dashboard/app/(main)/links/_components/link-utils.ts index 05530d5db8..1bc9ba27c3 100644 --- a/apps/dashboard/app/(main)/links/_components/link-utils.ts +++ b/apps/dashboard/app/(main)/links/_components/link-utils.ts @@ -3,30 +3,6 @@ import { appendUtmToUrl, type UtmParams } from "./utm-builder"; const HTTP_PROTOCOL_PREFIX = /^https?:\/\//i; -export function formatTarget(targetUrl: string): string { - try { - const parsed = new URL(targetUrl); - return parsed.host + (parsed.pathname === "/" ? "" : parsed.pathname); - } catch { - return targetUrl; - } -} - -export function shortenId(id: string): string { - if (id.length <= 8) { - return id; - } - return `${id.slice(0, 3)}…${id.slice(-3)}`; -} - -export function shortenUrl(url: string): string { - try { - return new URL(url).host; - } catch { - return url.length <= 12 ? url : `${url.slice(0, 9)}…`; - } -} - export function stripProtocol(url: string | null): string { if (!url) { return ""; diff --git a/apps/dashboard/app/(main)/organizations/components/empty-state.tsx b/apps/dashboard/app/(main)/organizations/components/empty-state.tsx deleted file mode 100644 index 512de8da79..0000000000 --- a/apps/dashboard/app/(main)/organizations/components/empty-state.tsx +++ /dev/null @@ -1,65 +0,0 @@ -"use client"; - -import type { ComponentType, ReactNode, SVGProps } from "react"; - -type IconComponent = ComponentType< - SVGProps & { size?: number | string; weight?: string } ->; - -interface EmptyStateProps { - action?: ReactNode; - description: string; - features?: Array<{ - label: string; - }>; - icon: IconComponent; - title: string; - variant?: "default" | "success" | "warning" | "destructive"; -} - -export function EmptyState({ - icon: Icon, - title, - description, - features, - action, - variant = "default", -}: EmptyStateProps) { - const variantStyles = { - default: "border-accent bg-accent/50 text-primary", - success: "border-green-200 bg-green-100 text-green-600", - warning: "border-orange-200 bg-orange-100 text-orange-600", - destructive: "border-destructive/20 bg-destructive/10 text-destructive", - }; - - return ( -
-
- -
-

{title}

-

- {description} -

- {features && ( -
-
- {features.map((feature, index) => ( -
-
- {feature.label} -
- ))} -
-
- )} - {action &&
{action}
} -
- ); -} diff --git a/apps/dashboard/app/(main)/settings/_components/chart-preview.tsx b/apps/dashboard/app/(main)/settings/_components/chart-preview.tsx deleted file mode 100644 index 7ce3533de2..0000000000 --- a/apps/dashboard/app/(main)/settings/_components/chart-preview.tsx +++ /dev/null @@ -1,60 +0,0 @@ -"use client"; - -import { Chart } from "@/components/ui/composables/chart"; -import { cn } from "@/lib/utils"; -import { Card } from "@databuddy/ui"; - -const previewData = [ - { date: "Mon", value: 186 }, - { date: "Tue", value: 305 }, - { date: "Wed", value: 237 }, - { date: "Thu", value: 73 }, - { date: "Fri", value: 209 }, - { date: "Sat", value: 214 }, -]; - -const ChartPreview = ({ - chartType, - className, - size = 150, -}: { - chartType: "bar" | "line" | "area" | "composed"; - className?: string; - size?: number; -}) => { - const chartId = `chart-preview-${chartType}`; - const chartHeight = size - 16; - - const seriesKind = - chartType === "bar" || chartType === "composed" - ? "bar" - : chartType === "line" - ? "line" - : "area"; - - return ( - - -
- -
-
-
- ); -}; - -export default ChartPreview; diff --git a/apps/dashboard/app/(main)/settings/_components/settings-section.tsx b/apps/dashboard/app/(main)/settings/_components/settings-section.tsx index 81dbae36c0..1706dcd176 100644 --- a/apps/dashboard/app/(main)/settings/_components/settings-section.tsx +++ b/apps/dashboard/app/(main)/settings/_components/settings-section.tsx @@ -1,7 +1,4 @@ -import { useHotkeys } from "react-hotkeys-hook"; import { cn } from "@/lib/utils"; -import { CircleNotchIcon } from "@databuddy/ui/icons"; -import { Button } from "@databuddy/ui"; interface SettingsSectionProps { children: React.ReactNode; @@ -29,37 +26,6 @@ export function SettingsSection({ ); } -interface SettingsRowProps { - children: React.ReactNode; - className?: string; - description?: string; - label: React.ReactNode; -} - -export function SettingsRow({ - label, - description, - children, - className, -}: SettingsRowProps) { - return ( -
-
-

{label}

- {description && ( -

{description}

- )} -
-
{children}
-
- ); -} - interface ComingSoonProps { description: string; icon: React.ReactNode; @@ -79,58 +45,3 @@ export function ComingSoon({ title, description, icon }: ComingSoonProps) {
); } - -interface UnsavedChangesFooterProps { - hasChanges: boolean; - isSaving: boolean; - message?: string; - onDiscard?: () => void; - onSave: () => void; - saveLabel?: string; -} - -export function UnsavedChangesFooter({ - hasChanges, - isSaving, - onSave, - onDiscard, - saveLabel = "Save Changes", - message = "You have unsaved changes", -}: UnsavedChangesFooterProps) { - useHotkeys( - "escape", - () => { - if (hasChanges && !isSaving && onDiscard) { - onDiscard(); - } - }, - { enabled: hasChanges && !isSaving && Boolean(onDiscard) }, - [hasChanges, isSaving, onDiscard] - ); - - if (!(hasChanges || isSaving)) { - return null; - } - - return ( -
-

{message}

-
- {onDiscard && ( - - )} - -
-
- ); -} diff --git a/apps/dashboard/app/(main)/websites/[id]/_components/analytics-date-controls.tsx b/apps/dashboard/app/(main)/websites/[id]/_components/analytics-date-controls.tsx index 08da4d6a39..146dd1b0e1 100644 --- a/apps/dashboard/app/(main)/websites/[id]/_components/analytics-date-controls.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/_components/analytics-date-controls.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback } from "react"; +import { useCallback, useMemo } from "react"; import type { DateRange as DayPickerRange } from "react-day-picker"; import { useHotkeys } from "react-hotkeys-hook"; import { DateRangePicker } from "@/components/date-range-picker"; @@ -62,10 +62,13 @@ export function AnalyticsDateControls({ ); const isHourlyDisabled = dateRangeDays > MAX_HOURLY_DAYS; - const selectedRange: DayPickerRange = { - from: currentDateRange.startDate, - to: currentDateRange.endDate, - }; + const selectedRange: DayPickerRange = useMemo( + () => ({ + from: currentDateRange.startDate, + to: currentDateRange.endDate, + }), + [currentDateRange.startDate, currentDateRange.endDate] + ); const handleQuickRangeSelect = useCallback( (range: QuickRange) => { diff --git a/apps/dashboard/app/(main)/websites/[id]/_components/constants/settings-constants.ts b/apps/dashboard/app/(main)/websites/[id]/_components/constants/settings-constants.ts index 3bd13dbb83..aab5a6483e 100644 --- a/apps/dashboard/app/(main)/websites/[id]/_components/constants/settings-constants.ts +++ b/apps/dashboard/app/(main)/websites/[id]/_components/constants/settings-constants.ts @@ -1,21 +1,6 @@ import type { TrackingOptionConfig } from "../utils/types"; export const COPY_SUCCESS_TIMEOUT = 2000; -export const BATCH_SIZE_LIMITS = { min: 1, max: 10 } as const; -export const RETRY_LIMITS = { min: 1, max: 10 } as const; -export const TIMEOUT_LIMITS = { min: 100, max: 5000, step: 100 } as const; -export const SAMPLING_RATE_LIMITS = { min: 1, max: 100, step: 1 } as const; - -export const SETTINGS_TABS = { - TRACKING: "tracking", - BASIC: "basic", - ADVANCED: "advanced", - OPTIMIZATION: "optimization", - PRIVACY: "privacy", - EXPORT: "export", -} as const; - -export type SettingsTab = (typeof SETTINGS_TABS)[keyof typeof SETTINGS_TABS]; export const TOAST_MESSAGES = { SCRIPT_COPIED: "Script tag copied to clipboard!", @@ -31,7 +16,7 @@ export const TOAST_MESSAGES = { WEBSITE_DELETE_ERROR: "Failed to delete website.", } as const; -export const PACKAGE_MANAGERS = { +const PACKAGE_MANAGERS = { NPM: "npm", YARN: "yarn", PNPM: "pnpm", @@ -45,24 +30,6 @@ export const INSTALL_COMMANDS = { [PACKAGE_MANAGERS.BUN]: "bun add @databuddy/sdk", } as const; -export const CODE_LANGUAGES = { - BASH: "bash", - HTML: "html", - JSX: "jsx", - JAVASCRIPT: "javascript", -} as const; - -export const DOCUMENTATION_URLS = { - DOCS: "https://www.databuddy.cc/docs", - API: "https://www.databuddy.cc/docs/api", -} as const; - -export const BADGE_STATUS = { - READY: "Ready", - CUSTOM: "Custom", - DEFAULT: "Default", -} as const; - export const BASIC_TRACKING_OPTIONS: TrackingOptionConfig[] = [ { key: "disabled", @@ -114,14 +81,3 @@ export const ADVANCED_TRACKING_OPTIONS: TrackingOptionConfig[] = [ data: ["Error message", "Stack trace", "File location"], }, ]; - -export const WARNING_MESSAGES = { - PAGE_VIEWS_REQUIRED: - "Disabling page views will prevent analytics from working. This option is required.", - DELETE_WARNING: "Warning:", - DELETE_CONSEQUENCES: [ - "All analytics data will be permanently deleted", - "Tracking will stop immediately", - "All website settings will be lost", - ], -} as const; diff --git a/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview/_components/traffic-trends-chart.tsx b/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview/_components/traffic-trends-chart.tsx index 701f07c845..4f46c06692 100644 --- a/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview/_components/traffic-trends-chart.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview/_components/traffic-trends-chart.tsx @@ -304,12 +304,10 @@ function TrafficTrendsRechartsPlot({ }; const handleMouseUp = (e: { activeLabel?: number | string }) => { - setIsDragging((wasDragging) => { - if (wasDragging) { - setTimeout(() => setSuppressTooltip(false), 150); - } - return false; - }); + if (isDragging) { + setTimeout(() => setSuppressTooltip(false), 150); + } + setIsDragging(false); if (!(e?.activeLabel && refAreaLeft)) { setRefAreaLeft(null); diff --git a/apps/dashboard/app/(main)/websites/[id]/_components/tabs/performance/_utils/performance-utils.ts b/apps/dashboard/app/(main)/websites/[id]/_components/tabs/performance/_utils/performance-utils.ts index 1aeb05aa9a..56c816eb7b 100644 --- a/apps/dashboard/app/(main)/websites/[id]/_components/tabs/performance/_utils/performance-utils.ts +++ b/apps/dashboard/app/(main)/websites/[id]/_components/tabs/performance/_utils/performance-utils.ts @@ -10,64 +10,3 @@ export const formatPerformanceTime = (value: number): string => { ? `${seconds.toFixed(0)}s` : `${seconds.toFixed(1)}s`; }; - -export const getPerformanceRating = ( - score: number -): { rating: string; className: string } => { - if (typeof score !== "number" || Number.isNaN(score)) { - return { rating: "Unknown", className: "text-muted-foreground" }; - } - if (score >= 90) { - return { rating: "Excellent", className: "text-green-600" }; - } - if (score >= 70) { - return { rating: "Good", className: "text-green-600" }; - } - if (score >= 50) { - return { rating: "Moderate", className: "text-yellow-600" }; - } - if (score >= 30) { - return { rating: "Poor", className: "text-orange-600" }; - } - return { rating: "Very Poor", className: "text-red-600" }; -}; - -export const getMetricStyles = (value: number, type: "time" | "cls") => { - if (type === "cls") { - return { - colorClass: - value < 0.1 - ? "text-green-600" - : value < 0.25 - ? "text-yellow-600" - : "text-red-600", - isGood: value < 0.1, - isPoor: value >= 0.25, - }; - } - - return { - colorClass: - value < 1000 - ? "text-green-600" - : value < 3000 - ? "text-yellow-600" - : "text-red-600", - isGood: value < 1000, - isPoor: value >= 3000, - }; -}; - -export const getPerformanceColor = (avgLoadTime: number): string => - avgLoadTime < 1500 - ? "text-green-600" - : avgLoadTime < 3000 - ? "text-yellow-600" - : "text-red-600"; - -export const getPerformanceScoreColor = (score: number): string => - score >= 80 - ? "text-green-600" - : score >= 60 - ? "text-yellow-600" - : "text-red-600"; diff --git a/apps/dashboard/app/(main)/websites/[id]/_components/utils/analytics-helpers.tsx b/apps/dashboard/app/(main)/websites/[id]/_components/utils/analytics-helpers.tsx index 99aeaafe35..db33f2487b 100644 --- a/apps/dashboard/app/(main)/websites/[id]/_components/utils/analytics-helpers.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/_components/utils/analytics-helpers.tsx @@ -1,4 +1,3 @@ -import { toast } from "sonner"; import { dayjs } from "@databuddy/ui"; export function clampBounceRate(value: number | null | undefined): number { @@ -10,57 +9,6 @@ export function clampBounceRate(value: number | null | undefined): number { type Granularity = "daily" | "hourly"; -interface DataItem { - [key: string]: any; -} - -interface ChartDataPoint { - color?: string; - name: string; - value: number; -} - -export const handleDataRefresh = async ( - isRefreshing: boolean, - refetchFn: () => Promise, - setIsRefreshing: (value: boolean) => void, - _successMessage = "Data has been updated" -): Promise => { - if (!isRefreshing) { - return; - } - - try { - const result = await refetchFn(); - setIsRefreshing(false); - return result; - } catch (error) { - toast.error("Failed to refresh data"); - console.error(error); - setIsRefreshing(false); - throw error; - } -}; - -export const safeParseDate = ( - date: string | Date | null | undefined -): dayjs.Dayjs => { - if (!date) { - return dayjs(); - } - - if (typeof date === "object" && date instanceof Date) { - return dayjs(date).isValid() ? dayjs(date) : dayjs(); - } - - try { - const parsed = dayjs(date.toString()); - return parsed.isValid() ? parsed : dayjs(); - } catch { - return dayjs(); - } -}; - export const formatDateByGranularity = ( date: string | Date, granularity: Granularity = "daily" @@ -71,112 +19,6 @@ export const formatDateByGranularity = ( : dateObj.format("MMM D"); }; -export const createMetricToggles = ( - initialMetrics: T[] -): Record => { - const initialState = {} as Record; - for (const metric of initialMetrics) { - initialState[metric] = true; - } - return initialState; -}; - -export const formatDistributionData = ( - data: T[] | undefined, - nameField: keyof T, - valueField: keyof T = "visitors" as keyof T -): ChartDataPoint[] => { - if (!data?.length) { - return []; - } - - return data.map((item) => ({ - name: - typeof item[nameField] === "string" - ? (item[nameField] as string)?.charAt(0).toUpperCase() + - (item[nameField] as string)?.slice(1) || "Unknown" - : String(item[nameField] || "Unknown"), - value: Number(item[valueField]) || 0, - })); -}; - -export const groupBrowserData = ( - browserVersions: Array<{ browser: string; visitors: number }> | undefined -): ChartDataPoint[] => { - if (!browserVersions?.length) { - return []; - } - - const browserCounts = browserVersions.reduce( - (acc, item) => { - const browserName = item.browser; - if (!acc[browserName]) { - acc[browserName] = { visitors: 0 }; - } - acc[browserName].visitors += item.visitors; - return acc; - }, - {} as Record - ); - - return Object.entries( - browserCounts as Record - ).map(([browser, data]) => ({ - name: browser, - value: data.visitors, - })); -}; - -export const getColorVariant = ( - value: number, - destructiveThreshold: number, - warningThreshold: number -): "destructive" | "warning" | "success" => { - if (value > destructiveThreshold) { - return "destructive"; - } - if (value > warningThreshold) { - return "warning"; - } - return "success"; -}; - -const PROTOCOL_REGEX = /^https?:\/\//; -const SLASH_REGEX = /\//; - -export const formatDomainLink = ( - path: string, - domain?: string, - maxLength = 30 -): { href: string; display: string; title: string } => { - const displayPath = - path.length > maxLength ? `${path.slice(0, maxLength - 3)}...` : path; - - if (domain) { - const cleanDomain = domain - .replace(PROTOCOL_REGEX, "") - .replace(SLASH_REGEX, ""); - let cleanPath = path.startsWith("/") ? path : `/${path}`; - cleanPath = cleanPath.replace(/\/+/g, "/"); - const href = `https://${cleanDomain}${cleanPath}`; - return { - href, - display: displayPath, - title: href, - }; - } - return { - href: `#${path}`, - display: displayPath, - title: path, - }; -}; - -export const formatRelativeTime = (date: string | Date): string => { - const dateObj = safeParseDate(date); - return dateObj.fromNow(); -}; - export const calculatePercentChange = ( current: number, previous: number @@ -186,45 +28,3 @@ export const calculatePercentChange = ( } return ((current - previous) / previous) * 100; }; - -export const formatPercentChange = (change: number): string => { - const sign = change > 0 ? "+" : ""; - return `${sign}${change.toFixed(1)}%`; -}; - -export const PERFORMANCE_THRESHOLDS = { - load_time: { good: 1500, average: 3000, unit: "ms" }, - ttfb: { good: 500, average: 1000, unit: "ms" }, - dom_ready: { good: 1000, average: 2000, unit: "ms" }, - render_time: { good: 1000, average: 2000, unit: "ms" }, - fcp: { good: 1800, average: 3000, unit: "ms" }, - lcp: { good: 2500, average: 4000, unit: "ms" }, - cls: { good: 0.1, average: 0.25, unit: "" }, -}; - -export function isTrackingNotSetup(analytics: any): boolean { - if (!analytics?.summary) { - return true; - } - - const { summary, events_by_date, top_pages, top_referrers } = analytics; - - const hasData = - (summary.pageviews || 0) > 0 || - (summary.visitors || summary.unique_visitors || 0) > 0 || - (summary.sessions || 0) > 0; - - const hasEvents = events_by_date?.some( - (event: any) => - (event.pageviews || 0) > 0 || - (event.visitors || event.unique_visitors || 0) > 0 - ); - - const hasPages = top_pages?.some((page: any) => (page.pageviews || 0) > 0); - - const hasReferrers = top_referrers?.some( - (ref: any) => (ref.visitors || 0) > 0 - ); - - return !(hasData || hasEvents || hasPages || hasReferrers); -} diff --git a/apps/dashboard/app/(main)/websites/[id]/_components/utils/code-generators.ts b/apps/dashboard/app/(main)/websites/[id]/_components/utils/code-generators.ts index 71d151cddf..49aee76af9 100644 --- a/apps/dashboard/app/(main)/websites/[id]/_components/utils/code-generators.ts +++ b/apps/dashboard/app/(main)/websites/[id]/_components/utils/code-generators.ts @@ -52,10 +52,6 @@ ${optionsLine}${integrityLine} crossorigin="anonymous" async >`; } - -/** - * 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 ( -
- {entry.name} -
- ); - } - - 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 ( - - ); -}; - -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]/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 ( - - - - ); -} 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]/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]/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}

-
- -
-
-
- ); - } - - 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]/pulse/_components/monitor-card.tsx b/apps/dashboard/app/(main)/websites/[id]/pulse/_components/monitor-card.tsx deleted file mode 100644 index fd74d7a15c..0000000000 --- a/apps/dashboard/app/(main)/websites/[id]/pulse/_components/monitor-card.tsx +++ /dev/null @@ -1,151 +0,0 @@ -"use client"; - -import { useMutation } from "@tanstack/react-query"; -import { useState } from "react"; -import { toast } from "sonner"; -import { orpc } from "@/lib/orpc"; -import { - DotsThreeIcon, - HeartbeatIcon, - PencilIcon, - TrashIcon, -} from "@databuddy/ui/icons"; -import { Card, GhostTriggerButton } from "@databuddy/ui"; -import { DropdownMenu } from "@databuddy/ui/client"; - -const granularityLabels: Record = { - 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} -
-
- -
- - - - - - - } - /> - - - - Delete Monitor - - - -
-
- - ); -} 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]/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..29091fc591 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; diff --git a/apps/dashboard/components/agent/agent-input.tsx b/apps/dashboard/components/agent/agent-input.tsx index e66e5af90a..2cce7df51f 100644 --- a/apps/dashboard/components/agent/agent-input.tsx +++ b/apps/dashboard/components/agent/agent-input.tsx @@ -78,7 +78,9 @@ export function AgentInput() { const textareaRef = useRef(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 ?? ( - - )} - - ); -}; - -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) => ( - {props.alt} -); 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 = ( - - ); - - 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 ( - - ); -}; - -export type MessageBranchNextProps = ComponentProps; - -export const MessageBranchNext = ({ - children, - className, - ...props -}: MessageBranchNextProps) => { - const { goToNext, totalBranches } = useMessageBranch(); - - return ( - - ); -}; - -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 ? ( - <> - {filename - {onRemove && ( - - )} - - ) : ( - <> - {attachmentLabel}

}> -
- -
-
- {onRemove && ( - - )} - - )} -
- ); -} - -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) => ( - {`${provider} -); - -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 ( - - ); -}; 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/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 ( - - ); -} - -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 -

- -
- {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 }) => ( - - ))} -
-
- - - -
-

- - Charts -

- -
-
- All Charts -
- - -
-
- - - - {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]} - -
-
- - -
-
- ); - })} -
- ) : 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 ( - <> - - - - - -
-
- - 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 ( - - ); - } - - return ( -
- - {showPlusBadge && ( - - )} -
- ); - }; - - 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 && ( - - )} - {secondaryAction && ( - - )} -
- )} -
-
-
- ); - }; - - 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/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 ( - - ); -} - -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(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/profile-button-client.tsx b/apps/dashboard/components/layout/profile-button-client.tsx index 4f2f8d7240..1a84a16197 100644 --- a/apps/dashboard/components/layout/profile-button-client.tsx +++ b/apps/dashboard/components/layout/profile-button-client.tsx @@ -13,7 +13,7 @@ import { SpinnerGapIcon, } from "@databuddy/ui/icons"; import { Avatar, DropdownMenu } from "@databuddy/ui/client"; -import { Text, Tooltip } from "@databuddy/ui"; +import { Text } from "@databuddy/ui"; import { clearPersistedQueryCache } from "@/lib/query-client"; export interface ProfileButtonUser { @@ -237,39 +237,3 @@ export function ProfileDropdownContent({ ); } - -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.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 ( -
- - - - {isExpanded && ( - -
{children}
-
- )} -
-
- ); -} diff --git a/apps/dashboard/components/monitors/monitor-sheet.tsx b/apps/dashboard/components/monitors/monitor-sheet.tsx index 5cf93f3c60..cb33f17e99 100644 --- a/apps/dashboard/components/monitors/monitor-sheet.tsx +++ b/apps/dashboard/components/monitors/monitor-sheet.tsx @@ -389,8 +389,10 @@ 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" suffix="sec" 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..2dbb5ef83e 100644 --- a/apps/dashboard/components/providers/billing-provider.tsx +++ b/apps/dashboard/components/providers/billing-provider.tsx @@ -22,7 +22,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,7 +30,7 @@ export interface FeatureAccess { usagePercent: number | null; } -export interface GatedFeatureAccess { +interface GatedFeatureAccess { allowed: boolean; minPlan: PlanId | null; upgradeMessage: string | 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/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 ( - - ); -} - -function CarouselNext({ - className, - variant = "secondary", - size = "sm", - ...props -}: React.ComponentProps) { - const { orientation, scrollNext, canScrollNext } = useCarousel(); - - return ( - - ); -} - -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 = ( - <> - - - - ); - - 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 ( - - ); - })} -
- ); -} 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 ( -