diff --git a/.agents/skills/databuddy-internal/SKILL.md b/.agents/skills/databuddy-internal/SKILL.md index 7b8b0cf924..8cffabc852 100644 --- a/.agents/skills/databuddy-internal/SKILL.md +++ b/.agents/skills/databuddy-internal/SKILL.md @@ -77,6 +77,7 @@ Keep additions **minimal**: one bullet, a new `rg` hint, or a routing note—eno - `packages/sdk`: published analytics SDK for React, Vue, and Node - `packages/tracker`: internal tracker script build and release package - `packages/encryption`, `packages/notifications`, `packages/cache`, `packages/redis`, `packages/services`, `packages/validation`, `packages/api-keys`: shared infra and domain packages +- Knip is configured in root `knip.json` (run `bun run knip`); per-workspace test globs are required because the root `test:watch` (`bun test --watch ./apps`) script shadows the Bun plugin's per-workspace script parsing; `apps/cron` is ignored (standalone scripts, no package.json) Read [codebase-map.md](./references/codebase-map.md) when you need deeper routing guidance. diff --git a/.changeset/sdk-storage-error-fix.md b/.changeset/sdk-storage-error-fix.md new file mode 100644 index 0000000000..54d4fb7143 --- /dev/null +++ b/.changeset/sdk-storage-error-fix.md @@ -0,0 +1,5 @@ +--- +"@databuddy/sdk": patch +--- + +`getAnonymousId` and `getSessionId` now return `null` instead of throwing when `localStorage` or `sessionStorage` access raises a `DOMException`. Follows the same try/catch pattern already used by `getProfileId`. URL params continue to take priority without touching storage. diff --git a/.github/workflows/sdk-e2e.yml b/.github/workflows/sdk-e2e.yml new file mode 100644 index 0000000000..a4a5800abe --- /dev/null +++ b/.github/workflows/sdk-e2e.yml @@ -0,0 +1,63 @@ +name: SDK E2E + +on: + push: + branches: [main] + paths: + - ".github/workflows/sdk-e2e.yml" + - "packages/sdk/**" + - "packages/tracker/**" + - "bun.lock" + - "package.json" + - "turbo.json" + pull_request: + branches: [main, staging] + paths: + - ".github/workflows/sdk-e2e.yml" + - "packages/sdk/**" + - "packages/tracker/**" + - "bun.lock" + - "package.json" + - "turbo.json" + +permissions: + contents: read + +concurrency: + group: sdk-e2e-${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.head_ref || github.ref_name }} + cancel-in-progress: true + +jobs: + sdk-e2e: + name: SDK Playwright + runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 15 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: "1.3.14" + - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }} + restore-keys: ${{ runner.os }}-bun- + - run: bun install --frozen-lockfile --ignore-scripts + - name: Build sdk and tracker + run: bunx turbo run build --filter @databuddy/sdk --filter @databuddy/tracker + - name: Install Playwright browser + run: bun run --cwd packages/sdk playwright install --with-deps chromium + - name: Run sdk E2E + run: bun run --cwd packages/sdk playwright test --project=chromium + - name: Run tracker E2E + run: bun run --cwd packages/tracker playwright test --project=chromium + - name: Upload Playwright artifacts + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: sdk-e2e-artifacts + path: | + packages/sdk/test-results + packages/tracker/test-results + if-no-files-found: ignore + retention-days: 7 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8a4d0d05e4..43362c2881 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,12 +18,15 @@ git clone https://github.com/databuddy-analytics/Databuddy.git cd databuddy ``` -2. Install dependencies: +2. Install dependencies (requires Bun 1.2.0+, check with `bun --version`): ```bash bun install ``` +> [!NOTE] +> Bun 1.2.0 is the minimum for `catalog:` dependency support. The repo pins an exact version in the `packageManager` field of `package.json`; match it with `bun upgrade` or by installing that version from https://bun.sh. + 3. Set up environment variables: ```bash diff --git a/apps/api/package.json b/apps/api/package.json index b165224748..9af45a747a 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -10,7 +10,6 @@ "test:watch": "TZ=UTC bunx --bun vitest" }, "dependencies": { - "@ai-sdk/provider": "^3.0.5", "@contextcompany/otel": "^1.0.13", "@databuddy/ai": "workspace:*", "@databuddy/api-keys": "workspace:*", @@ -22,7 +21,6 @@ "@databuddy/notifications": "workspace:*", "@databuddy/redis": "workspace:*", "@databuddy/rpc": "workspace:*", - "@databuddy/sdk": "workspace:*", "@databuddy/services": "workspace:*", "@databuddy/shared": "workspace:*", "@databuddy/validation": "workspace:*", @@ -37,21 +35,17 @@ "ai": "^6.0.188", "autumn-js": "catalog:", "bullmq": "^5.78.0", - "dayjs": "^1.11.19", "elysia": "catalog:", "evlog": "catalog:", - "jszip": "^3.10.1", "keypal": "0.2.0", "lru-cache": "^11.2.7", "resend": "^4.0.1", - "supermemory": "^4.17.0", "svix": "^1.84.1", "zod": "catalog:" }, "devDependencies": { "@databuddy/test": "workspace:*", "@types/bun": "catalog:", - "mitata": "^1.0.34", "vitest": "^4.1.5" }, "peerDependencies": { diff --git a/apps/api/src/http/cors.ts b/apps/api/src/http/cors.ts index 4a9b0e78d3..95dd3cb265 100644 --- a/apps/api/src/http/cors.ts +++ b/apps/api/src/http/cors.ts @@ -4,7 +4,7 @@ const DATABUDDY_HOST_RE = /(?:^|\.)databuddy\.cc$/; const allowedApiOrigins = new Set(config.cors.apiOrigins); const MCP_PATHS = new Set(["/v1/mcp", "/v1/mcp/", "/mcp", "/mcp/"]); -export function isMcpRequest(request: Request): boolean { +function isMcpRequest(request: Request): boolean { return MCP_PATHS.has(new URL(request.url).pathname); } diff --git a/apps/api/src/integration/cache-auth-bypass.test.ts b/apps/api/src/integration/cache-auth-bypass.test.ts index 4c42718133..cd94c66e35 100644 --- a/apps/api/src/integration/cache-auth-bypass.test.ts +++ b/apps/api/src/integration/cache-auth-bypass.test.ts @@ -1,7 +1,7 @@ import "@databuddy/test/env"; import { flags, targetGroups } from "@databuddy/db/schema"; -import { appRouter, type Context } from "@databuddy/rpc"; +import { appRouter } from "@databuddy/rpc"; import { addToOrganization, apiKeyContext, @@ -16,16 +16,12 @@ import { signUp, userContext, } from "@databuddy/test"; -import { createProcedureClient } from "@orpc/server"; import { randomUUIDv7 } from "bun"; import { afterAll, beforeEach, describe, expect, it } from "vitest"; +import { call } from "./helpers"; const iit = hasTestDb ? it : it.skip; -function call(procedure: T, ctx: Context) { - return createProcedureClient(procedure as any, { context: ctx }); -} - async function setupOwnedSite(siteOverrides?: { isPublic?: boolean }) { const user = await signUp(); const org = await insertOrganization(); diff --git a/apps/api/src/integration/helpers.ts b/apps/api/src/integration/helpers.ts new file mode 100644 index 0000000000..4e6540c6eb --- /dev/null +++ b/apps/api/src/integration/helpers.ts @@ -0,0 +1,6 @@ +import type { Context } from "@databuddy/rpc"; +import { type AnyProcedure, createProcedureClient } from "@orpc/server"; + +export function call(procedure: T, context: Context) { + return createProcedureClient(procedure, { context }); +} diff --git a/apps/api/src/integration/insights-handlers.test.ts b/apps/api/src/integration/insights-handlers.test.ts index 0988e88f5c..424d628274 100644 --- a/apps/api/src/integration/insights-handlers.test.ts +++ b/apps/api/src/integration/insights-handlers.test.ts @@ -13,7 +13,6 @@ import { appRouter, createInternalPrincipal, createRPCContext, - type Context, } from "@databuddy/rpc"; import { closeInsightsQueue, @@ -33,9 +32,9 @@ import { signUp, userContext, } from "@databuddy/test"; -import { createProcedureClient, type AnyProcedure } from "@orpc/server"; import { randomUUIDv7 } from "bun"; import { afterAll, beforeEach, describe, expect, it } from "vitest"; +import { call } from "./helpers"; const iit = hasTestDb ? it : it.skip; @@ -67,10 +66,6 @@ function investigationOutcome(nextType: "act" | "watch"): InvestigationOutcome { }; } -function call(procedure: T, context: Context) { - return createProcedureClient(procedure, { context }); -} - async function seedExecutableGoalAction() { const member = await signUp(); const organization = await insertOrganization(); @@ -692,15 +687,6 @@ describe("insight investigation timeline", () => { type: "resolve", }, publish: true, - recommendation: { - action: - "Add “Counts completed signup events” to Signup completed’s description.", - changes: { - description: "Counts completed signup events.", - name: null, - }, - operation: "edit", - }, rootCause: null, summary: "Signup conversion improved from 40% to 50%.", title: "Signup conversion improved", @@ -796,15 +782,6 @@ describe("insight investigation timeline", () => { expect(firstPage.insights[0]).toMatchObject({ impact: "Ten more visitors completed signup per 100 entrants.", investigationId: null, - recommendation: { - action: - "Add “Counts completed signup events” to Signup completed’s description.", - changes: { - description: "Counts completed signup events.", - name: null, - }, - operation: "edit", - }, signal: { changePercent: 25, sentiment: "positive", @@ -828,677 +805,9 @@ describe("insight investigation timeline", () => { websiteId: secondWebsite.id, }); expect(websiteOnly.insights).toHaveLength(1); - expect(websiteOnly.insights[0]?.recommendation).toBeNull(); expect(websiteOnly.insights[0]?.websiteId).toBe(secondWebsite.id); }); - iit("returns only the current recommendation for each signal", async () => { - const member = await signUp(); - const organization = await insertOrganization(); - await addToOrganization(member.id, organization.id, "member"); - const website = await insertWebsite({ organizationId: organization.id }); - const secondWebsite = await insertWebsite({ - organizationId: organization.id, - }); - const emptyWebsite = await insertWebsite({ - organizationId: organization.id, - }); - const otherOrganization = await insertOrganization(); - const otherWebsite = await insertWebsite({ - organizationId: otherOrganization.id, - }); - const recommendationOutcome = ( - title: string, - action: string, - operation: "edit" | "delete" | null = "edit" - ): InvestigationOutcome => ({ - evidence: [`${title} is supported by current analytics.`], - impact: null, - next: { - reason: "This suggestion does not need an investigation.", - type: "resolve", - }, - publish: true, - recommendation: { - action, - changes: - operation === "edit" - ? { description: `${title} definition.`, name: null } - : null, - operation, - }, - rootCause: null, - summary: `${title} has a concrete improvement available.`, - title, - }); - const observation = (input: { - action?: string; - asOf: string; - createdAt?: string; - entity?: { id: string; label: string; type: "goal" }; - organizationId?: string; - operation?: "edit" | "delete" | null; - publish?: boolean; - signalKey: string; - title: string; - websiteId?: string; - }) => { - const outcome = input.action - ? recommendationOutcome( - input.title, - input.action, - input.operation === undefined ? "edit" : input.operation - ) - : { - ...investigationOutcome("watch"), - recommendation: null, - title: input.title, - }; - outcome.publish = input.publish ?? true; - return { - asOf: new Date(input.asOf), - createdAt: new Date(input.createdAt ?? input.asOf), - id: randomUUIDv7(), - insightId: null, - organizationId: input.organizationId ?? organization.id, - outcome, - recheckAt: new Date("2026-02-01T00:00:00.000Z"), - signal: input.entity - ? { ...signal(input.signalKey), entity: input.entity } - : signal(input.signalKey), - signalKey: input.signalKey, - websiteId: input.websiteId ?? website.id, - }; - }; - - await db().insert(insightObservations).values([ - observation({ - action: "Use the original signup goal.", - asOf: "2026-01-01T00:00:00.000Z", - signalKey: "goal:signup", - title: "Original signup recommendation", - }), - observation({ - asOf: "2026-01-03T00:00:00.000Z", - publish: false, - signalKey: "goal:signup", - title: "Routine signup recheck", - }), - observation({ - action: "Use the updated signup goal.", - asOf: "2026-01-02T00:00:00.000Z", - signalKey: "goal:signup", - title: "Updated signup recommendation", - }), - observation({ - action: "Add the measured checkout goal.", - asOf: "2026-01-04T00:00:00.000Z", - publish: false, - signalKey: "goal:checkout", - title: "Checkout recommendation", - }), - observation({ - action: "Use the old activation goal.", - asOf: "2026-01-05T00:00:00.000Z", - signalKey: "goal:stale", - title: "Stale recommendation", - }), - observation({ - asOf: "2026-01-06T00:00:00.000Z", - signalKey: "goal:stale", - title: "Stale recommendation retired", - }), - observation({ - action: "Add the activation goal.", - asOf: "2026-01-07T00:00:00.000Z", - signalKey: "goal:activation", - title: "Activation recommendation", - websiteId: secondWebsite.id, - }), - observation({ - action: "Do not expose this recommendation.", - asOf: "2026-01-08T00:00:00.000Z", - organizationId: otherOrganization.id, - signalKey: "goal:other", - title: "Other organization recommendation", - websiteId: otherWebsite.id, - }), - observation({ - action: "Keep this display-only suggestion out of the feed.", - asOf: "2026-01-09T00:00:00.000Z", - operation: null, - signalKey: "goal:display-only", - title: "Display-only suggestion", - }), - ]); - - const context = userContext(member, organization.id); - const firstPage = await call(appRouter.insights.recommendations, context)({ - limit: 10, - offset: 0, - organizationId: organization.id, - }); - expect(firstPage.hasMore).toBe(false); - expect(firstPage.total).toBe(2); - expect( - firstPage.recommendations.map((item) => item.recommendation.action) - ).toEqual([ - "Add the activation goal.", - "Add the measured checkout goal.", - ]); - - const websiteOnly = await call( - appRouter.insights.recommendations, - context - )({ - limit: 10, - offset: 0, - organizationId: organization.id, - websiteId: website.id, - }); - expect( - websiteOnly.recommendations.map((item) => item.recommendation.action) - ).toEqual(["Add the measured checkout goal."]); - expect(websiteOnly.total).toBe(1); - - const emptyScope = await call(appRouter.insights.recommendations, context)({ - limit: 10, - offset: 0, - organizationId: organization.id, - websiteId: emptyWebsite.id, - }); - expect(emptyScope).toMatchObject({ - hasMore: false, - recommendations: [], - total: 0, - }); - - const editableGoalId = randomUUIDv7(); - const deletedGoalId = randomUUIDv7(); - await db().insert(goals).values([ - { - createdBy: member.id, - description: "An incomplete definition.", - id: editableGoalId, - name: "Tracked signup", - target: "signup_completed", - type: "EVENT", - websiteId: website.id, - }, - { - createdBy: member.id, - id: deletedGoalId, - name: "Retired signup", - target: "signup_started", - type: "EVENT", - websiteId: website.id, - }, - ]); - await db().insert(insightObservations).values([ - observation({ - action: "Clarify the tracked signup goal.", - asOf: "2026-01-10T00:00:00.000Z", - entity: { - id: editableGoalId, - label: "Tracked signup", - type: "goal", - }, - signalKey: "goal:tracked-signup", - title: "Tracked signup", - }), - observation({ - action: "Delete the retired signup goal.", - asOf: "2026-01-11T00:00:00.000Z", - entity: { - id: deletedGoalId, - label: "Retired signup", - type: "goal", - }, - operation: "delete", - signalKey: "goal:retired-signup", - title: "Retired signup", - }), - ]); - const currentDefinitionActions = await call( - appRouter.insights.recommendations, - context - )({ limit: 10, offset: 0, organizationId: organization.id }); - expect( - currentDefinitionActions.recommendations.map( - (item) => item.recommendation.action - ) - ).toEqual( - expect.arrayContaining([ - "Clarify the tracked signup goal.", - "Delete the retired signup goal.", - ]) - ); - - const completedAt = new Date("2026-01-12T00:00:00.000Z"); - await db() - .update(goals) - .set({ description: "Tracked signup definition." }) - .where(eq(goals.id, editableGoalId)); - await db() - .update(goals) - .set({ deletedAt: completedAt, isActive: false }) - .where(eq(goals.id, deletedGoalId)); - const completedDefinitionActions = await call( - appRouter.insights.recommendations, - context - )({ limit: 10, offset: 0, organizationId: organization.id }); - expect( - completedDefinitionActions.recommendations.map( - (item) => item.recommendation.action - ) - ).not.toEqual( - expect.arrayContaining([ - "Clarify the tracked signup goal.", - "Delete the retired signup goal.", - ]) - ); - expect( - completedDefinitionActions.completed - .map((item) => item.recommendation.action) - .sort() - ).toEqual([ - "Clarify the tracked signup goal.", - "Delete the retired signup goal.", - ]); - }); - - iit("expires standalone setup recommendations at their renewal deadline", async () => { - const member = await signUp(); - const organization = await insertOrganization(); - await addToOrganization(member.id, organization.id, "member"); - const website = await insertWebsite({ organizationId: organization.id }); - const now = Date.now(); - const expired = new Date(now - 60_000); - const fresh = new Date(now + 24 * 60 * 60_000); - const standaloneRecommendation = ( - action: string, - kind: "databuddy_setup" | "instrumentation" - ): InvestigationOutcome => ({ - evidence: ["The current setup leaves one product question unanswered."], - impact: null, - next: { - reason: "This setup recommendation does not need an investigation.", - type: "resolve", - }, - publish: false, - recommendation: - kind === "instrumentation" - ? { - action, - events: [ - { - description: - "Measure only after the confirmed signup outcome.", - name: "signup_completed", - }, - ], - kind, - } - : { action, feature: "tracking", kind }, - rootCause: null, - summary: "The current setup cannot answer the measured product question.", - title: "Product behavior needs setup", - }); - const activeSetup: InvestigationOutcome = { - ...standaloneRecommendation( - "Keep the active identity recommendation.", - "databuddy_setup" - ), - impact: "The affected error cohort cannot yet be tied to profiles.", - next: { - question: - "Can you connect the repository that owns the affected application?", - type: "ask", - }, - publish: true, - }; - const observation = ( - signalKey: string, - outcome: InvestigationOutcome, - recheckAt: Date - ) => ({ - asOf: new Date(now - 120_000), - createdAt: new Date(now - 120_000), - id: randomUUIDv7(), - insightId: null, - organizationId: organization.id, - outcome, - recheckAt, - signal: signal(signalKey), - signalKey, - websiteId: website.id, - }); - - await db().insert(insightObservations).values([ - observation( - "measurement:expired-instrumentation", - standaloneRecommendation( - "Add the expired completion event.", - "instrumentation" - ), - expired - ), - observation( - "measurement:fresh-instrumentation", - standaloneRecommendation( - "Add the current completion event.", - "instrumentation" - ), - fresh - ), - observation( - "measurement:expired-setup", - standaloneRecommendation( - "Add the expired tracking setup.", - "databuddy_setup" - ), - expired - ), - observation( - "measurement:fresh-setup", - standaloneRecommendation( - "Add the current tracking setup.", - "databuddy_setup" - ), - fresh - ), - observation("error:active-setup", activeSetup, expired), - ]); - - const result = await call(appRouter.insights.recommendations, userContext(member, organization.id))({ - limit: 10, - offset: 0, - organizationId: organization.id, - }); - expect(result.total).toBe(3); - expect( - result.recommendations.map((item) => item.recommendation.action).sort() - ).toEqual([ - "Add the current completion event.", - "Add the current tracking setup.", - "Keep the active identity recommendation.", - ]); - expect(result.completed).toEqual([]); - }); - - iit("keeps precise measurement drafts current until their definition exists", async () => { - const member = await signUp(); - const organization = await insertOrganization(); - await addToOrganization(member.id, organization.id, "member"); - const website = await insertWebsite({ organizationId: organization.id }); - const observedAt = new Date("2026-01-10T00:00:00.000Z"); - const goalSignalKey = "measurement:uncovered-event:account_created"; - const funnelSignalKey = "measurement:conversion-coverage"; - const goalDraft = { - evidence: ["An observed completion event is not covered by a goal."], - impact: "The team cannot review this completion as a goal.", - next: { - reason: "The draft is ready for teammate review.", - type: "resolve", - }, - publish: true, - recommendation: { - action: "Review a goal for account creation.", - draft: { - description: "Counts successful account creation.", - filters: [], - ignoreHistoricData: false, - name: "Account created", - target: "account_created", - type: "EVENT", - }, - kind: "goal_draft", - }, - rootCause: null, - summary: "A high-reach completion event has no reviewed goal.", - title: "Account creation lacks a goal", - } satisfies InvestigationOutcome; - const priorGoalDraft = { - ...goalDraft, - recommendation: { - ...goalDraft.recommendation, - draft: { - ...goalDraft.recommendation.draft, - name: "Earlier account creation", - target: "earlier_account_created", - }, - }, - } satisfies InvestigationOutcome; - const funnelDraft: InvestigationOutcome = { - evidence: ["Two inspected steps form an uncovered product journey."], - impact: "The team cannot measure the ordered journey.", - next: { - reason: "The draft is ready for teammate review.", - type: "resolve", - }, - publish: true, - recommendation: { - action: "Review the account-creation journey.", - draft: { - description: "Tracks account creation from the landing page.", - filters: [], - ignoreHistoricData: false, - name: "Landing to account creation", - steps: [ - { name: "Landing", target: "/", type: "PAGE_VIEW" }, - { - name: "Account created", - target: "account_created", - type: "EVENT", - }, - ], - }, - kind: "funnel_draft", - }, - rootCause: null, - summary: "An observed journey has no reviewed funnel.", - title: "Account creation lacks a funnel", - }; - - await db().insert(goals).values({ - createdBy: member.id, - filters: [], - id: randomUUIDv7(), - ignoreHistoricData: false, - name: "Unrelated engagement", - target: "nav_clicked", - type: "EVENT", - websiteId: website.id, - }); - await db().insert(funnelDefinitions).values({ - createdBy: member.id, - filters: [], - id: randomUUIDv7(), - ignoreHistoricData: false, - name: "Unrelated journey", - steps: [ - { name: "Docs", target: "/docs", type: "PAGE_VIEW" }, - { name: "Pricing", target: "/pricing", type: "PAGE_VIEW" }, - ], - websiteId: website.id, - }); - await db().insert(insightObservations).values([ - { - asOf: new Date("2026-01-09T00:00:00.000Z"), - id: randomUUIDv7(), - insightId: null, - organizationId: organization.id, - outcome: priorGoalDraft, - recheckAt: observedAt, - signal: signal(goalSignalKey), - signalKey: goalSignalKey, - websiteId: website.id, - }, - { - asOf: observedAt, - id: randomUUIDv7(), - insightId: null, - organizationId: organization.id, - outcome: goalDraft, - recheckAt: observedAt, - signal: signal(goalSignalKey), - signalKey: goalSignalKey, - websiteId: website.id, - }, - { - asOf: observedAt, - id: randomUUIDv7(), - insightId: null, - organizationId: organization.id, - outcome: funnelDraft, - recheckAt: observedAt, - signal: signal(funnelSignalKey), - signalKey: funnelSignalKey, - websiteId: website.id, - }, - ]); - - const context = userContext(member, organization.id); - const beforeCreation = await call( - appRouter.insights.recommendations, - context - )({ limit: 10, offset: 0, organizationId: organization.id }); - expect(beforeCreation.total).toBe(2); - expect( - beforeCreation.recommendations - .map((item) => item.recommendation.kind) - .sort() - ).toEqual(["funnel_draft", "goal_draft"]); - expect(beforeCreation.completed).toEqual([]); - - await db().insert(goals).values({ - createdBy: member.id, - filters: [], - id: randomUUIDv7(), - ignoreHistoricData: false, - name: "Team account conversion", - target: "account_created", - type: "EVENT", - websiteId: website.id, - }); - const afterGoalCreation = await call( - appRouter.insights.recommendations, - context - )({ limit: 10, offset: 0, organizationId: organization.id }); - expect( - afterGoalCreation.recommendations.map( - (item) => item.recommendation.kind - ) - ).toEqual(["funnel_draft"]); - expect(afterGoalCreation.total).toBe(1); - expect( - afterGoalCreation.completed.map((item) => item.recommendation.kind) - ).toEqual(["goal_draft"]); - - const resolvedGoal: InvestigationOutcome = { - evidence: ["The account_created event is now covered by a goal."], - impact: "The team can review this completion as a goal.", - next: { - reason: "The current goal covers the event.", - type: "resolve", - }, - publish: false, - recommendation: null, - rootCause: null, - summary: "Account creation is now covered by a goal.", - title: "Account creation goal is configured", - }; - await db().insert(insightObservations).values({ - asOf: new Date("2026-01-11T00:00:00.000Z"), - createdAt: new Date("2026-01-11T00:00:00.000Z"), - id: randomUUIDv7(), - insightId: null, - organizationId: organization.id, - outcome: resolvedGoal, - recheckAt: new Date("2026-01-18T00:00:00.000Z"), - signal: signal(goalSignalKey), - signalKey: goalSignalKey, - websiteId: website.id, - }); - const afterGoalRecheck = await call( - appRouter.insights.recommendations, - context - )({ limit: 10, offset: 0, organizationId: organization.id }); - expect( - afterGoalRecheck.recommendations.map((item) => item.recommendation.kind) - ).toEqual(["funnel_draft"]); - expect(afterGoalRecheck.total).toBe(1); - expect( - afterGoalRecheck.completed.map((item) => item.recommendation.kind) - ).toEqual(["goal_draft"]); - - await db().insert(funnelDefinitions).values({ - createdBy: member.id, - filters: [], - id: randomUUIDv7(), - ignoreHistoricData: false, - name: "Conditional account journey", - steps: [ - { - conditions: { source: "ads" }, - name: "Entry", - target: "/", - type: "PAGE_VIEW", - }, - { - name: "Registered account", - target: "account_created", - type: "EVENT", - }, - ], - websiteId: website.id, - }); - const afterConditionalFunnelCreation = await call( - appRouter.insights.recommendations, - context - )({ limit: 10, offset: 0, organizationId: organization.id }); - expect( - afterConditionalFunnelCreation.recommendations.map( - (item) => item.recommendation.kind - ) - ).toEqual(["funnel_draft"]); - expect( - afterConditionalFunnelCreation.completed.map( - (item) => item.recommendation.kind - ) - ).toEqual(["goal_draft"]); - - await db().insert(funnelDefinitions).values({ - createdBy: member.id, - filters: [], - id: randomUUIDv7(), - ignoreHistoricData: false, - name: "Team account journey", - steps: [ - { name: "Entry", target: "/", type: "PAGE_VIEW" }, - { - name: "Registered account", - target: "account_created", - type: "EVENT", - }, - ], - websiteId: website.id, - }); - const afterFunnelCreation = await call( - appRouter.insights.recommendations, - context - )({ limit: 10, offset: 0, organizationId: organization.id }); - expect(afterFunnelCreation).toMatchObject({ - recommendations: [], - total: 0, - }); - expect( - afterFunnelCreation.completed - .map((item) => item.recommendation.kind) - .sort() - ).toEqual(["funnel_draft", "goal_draft"]); - }); - iit("persists a reply beside every observation for the same signal", async () => { const member = await signUp(); const organization = await insertOrganization(); diff --git a/apps/api/src/integration/link-handlers.test.ts b/apps/api/src/integration/link-handlers.test.ts index 82abc8a22b..5ec6c7558a 100644 --- a/apps/api/src/integration/link-handlers.test.ts +++ b/apps/api/src/integration/link-handlers.test.ts @@ -1,8 +1,7 @@ import "@databuddy/test/env"; import { describe, it, expect, beforeEach, afterAll } from "vitest"; -import { createProcedureClient } from "@orpc/server"; -import { appRouter, type Context } from "@databuddy/rpc"; +import { appRouter } from "@databuddy/rpc"; import { reset, cleanup, @@ -14,13 +13,10 @@ import { signUp, addToOrganization, } from "@databuddy/test"; +import { call } from "./helpers"; const iit = hasTestDb ? it : it.skip; -function call(procedure: T, ctx: Context) { - return createProcedureClient(procedure as any, { context: ctx }); -} - beforeEach(() => reset()); afterAll(() => cleanup()); diff --git a/apps/api/src/integration/profile-handlers.test.ts b/apps/api/src/integration/profile-handlers.test.ts index c3524c2dea..0645c6593e 100644 --- a/apps/api/src/integration/profile-handlers.test.ts +++ b/apps/api/src/integration/profile-handlers.test.ts @@ -6,7 +6,7 @@ import { profileTraitChanges, } from "@databuddy/db/schema"; import { eq } from "@databuddy/db"; -import { appRouter, type Context } from "@databuddy/rpc"; +import { appRouter } from "@databuddy/rpc"; import { getTraitDistribution, resolveTraitSegment, @@ -25,15 +25,11 @@ import { signUp, userContext, } from "@databuddy/test"; -import { createProcedureClient } from "@orpc/server"; import { afterAll, beforeEach, describe, expect, it } from "vitest"; +import { call } from "./helpers"; const iit = hasTestDb ? it : it.skip; -function call(procedure: T, ctx: Context) { - return createProcedureClient(procedure as any, { context: ctx }); -} - beforeEach(() => reset()); afterAll(() => cleanup()); diff --git a/apps/api/src/integration/uptime-handlers.test.ts b/apps/api/src/integration/uptime-handlers.test.ts index 1affb5bc35..0d709ce095 100644 --- a/apps/api/src/integration/uptime-handlers.test.ts +++ b/apps/api/src/integration/uptime-handlers.test.ts @@ -23,10 +23,10 @@ import { signUp, userContext, } from "@databuddy/test"; -import { createProcedureClient, type AnyProcedure } from "@orpc/server"; import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest"; import { randomUUIDv7 } from "bun"; import type { Job } from "bullmq"; +import { call } from "./helpers"; const canRun = hasTestDb && @@ -35,10 +35,6 @@ const canRun = const iit = canRun ? it : it.skip; const scheduleIds = new Set(); -function call(procedure: T, context: Context) { - return createProcedureClient(procedure, { context }); -} - beforeEach(async () => { await reset(); scheduleIds.clear(); @@ -103,7 +99,6 @@ async function createSchedule(values: { granularity: "five_minutes" as const, timeout: 5000, cacheBust: true, - jsonParsingConfig: { enabled: false }, }); return scheduleIdFrom(result); } @@ -167,11 +162,9 @@ describe("uptime router BullMQ integration", () => { expect(row?.url).toBe("https://create.example.com/health"); expect(row?.websiteId).toBe(website.id); expect(row?.granularity).toBe("five_minutes"); - expect(row?.cron).toBe("*/5 * * * *"); expect(row?.isPaused).toBe(false); expect(row?.timeout).toBe(5000); expect(row?.cacheBust).toBe(true); - expect(row?.jsonParsingConfig).toEqual({ enabled: false }); expect(await scheduler(scheduleId)).toBeTruthy(); const jobs = await jobsForSchedule(scheduleId); @@ -194,16 +187,13 @@ describe("uptime router BullMQ integration", () => { granularity: "ten_minutes", timeout: null, cacheBust: false, - jsonParsingConfig: { enabled: true }, }); const row = await scheduleRow(scheduleId); expect(row?.name).toBe("Renamed API"); expect(row?.granularity).toBe("ten_minutes"); - expect(row?.cron).toBe("*/10 * * * *"); expect(row?.timeout).toBeNull(); expect(row?.cacheBust).toBe(false); - expect(row?.jsonParsingConfig).toEqual({ enabled: true }); expect(await scheduler(scheduleId)).toBeTruthy(); expect(await jobsForSchedule(scheduleId)).toHaveLength(1); }); diff --git a/apps/api/src/integration/with-workspace.test.ts b/apps/api/src/integration/with-workspace.test.ts index 484f45472d..8572ccd524 100644 --- a/apps/api/src/integration/with-workspace.test.ts +++ b/apps/api/src/integration/with-workspace.test.ts @@ -1,13 +1,7 @@ import "@databuddy/test/env"; import { afterAll, beforeEach, describe, expect, it } from "vitest"; -import { createProcedureClient } from "@orpc/server"; -import { - withWorkspace, - withPublicWorkspace, - appRouter, - type Context, -} from "@databuddy/rpc"; +import { withWorkspace, withPublicWorkspace, appRouter } from "@databuddy/rpc"; import { reset, cleanup, @@ -21,13 +15,10 @@ import { signUp, addToOrganization, } from "@databuddy/test"; +import { call } from "./helpers"; const iit = hasTestDb ? it : it.skip; -function call(procedure: T, ctx: Context) { - return createProcedureClient(procedure as any, { context: ctx }); -} - beforeEach(() => reset()); afterAll(() => cleanup()); diff --git a/apps/api/src/lib/api-key.test.ts b/apps/api/src/lib/api-key.test.ts index dbbba42d7d..761fbce91e 100644 --- a/apps/api/src/lib/api-key.test.ts +++ b/apps/api/src/lib/api-key.test.ts @@ -1,5 +1,41 @@ -import { describe, expect, it } from "vitest"; -import { hasScope, isExpired } from "keypal"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + findFirst: vi.fn(async () => state.row), + lastUsedWrites: 0, + lockReply: "OK" as "OK" | null, + redisSet: vi.fn(async () => state.lockReply), + row: null as unknown, +})); + +vi.mock("@databuddy/db", () => ({ + db: { + transaction: async (fn: (tx: unknown) => Promise) => + fn({ + execute: async () => undefined, + query: { apikey: { findFirst: state.findFirst } }, + }), + update: () => ({ + set: () => ({ + where: async () => { + state.lastUsedWrites += 1; + }, + }), + }), + }, + eq: vi.fn(), + sql: () => "", +})); + +vi.mock("@databuddy/db/schema", () => ({ apikey: {} })); + +vi.mock("@databuddy/redis", () => ({ + cacheNamespaces: { apiKeyByHash: "api-key-by-hash" }, + cacheable: (fn: (...args: never[]) => unknown) => + Object.assign(fn, { invalidate: vi.fn(async () => undefined) }), + redis: { set: state.redisSet }, +})); + import { type ApiKeyRow, extractSecret, @@ -13,158 +49,214 @@ import { hasWebsiteAnyScope, hasWebsiteScope, isApiKeyPresent, + resolveApiKey, + resolveApiKeySecret, resolveEffectiveScopesForWebsite, } from "@databuddy/api-keys/resolve"; -const createMockKey = (overrides: Partial = {}): ApiKeyRow => - ({ +const VALID_SECRET = "dbdy_test123"; + +function createMockKey(overrides: Partial = {}): ApiKeyRow { + const now = new Date("2026-08-01T00:00:00.000Z"); + return { + createdAt: now, + enabled: true, + expiresAt: null, id: "key-123", - name: "Test Key", - prefix: "dbdy", - start: "dbdy_abc", keyHash: "hashed", - userId: "user-1", + lastUsedAt: null, + metadata: {}, + name: "Test Key", organizationId: null, - type: "user", - scopes: ["read:data", "write:data"], - enabled: true, - revokedAt: null, - expiresAt: null, + prefix: "dbdy", rateLimitEnabled: true, rateLimitMax: null, rateLimitTimeWindow: null, - metadata: {}, - createdAt: new Date(), - updatedAt: new Date(), + revokedAt: null, + scopes: ["read:data", "write:data"], + start: "dbdy_abc", + type: "user", + updatedAt: now, + userId: "user-1", ...overrides, - }) as ApiKeyRow; + }; +} + +beforeEach(() => { + state.findFirst.mockClear(); + state.lastUsedWrites = 0; + state.lockReply = "OK"; + state.redisSet.mockClear(); + state.row = null; +}); describe("isApiKeyPresent", () => { - it("returns true when x-api-key header is present", () => { - const headers = new Headers({ "x-api-key": "dbdy_test123" }); - expect(isApiKeyPresent(headers)).toBe(true); - }); - - it("returns true when Bearer token is present", () => { - const headers = new Headers({ authorization: "Bearer dbdy_test123" }); - expect(isApiKeyPresent(headers)).toBe(true); - }); - - it("returns false when no API key headers", () => { - const headers = new Headers({}); - expect(isApiKeyPresent(headers)).toBe(false); - }); - - it("returns false for non-Bearer authorization", () => { - const headers = new Headers({ authorization: "Basic dXNlcjpwYXNz" }); - expect(isApiKeyPresent(headers)).toBe(false); - }); - - it("returns true for lowercase bearer", () => { - const headers = new Headers({ authorization: "bearer dbdy_test123" }); - expect(isApiKeyPresent(headers)).toBe(true); - }); - - it("returns false for empty x-api-key", () => { - const headers = new Headers({ "x-api-key": "" }); - expect(isApiKeyPresent(headers)).toBe(false); + it.each([ + ["x-api-key header", { "x-api-key": VALID_SECRET }, true], + ["Bearer token", { authorization: `Bearer ${VALID_SECRET}` }, true], + ["lowercase bearer", { authorization: `bearer ${VALID_SECRET}` }, true], + ["no headers", {}, false], + ["Basic authorization", { authorization: "Basic dXNlcjpwYXNz" }, false], + ["empty x-api-key", { "x-api-key": "" }, false], + ])("%s -> %s", (_name, headers, expected) => { + expect(isApiKeyPresent(new Headers(headers))).toBe(expected); }); }); describe("extractSecret", () => { - it("extracts x-api-key header", () => { - const headers = new Headers({ "x-api-key": "dbdy_test123" }); - expect(extractSecret(headers)).toBe("dbdy_test123"); - }); - - it("extracts Bearer token from authorization", () => { - const headers = new Headers({ authorization: "Bearer dbdy_test123" }); - expect(extractSecret(headers)).toBe("dbdy_test123"); + it.each([ + ["x-api-key header", { "x-api-key": VALID_SECRET }, VALID_SECRET], + [ + "Bearer token", + { authorization: `Bearer ${VALID_SECRET}` }, + VALID_SECRET, + ], + [ + "x-api-key over Bearer", + { + authorization: "Bearer dbdy_bearer_token", + "x-api-key": "dbdy_xapikey_token", + }, + "dbdy_xapikey_token", + ], + [ + "whitespace around x-api-key", + { "x-api-key": ` ${VALID_SECRET} ` }, + VALID_SECRET, + ], + [ + "whitespace around Bearer token", + { authorization: `Bearer ${VALID_SECRET} ` }, + VALID_SECRET, + ], + [ + "case-insensitive Bearer", + { authorization: `BEARER ${VALID_SECRET}` }, + VALID_SECRET, + ], + ["no headers", {}, null], + ["Basic authorization", { authorization: "Basic dXNlcjpwYXNz" }, null], + ["whitespace-only x-api-key", { "x-api-key": " " }, null], + ["empty Bearer token", { authorization: "Bearer " }, null], + [ + "Bearer token without dbdy_ prefix", + { authorization: "Bearer invalid_token" }, + null, + ], + ["Bearer token below minimum length", { authorization: "Bearer dbdy_" }, null], + [ + "Bearer token above maximum length", + { authorization: `Bearer dbdy_${"a".repeat(200)}` }, + null, + ], + ["x-api-key without dbdy_ prefix", { "x-api-key": "invalid_token" }, null], + ["x-api-key below minimum length", { "x-api-key": "dbdy_" }, null], + [ + "x-api-key above maximum length", + { "x-api-key": `dbdy_${"a".repeat(200)}` }, + null, + ], + ])("%s", (_name, headers, expected) => { + expect(extractSecret(new Headers(headers))).toBe(expected); }); +}); - it("prefers x-api-key over Bearer", () => { - const headers = new Headers({ - "x-api-key": "dbdy_xapikey", - authorization: "Bearer dbdy_bearer", +describe("resolveApiKeySecret", () => { + it.each([ + ["wrong prefix", `sk_live_${"a".repeat(20)}`], + ["below minimum length", "dbdy_a"], + ["above maximum length", `dbdy_${"a".repeat(200)}`], + ])("rejects %s without hitting the database", async (_name, secret) => { + await expect(resolveApiKeySecret(secret)).resolves.toEqual({ + key: null, + outcome: "invalid", }); - expect(extractSecret(headers)).toBe("dbdy_xapikey"); + expect(state.findFirst).not.toHaveBeenCalled(); }); - it("returns null when no API key present", () => { - const headers = new Headers({}); - expect(extractSecret(headers)).toBeNull(); - }); + it("reports unknown secrets as invalid with prefix diagnostics", async () => { + const secret = "dbdy_unknown_secret_value"; - it("returns null for non-Bearer authorization", () => { - const headers = new Headers({ authorization: "Basic dXNlcjpwYXNz" }); - expect(extractSecret(headers)).toBeNull(); + await expect(resolveApiKeySecret(secret)).resolves.toEqual({ + key: null, + outcome: "invalid", + prefix: "dbdy", + start: secret.slice(0, 8), + }); + expect(state.findFirst).toHaveBeenCalledTimes(1); }); - it("trims whitespace from x-api-key", () => { - const headers = new Headers({ "x-api-key": " dbdy_test123 " }); - expect(extractSecret(headers)).toBe("dbdy_test123"); - }); + it.each([ + ["disabled", { enabled: false }], + ["revoked", { revokedAt: new Date("2026-08-01T00:00:00.000Z") }], + ["expired", { expiresAt: new Date(Date.now() - 1000) }], + ])("returns no key for a %s key", async (outcome, overrides) => { + state.row = createMockKey(overrides); - it("trims whitespace from Bearer token", () => { - const headers = new Headers({ authorization: "Bearer dbdy_test123 " }); - expect(extractSecret(headers)).toBe("dbdy_test123"); - }); + const result = await resolveApiKeySecret(VALID_SECRET); - it("handles case-insensitive Bearer", () => { - const headers = new Headers({ authorization: "bearer dbdy_test123" }); - expect(extractSecret(headers)).toBe("dbdy_test123"); + expect(result.outcome).toBe(outcome); + expect(result.key).toBeNull(); }); - it("returns null for empty x-api-key after trim", () => { - const headers = new Headers({ "x-api-key": " " }); - expect(extractSecret(headers)).toBeNull(); - }); + it("resolves an enabled key with a future expiration", async () => { + state.row = createMockKey({ + expiresAt: new Date(Date.now() + 86_400_000), + }); - it("handles BEARER in uppercase", () => { - const headers = new Headers({ authorization: "BEARER dbdy_test123" }); - expect(extractSecret(headers)).toBe("dbdy_test123"); - }); + const result = await resolveApiKeySecret(VALID_SECRET); - it("rejects Bearer token without dbdy_ prefix", () => { - const headers = new Headers({ authorization: "Bearer invalid_token" }); - expect(extractSecret(headers)).toBeNull(); + expect(result.outcome).toBe("ok"); + expect(result.key?.id).toBe("key-123"); + expect(result.prefix).toBe("dbdy"); + expect(result.start).toBe(VALID_SECRET.slice(0, 8)); }); - it("rejects Bearer token that is too short", () => { - const headers = new Headers({ authorization: "Bearer dbdy_" }); - expect(extractSecret(headers)).toBeNull(); - }); + it("records last-used once per debounce window without blocking resolution", async () => { + state.row = createMockKey(); - it("rejects Bearer token that is too long", () => { - const longToken = "dbdy_" + "a".repeat(200); - const headers = new Headers({ authorization: `Bearer ${longToken}` }); - expect(extractSecret(headers)).toBeNull(); - }); + await resolveApiKeySecret(VALID_SECRET); + await vi.waitFor(() => expect(state.lastUsedWrites).toBe(1)); + expect(state.redisSet).toHaveBeenCalledWith( + "api-key:last-used-lock:key-123", + "1", + "EX", + expect.any(Number), + "NX" + ); - it("rejects empty Bearer token", () => { - const headers = new Headers({ authorization: "Bearer " }); - expect(extractSecret(headers)).toBeNull(); + state.lockReply = null; + await resolveApiKeySecret(VALID_SECRET); + await vi.waitFor(() => expect(state.redisSet).toHaveBeenCalledTimes(2)); + expect(state.lastUsedWrites).toBe(1); }); +}); - it("rejects Bearer token with only whitespace", () => { - const headers = new Headers({ authorization: "Bearer " }); - expect(extractSecret(headers)).toBeNull(); +describe("resolveApiKey", () => { + it("returns missing when no API key headers are present", async () => { + await expect(resolveApiKey(new Headers())).resolves.toEqual({ + key: null, + outcome: "missing", + }); + expect(state.findFirst).not.toHaveBeenCalled(); }); - it("rejects x-api-key without dbdy_ prefix", () => { - const headers = new Headers({ "x-api-key": "invalid_token" }); - expect(extractSecret(headers)).toBeNull(); + it("returns invalid when a header is present but malformed", async () => { + await expect( + resolveApiKey(new Headers({ "x-api-key": "invalid_token" })) + ).resolves.toEqual({ key: null, outcome: "invalid" }); + expect(state.findFirst).not.toHaveBeenCalled(); }); - it("rejects x-api-key that is too short", () => { - const headers = new Headers({ "x-api-key": "dbdy_" }); - expect(extractSecret(headers)).toBeNull(); - }); + it("resolves a well-formed header against the database", async () => { + state.row = createMockKey(); + + const result = await resolveApiKey( + new Headers({ "x-api-key": VALID_SECRET }) + ); - it("rejects x-api-key that is too long", () => { - const longToken = "dbdy_" + "a".repeat(200); - const headers = new Headers({ "x-api-key": longToken }); - expect(extractSecret(headers)).toBeNull(); + expect(result.outcome).toBe("ok"); + expect(result.key?.id).toBe("key-123"); }); }); @@ -173,519 +265,209 @@ describe("getEffectiveScopes", () => { expect(getEffectiveScopes(null)).toEqual([]); }); - it("returns key scopes when no resources", () => { + it("returns base scopes when metadata has no resources", () => { const key = createMockKey({ scopes: ["read:data", "write:data"] }); - const scopes = getEffectiveScopes(key); - expect(scopes).toContain("read:data"); - expect(scopes).toContain("write:data"); - expect(scopes).toHaveLength(2); - }); - - it("returns key scopes when resources is empty", () => { - const key = createMockKey({ - scopes: ["read:data"], - metadata: { resources: {} }, - }); - const scopes = getEffectiveScopes(key); - expect(scopes).toEqual(["read:data"]); + expect(getEffectiveScopes(key).sort()).toEqual(["read:data", "write:data"]); }); - it("includes global resource scopes", () => { + it("handles null metadata", () => { const key = createMockKey({ + metadata: null as unknown as Record, scopes: ["read:data"], - metadata: { resources: { global: ["admin:apikeys"] } }, }); - const scopes = getEffectiveScopes(key); - expect(scopes).toContain("read:data"); - expect(scopes).toContain("admin:apikeys"); + expect(getEffectiveScopes(key)).toEqual(["read:data"]); }); - it("includes resource-specific scopes when resource matches", () => { + it("combines base, global, and matching resource scopes", () => { const key = createMockKey({ - scopes: ["read:data"], metadata: { resources: { + global: ["track:events"], "website:site-123": ["write:data", "read:analytics"], }, }, - }); - - const scopes = getEffectiveScopes(key, "website:site-123"); - expect(scopes).toContain("read:data"); - expect(scopes).toContain("write:data"); - expect(scopes).toContain("read:analytics"); - }); - - it("does not include resource scopes when resource does not match", () => { - const key = createMockKey({ scopes: ["read:data"], - metadata: { - resources: { - "website:site-123": ["write:data"], - }, - }, }); - const scopes = getEffectiveScopes(key, "website:site-456"); - expect(scopes).toContain("read:data"); - expect(scopes).not.toContain("write:data"); + expect(getEffectiveScopes(key, "website:site-123").sort()).toEqual([ + "read:analytics", + "read:data", + "track:events", + "write:data", + ]); }); - it("combines global and resource-specific scopes", () => { + it("excludes scopes of non-matching resources", () => { const key = createMockKey({ + metadata: { resources: { "website:site-123": ["write:data"] } }, scopes: ["read:data"], - metadata: { - resources: { - global: ["track:events"], - "website:site-123": ["write:data"], - }, - }, }); - const scopes = getEffectiveScopes(key, "website:site-123"); - expect(scopes).toContain("read:data"); - expect(scopes).toContain("track:events"); - expect(scopes).toContain("write:data"); + expect(getEffectiveScopes(key, "website:site-456")).toEqual(["read:data"]); }); - it("deduplicates scopes", () => { + it("deduplicates scopes repeated across base and resources", () => { const key = createMockKey({ - scopes: ["read:data"], metadata: { resources: { global: ["read:data"], "website:site-123": ["read:data"], }, }, - }); - - const scopes = getEffectiveScopes(key, "website:site-123"); - expect(scopes.filter((s) => s === "read:data")).toHaveLength(1); - }); - - it("handles key with empty scopes array", () => { - const key = createMockKey({ - scopes: [], - metadata: { resources: { global: ["read:data"] } }, - }); - const scopes = getEffectiveScopes(key); - expect(scopes).toEqual(["read:data"]); - }); - - it("handles null metadata", () => { - const key = createMockKey({ scopes: ["read:data"], - metadata: null as unknown as Record, }); - const scopes = getEffectiveScopes(key); - expect(scopes).toEqual(["read:data"]); - }); -}); -describe("hasKeyScope", () => { - it("returns false for null key", () => { - expect(hasKeyScope(null, "read:data")).toBe(false); - }); - - it("returns true when key has scope in base scopes", () => { - const key = createMockKey({ scopes: ["read:data", "write:data"] }); - expect(hasKeyScope(key, "read:data")).toBe(true); - }); - - it("returns false when key does not have scope", () => { - const key = createMockKey({ scopes: ["read:data"] }); - expect(hasKeyScope(key, "admin:apikeys")).toBe(false); - }); - - it("checks resource-specific scopes with matching resource", () => { - const key = createMockKey({ - scopes: [], - metadata: { - resources: { "website:site-123": ["read:analytics"] }, - }, - }); - - expect(hasKeyScope(key, "read:analytics", "website:site-123")).toBe(true); - }); - - it("returns false for resource-specific scopes with non-matching resource", () => { - const key = createMockKey({ - scopes: [], - metadata: { - resources: { "website:site-123": ["read:analytics"] }, - }, - }); - - expect(hasKeyScope(key, "read:analytics", "website:site-456")).toBe(false); - }); - - it("checks global scopes even when resource is specified", () => { - const key = createMockKey({ - scopes: ["read:data"], - metadata: {}, - }); - - expect(hasKeyScope(key, "read:data", "website:site-123")).toBe(true); + expect(getEffectiveScopes(key, "website:site-123")).toEqual(["read:data"]); }); }); -describe("hasKeyAnyScope", () => { - it("returns false for null key", () => { +describe("scope predicates", () => { + it("all predicates deny a null key", () => { + expect(hasKeyScope(null, "read:data")).toBe(false); expect(hasKeyAnyScope(null, ["read:data"])).toBe(false); + expect(hasKeyAllScopes(null, ["read:data"])).toBe(false); + expect(hasWebsiteScope(null, "site-123", "read:data")).toBe(false); + expect(hasWebsiteAnyScope(null, "site-123", ["read:data"])).toBe(false); + expect(hasWebsiteAllScopes(null, "site-123", ["read:data"])).toBe(false); + expect(hasGlobalAccess(null)).toBe(false); + expect(resolveEffectiveScopesForWebsite(null, "site-123").size).toBe(0); + expect(getAccessibleWebsiteIds(null)).toEqual([]); }); - it("returns true when key has any of the scopes", () => { - const key = createMockKey({ scopes: ["read:data"] }); - expect(hasKeyAnyScope(key, ["read:data", "write:data"])).toBe(true); - }); - - it("returns false when key has none of the scopes", () => { - const key = createMockKey({ scopes: ["track:events"] }); - expect(hasKeyAnyScope(key, ["read:data", "write:data"])).toBe(false); - }); + it("hasKeyScope checks base, resource, and global scopes", () => { + const base = createMockKey({ scopes: ["read:data"] }); + expect(hasKeyScope(base, "read:data")).toBe(true); + expect(hasKeyScope(base, "admin:apikeys")).toBe(false); + expect(hasKeyScope(base, "read:data", "website:site-123")).toBe(true); - it("checks resource-specific scopes", () => { - const key = createMockKey({ - scopes: [], + const scoped = createMockKey({ metadata: { resources: { "website:site-123": ["read:analytics"] } }, + scopes: [], }); - expect( - hasKeyAnyScope(key, ["read:analytics", "write:data"], "website:site-123") - ).toBe(true); - }); -}); - -describe("hasKeyAllScopes", () => { - it("returns false for null key", () => { - expect(hasKeyAllScopes(null, ["read:data"])).toBe(false); - }); - - it("returns true when key has all scopes", () => { - const key = createMockKey({ scopes: ["read:data", "write:data"] }); - expect(hasKeyAllScopes(key, ["read:data", "write:data"])).toBe(true); + expect(hasKeyScope(scoped, "read:analytics", "website:site-123")).toBe( + true + ); + expect(hasKeyScope(scoped, "read:analytics", "website:site-456")).toBe( + false + ); }); - it("returns false when key is missing a scope", () => { + it("hasKeyAnyScope passes when any scope matches", () => { const key = createMockKey({ scopes: ["read:data"] }); - expect(hasKeyAllScopes(key, ["read:data", "write:data"])).toBe(false); + expect(hasKeyAnyScope(key, ["read:data", "write:data"])).toBe(true); + expect(hasKeyAnyScope(key, ["track:events", "write:data"])).toBe(false); }); - it("combines base and resource scopes", () => { + it("hasKeyAllScopes requires every scope across base and resources", () => { const key = createMockKey({ - scopes: ["read:data"], metadata: { resources: { "website:site-123": ["write:data"] } }, + scopes: ["read:data"], }); expect( hasKeyAllScopes(key, ["read:data", "write:data"], "website:site-123") ).toBe(true); + expect(hasKeyAllScopes(key, ["read:data", "write:data"])).toBe(false); }); }); -describe("resolveEffectiveScopesForWebsite", () => { - it("returns empty set for null key", () => { - const scopes = resolveEffectiveScopesForWebsite(null, "site-123"); - expect(scopes.size).toBe(0); - }); - - it("returns scopes for website resource", () => { - const key = createMockKey({ - scopes: ["read:data"], - metadata: { - resources: { "website:site-123": ["write:data"] }, - }, - }); - - const scopes = resolveEffectiveScopesForWebsite(key, "site-123"); - expect(scopes.has("read:data")).toBe(true); - expect(scopes.has("write:data")).toBe(true); - }); - - it("formats websiteId with website: prefix", () => { - const key = createMockKey({ - scopes: [], - metadata: { - resources: { "website:my-site": ["read:analytics"] }, - }, - }); - - const scopes = resolveEffectiveScopesForWebsite(key, "my-site"); - expect(scopes.has("read:analytics")).toBe(true); - }); - - it("includes global scopes", () => { - const key = createMockKey({ - scopes: [], - metadata: { - resources: { global: ["track:events"] }, - }, - }); - - const scopes = resolveEffectiveScopesForWebsite(key, "site-123"); - expect(scopes.has("track:events")).toBe(true); - }); -}); - -describe("hasWebsiteScope", () => { - it("returns false for null key", () => { - expect(hasWebsiteScope(null, "site-123", "read:data")).toBe(false); - }); - - it("returns true when key has website-specific scope", () => { +describe("website scope helpers", () => { + it("hasWebsiteScope resolves the website resource prefix", () => { const key = createMockKey({ + metadata: { resources: { "website:site-123": ["read:analytics"] } }, scopes: [], - metadata: { - resources: { "website:site-123": ["read:analytics"] }, - }, }); - expect(hasWebsiteScope(key, "site-123", "read:analytics")).toBe(true); + expect(hasWebsiteScope(key, "site-456", "read:analytics")).toBe(false); }); - it("returns true when key has scope in base scopes", () => { - const key = createMockKey({ scopes: ["read:data"] }); - expect(hasWebsiteScope(key, "site-123", "read:data")).toBe(true); - }); - - it("returns true when key has scope in global resources", () => { - const key = createMockKey({ - scopes: [], - metadata: { resources: { global: ["track:events"] } }, - }); - expect(hasWebsiteScope(key, "site-123", "track:events")).toBe(true); - }); - - it("returns false when key lacks required scope", () => { - const key = createMockKey({ scopes: ["read:data"] }); - expect(hasWebsiteScope(key, "site-123", "admin:apikeys")).toBe(false); - }); - - it("returns false when scope exists for different website", () => { - const key = createMockKey({ - scopes: [], - metadata: { - resources: { "website:site-456": ["read:analytics"] }, - }, - }); - - expect(hasWebsiteScope(key, "site-123", "read:analytics")).toBe(false); - }); -}); - -describe("hasWebsiteAnyScope", () => { - it("returns false for null key", () => { - expect(hasWebsiteAnyScope(null, "site-123", ["read:data"])).toBe(false); - }); - - it("returns true when key has any of the scopes for website", () => { - const key = createMockKey({ - scopes: [], - metadata: { resources: { "website:site-123": ["read:analytics"] } }, - }); + it("hasWebsiteScope accepts base and global scopes for any website", () => { expect( - hasWebsiteAnyScope(key, "site-123", ["read:analytics", "write:data"]) + hasWebsiteScope( + createMockKey({ scopes: ["read:data"] }), + "site-123", + "read:data" + ) ).toBe(true); - }); - - it("returns false when key has none of the scopes", () => { - const key = createMockKey({ scopes: ["track:events"] }); expect( - hasWebsiteAnyScope(key, "site-123", ["read:analytics", "write:data"]) - ).toBe(false); - }); -}); - -describe("hasWebsiteAllScopes", () => { - it("returns false for null key", () => { - expect(hasWebsiteAllScopes(null, "site-123", ["read:data"])).toBe(false); + hasWebsiteScope( + createMockKey({ + metadata: { resources: { global: ["track:events"] } }, + scopes: [], + }), + "site-123", + "track:events" + ) + ).toBe(true); }); - it("returns true when key has all scopes for website", () => { + it("hasWebsiteAnyScope and hasWebsiteAllScopes evaluate against the website resource", () => { const key = createMockKey({ + metadata: { resources: { "website:site-123": ["read:analytics"] } }, scopes: ["read:data"], - metadata: { resources: { "website:site-123": ["write:data"] } }, }); expect( - hasWebsiteAllScopes(key, "site-123", ["read:data", "write:data"]) + hasWebsiteAnyScope(key, "site-123", ["read:analytics", "write:data"]) + ).toBe(true); + expect(hasWebsiteAnyScope(key, "site-456", ["read:analytics"])).toBe(false); + expect( + hasWebsiteAllScopes(key, "site-123", ["read:data", "read:analytics"]) ).toBe(true); - }); - - it("returns false when key is missing a scope", () => { - const key = createMockKey({ - scopes: [], - metadata: { resources: { "website:site-123": ["read:analytics"] } }, - }); expect( hasWebsiteAllScopes(key, "site-123", ["read:analytics", "write:data"]) ).toBe(false); }); -}); - -describe("key validity simulation (matches getApiKeyFromHeader logic)", () => { - const isKeyValid = (key: ApiKeyRow | null): boolean => { - if (!key?.enabled || key.revokedAt || isExpired(key.expiresAt)) { - return false; - } - return true; - }; - - it("returns false for null key", () => { - expect(isKeyValid(null)).toBe(false); - }); - - it("returns false for disabled key", () => { - const key = createMockKey({ enabled: false }); - expect(isKeyValid(key)).toBe(false); - }); - - it("returns false for revoked key", () => { - const key = createMockKey({ revokedAt: new Date() }); - expect(isKeyValid(key)).toBe(false); - }); - - it("returns false for expired key", () => { - const key = createMockKey({ - expiresAt: new Date(Date.now() - 1000).toISOString(), - }); - expect(isKeyValid(key)).toBe(false); - }); - - it("returns true for valid enabled key", () => { - const key = createMockKey({ - enabled: true, - revokedAt: null, - expiresAt: null, - }); - expect(isKeyValid(key)).toBe(true); - }); - it("returns true for key with future expiration", () => { + it("resolveEffectiveScopesForWebsite returns the combined scope set", () => { const key = createMockKey({ - enabled: true, - expiresAt: new Date(Date.now() + 86_400_000).toISOString(), - }); - expect(isKeyValid(key)).toBe(true); - }); - - it("returns false for disabled key even with valid expiration", () => { - const key = createMockKey({ - enabled: false, - expiresAt: new Date(Date.now() + 86_400_000).toISOString(), + metadata: { + resources: { + global: ["track:events"], + "website:site-123": ["write:data"], + }, + }, + scopes: ["read:data"], }); - expect(isKeyValid(key)).toBe(false); - }); - it("returns false for revoked key even if enabled", () => { - const key = createMockKey({ - enabled: true, - revokedAt: new Date(), - }); - expect(isKeyValid(key)).toBe(false); + expect(resolveEffectiveScopesForWebsite(key, "site-123")).toEqual( + new Set(["read:data", "track:events", "write:data"]) + ); }); }); describe("hasGlobalAccess", () => { - it("returns false for null key", () => { - expect(hasGlobalAccess(null)).toBe(false); - }); - - it("returns false when no resources", () => { - const key = createMockKey({ metadata: {} }); - expect(hasGlobalAccess(key)).toBe(false); - }); - - it("returns false when no global resource", () => { - const key = createMockKey({ - metadata: { resources: { "website:site-123": ["read:data"] } }, - }); - expect(hasGlobalAccess(key)).toBe(false); - }); - - it("returns false when global resource is empty", () => { - const key = createMockKey({ - metadata: { resources: { global: [] } }, - }); - expect(hasGlobalAccess(key)).toBe(false); - }); - - it("returns true when global resource has scopes", () => { - const key = createMockKey({ - metadata: { resources: { global: ["read:data"] } }, - }); - expect(hasGlobalAccess(key)).toBe(true); + it.each([ + ["no resources", {}, false], + ["only website resources", { resources: { "website:site-123": ["read:data"] } }, false], + ["empty global resource", { resources: { global: [] } }, false], + ["populated global resource", { resources: { global: ["read:data"] } }, true], + ])("%s -> %s", (_name, metadata, expected) => { + expect(hasGlobalAccess(createMockKey({ metadata }))).toBe(expected); }); }); describe("getAccessibleWebsiteIds", () => { - it("returns empty array for null key", () => { - expect(getAccessibleWebsiteIds(null)).toEqual([]); - }); - - it("returns empty array when no resources", () => { - const key = createMockKey({ metadata: {} }); - expect(getAccessibleWebsiteIds(key)).toEqual([]); - }); - - it("returns empty array when no website resources", () => { - const key = createMockKey({ - metadata: { resources: { global: ["read:data"] } }, - }); - expect(getAccessibleWebsiteIds(key)).toEqual([]); + it("returns empty array when no website resources exist", () => { + expect(getAccessibleWebsiteIds(createMockKey({ metadata: {} }))).toEqual( + [] + ); + expect( + getAccessibleWebsiteIds( + createMockKey({ metadata: { resources: { global: ["read:data"] } } }) + ) + ).toEqual([]); }); - it("returns website ids from resources", () => { + it("extracts ids from website resources only", () => { const key = createMockKey({ metadata: { resources: { + global: ["track:events"], "website:site-1": ["read:data"], "website:site-2": ["write:data"], - global: ["track:events"], }, }, }); - const ids = getAccessibleWebsiteIds(key); - expect(ids).toContain("site-1"); - expect(ids).toContain("site-2"); - expect(ids).toHaveLength(2); - }); - - it("extracts id correctly from website:id format", () => { - const key = createMockKey({ - metadata: { - resources: { "website:my-long-id-123": ["read:data"] }, - }, - }); - expect(getAccessibleWebsiteIds(key)).toEqual(["my-long-id-123"]); - }); -}); - -describe("keypal utilities used in implementation", () => { - it("hasScope returns true when scope exists", () => { - expect(hasScope(["read:data", "write:data"], "read:data")).toBe(true); - }); - - it("hasScope returns false when scope does not exist", () => { - expect(hasScope(["read:data"], "write:data")).toBe(false); - }); - - it("hasScope handles undefined scopes", () => { - expect(hasScope(undefined, "read:data")).toBe(false); - }); - - it("isExpired returns false for null", () => { - expect(isExpired(null)).toBe(false); - }); - - it("isExpired returns false for undefined", () => { - expect(isExpired(undefined)).toBe(false); - }); - - it("isExpired returns true for past date", () => { - const past = new Date(Date.now() - 1000).toISOString(); - expect(isExpired(past)).toBe(true); - }); - it("isExpired returns false for future date", () => { - const future = new Date(Date.now() + 100_000).toISOString(); - expect(isExpired(future)).toBe(false); + expect(getAccessibleWebsiteIds(key).sort()).toEqual(["site-1", "site-2"]); }); }); diff --git a/apps/api/src/lib/autumn-mount.ts b/apps/api/src/lib/autumn-mount.ts index 80e884013a..0c8d444c67 100644 --- a/apps/api/src/lib/autumn-mount.ts +++ b/apps/api/src/lib/autumn-mount.ts @@ -1,4 +1,3 @@ -/** Elysia `.mount("/api/autumn", …)` strips the prefix, so the inner pathname is `/attach` not `/api/autumn/attach`. Autumn's router matches full paths under `/api/autumn`. */ const AUTUMN_API_PREFIX = "/api/autumn"; export function withAutumnApiPath(request: Request): Request { diff --git a/apps/api/src/lib/tcc-otel.ts b/apps/api/src/lib/tcc-otel.ts index d5e653212d..78bf99db4b 100644 --- a/apps/api/src/lib/tcc-otel.ts +++ b/apps/api/src/lib/tcc-otel.ts @@ -8,12 +8,6 @@ import { import pkg from "../../package.json"; let sdk: NodeSDK | null = null; - -/** - * Registers OpenTelemetry with The Context Company's span processor so - * Vercel AI SDK `experimental_telemetry` spans (ai.*) are exported to TCC. - * No-op when TCC_API_KEY is unset (local dev without observability). - */ export function initTccTracing(): void { if (sdk || !process.env.TCC_API_KEY) { return; diff --git a/apps/api/src/middleware/api-key-rate-limit.ts b/apps/api/src/middleware/api-key-rate-limit.ts index 3f51f4b868..3fe14a01fe 100644 --- a/apps/api/src/middleware/api-key-rate-limit.ts +++ b/apps/api/src/middleware/api-key-rate-limit.ts @@ -123,7 +123,6 @@ export function releaseApiKeyInFlight( } export interface EnforceApiKeyRateLimitOptions { - /** undefined means auth was not pre-resolved; null means it resolved without a key. */ apiKey?: ApiKeyRow | null; dependencies?: ApiKeyAdmissionDependencies; } @@ -196,12 +195,6 @@ export function enforceApiKeyInFlightLimit( "Too many concurrent API key requests" ); } - -/** - * Enforce an API key's configured distributed rolling-window limit after auth - * resolution. Every presented key has already passed through the local - * in-flight gate above. - */ export async function enforceApiKeyRateLimit( request: Request, setHeader: (name: string, value: string) => void, diff --git a/apps/api/src/middleware/website-auth.ts b/apps/api/src/middleware/website-auth.ts deleted file mode 100644 index 0199fcbbd3..0000000000 --- a/apps/api/src/middleware/website-auth.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { - getApiKeyFromHeader, - hasWebsiteScopeForOrganization, - isApiKeyPresent, -} from "@databuddy/api-keys/resolve"; -import { auth } from "@databuddy/auth"; -import { db } from "@databuddy/db"; -import { Elysia } from "elysia"; -import { getResolvedAuth } from "../lib/auth-wide-event"; -import { getCachedWebsite, getTimezone } from "@databuddy/ai/lib/website-utils"; - -interface SessionUser { - email: string; - id: string; - name: string; -} - -function json(status: number, body: unknown) { - return new Response(JSON.stringify(body), { - status, - headers: { "Content-Type": "application/json" }, - }); -} - -function getSessionUser( - session: Awaited> | null -): SessionUser | null { - if (!session?.user) { - return null; - } - - return { - email: session.user.email, - id: session.user.id, - name: session.user.name, - }; -} - -export function websiteAuth() { - return new Elysia() - .derive(async ({ request }) => { - if (isPreflight(request)) { - return { - user: null, - session: null, - website: undefined, - timezone: "UTC", - _apiKey: null, - _apiKeyPresent: false, - _authChecked: true, - } as const; - } - - const url = new URL(request.url); - const websiteId = url.searchParams.get("website_id"); - - const preResolved = getResolvedAuth(request.headers); - let sessionUser: SessionUser | null = null; - let session: Awaited> | null = - null; - let apiKey: Awaited> | null = null; - const apiKeyPresent = isApiKeyPresent(request.headers); - - if (preResolved) { - session = preResolved.session; - sessionUser = getSessionUser(session); - apiKey = preResolved.apiKeyResult?.key ?? null; - } else { - const [resolvedApiKey, resolvedSession] = await Promise.all([ - apiKeyPresent ? getApiKeyFromHeader(request.headers) : null, - auth.api.getSession({ headers: request.headers }), - ]); - session = resolvedSession; - sessionUser = getSessionUser(session); - apiKey = resolvedApiKey; - } - - const website = websiteId ? await getCachedWebsite(websiteId) : undefined; - - const timezone = session?.user - ? await getTimezone(request, session) - : await getTimezone(request, null); - - return { - user: sessionUser, - session, - website, - timezone, - _apiKey: apiKey, - _apiKeyPresent: apiKeyPresent, - _authChecked: true, - } as const; - }) - .onBeforeHandle(({ user, website, _apiKey, _apiKeyPresent, request }) => { - if (isPreflight(request)) { - return; - } - - const url = new URL(request.url); - const websiteId = url.searchParams.get("website_id"); - - if (!websiteId) { - if (user || _apiKey) { - return null; - } - return json(401, { - success: false, - error: "Authentication required", - code: "AUTH_REQUIRED", - }); - } - - return checkWebsiteAuth( - websiteId, - user, - website ?? null, - _apiKey, - _apiKeyPresent - ); - }); -} - -function isPreflight(request: Request): boolean { - return request.method === "OPTIONS" || request.method === "HEAD"; -} - -async function checkWebsiteAuth( - _websiteId: string, - sessionUser: SessionUser | null, - website: Awaited> | null, - apiKey: Awaited> | null, - apiKeyPresent: boolean -): Promise { - if (!website) { - return json(404, { - success: false, - error: "Website not found", - code: "NOT_FOUND", - }); - } - if (website.isPublic) { - return null; - } - - if (sessionUser) { - if (!website.organizationId) { - return json(403, { - success: false, - error: "Website must belong to a workspace", - code: "FORBIDDEN", - }); - } - - const membership = await db.query.member.findFirst({ - where: { userId: sessionUser.id, organizationId: website.organizationId }, - columns: { - id: true, - }, - }); - - if (membership) { - return null; - } - - return json(403, { - success: false, - error: "Access denied to this website", - code: "FORBIDDEN", - }); - } - - if (!apiKeyPresent) { - return json(401, { - success: false, - error: "Authentication required", - code: "AUTH_REQUIRED", - }); - } - if (!apiKey) { - return json(401, { - success: false, - error: "Invalid or expired API key", - code: "AUTH_REQUIRED", - }); - } - const ok = hasWebsiteScopeForOrganization(apiKey, website, "read:data"); - if (!ok) { - return json(403, { - success: false, - error: "Insufficient permissions", - code: "FORBIDDEN", - }); - } - return null; -} diff --git a/apps/api/src/routes/public/flags-boundary.test.ts b/apps/api/src/routes/public/flags-boundary.test.ts index e8c33b877b..296c6900bb 100644 --- a/apps/api/src/routes/public/flags-boundary.test.ts +++ b/apps/api/src/routes/public/flags-boundary.test.ts @@ -3,6 +3,7 @@ import { Elysia } from "elysia"; import { describe, expect, it, vi } from "vitest"; const state = vi.hoisted(() => ({ + findFirst: vi.fn(async () => null as unknown), flags: [ { defaultValue: true, @@ -18,6 +19,7 @@ const state = vi.hoisted(() => ({ variants: null, }, ], + rateLimited: false, })); vi.mock("@databuddy/db", async (importOriginal) => ({ @@ -25,6 +27,7 @@ vi.mock("@databuddy/db", async (importOriginal) => ({ db: { query: { flags: { + findFirst: state.findFirst, findMany: vi.fn(async () => state.flags), }, }, @@ -37,8 +40,13 @@ vi.mock("@databuddy/redis", async (importOriginal) => ({ })); vi.mock("@databuddy/redis/rate-limit", () => ({ - getRateLimitHeaders: () => ({}), - ratelimit: async () => ({ success: true }), + getRateLimitHeaders: () => ({ "x-ratelimit-remaining": "0" }), + ratelimit: async () => ({ + limit: 600, + remaining: state.rateLimited ? 0 : 599, + reset: Date.now() + 60_000, + success: !state.rateLimited, + }), })); const { flagsRoute } = await import("./flags"); @@ -111,4 +119,69 @@ describe("public bulk flags boundary", () => { }); expect(postResponse.status).toBe(422); }); + + it("rejects requests without a usable clientId", async () => { + const missing = await request("/v1/flags/bulk"); + expect(missing.status).toBe(422); + + const blank = await request("/v1/flags/bulk?clientId="); + expect(blank.status).toBe(400); + expect(await blank.json()).toMatchObject({ + count: 0, + error: "Missing required clientId parameter", + }); + }); +}); + +describe("public flag evaluation boundary", () => { + it("rejects evaluation without a usable clientId or key", async () => { + const missingParams = await request("/v1/flags/evaluate?key=&clientId="); + expect(missingParams.status).toBe(400); + expect(await missingParams.json()).toMatchObject({ + enabled: false, + reason: "MISSING_REQUIRED_PARAMS", + }); + + const missingClientId = await request("/v1/flags/evaluate?key=some-flag"); + expect(missingClientId.status).toBe(422); + }); + + it("caches missing flags so repeated misses skip the database", async () => { + const path = "/v1/flags/evaluate?key=absent-flag&clientId=neg_cache_site"; + + const first = await request(path); + const second = await request(path); + + expect(first.status).toBe(200); + expect(await first.json()).toMatchObject({ + enabled: false, + reason: "FLAG_NOT_FOUND", + }); + expect(await second.json()).toMatchObject({ reason: "FLAG_NOT_FOUND" }); + expect(state.findFirst).toHaveBeenCalledTimes(1); + }); + + it("returns 429 with rate limit headers when the per-client budget is exhausted", async () => { + state.rateLimited = true; + try { + const evaluate = await request( + "/v1/flags/evaluate?key=some-flag&clientId=limited_site" + ); + expect(evaluate.status).toBe(429); + expect(evaluate.headers.get("x-ratelimit-remaining")).toBe("0"); + expect(await evaluate.json()).toMatchObject({ + enabled: false, + reason: "RATE_LIMITED", + }); + + const bulk = await request("/v1/flags/bulk?clientId=limited_site"); + expect(bulk.status).toBe(429); + expect(await bulk.json()).toMatchObject({ + count: 0, + reason: "RATE_LIMITED", + }); + } finally { + state.rateLimited = false; + } + }); }); diff --git a/apps/api/src/routes/public/flags.test.ts b/apps/api/src/routes/public/flags.test.ts index f6ca750ad1..94febbd0ae 100644 --- a/apps/api/src/routes/public/flags.test.ts +++ b/apps/api/src/routes/public/flags.test.ts @@ -66,173 +66,25 @@ describe("evaluateStringRule", () => { enabled: true, batch: false, }); - - it("handles all operators correctly", () => { - expect(evaluateStringRule("user-123", rule("equals", "user-123"))).toBe( - true - ); - expect(evaluateStringRule("other", rule("equals", "user-123"))).toBe(false); - - expect( - evaluateStringRule("user@company.com", rule("contains", "@company")) - ).toBe(true); - expect( - evaluateStringRule("user@other.com", rule("contains", "@company")) - ).toBe(false); - - expect( - evaluateStringRule("admin-user", rule("starts_with", "admin-")) - ).toBe(true); - expect( - evaluateStringRule("user-admin", rule("starts_with", "admin-")) - ).toBe(false); - - expect(evaluateStringRule("file.com", rule("ends_with", ".com"))).toBe( - true - ); - expect(evaluateStringRule("file.org", rule("ends_with", ".com"))).toBe( - false - ); - - const vals = ["a", "b", "c"]; - expect(evaluateStringRule("b", rule("in", undefined, vals))).toBe(true); - expect(evaluateStringRule("z", rule("in", undefined, vals))).toBe(false); - expect(evaluateStringRule("z", rule("not_in", undefined, vals))).toBe(true); - expect(evaluateStringRule("a", rule("not_in", undefined, vals))).toBe( - false - ); - - expect(evaluateStringRule("test", rule("unknown_op", "test"))).toBe(false); - expect(evaluateStringRule(undefined, rule("equals", "test"))).toBe(false); - }); -}); - -describe("evaluateStringRule - email pattern matching", () => { - const emailRule = (op: string, val?: string, vals?: string[]) => ({ - type: "email" as const, - operator: op, - value: val, - values: vals, - enabled: true, - batch: false, - }); - - it("handles ends_with for email domain patterns", () => { - // Common use case: target users by email domain - expect( - evaluateStringRule( - "user@databuddy.cc", - emailRule("ends_with", "@databuddy.cc") - ) - ).toBe(true); - expect( - evaluateStringRule( - "admin@databuddy.cc", - emailRule("ends_with", "@databuddy.cc") - ) - ).toBe(true); - expect( - evaluateStringRule( - "user@other.com", - emailRule("ends_with", "@databuddy.cc") - ) - ).toBe(false); - expect( - evaluateStringRule("user@company.io", emailRule("ends_with", ".io")) - ).toBe(true); - expect( - evaluateStringRule("user@company.com", emailRule("ends_with", ".io")) - ).toBe(false); - }); - - it("handles starts_with for email prefix patterns", () => { - // Target emails starting with a prefix (e.g., admin@, support@) - expect( - evaluateStringRule( - "admin@company.com", - emailRule("starts_with", "admin@") - ) - ).toBe(true); - expect( - evaluateStringRule("admin@other.org", emailRule("starts_with", "admin@")) - ).toBe(true); - expect( - evaluateStringRule("user@company.com", emailRule("starts_with", "admin@")) - ).toBe(false); - expect( - evaluateStringRule( - "support@company.com", - emailRule("starts_with", "support") - ) - ).toBe(true); - }); - - it("handles contains for partial email matching", () => { - // Target emails containing a substring - expect( - evaluateStringRule( - "user@company.internal.com", - emailRule("contains", "internal") - ) - ).toBe(true); - expect( - evaluateStringRule( - "internal-user@company.com", - emailRule("contains", "internal") - ) - ).toBe(true); - expect( - evaluateStringRule("user@company.com", emailRule("contains", "internal")) - ).toBe(false); - expect( - evaluateStringRule( - "beta-tester@company.com", - emailRule("contains", "beta") - ) - ).toBe(true); - }); - - it("handles exact match for full email addresses", () => { - expect( - evaluateStringRule( - "user@databuddy.cc", - emailRule("equals", "user@databuddy.cc") - ) - ).toBe(true); - expect( - evaluateStringRule( - "other@databuddy.cc", - emailRule("equals", "user@databuddy.cc") - ) - ).toBe(false); - }); - - it("handles in/not_in for email lists", () => { - const allowedEmails = ["admin@co.com", "support@co.com", "dev@co.com"]; - expect( - evaluateStringRule( - "admin@co.com", - emailRule("in", undefined, allowedEmails) - ) - ).toBe(true); - expect( - evaluateStringRule( - "random@co.com", - emailRule("in", undefined, allowedEmails) - ) - ).toBe(false); - expect( - evaluateStringRule( - "random@co.com", - emailRule("not_in", undefined, allowedEmails) - ) - ).toBe(true); - expect( - evaluateStringRule( - "admin@co.com", - emailRule("not_in", undefined, allowedEmails) - ) - ).toBe(false); + const vals = ["a", "b", "c"]; + + it.each([ + ["user-123", rule("equals", "user-123"), true], + ["other", rule("equals", "user-123"), false], + ["user@company.com", rule("contains", "@company"), true], + ["user@other.com", rule("contains", "@company"), false], + ["admin-user", rule("starts_with", "admin-"), true], + ["user-admin", rule("starts_with", "admin-"), false], + ["file.com", rule("ends_with", ".com"), true], + ["file.org", rule("ends_with", ".com"), false], + ["b", rule("in", undefined, vals), true], + ["z", rule("in", undefined, vals), false], + ["z", rule("not_in", undefined, vals), true], + ["a", rule("not_in", undefined, vals), false], + ["test", rule("unknown_op", "test"), false], + [undefined, rule("equals", "test"), false], + ])("%j with %j -> %s", (value, testRule, expected) => { + expect(evaluateStringRule(value, testRule)).toBe(expected); }); }); @@ -246,32 +98,24 @@ describe("evaluateValueRule", () => { batch: false, }); - it("handles all operators correctly", () => { - expect(evaluateValueRule(25, rule("equals", 25))).toBe(true); - expect(evaluateValueRule(30, rule("equals", 25))).toBe(false); - expect(evaluateValueRule("professional", rule("contains", "pro"))).toBe( - true - ); - expect(evaluateValueRule("basic", rule("contains", "pro"))).toBe(false); - expect( - evaluateValueRule("pro", rule("in", undefined, ["pro", "ent"])) - ).toBe(true); - expect( - evaluateValueRule("free", rule("in", undefined, ["pro", "ent"])) - ).toBe(false); - expect( - evaluateValueRule("ok", rule("not_in", undefined, ["bad", "worse"])) - ).toBe(true); - expect( - evaluateValueRule("bad", rule("not_in", undefined, ["bad", "worse"])) - ).toBe(false); - expect(evaluateValueRule("val", rule("exists"))).toBe(true); - expect(evaluateValueRule(0, rule("exists"))).toBe(true); - expect(evaluateValueRule(undefined, rule("exists"))).toBe(false); - expect(evaluateValueRule(null, rule("exists"))).toBe(false); - expect(evaluateValueRule(undefined, rule("not_exists"))).toBe(true); - expect(evaluateValueRule("x", rule("not_exists"))).toBe(false); - expect(evaluateValueRule("x", rule("unknown_op"))).toBe(false); + it.each([ + [25, rule("equals", 25), true], + [30, rule("equals", 25), false], + ["professional", rule("contains", "pro"), true], + ["basic", rule("contains", "pro"), false], + ["pro", rule("in", undefined, ["pro", "ent"]), true], + ["free", rule("in", undefined, ["pro", "ent"]), false], + ["ok", rule("not_in", undefined, ["bad", "worse"]), true], + ["bad", rule("not_in", undefined, ["bad", "worse"]), false], + ["val", rule("exists"), true], + [0, rule("exists"), true], + [undefined, rule("exists"), false], + [null, rule("exists"), false], + [undefined, rule("not_exists"), true], + ["x", rule("not_exists"), false], + ["x", rule("unknown_op"), false], + ])("%j with %j -> %s", (value, testRule, expected) => { + expect(evaluateValueRule(value, testRule)).toBe(expected); }); }); @@ -848,23 +692,6 @@ describe("edge cases and stress tests", () => { } }); - it("handles rapid sequential evaluations", () => { - const flag = { - key: "rapid", - type: "rollout" as const, - rolloutPercentage: 50, - status: "active" as const, - defaultValue: false, - }; - - const start = performance.now(); - for (let i = 0; i < 10_000; i += 1) { - evaluateFlag(flag, { userId: `u${i}` }); - } - const duration = performance.now() - start; - expect(duration).toBeLessThan(1000); - }); - it("handles percentage edge values", () => { for (let i = 0; i < 100; i += 1) { const ctx = { userId: randomId() }; diff --git a/apps/api/src/routes/public/flags.ts b/apps/api/src/routes/public/flags.ts index 04b33cc867..f40edad867 100644 --- a/apps/api/src/routes/public/flags.ts +++ b/apps/api/src/routes/public/flags.ts @@ -133,6 +133,24 @@ const bulkFlagBodySchema = t.Object({ environment: t.Optional(t.String()), }); +interface TargetGroupJoin { + targetGroup: { + deletedAt: Date | null; + id: string; + rules: FlagRule[]; + } | null; +} + +function resolveTargetGroups(joins: TargetGroupJoin[]): TargetGroupData[] { + const resolved: TargetGroupData[] = []; + for (const { targetGroup } of joins) { + if (targetGroup && !targetGroup.deletedAt) { + resolved.push({ id: targetGroup.id, rules: targetGroup.rules }); + } + } + return resolved; +} + const getCachedFlag = cacheable( async (key: string, clientId: string, environment?: string) => { const flag = await db.query.flags.findFirst({ @@ -162,16 +180,9 @@ const getCachedFlag = cacheable( return null; } - const resolvedTargetGroups: TargetGroupData[] = flag.flagsToTargetGroups - .filter((ftg) => ftg.targetGroup && !ftg.targetGroup.deletedAt) - .map((ftg) => ({ - id: ftg.targetGroup.id, - rules: ftg.targetGroup.rules, - })); - return { ...flag, - resolvedTargetGroups, + resolvedTargetGroups: resolveTargetGroups(flag.flagsToTargetGroups), }; }, { @@ -210,19 +221,10 @@ const getCachedFlagsForClient = cacheable( }, }); - return flagsList.map((flag) => { - const resolvedTargetGroups: TargetGroupData[] = flag.flagsToTargetGroups - .filter((ftg) => ftg.targetGroup && !ftg.targetGroup.deletedAt) - .map((ftg) => ({ - id: ftg.targetGroup.id, - rules: ftg.targetGroup.rules, - })); - - return { - ...flag, - resolvedTargetGroups, - }; - }); + return flagsList.map((flag) => ({ + ...flag, + resolvedTargetGroups: resolveTargetGroups(flag.flagsToTargetGroups), + })); }, { expireInSec: 30, @@ -285,19 +287,10 @@ const getCachedFlagsForUser = cacheable( }, }); - return flagsList.map((flag) => { - const resolvedTargetGroups: TargetGroupData[] = flag.flagsToTargetGroups - .filter((ftg) => ftg.targetGroup && !ftg.targetGroup.deletedAt) - .map((ftg) => ({ - id: ftg.targetGroup.id, - rules: ftg.targetGroup.rules, - })); - - return { - ...flag, - resolvedTargetGroups, - }; - }); + return flagsList.map((flag) => ({ + ...flag, + resolvedTargetGroups: resolveTargetGroups(flag.flagsToTargetGroups), + })); }, { expireInSec: 30, @@ -587,10 +580,6 @@ export function evaluateFlag( }; } - let enabled = Boolean(flag.defaultValue); - let value = enabled; - let reason = "DEFAULT_VALUE"; - if (flag.type === "rollout") { let identifier: string; @@ -605,24 +594,22 @@ export function evaluateFlag( identifier = context.userId || context.email || "anonymous"; } - const hash = hashString(`${flag.key}:${identifier}`); - const percentage = hash % 100; - const rolloutPercentage = flag.rolloutPercentage || 0; - - enabled = percentage < rolloutPercentage; - value = enabled; - reason = enabled ? "ROLLOUT_ENABLED" : "ROLLOUT_DISABLED"; - } else { - enabled = Boolean(flag.defaultValue); - value = enabled; - reason = "BOOLEAN_DEFAULT"; + const percentage = hashString(`${flag.key}:${identifier}`) % 100; + const enabled = percentage < (flag.rolloutPercentage || 0); + return { + enabled, + value: enabled, + payload: enabled ? flag.payload : null, + reason: enabled ? "ROLLOUT_ENABLED" : "ROLLOUT_DISABLED", + }; } + const enabled = Boolean(flag.defaultValue); return { enabled, - value, + value: enabled, payload: enabled ? flag.payload : null, - reason, + reason: "BOOLEAN_DEFAULT", }; } diff --git a/apps/api/src/routes/query.ts b/apps/api/src/routes/query.ts index a326efa798..e3e34bf9f8 100644 --- a/apps/api/src/routes/query.ts +++ b/apps/api/src/routes/query.ts @@ -26,6 +26,8 @@ import { getOrganizationOwnerId } from "@databuddy/rpc/organization"; import { type GatedFeatureId, GATED_FEATURES, + getFeatureUnavailableMessage, + getNextPlanForFeature, isFeatureAvailable, } from "@databuddy/shared/types/features"; import { @@ -473,13 +475,19 @@ async function enforceFeatureGatesForQueryTypes( const ownerId = website.organizationId ? await getOrganizationOwnerId(website.organizationId) : null; - if (!ownerId) { - return null; - } - const billing = await getBillingOwner(ownerId, website.organizationId); + const planId = ownerId + ? (await getBillingOwner(ownerId, website.organizationId)).planId + : null; + for (const feature of required) { - if (!isFeatureAvailable(billing.planId, feature)) { - return { error: "This feature is not available on the plan", feature }; + if (!isFeatureAvailable(planId, feature)) { + return { + error: getFeatureUnavailableMessage( + feature, + getNextPlanForFeature(planId, feature) + ), + feature, + }; } } return null; diff --git a/apps/api/src/routes/webhooks/autumn-inbox.ts b/apps/api/src/routes/webhooks/autumn-inbox.ts index b58778efdc..1b58868c94 100644 --- a/apps/api/src/routes/webhooks/autumn-inbox.ts +++ b/apps/api/src/routes/webhooks/autumn-inbox.ts @@ -25,7 +25,7 @@ const DEAD_LETTER_RETENTION_MS = 90 * 24 * 60 * 60 * 1000; const RETRY_BASE_MS = 5 * 60 * 1000; const RETRY_MAX_MS = 6 * 60 * 60 * 1000; -export const AUTUMN_WEBHOOK_LEASE_MS = 5 * 60 * 1000; +const AUTUMN_WEBHOOK_LEASE_MS = 5 * 60 * 1000; export const AUTUMN_WEBHOOK_MAX_ATTEMPTS = 12; export interface StoredAutumnWebhook { diff --git a/apps/api/src/routes/webhooks/autumn.test.ts b/apps/api/src/routes/webhooks/autumn.test.ts index b0f8ade46d..681a4261a9 100644 --- a/apps/api/src/routes/webhooks/autumn.test.ts +++ b/apps/api/src/routes/webhooks/autumn.test.ts @@ -128,11 +128,11 @@ vi.mock("./autumn-inbox", () => ({ vi.mock("@databuddy/db", () => ({ and: (...conditions: unknown[]) => ({ conditions }), - db: { - query: { - member: { - findMany: vi.fn(async () => state.ownedOrganizations), - }, + db: { + query: { + member: { + findMany: vi.fn(async () => state.ownedOrganizations), + }, organization: { findFirst: vi.fn(async () => null) }, user: { findFirst: vi.fn(async () => state.userRow) }, }, @@ -140,11 +140,11 @@ vi.mock("@databuddy/db", () => ({ eq: (field: unknown, value: unknown) => ({ field, op: "eq", value }), gt: (field: unknown, value: unknown) => ({ field, op: "gt", value }), isNull: (field: unknown) => ({ field, op: "isNull" }), - normalizeEmailNotificationSettings: (raw?: { - billing?: { usageWarnings?: boolean }; - }) => ({ - billing: { usageWarnings: raw?.billing?.usageWarnings ?? true }, - }), + normalizeEmailNotificationSettings: (raw?: { + billing?: { usageWarnings?: boolean }; + }) => ({ + billing: { usageWarnings: raw?.billing?.usageWarnings ?? true }, + }), or: (...conditions: unknown[]) => ({ conditions, op: "or" }), sql: (strings: TemplateStringsArray, ...values: unknown[]) => ({ strings: Array.from(strings), @@ -359,13 +359,13 @@ describe("sendAlertEmail", () => { "send", "insert", ]); - expect(state.send).toHaveBeenCalledWith({ - from: "alerts@databuddy.cc", - to: "member@example.com", - subject: "Limit reached", - html: "", - text: "", - }); + expect(state.send).toHaveBeenCalledWith({ + from: "alerts@databuddy.cc", + to: "member@example.com", + subject: "Limit reached", + html: "", + text: "", + }); expect(state.inserted).toEqual([ expect.objectContaining({ alertType: "included", @@ -827,4 +827,49 @@ describe("Autumn webhook inbox", () => { expect(state.send).toHaveBeenCalledTimes(1); expect(state.storedWebhooks.get("msg-replay")?.status).toBe("completed"); }); + + it("fails a replay for an unknown webhook id without side effects", async () => { + await expect(replayDeferredAutumnWebhook("msg-unknown")).resolves.toEqual({ + message: "Stored webhook not found", + success: false, + }); + expect(state.send).not.toHaveBeenCalled(); + }); + + it("leaves a webhook claimed by another worker queued", async () => { + state.storedWebhooks.set("msg-claimed", { + attempts: 3, + claimToken: "claim-other-worker", + id: "msg-claimed", + payload: {}, + status: "processing", + type: "balances.limit_reached", + }); + + await expect(replayDeferredAutumnWebhook("msg-claimed")).resolves.toEqual({ + disposition: "deferred", + message: "Webhook already queued for replay", + success: true, + }); + expect(state.send).not.toHaveBeenCalled(); + expect(state.storedWebhooks.get("msg-claimed")?.status).toBe("processing"); + }); + + it("acknowledges dead-lettered webhooks without reprocessing them", async () => { + state.storedWebhooks.set("msg-dead", { + attempts: 12, + claimToken: null, + id: "msg-dead", + payload: {}, + status: "dead_letter", + type: "balances.limit_reached", + }); + + await expect(replayDeferredAutumnWebhook("msg-dead")).resolves.toEqual({ + disposition: "duplicate", + message: "Webhook retained for investigation", + success: true, + }); + expect(state.send).not.toHaveBeenCalled(); + }); }); diff --git a/apps/api/src/routes/webhooks/autumn.ts b/apps/api/src/routes/webhooks/autumn.ts index 4035448505..09847a9d9a 100644 --- a/apps/api/src/routes/webhooks/autumn.ts +++ b/apps/api/src/routes/webhooks/autumn.ts @@ -195,7 +195,7 @@ const getBillingRecipient = cacheable( } ); -export async function resolveBillingOrganization( +async function resolveBillingOrganization( customerId: string, entityId?: string ): Promise { @@ -749,6 +749,35 @@ function webhookIdempotencyKey(svixId: string): string { return createHash("sha256").update(svixId).digest("hex"); } +async function recordRetryAttempt( + stored: ClaimedAutumnWebhook, + status: "deferred" | "pending", + message: string +): Promise { + const outcome = await recordAutumnWebhookAttempt({ + attempts: stored.attempts, + claimToken: stored.claimToken, + errorMessage: message, + id: stored.id, + status, + }); + if (outcome === "completed") { + return { + disposition: "duplicate", + message: "Webhook already processed", + success: true, + }; + } + if (outcome === "dead_letter") { + return { + disposition: "dead_letter", + message: "Webhook moved to dead letter", + success: true, + }; + } + return null; +} + async function processClaimedAutumnWebhook( stored: ClaimedAutumnWebhook ): Promise { @@ -763,82 +792,35 @@ async function processClaimedAutumnWebhook( } result = await dispatch(event, webhookIdempotencyKey(stored.id)); } catch (error) { - const status = await recordAutumnWebhookAttempt({ - attempts: stored.attempts, - claimToken: stored.claimToken, - errorMessage: errorMessage(error), - id: stored.id, - status: "pending", - }); - if (status === "completed") { - return { - disposition: "duplicate", - message: "Webhook already processed", - success: true, - }; - } - if (status === "dead_letter") { - return { - disposition: "dead_letter", - message: "Webhook moved to dead letter", - success: true, - }; + const settled = await recordRetryAttempt( + stored, + "pending", + errorMessage(error) + ); + if (settled) { + return settled; } throw error; } if (result.disposition === "deferred") { - const status = await recordAutumnWebhookAttempt({ - attempts: stored.attempts, - claimToken: stored.claimToken, - errorMessage: result.message, - id: stored.id, - status: "deferred", - }); - if (status === "completed") { - return { - disposition: "duplicate", - message: "Webhook already processed", - success: true, - }; - } - if (status === "dead_letter") { - return { - disposition: "dead_letter", - message: "Webhook moved to dead letter", + const settled = await recordRetryAttempt( + stored, + "deferred", + result.message + ); + return ( + settled ?? { + disposition: "deferred", + message: "Webhook stored for replay", success: true, - }; - } - return { - disposition: "deferred", - message: "Webhook stored for replay", - success: true, - }; + } + ); } if (!result.success) { - const status = await recordAutumnWebhookAttempt({ - attempts: stored.attempts, - claimToken: stored.claimToken, - errorMessage: result.message, - id: stored.id, - status: "pending", - }); - if (status === "completed") { - return { - disposition: "duplicate", - message: "Webhook already processed", - success: true, - }; - } - if (status === "dead_letter") { - return { - disposition: "dead_letter", - message: "Webhook moved to dead letter", - success: true, - }; - } - return result; + const settled = await recordRetryAttempt(stored, "pending", result.message); + return settled ?? result; } await recordAutumnWebhookAttempt({ diff --git a/apps/api/src/rpc/handlers.ts b/apps/api/src/rpc/handlers.ts index 4efe76d812..5e08689631 100644 --- a/apps/api/src/rpc/handlers.ts +++ b/apps/api/src/rpc/handlers.ts @@ -26,7 +26,7 @@ export const rpcHandler = new RPCHandler(appRouter, { interceptors: [createAbortSignalInterceptor(), onError(logOrpcHandlerError)], }); -export function createAuthenticatedOrpcContext(request: Request) { +function createAuthenticatedOrpcContext(request: Request) { const preResolvedAuth = getPreResolvedAuth(request.headers); return createRPCContext( { headers: request.headers, requestId: getRequestId(request) }, @@ -34,7 +34,7 @@ export function createAuthenticatedOrpcContext(request: Request) { ); } -export function createAnonymousOrpcContext(request: Request) { +function createAnonymousOrpcContext(request: Request) { return createRPCContext( { headers: request.headers, requestId: getRequestId(request) }, ANONYMOUS_AUTH diff --git a/apps/api/src/schemas/query-schemas.ts b/apps/api/src/schemas/query-schemas.ts index 111920daa9..b5342e2843 100644 --- a/apps/api/src/schemas/query-schemas.ts +++ b/apps/api/src/schemas/query-schemas.ts @@ -6,14 +6,11 @@ const QUERY_BUILDER_TYPES = Object.keys(QueryBuilders) as Array< keyof typeof QueryBuilders >; -export { - DatePresets, - type DatePreset, -} from "@databuddy/ai/lib/date-presets"; +export { DatePresets } from "@databuddy/ai/lib/date-presets"; -export const DatePresetSchema = t.Enum(DatePresets); +const DatePresetSchema = t.Enum(DatePresets); -export const FilterSchema = t.Object({ +const FilterSchema = t.Object({ field: t.String(), op: t.Enum({ eq: "eq", @@ -31,7 +28,7 @@ export const FilterSchema = t.Object({ ]), }); -export const ParameterWithDatesSchema = t.Object({ +const ParameterWithDatesSchema = t.Object({ name: t.String(), start_date: t.Optional(t.String()), end_date: t.Optional(t.String()), @@ -90,7 +87,7 @@ export const CompileRequestSchema = t.Object({ offset: t.Optional(t.Number({ minimum: 0 })), }); -export interface FilterType { +interface FilterType { field: string; op: | "eq" @@ -103,7 +100,7 @@ export interface FilterType { value: string | number | Array; } -export interface ParameterWithDatesType { +interface ParameterWithDatesType { end_date?: string; granularity?: "hourly" | "daily" | "hour" | "day"; id?: string; diff --git a/apps/basket/package.json b/apps/basket/package.json index 419e2ab675..7c7be39893 100644 --- a/apps/basket/package.json +++ b/apps/basket/package.json @@ -22,18 +22,13 @@ "@databuddy/shared": "workspace:*", "@databuddy/validation": "workspace:*", "@maxmind/geoip2-node": "^6.3.4", - "@types/ua-parser-js": "^0.7.39", - "async-mutex": "^0.5.0", "effect": "^4.0.0-beta.90", "elysia": "catalog:", "evlog": "catalog:", "kafkajs": "^2.2.4", - "keypal": "0.2.0", - "ua-parser-js": "^2.0.7", "zod": "catalog:" }, "devDependencies": { - "bun-types": "catalog:", "vitest": "^4.1.4" }, "packageManager": "bun@1.3.14" diff --git a/apps/basket/src/hooks/auth.ts b/apps/basket/src/hooks/auth.ts index 3180727653..1f03ea1592 100644 --- a/apps/basket/src/hooks/auth.ts +++ b/apps/basket/src/hooks/auth.ts @@ -1,10 +1,3 @@ -/** - * Website Authentication Hook for Analytics - * - * This hook provides authentication for website tracking by validating - * client IDs and origins against registered websites. - */ - import { db } from "@databuddy/db"; import type { Website } from "@databuddy/db/schema"; import { cacheNamespaces } from "@databuddy/redis/cache-invalidation"; @@ -14,10 +7,7 @@ import { captureError, record } from "@lib/tracing"; import { isValidOriginFromSettings } from "@utils/origin-ip-validation"; import { createError, EvlogError } from "evlog"; -export { - isValidIpFromSettings, - isValidOriginFromSettings, -} from "@utils/origin-ip-validation"; +export { isValidIpFromSettings } from "@utils/origin-ip-validation"; type WebsiteWithOwner = Website & { ownerId: string | null; @@ -72,14 +62,7 @@ export const resolveApiKeyOwnerId = cacheable( staleTime: 60, } ); - -/** - * Validates if an origin header matches or is a subdomain of the allowed domain - */ -export function isValidOrigin( - originHeader: string, - allowedDomain: string -): boolean { +function isValidOrigin(originHeader: string, allowedDomain: string): boolean { const trimmedOrigin = originHeader?.trim(); if (!trimmedOrigin) { return true; @@ -108,11 +91,7 @@ export function isValidOrigin( return false; } } - -/** - * Normalizes a domain by removing the protocol, port, and "www." prefix. - */ -export function normalizeDomain(domain: string): string { +function normalizeDomain(domain: string): string { if (!domain) { return ""; } @@ -153,17 +132,14 @@ export function normalizeDomain(domain: string): string { } } -export function isSubdomain( - originDomain: string, - allowedDomain: string -): boolean { +function isSubdomain(originDomain: string, allowedDomain: string): boolean { return ( originDomain.endsWith(`.${allowedDomain}`) && originDomain.length > allowedDomain.length + 1 ); } -export function isValidDomainFormat(domain: string): boolean { +function isValidDomainFormat(domain: string): boolean { if ( !domain || domain.length > 253 || diff --git a/apps/basket/src/lib/api-key.ts b/apps/basket/src/lib/api-key.ts index 37aca03315..c9f0f8be8b 100644 --- a/apps/basket/src/lib/api-key.ts +++ b/apps/basket/src/lib/api-key.ts @@ -10,7 +10,7 @@ import { import { record } from "@lib/tracing"; import { useLogger } from "evlog/elysia"; -export type { ApiKeyRow, ApiScope } from "@databuddy/api-keys/resolve"; +export type { ApiKeyRow } from "@databuddy/api-keys/resolve"; export const hasKeyScope = _hasKeyScope; export const hasGlobalAccess = _hasGlobalAccess; diff --git a/apps/basket/src/lib/billing.test.ts b/apps/basket/src/lib/billing.test.ts index 851876762f..e8e523f4b1 100644 --- a/apps/basket/src/lib/billing.test.ts +++ b/apps/basket/src/lib/billing.test.ts @@ -39,8 +39,6 @@ describe("checkAutumnUsage", () => { mockLoggerWarn.mockReset(); }); - // ── Enforcement ── - test("allowed response → allowed", async () => { mockCheck.mockResolvedValue({ allowed: true, @@ -73,8 +71,6 @@ describe("checkAutumnUsage", () => { }); }); - // ── Still calls Autumn (metering for paying customers) ── - test("calls autumn.check with sendEvent: true", async () => { mockCheck.mockResolvedValue({ allowed: true, @@ -107,8 +103,6 @@ describe("checkAutumnUsage", () => { }); }); - // ── Logging ── - test("logs balance context from Autumn response", async () => { mockCheck.mockResolvedValue({ allowed: true, diff --git a/apps/basket/src/lib/cors-safe-json.ts b/apps/basket/src/lib/cors-safe-json.ts index cf56a64da0..30d0065f9a 100644 --- a/apps/basket/src/lib/cors-safe-json.ts +++ b/apps/basket/src/lib/cors-safe-json.ts @@ -2,11 +2,6 @@ interface ParseContext { contentType: string; request: Request; } - -/** - * Unload beacons use text/plain so cross-origin delivery remains a CORS simple - * request. Parse that JSON before the normal ingest schemas validate it. - */ export async function parseCorsSafeJson({ contentType, request, diff --git a/apps/basket/src/lib/event-service.test.ts b/apps/basket/src/lib/event-service.test.ts index 353fe536cd..d4ea4bcae5 100644 --- a/apps/basket/src/lib/event-service.test.ts +++ b/apps/basket/src/lib/event-service.test.ts @@ -2,8 +2,6 @@ import { describe, expect, test } from "vitest"; import { CONTROL_CHARS, longString, XSS_PAYLOADS } from "../test-helpers"; import { buildTrackEvent, type TrackEventContext } from "./event-service"; -// ── Fixtures ── - const NOW = 1_700_000_000_000; const fullTrackData = { @@ -65,89 +63,72 @@ const fullCtx: TrackEventContext = { now: NOW, }; -// ── Field mapping snapshot ── - describe("buildTrackEvent — field mapping", () => { test("full input → every field mapped correctly", () => { const result = buildTrackEvent(fullTrackData, fullCtx); - // Identity - expect(result.id).toBeTruthy(); // randomUUIDv7 - expect(result.client_id).toBe("ws_test"); - - // Names & content - expect(result.event_name).toBe("pageview"); - expect(result.title).toBe("Dashboard | App"); - expect(result.referrer).toBe("https://google.com"); - expect(result.path).toBe("/dashboard"); - expect(result.url).toBe("/dashboard"); // url === path - - // User identity - expect(result.anonymous_id).toBe("salted_anon_1"); - expect(result.session_id).toBe("sess_abc123"); - - // Timestamps — uses trackData values when numeric - expect(result.timestamp).toBe(1_700_000_001_000); - expect(result.time).toBe(1_700_000_001_000); - expect(result.created_at).toBe(NOW); - - // Geo - expect(result.ip).toBe("abc123def456"); - expect(result.country).toBe("United States"); - expect(result.region).toBe("California"); - expect(result.city).toBe("San Francisco"); - - // UA - expect(result.user_agent).toBe(""); // always empty (privacy) - expect(result.browser_name).toBe("Chrome"); - expect(result.browser_version).toBe("120.0"); - expect(result.os_name).toBe("Windows"); - expect(result.os_version).toBe("10"); - expect(result.device_type).toBe("desktop"); - expect(result.device_brand).toBe("Dell"); - expect(result.device_model).toBe("XPS"); - - // Client context — passthrough - expect(result.viewport_size).toBe("1024x768"); - expect(result.language).toBe("en-US"); - expect(result.timezone).toBe("America/New_York"); - - // Engagement - expect(result.time_on_page).toBe(30_000); - expect(result.scroll_depth).toBe(75); - expect(result.interaction_count).toBe(12); - expect(result.page_count).toBe(3); - - // UTM - expect(result.utm_source).toBe("google"); - expect(result.utm_medium).toBe("cpc"); - expect(result.utm_campaign).toBe("summer"); - expect(result.utm_term).toBe("analytics"); - expect(result.utm_content).toBe("banner"); - expect(result.gclid).toBe("gclid_abc"); - - // Performance — validated through validatePerformanceMetric - expect(result.dom_ready_time).toBe(800); - expect(result.ttfb).toBe(200); - expect(result.render_time).toBe(100); - - // Properties - expect(result.properties).toBe('{"plan":"pro","color":"blue"}'); + expect(result).toMatchObject({ + client_id: "ws_test", + event_name: "pageview", + title: "Dashboard | App", + referrer: "https://google.com", + path: "/dashboard", + url: "/dashboard", + anonymous_id: "salted_anon_1", + session_id: "sess_abc123", + timestamp: 1_700_000_001_000, + time: 1_700_000_001_000, + created_at: NOW, + ip: "abc123def456", + country: "United States", + region: "California", + city: "San Francisco", + user_agent: "", + browser_name: "Chrome", + browser_version: "120.0", + os_name: "Windows", + os_version: "10", + device_type: "desktop", + device_brand: "Dell", + device_model: "XPS", + viewport_size: "1024x768", + language: "en-US", + timezone: "America/New_York", + time_on_page: 30_000, + scroll_depth: 75, + interaction_count: 12, + page_count: 3, + utm_source: "google", + utm_medium: "cpc", + utm_campaign: "summer", + utm_term: "analytics", + utm_content: "banner", + gclid: "gclid_abc", + dom_ready_time: 800, + ttfb: 200, + render_time: 100, + properties: '{"plan":"pro","color":"blue"}', + }); + expect(result.id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + ); }); test("minimal input → defaults applied", () => { const result = buildTrackEvent({ name: "click" }, fullCtx); - expect(result.event_name).toBe("click"); - expect(result.timestamp).toBe(NOW); // falls back to ctx.now - expect(result.time).toBe(NOW); - expect(result.page_count).toBe(1); // default - expect(result.properties).toBe("{}"); // empty - expect(result.referrer).toBe(""); - expect(result.path).toBe(""); - expect(result.url).toBe(""); - expect(result.title).toBe(""); - expect(result.session_id).toBe(""); + expect(result).toMatchObject({ + event_name: "click", + timestamp: NOW, + time: NOW, + page_count: 1, + properties: "{}", + referrer: "", + path: "", + url: "", + title: "", + session_id: "", + }); }); test("missing geo fields → empty strings", () => { @@ -175,9 +156,9 @@ describe("buildTrackEvent — field mapping", () => { expect(result.timestamp).toBe(NOW); }); - test("performance metrics validated (negative → undefined)", () => { + test("performance metrics over the 300s cap → undefined", () => { const result = buildTrackEvent({ name: "x", ttfb: 999_999 }, fullCtx); - expect(result.ttfb).toBeUndefined(); // >300000 + expect(result.ttfb).toBeUndefined(); }); test("event_name sanitized (truncated to 255)", () => { @@ -201,8 +182,6 @@ describe("buildTrackEvent — field mapping", () => { }); }); -// ── Sanitization boundary ── - describe("buildTrackEvent — sanitization boundary", () => { for (const payload of XSS_PAYLOADS) { test(`XSS in name: ${payload.slice(0, 30)}…`, () => { @@ -252,16 +231,14 @@ describe("buildTrackEvent — sanitization boundary", () => { } }); - test("properties with XSS are JSON-stringified (not sanitized — stored as JSON)", () => { + test("properties are JSON-stringified verbatim, not HTML-sanitized", () => { const result = buildTrackEvent( { name: "x", properties: { evil: "" } }, fullCtx ); - // Properties are JSON-stringified, not HTML-sanitized (they're stored as JSON in CH) - expect(result.properties).toContain("script"); - expect(typeof result.properties).toBe("string"); - // But it's valid JSON - expect(() => JSON.parse(result.properties as string)).not.toThrow(); + expect(JSON.parse(result.properties as string)).toEqual({ + evil: "", + }); }); test("passthrough fields (language, timezone, etc.) are NOT sanitized", () => { @@ -277,76 +254,11 @@ describe("buildTrackEvent — sanitization boundary", () => { expect(result.timezone).toBe("America/New_York"); }); - test("session_id validated (rejects special chars)", () => { + test("session_id with stripped tags still passes the session id charset", () => { const result = buildTrackEvent( { name: "x", sessionId: "sess", @@ -109,8 +71,6 @@ export function longString(n: number, char = "a"): string { return char.repeat(n); } -// ── Request factory ── - export function req( url = "https://example.com", headers: Record = {} diff --git a/apps/basket/src/utils/ip-geo.test.ts b/apps/basket/src/utils/ip-geo.test.ts index 5b2f746b1f..3cc6eb629f 100644 --- a/apps/basket/src/utils/ip-geo.test.ts +++ b/apps/basket/src/utils/ip-geo.test.ts @@ -1,4 +1,4 @@ -import { afterAll, describe, expect, test } from "vitest"; +import { afterAll, describe, expect, test, vi } from "vitest"; import { randomIPv4, randomPublicIPv4, req } from "../test-helpers"; import { anonymizeIp, @@ -11,8 +11,6 @@ const HEX12 = /^[a-f0-9]{12}$/; afterAll(() => closeGeoIPReader()); -// ── anonymizeIp ── - describe("anonymizeIp", () => { test("empty → empty", () => expect(anonymizeIp("")).toBe("")); @@ -43,8 +41,6 @@ describe("anonymizeIp", () => { }); }); -// ── extractIpFromRequest ── - describe("extractIpFromRequest", () => { const table: [string, Record, string][] = [ ["cf-connecting-ip", { "cf-connecting-ip": "1.2.3.4" }, "1.2.3.4"], @@ -89,8 +85,6 @@ describe("extractIpFromRequest", () => { }); }); -// ── getGeo ── - describe("getGeo", () => { test("empty IP → empty anonymizedIP, no geo", async () => { const r = await getGeo(""); @@ -116,48 +110,51 @@ describe("getGeo", () => { } }); - test("200 random public IPs → valid structure", { - timeout: 60_000, - }, async () => { - const probe = await Promise.race([ - getGeo("8.8.8.8"), - new Promise((r) => setTimeout(() => r(null), 30_000)), - ]); - if (!(probe && probe.anonymizedIP)) { - console.log("Skipping: GeoIP CDN unreachable"); - return; + test("falls back to the Cloudflare country header when MaxMind is unavailable", async () => { + closeGeoIPReader(); + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockRejectedValue(new Error("CDN unreachable")); + try { + const withHeader = await getGeo( + randomPublicIPv4(), + req("https://x.com", { "cf-ipcountry": "US" }) + ); + expect(withHeader.country).toBe("US"); + expect(withHeader.region).toBeUndefined(); + expect(withHeader.city).toBeUndefined(); + + const badHeader = await getGeo( + randomPublicIPv4(), + req("https://x.com", { "cf-ipcountry": "USA" }) + ); + expect(badHeader.country).toBeUndefined(); + } finally { + fetchSpy.mockRestore(); + closeGeoIPReader(); } + }); - const results = await Promise.all( - Array.from({ length: 200 }, () => getGeo(randomPublicIPv4())) - ); - for (const r of results) { - expect(typeof r.anonymizedIP).toBe("string"); - if (r.country !== undefined) { - expect(typeof r.country).toBe("string"); - } - if (r.region !== undefined) { - expect(typeof r.region).toBe("string"); - } - if (r.city !== undefined) { - expect(typeof r.city).toBe("string"); + test("accepts compressed and ipv4-mapped IPv6 addresses", async () => { + closeGeoIPReader(); + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockRejectedValue(new Error("CDN unreachable")); + try { + for (const ip of [ + "2a00:1450:4009:81f::200e", + "2001:db8::1", + "::ffff:8.8.8.8", + ]) { + const r = await getGeo( + ip, + req("https://x.com", { "cf-ipcountry": "DE" }) + ); + expect(r.country).toBe("DE"); } + } finally { + fetchSpy.mockRestore(); + closeGeoIPReader(); } }); - - test("same IP → consistent results", async () => { - const ip = randomPublicIPv4(); - const [a, b] = await Promise.all([getGeo(ip), getGeo(ip)]); - expect(a.anonymizedIP).toBe(b.anonymizedIP); - expect(a.country).toBe(b.country); - }); - - test("Cloudflare country fallback", async () => { - const r = await getGeo( - "not-valid-ip", - req("https://x.com", { "cf-ipcountry": "US" }) - ); - // Should either return CF country or undefined (depends on reader state) - expect(typeof r.anonymizedIP).toBe("string"); - }); }); diff --git a/apps/basket/src/utils/ip-geo.ts b/apps/basket/src/utils/ip-geo.ts index e2cce394c0..d579098f00 100644 --- a/apps/basket/src/utils/ip-geo.ts +++ b/apps/basket/src/utils/ip-geo.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { isIP } from "node:net"; import { captureError, mergeWideEvent, record } from "@lib/tracing"; import type { City } from "@maxmind/geoip2-node"; import { @@ -110,13 +111,8 @@ function loadDatabase() { const ignore = ["127.0.0.1", "::1"]; -const ipv4Regex = - /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; - -const ipv6Regex = /^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/; - function isValidIp(ip: string): boolean { - return Boolean(ip && (ipv4Regex.test(ip) || ipv6Regex.test(ip))); + return isIP(ip) !== 0; } function getCloudflareCountry(headers: Headers): string | undefined { diff --git a/apps/basket/src/utils/origin-ip-validation.test.ts b/apps/basket/src/utils/origin-ip-validation.test.ts new file mode 100644 index 0000000000..f5b4f8d8f7 --- /dev/null +++ b/apps/basket/src/utils/origin-ip-validation.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, test } from "vitest"; +import { + isValidIpFromSettings, + isValidOriginFromSettings, + normalizeIpv6, +} from "./origin-ip-validation"; + +describe("normalizeIpv6", () => { + test("expands compressed notation", () => + expect(normalizeIpv6("2001:db8::1")).toBe( + "2001:0db8:0000:0000:0000:0000:0000:0001" + )); + + test("strips leading zeros before padding", () => + expect(normalizeIpv6("2001:0db8:0:0:0:0:0:0001")).toBe( + "2001:0db8:0000:0000:0000:0000:0000:0001" + )); + + test("lowercases hex groups", () => + expect(normalizeIpv6("2001:DB8::A")).toBe( + "2001:0db8:0000:0000:0000:0000:0000:000a" + )); + + test("handles loopback", () => + expect(normalizeIpv6("::1")).toBe( + "0000:0000:0000:0000:0000:0000:0000:0001" + )); + + test("handles all zeros", () => + expect(normalizeIpv6("::")).toBe( + "0000:0000:0000:0000:0000:0000:0000:0000" + )); + + test("handles IPv4-mapped addresses", () => + expect(normalizeIpv6("::ffff:192.168.1.1")).toBe( + "0000:0000:0000:0000:0000:ffff:c0a8:0101" + )); + + test("rejects IPv4", () => expect(normalizeIpv6("192.168.1.1")).toBeNull()); + + test("rejects double compression", () => + expect(normalizeIpv6("2001::db8::1")).toBeNull()); + + test("rejects too many groups", () => + expect(normalizeIpv6("1:2:3:4:5:6:7:8:9")).toBeNull()); + + test("rejects full-length address with compression marker", () => + expect(normalizeIpv6("1:2:3:4:5:6:7::8")).toBeNull()); + + test("rejects invalid hex", () => + expect(normalizeIpv6("gggg::1")).toBeNull()); + + test("rejects zone identifiers", () => + expect(normalizeIpv6("fe80::1%eth0")).toBeNull()); +}); + +describe("isValidIpFromSettings IPv6", () => { + test("compressed client matches expanded allowlist entry", () => + expect( + isValidIpFromSettings("2001:db8::1", [ + "2001:0db8:0000:0000:0000:0000:0000:0001", + ]) + ).toBe(true)); + + test("expanded client matches compressed allowlist entry", () => + expect( + isValidIpFromSettings("2001:0db8:0000:0000:0000:0000:0000:0001", [ + "2001:db8::1", + ]) + ).toBe(true)); + + test("partially compressed forms match", () => + expect( + isValidIpFromSettings("2001:db8:0:0:0:0:0:1", ["2001:db8::1"]) + ).toBe(true)); + + test("case differences match", () => + expect(isValidIpFromSettings("2001:DB8::A", ["2001:db8::a"])).toBe(true)); + + test("different IPv6 addresses do not match", () => + expect(isValidIpFromSettings("2001:db8::2", ["2001:db8::1"])).toBe(false)); + + test("IPv6 client does not match IPv4 allowlist entry", () => + expect(isValidIpFromSettings("2001:db8::1", ["192.168.1.1"])).toBe(false)); + + test("IPv4 client does not match IPv6 allowlist entry", () => + expect(isValidIpFromSettings("192.168.1.1", ["2001:db8::1"])).toBe(false)); + + test("invalid IPv6 allowlist entry only matches exactly", () => { + expect(isValidIpFromSettings("2001::db8::1", ["2001::db8::1"])).toBe(true); + expect(isValidIpFromSettings("2001:db8::1", ["2001::db8::1"])).toBe(false); + }); + + test("exact IPv4 match still works", () => + expect(isValidIpFromSettings("192.168.1.1", ["192.168.1.1"])).toBe(true)); + + test("IPv4 CIDR match still works", () => + expect(isValidIpFromSettings("192.168.1.42", ["192.168.1.0/24"])).toBe( + true + )); + + test("empty allowlist accepts any ip", () => + expect(isValidIpFromSettings("2001:db8::1", [])).toBe(true)); + + test("empty ip with allowlist is rejected", () => + expect(isValidIpFromSettings("", ["2001:db8::1"])).toBe(false)); +}); + +describe("isValidOriginFromSettings", () => { + test("missing origin header is accepted", () => + expect(isValidOriginFromSettings("", ["example.com"])).toBe(true)); + + test("origin matching allowlist is accepted", () => + expect( + isValidOriginFromSettings("https://example.com", ["example.com"]) + ).toBe(true)); + + test("origin not in allowlist is rejected", () => + expect( + isValidOriginFromSettings("https://evil.com", ["example.com"]) + ).toBe(false)); +}); diff --git a/apps/basket/src/utils/origin-ip-validation.ts b/apps/basket/src/utils/origin-ip-validation.ts index 22d4074455..c56f27327d 100644 --- a/apps/basket/src/utils/origin-ip-validation.ts +++ b/apps/basket/src/utils/origin-ip-validation.ts @@ -78,23 +78,106 @@ export function isValidOriginFromSettings( } } +const IPV6_GROUP_REGEX = /^[0-9a-f]{1,4}$/; +const IPV4_TAIL_REGEX = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/; + +function ipv4TailToGroups(tail: string): string[] | null { + const match = tail.match(IPV4_TAIL_REGEX); + if (!match) { + return null; + } + const octets = match.slice(1, 5).map((part) => Number.parseInt(part, 10)); + if (octets.some((octet) => octet > 255)) { + return null; + } + const [a, b, c, d] = octets as [number, number, number, number]; + return [(a * 256 + b).toString(16), (c * 256 + d).toString(16)]; +} + +export function normalizeIpv6(ip: string): string | null { + const value = ip.trim().toLowerCase(); + if (!value.includes(":") || value.includes("%")) { + return null; + } + + const halves = value.split("::"); + if (halves.length > 2) { + return null; + } + + const parseSide = (side: string): string[] | null => { + if (side === "") { + return []; + } + const groups: string[] = []; + for (const group of side.split(":")) { + if (IPV6_GROUP_REGEX.test(group)) { + groups.push(group); + continue; + } + const ipv4Groups = ipv4TailToGroups(group); + if (!ipv4Groups) { + return null; + } + groups.push(...ipv4Groups); + } + return groups; + }; + + const head = parseSide(halves[0] ?? ""); + const tail = halves.length === 2 ? parseSide(halves[1] ?? "") : []; + if (!(head && tail)) { + return null; + } + + if (halves.length === 1) { + if (head.length !== 8) { + return null; + } + return head.map((group) => group.padStart(4, "0")).join(":"); + } + + const missing = 8 - head.length - tail.length; + if (missing < 1) { + return null; + } + + const groups = [ + ...head, + ...Array.from({ length: missing }, () => "0"), + ...tail, + ]; + return groups.map((group) => group.padStart(4, "0")).join(":"); +} + export function isValidIpFromSettings( ip: string, allowedIps?: string[] ): boolean { - if (!ip?.trim()) { - return true; - } if (!allowedIps || allowedIps.length === 0) { return true; } + if (!ip?.trim()) { + return false; + } const trimmedIp = ip.trim(); + const normalizedIpv6 = trimmedIp.includes(":") + ? normalizeIpv6(trimmedIp) + : null; for (const allowed of allowedIps) { if (allowed === trimmedIp) { return true; } + if ( + normalizedIpv6 && + allowed.includes(":") && + !allowed.includes("/") && + normalizeIpv6(allowed) === normalizedIpv6 + ) { + return true; + } if (allowed.includes("/") && isIpInCidrRange(trimmedIp, allowed)) { return true; } diff --git a/apps/basket/src/utils/parsing-helpers.test.ts b/apps/basket/src/utils/parsing-helpers.test.ts index 62bb7ed9a8..16a1b59918 100644 --- a/apps/basket/src/utils/parsing-helpers.test.ts +++ b/apps/basket/src/utils/parsing-helpers.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "vitest"; import type { z } from "zod"; +import { cases, longString } from "../test-helpers"; import { batchBotIgnoredItem, batchSchemaItemFailure, @@ -8,72 +9,61 @@ import { parseTimestamp, } from "./parsing-helpers"; -// ── parseTimestamp ── +cases( + "parseTimestamp keeps numeric timestamps verbatim", + [ + ["positive epoch", 1_700_000_000, 1_700_000_000], + ["zero", 0, 0], + ["negative", -1, -1], + ], + (input) => parseTimestamp(input) +); -describe("parseTimestamp", () => { - test("number → passthrough", () => - expect(parseTimestamp(1_700_000_000)).toBe(1_700_000_000)); - test("0 → 0", () => expect(parseTimestamp(0)).toBe(0)); - test("negative → passthrough", () => expect(parseTimestamp(-1)).toBe(-1)); - test("string → Date.now()", () => { - const before = Date.now(); - const result = parseTimestamp("not-a-number"); - expect(result).toBeGreaterThanOrEqual(before); - expect(result).toBeLessThanOrEqual(Date.now()); - }); - test("null → Date.now()", () => { - const result = parseTimestamp(null); - expect(typeof result).toBe("number"); - expect(result).toBeGreaterThan(0); - }); - test("undefined → Date.now()", () => { - const result = parseTimestamp(undefined); - expect(typeof result).toBe("number"); - }); - test("object → Date.now()", () => { - expect(typeof parseTimestamp({})).toBe("number"); - }); +describe("parseTimestamp replaces non-numeric input with the current time", () => { + test.each([["not-a-number"], [null], [undefined], [{}]])( + "%j falls back to Date.now()", + (input) => { + const before = Date.now(); + const result = parseTimestamp(input); + expect(result).toBeGreaterThanOrEqual(before); + expect(result).toBeLessThanOrEqual(Date.now()); + } + ); }); -// ── parseProperties ── - -describe("parseProperties", () => { - test("object → JSON string", () => - expect(parseProperties({ a: 1 })).toBe('{"a":1}')); - test("null → '{}'", () => expect(parseProperties(null)).toBe("{}")); - test("undefined → '{}'", () => expect(parseProperties(undefined)).toBe("{}")); - test("false → '{}'", () => expect(parseProperties(false)).toBe("{}")); - test("0 → '{}'", () => expect(parseProperties(0)).toBe("{}")); - test("empty string → '{}'", () => expect(parseProperties("")).toBe("{}")); - test("non-empty string → JSON string", () => - expect(parseProperties("hello")).toBe('"hello"')); - test("array → JSON array", () => - expect(parseProperties([1, 2])).toBe("[1,2]")); - test("nested object", () => - expect(parseProperties({ a: { b: "c" } })).toBe('{"a":{"b":"c"}}')); -}); - -// ── parseEventId ── +cases( + "parseProperties serializes truthy values and defaults the rest", + [ + ["object", { a: 1 }, '{"a":1}'], + ["nested object", { a: { b: "c" } }, '{"a":{"b":"c"}}'], + ["array", [1, 2], "[1,2]"], + ["non-empty string", "hello", '"hello"'], + ["null", null, "{}"], + ["undefined", undefined, "{}"], + ["empty string", "", "{}"], + ], + (input) => parseProperties(input) +); describe("parseEventId", () => { const gen = () => "generated-uuid"; - test("valid string → passthrough", () => - expect(parseEventId("evt_123", gen)).toBe("evt_123")); - test("empty string → calls generator", () => - expect(parseEventId("", gen)).toBe("generated-uuid")); - test("null → calls generator", () => - expect(parseEventId(null, gen)).toBe("generated-uuid")); - test("undefined → calls generator", () => - expect(parseEventId(undefined, gen)).toBe("generated-uuid")); - test("number → calls generator", () => - expect(parseEventId(123, gen)).toBe("generated-uuid")); - test("long string → truncated to event id limit", () => { - const long = "a".repeat(600); - const result = parseEventId(long, gen); - expect(result.length).toBe(512); + cases( + "keeps client ids and generates for unusable input", + [ + ["valid string", "evt_123", "evt_123"], + ["empty string", "", "generated-uuid"], + ["null", null, "generated-uuid"], + ["number", 123, "generated-uuid"], + ], + (input) => parseEventId(input, gen) + ); + + test("truncated a long id to the event id limit", () => { + expect(parseEventId(longString(600), gen).length).toBe(512); }); - test("generator called only when needed", () => { + + test("did not invoke the generator for a usable id", () => { let called = false; parseEventId("valid", () => { called = true; @@ -83,15 +73,12 @@ describe("parseEventId", () => { }); }); -// ── batchSchemaItemFailure ── - -describe("batchSchemaItemFailure", () => { - test("returns structured error with issues", () => { +describe("batch item failure shapes", () => { + test("schema failure flattens issue paths into field names", () => { const issues = [ { message: "bad", path: ["x"], code: "custom" as const }, ] as z.core.$ZodIssue[]; - const result = batchSchemaItemFailure(issues, "track", "evt_1"); - expect(result).toEqual({ + expect(batchSchemaItemFailure(issues, "track", "evt_1")).toEqual({ status: "error", message: "Invalid event schema", code: "INVALID_EVENT_SCHEMA", @@ -100,16 +87,13 @@ describe("batchSchemaItemFailure", () => { eventId: "evt_1", }); }); -}); - -// ── batchBotIgnoredItem ── -describe("batchBotIgnoredItem", () => { - test("returns bot-ignored structure", () => { - const result = batchBotIgnoredItem("track"); - expect(result.status).toBe("error"); - expect(result.message).toBe("Bot detected"); - expect(result.eventType).toBe("track"); - expect(result.error).toBe("ignored"); + test("bot-ignored item is an error marked as ignored", () => { + expect(batchBotIgnoredItem("track")).toEqual({ + status: "error", + message: "Bot detected", + eventType: "track", + error: "ignored", + }); }); }); diff --git a/apps/basket/src/utils/parsing-helpers.ts b/apps/basket/src/utils/parsing-helpers.ts index 0c45202599..3f59126acc 100644 --- a/apps/basket/src/utils/parsing-helpers.ts +++ b/apps/basket/src/utils/parsing-helpers.ts @@ -87,16 +87,9 @@ export function parseEventId( eventId: unknown, generateFn: () => string ): string { - const sanitizeString = (str: unknown, maxLength: number): string => { - if (typeof str !== "string") { - return ""; - } - return str.slice(0, maxLength); - }; - - const sanitized = sanitizeString( - eventId, - VALIDATION_LIMITS.EVENT_ID_MAX_LENGTH - ); + const sanitized = + typeof eventId === "string" + ? eventId.slice(0, VALIDATION_LIMITS.EVENT_ID_MAX_LENGTH) + : ""; return sanitized || generateFn(); } diff --git a/apps/basket/src/utils/pixel.test.ts b/apps/basket/src/utils/pixel.test.ts index 9c1856d132..684aa03898 100644 --- a/apps/basket/src/utils/pixel.test.ts +++ b/apps/basket/src/utils/pixel.test.ts @@ -1,8 +1,6 @@ import { describe, expect, test } from "vitest"; import { createPixelResponse, parsePixelQuery } from "./pixel"; -// ── createPixelResponse ── - describe("createPixelResponse", () => { test("returns 200 image/gif with no-cache headers", async () => { const r = createPixelResponse(); @@ -14,15 +12,11 @@ describe("createPixelResponse", () => { const buf = await r.arrayBuffer(); expect(buf.byteLength).toBeGreaterThan(0); - // GIF89a magic bytes - expect(new Uint8Array(buf).slice(0, 3)).toEqual( - new Uint8Array([0x47, 0x49, 0x46]) - ); + const gifMagicBytes = new Uint8Array([0x47, 0x49, 0x46]); + expect(new Uint8Array(buf).slice(0, 3)).toEqual(gifMagicBytes); }); }); -// ── parsePixelQuery ── - describe("parsePixelQuery", () => { test("empty query → empty eventData, type=track", () => { const { eventData, eventType } = parsePixelQuery({}); @@ -112,4 +106,25 @@ describe("parsePixelQuery", () => { expect(eventData[`field_${i}`]).toBe(i); } }); + + test("flat key followed by nested key on the same name does not crash", () => { + const { eventData } = parsePixelQuery({ a: "1", "a[b]": "2" }); + expect(eventData.a).toEqual({ b: 2 }); + }); + + test("nested key deepened on a later param keeps the deepest write", () => { + const { eventData } = parsePixelQuery({ "a[b]": "1", "a[b][c]": "2" }); + expect(eventData.a).toEqual({ b: { c: 2 } }); + }); + + test("__proto__ and constructor paths are dropped without polluting prototypes", () => { + const { eventData } = parsePixelQuery({ + "__proto__[polluted]": "yes", + "constructor[prototype][evil]": "1", + name: "pageview", + }); + expect(eventData).toEqual({ name: "pageview" }); + expect(({} as Record).polluted).toBeUndefined(); + expect(({} as Record).evil).toBeUndefined(); + }); }); diff --git a/apps/basket/src/utils/pixel.ts b/apps/basket/src/utils/pixel.ts index 4b3f5b7186..7ec7cce2f3 100644 --- a/apps/basket/src/utils/pixel.ts +++ b/apps/basket/src/utils/pixel.ts @@ -1,18 +1,15 @@ -// Regex patterns for parsing query parameters const NESTED_KEY_REGEX = /^([^[]+)(\[.*\])?$/; const BRACKET_EXTRACT_REGEX = /\[([^\]]+)\]/g; const INTEGER_REGEX = /^-?\d+$/; const FLOAT_REGEX = /^-?\d*\.\d+$/; -// 1x1 transparent GIF pixel (base64) +const SKIPPED_KEYS = new Set(["sdk_name", "sdk_version", "client_id"]); +const UNSAFE_KEY_SEGMENTS = new Set(["__proto__", "constructor", "prototype"]); + const TRANSPARENT_PIXEL = Buffer.from( "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", "base64" ); - -/** - * Returns a 1x1 transparent GIF response - */ export function createPixelResponse( options: { retryAfterSeconds?: number; status?: number } = {} ): Response { @@ -30,10 +27,6 @@ export function createPixelResponse( headers, }); } - -/** - * Parses string values to appropriate types - */ function parseValue(value: string): string | number | boolean { if (INTEGER_REGEX.test(value)) { return Number.parseInt(value, 10); @@ -49,11 +42,6 @@ function parseValue(value: string): string | number | boolean { } return value; } - -/** - * Converts pixel query parameters back into event data structure - * Handles nested keys like "key[subkey]" and JSON-stringified properties - */ export function parsePixelQuery(query: Record): { eventData: Record; eventType: string; @@ -61,12 +49,10 @@ export function parsePixelQuery(query: Record): { const result: Record = {}; for (const [key, value] of Object.entries(query)) { - // Skip SDK metadata - if (key === "sdk_name" || key === "sdk_version" || key === "client_id") { + if (SKIPPED_KEYS.has(key)) { continue; } - // Handle JSON-stringified properties if (key === "properties") { try { result.properties = JSON.parse(value); @@ -77,43 +63,24 @@ export function parsePixelQuery(query: Record): { } const match = key.match(NESTED_KEY_REGEX); - if (!match) { - result[key] = parseValue(value); - continue; - } - - const baseKey = match[1]; - const nestedPath = match[2]; - - if (!nestedPath) { - result[baseKey] = parseValue(value); - continue; - } - - // Extract nested keys from brackets + const baseKey = match?.[1] ?? key; const nestedKeys = - nestedPath.match(BRACKET_EXTRACT_REGEX)?.map((k) => k.slice(1, -1)) || []; - - if (nestedKeys.length === 0) { - result[baseKey] = parseValue(value); + match?.[2]?.match(BRACKET_EXTRACT_REGEX)?.map((k) => k.slice(1, -1)) ?? + []; + const path = [baseKey, ...nestedKeys]; + if (path.some((segment) => UNSAFE_KEY_SEGMENTS.has(segment))) { continue; } - // Build nested structure - if (!result[baseKey]) { - result[baseKey] = {}; - } - - let current = result[baseKey] as Record; - const lastIndex = nestedKeys.length - 1; - for (let i = 0; i < lastIndex; i++) { - const nestedKey = nestedKeys[i]; - if (!current[nestedKey]) { - current[nestedKey] = {}; + let current = result; + for (const segment of path.slice(0, -1)) { + const next = current[segment]; + if (!next || typeof next !== "object" || Array.isArray(next)) { + current[segment] = {}; } - current = current[nestedKey] as Record; + current = current[segment] as Record; } - current[nestedKeys[lastIndex]] = parseValue(value); + current[path.at(-1) ?? baseKey] = parseValue(value); } return { diff --git a/apps/basket/src/utils/user-agent.test.ts b/apps/basket/src/utils/user-agent.test.ts index 554eb72fcd..04f6756327 100644 --- a/apps/basket/src/utils/user-agent.test.ts +++ b/apps/basket/src/utils/user-agent.test.ts @@ -39,8 +39,6 @@ const { detectBot, parseUserAgent } = await import("./user-agent"); const dummyReq = new Request("https://example.com"); -// ── detectBot wrapper — tests the legacy category mapping ── - describe("detectBot", () => { test("not a bot → passes through", () => { mockDetectBotShared.mockReturnValue({ @@ -113,24 +111,8 @@ describe("detectBot", () => { expect(result.reason).toBe("suspicious_pattern"); expect(result.result).toEqual(sharedResult); }); - - test("non-bot has no category", () => { - mockDetectBotShared.mockReturnValue({ - isBot: false, - category: undefined, - action: undefined, - confidence: 0, - reason: undefined, - name: undefined, - }); - const result = detectBot("Chrome/120", dummyReq); - expect(result.category).toBeUndefined(); - expect(result.botName).toBeUndefined(); - }); }); -// ── parseUserAgent wrapper ── - describe("parseUserAgent", () => { test("returns parsed fields from shared function", async () => { mockParseUserAgentShared.mockReturnValue({ diff --git a/apps/basket/src/utils/validation.test.ts b/apps/basket/src/utils/validation.test.ts index 035a9d5886..f31a752d5c 100644 --- a/apps/basket/src/utils/validation.test.ts +++ b/apps/basket/src/utils/validation.test.ts @@ -16,10 +16,7 @@ import { validateSessionId, } from "./validation"; -// ── sanitizeString ── - describe("sanitizeString", () => { - // non-string → "" for (const input of [null, undefined, 123, true, {}, []]) { test(`${JSON.stringify(input)} → ""`, () => expect(sanitizeString(input)).toBe("")); @@ -38,13 +35,17 @@ describe("sanitizeString", () => { expect(sanitizeString("bold text")).toBe("bold text")); test("strips dangerous chars <>'\",&", () => { - // Angle brackets in HTML-like patterns are removed by tag stripper, - // and bare <, >, ', ", & are removed by char stripper expect(sanitizeString("a'b\"c&d")).toBe("abcd"); expect(sanitizeString("hellovalue")).toBe("testvalue"); }); + test("defeats stacked-tag bypasses that reassemble after one strip pass", () => { + const result = sanitizeString("ipt>alert(1)ipt>"); + expect(result).toBe("iptalert(1)ipt"); + expect(result.toLowerCase()).not.toContain(" { 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/page.tsx b/apps/dashboard/app/(main)/billing/page.tsx index be04f09324..ed483a669a 100644 --- a/apps/dashboard/app/(main)/billing/page.tsx +++ b/apps/dashboard/app/(main)/billing/page.tsx @@ -25,6 +25,7 @@ import { UsageRow } from "./components/usage-row"; import { useBilling, useBillingData } from "./hooks/use-billing"; import type { CustomerWithPaymentMethod } from "./types/billing"; import type { OverageInfo } from "./utils/billing-utils"; +import type { PricingTier } from "./utils/feature-usage"; import { ArrowSquareOutIcon, CalendarIcon, @@ -69,19 +70,22 @@ function getDefaultDateRange() { function calculateOverageInfo( balance: number, includedUsage: number, - unlimited: boolean + unlimited: boolean, + pricingTiers: PricingTier[] ): OverageInfo { if (unlimited || balance >= 0) { return { hasOverage: false, overageEvents: 0, includedEvents: includedUsage, + pricingTiers, }; } return { hasOverage: true, overageEvents: Math.abs(balance), includedEvents: includedUsage, + pricingTiers, }; } @@ -283,12 +287,19 @@ export default function BillingPage() { if (!orgUsage) { return null; } + const eventsFeature = usage?.features.find( + (feature) => feature.id === "events" + ); + if (!eventsFeature?.hasPricedOverage) { + return null; + } return calculateOverageInfo( orgUsage.balance ?? 0, orgUsage.includedUsage ?? 0, - orgUsage.unlimited + orgUsage.unlimited, + eventsFeature.pricingTiers ); - }, [orgUsage]); + }, [orgUsage, usage?.features]); const { onCancelClick, onCancelConfirm, 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..c5f3e29534 100644 --- a/apps/dashboard/app/(main)/billing/utils/billing-utils.ts +++ b/apps/dashboard/app/(main)/billing/utils/billing-utils.ts @@ -1,9 +1,13 @@ -export const EVENT_COST = 0.000_035; +import { + calculateGraduatedOverageCost, + type PricingTier, +} from "./feature-usage"; export interface OverageInfo { hasOverage: boolean; includedEvents: number; overageEvents: number; + pricingTiers: PricingTier[]; } export function calculateOverageCost( @@ -21,5 +25,10 @@ export function calculateOverageCost( } const ratio = eventCount / totalEvents; - return overageInfo.overageEvents * ratio * EVENT_COST; + return ( + calculateGraduatedOverageCost( + overageInfo.overageEvents, + overageInfo.pricingTiers + ) * ratio + ); } diff --git a/apps/dashboard/app/(main)/billing/utils/feature-usage.ts b/apps/dashboard/app/(main)/billing/utils/feature-usage.ts index e8ac43a501..9a9e27fc23 100644 --- a/apps/dashboard/app/(main)/billing/utils/feature-usage.ts +++ b/apps/dashboard/app/(main)/billing/utils/feature-usage.ts @@ -40,7 +40,7 @@ export interface FeatureUsage { unlimited: boolean; } -function calculateOverageCost( +export function calculateGraduatedOverageCost( overageAmount: number, tiers?: PricingTier[] ): number { @@ -103,7 +103,7 @@ export function calculateFeatureUsage( overageAmount > 0 ? { amount: overageAmount, - cost: calculateOverageCost(overageAmount, effectiveTiers), + cost: calculateGraduatedOverageCost(overageAmount, effectiveTiers), } : null; 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)/home/_components/monitors-section.tsx b/apps/dashboard/app/(main)/home/_components/monitors-section.tsx index ebb2380bf8..22e46944cf 100644 --- a/apps/dashboard/app/(main)/home/_components/monitors-section.tsx +++ b/apps/dashboard/app/(main)/home/_components/monitors-section.tsx @@ -214,10 +214,8 @@ export function MonitorsSection({ ); } - const hasIssues = activeMonitors < totalMonitors; - return ( - +
diff --git a/apps/dashboard/app/(main)/insights/_components/conversion-draft-recommendation.tsx b/apps/dashboard/app/(main)/insights/_components/conversion-draft-recommendation.tsx deleted file mode 100644 index 69917cb5ee..0000000000 --- a/apps/dashboard/app/(main)/insights/_components/conversion-draft-recommendation.tsx +++ /dev/null @@ -1,301 +0,0 @@ -"use client"; - -import { authClient } from "@databuddy/auth/client"; -import type { InsightMeasurementRecommendation } from "@databuddy/shared/insights"; -import { - GATED_FEATURES, - type GatedFeatureId, -} from "@databuddy/shared/types/features"; -import { Button } from "@databuddy/ui"; -import { CheckCircleIcon } from "@databuddy/ui/icons"; -import dynamic from "next/dynamic"; -import Link from "next/link"; -import { useState } from "react"; -import { toast } from "sonner"; -import { useFeatureGate } from "@/components/feature-gate"; -import { useAutocompleteData } from "@/hooks/use-autocomplete"; -import { - type CreateGoalData, - type Goal, - useGoalActions, -} from "@/hooks/use-goals"; -import { useFunnelActions } from "@/hooks/use-funnels"; -import type { CreateFunnelData } from "@/types/funnels"; - -const EditGoalDialog = dynamic( - () => - import( - "@/app/(main)/websites/[id]/goals/_components/edit-goal-dialog" - ).then((module) => module.EditGoalDialog), - { ssr: false } -); - -const EditFunnelDialog = dynamic( - () => - import( - "@/app/(main)/websites/[id]/funnels/_components/edit-funnel-dialog" - ).then((module) => module.EditFunnelDialog), - { ssr: false } -); - -type GoalDraftRecommendation = Extract< - InsightMeasurementRecommendation, - { kind: "goal_draft" } ->; -type FunnelDraftRecommendation = Extract< - InsightMeasurementRecommendation, - { kind: "funnel_draft" } ->; -type ConversionDraftRecommendation = - | GoalDraftRecommendation - | FunnelDraftRecommendation; - -interface DraftCreationAccess { - canCreate: boolean; - reason: string | null; -} - -interface CreatedDraft { - id: string; - name: string; -} - -export function ConversionDraftRecommendationAction({ - recommendation, - websiteId, -}: { - recommendation: ConversionDraftRecommendation; - websiteId: string; -}) { - const feature = - recommendation.kind === "goal_draft" - ? GATED_FEATURES.GOALS - : GATED_FEATURES.FUNNELS; - const creationAccess = useDraftCreationAccess(feature); - - if (recommendation.kind === "goal_draft") { - return ( - - ); - } - - return ( - - ); -} - -function GoalDraftAction({ - creationAccess, - recommendation, - websiteId, -}: { - creationAccess: DraftCreationAccess; - recommendation: GoalDraftRecommendation; - websiteId: string; -}) { - const [createdGoal, setCreatedGoal] = useState(null); - const [isOpen, setIsOpen] = useState(false); - const autocomplete = useAutocompleteData(websiteId, isOpen); - const { createGoal, isCreating } = useGoalActions(websiteId); - - const handleSave = async (data: Goal | Omit) => { - try { - const goalInput: CreateGoalData = { - description: data.description ?? null, - filters: data.filters ?? undefined, - ignoreHistoricData: data.ignoreHistoricData, - name: data.name, - target: data.target, - type: data.type, - websiteId, - }; - const goal = await createGoal(goalInput); - setIsOpen(false); - setCreatedGoal({ id: goal.id, name: goal.name }); - } catch (error) { - toast.error( - error instanceof Error ? error.message : "Could not create the goal" - ); - } - }; - - if (createdGoal) { - return ( - - ); - } - - return ( - <> - setIsOpen(true)} - /> - {isOpen ? ( - setIsOpen(false)} - onSave={handleSave} - /> - ) : null} - - ); -} - -function FunnelDraftAction({ - creationAccess, - recommendation, - websiteId, -}: { - creationAccess: DraftCreationAccess; - recommendation: FunnelDraftRecommendation; - websiteId: string; -}) { - const [createdFunnel, setCreatedFunnel] = useState(null); - const [isOpen, setIsOpen] = useState(false); - const autocomplete = useAutocompleteData(websiteId, isOpen); - const { createAction, isCreating } = useFunnelActions(websiteId); - - const handleCreate = async (data: CreateFunnelData) => { - try { - const funnel = await createAction(data); - setIsOpen(false); - setCreatedFunnel({ id: funnel.id, name: funnel.name }); - } catch (error) { - toast.error( - error instanceof Error ? error.message : "Could not create the funnel" - ); - throw error; - } - }; - - if (createdFunnel) { - return ( - - ); - } - - return ( - <> - setIsOpen(true)} - /> - {isOpen ? ( - setIsOpen(false)} - onCreate={handleCreate} - onSubmit={() => Promise.resolve()} - /> - ) : null} - - ); -} - -function DraftReviewButton({ - access, - label, - onClick, -}: { - access: DraftCreationAccess; - label: "funnel" | "goal"; - onClick: () => void; -}) { - return ( -
- - {access.reason ? ( -

{access.reason}

- ) : null} -
- ); -} - -function CreatedDraftLink({ - href, - label, - name, -}: { - href: string; - label: "Funnel" | "Goal"; - name: string; -}) { - return ( -
- - - {name} created - - -
- ); -} - -function useDraftCreationAccess(feature: GatedFeatureId): DraftCreationAccess { - const featureGate = useFeatureGate(feature); - const memberRole = authClient.useActiveMemberRole(); - - if (featureGate.isLoading || memberRole.isPending) { - return { canCreate: false, reason: "Checking access…" }; - } - if (!featureGate.isEnabled) { - return { - canCreate: false, - reason: - featureGate.upgradeMessage ?? - `${featureGate.featureName} are not available on this plan.`, - }; - } - if (memberRole.data?.role === "viewer") { - return { - canCreate: false, - reason: "You have view-only access to this website.", - }; - } - if (!memberRole.data) { - return { - canCreate: false, - reason: "You need edit access to create this.", - }; - } - - return { canCreate: true, reason: null }; -} diff --git a/apps/dashboard/app/(main)/insights/_components/insights-shell.tsx b/apps/dashboard/app/(main)/insights/_components/insights-shell.tsx index 99654a4ada..12c3cff2c1 100644 --- a/apps/dashboard/app/(main)/insights/_components/insights-shell.tsx +++ b/apps/dashboard/app/(main)/insights/_components/insights-shell.tsx @@ -16,16 +16,11 @@ import { GlobeIcon, LightbulbIcon, MagnifyingGlassIcon, - WrenchIcon, } from "@databuddy/ui/icons"; import { InvestigationSettings } from "./investigation-settings"; import { isActiveRun } from "../_lib/insight-run"; -const INSIGHTS_LIST_ROUTES = new Set([ - "/insights", - "/insights/investigations", - "/insights/recommendations", -]); +const INSIGHTS_LIST_ROUTES = new Set(["/insights", "/insights/investigations"]); export function InsightsShell({ children }: { children: ReactNode }) { const pathname = usePathname(); @@ -57,9 +52,6 @@ function InsightsListShell({ children }: { children: ReactNode }) { return isActiveRun(query.state.data?.status) ? 2000 : 30_000; }, }); - const recommendationTotal = useQuery( - insightQueries.recommendationTotal(organizationId) - ); const { websites, isLoading: websitesLoading } = useWebsitesLight(); const hasNoWebsites = !websitesLoading && websites !== undefined && websites.length === 0; @@ -159,17 +151,6 @@ function InsightsListShell({ children }: { children: ReactNode }) { id: "investigations", label: "Investigations", }, - { - count: recommendationTotal.data, - countLabel: recommendationTotal.data - ? `${recommendationTotal.data} current recommendation${recommendationTotal.data === 1 ? "" : "s"}` - : undefined, - countTone: "attention", - href: "/insights/recommendations", - icon: WrenchIcon, - id: "recommendations", - label: "Recommendations", - }, ]} variant="tabs" /> diff --git a/apps/dashboard/app/(main)/insights/recommendations/page.tsx b/apps/dashboard/app/(main)/insights/recommendations/page.tsx deleted file mode 100644 index 022fdc77d8..0000000000 --- a/apps/dashboard/app/(main)/insights/recommendations/page.tsx +++ /dev/null @@ -1,574 +0,0 @@ -"use client"; - -import { useInfiniteQuery } from "@tanstack/react-query"; -import { authClient } from "@databuddy/auth/client"; -import type { - InsightMeasurementRecommendation, - InsightRecommendation as SharedInsightRecommendation, -} from "@databuddy/shared/insights"; -import Link from "next/link"; -import { useOrganizationsContext } from "@/components/providers/organizations-provider"; -import { List } from "@/components/ui/composables/list"; -import { type InsightRecommendation, insightQueries } from "@/lib/insight-api"; -import { cn } from "@/lib/utils"; -import { - Badge, - Button, - Card, - EmptyState, - fromNow, - Skeleton, -} from "@databuddy/ui"; -import { Accordion } from "@databuddy/ui/client"; -import { - ArrowRightIcon, - ArrowSquareOutIcon, - CheckCircleIcon, - CodeIcon, - FilterIcon, - IdBadge2Icon, - PencilSimpleIcon, - TargetIcon, - TrashIcon, - WrenchIcon, -} from "@databuddy/ui/icons"; -import { ConversionDraftRecommendationAction } from "../_components/conversion-draft-recommendation"; - -type Recommendation = NonNullable; -type DatabuddySetupRecommendation = Extract< - Recommendation, - { kind: "databuddy_setup" } ->; -type DefinitionRecommendation = Extract< - Recommendation, - { operation: "delete" | "edit" } ->; -type ConversionDraftRecommendation = Extract< - InsightMeasurementRecommendation, - { kind: "goal_draft" | "funnel_draft" } ->; -type InstrumentationRecommendation = Extract< - InsightMeasurementRecommendation, - { kind: "instrumentation" } ->; - -export default function RecommendationsPage() { - const { activeOrganization, activeOrganizationId } = - useOrganizationsContext(); - const organizationId = - activeOrganization?.id ?? activeOrganizationId ?? undefined; - - return ( -
- - - Recommendations - - Concrete improvements found while analyzing your data. - - - - - - -
- ); -} - -function RecommendationList({ - organizationId, -}: { - organizationId: string | undefined; -}) { - const recommendations = useInfiniteQuery( - insightQueries.recommendationsInfinite(organizationId) - ); - const items = - recommendations.data?.pages.flatMap((page) => page.recommendations) ?? []; - const completed = recommendations.data?.pages[0]?.completed ?? []; - - if (recommendations.isLoading) { - return ( -
- {Array.from({ length: 4 }, (_, index) => ( - - ))} -
- ); - } - - if (recommendations.isError && items.length === 0 && completed.length === 0) { - return ( -
- { - recommendations.refetch().catch(() => undefined); - }, - variant: "secondary", - }} - description="Databuddy couldn't load current recommendations." - icon={} - title="Couldn't load recommendations" - variant="error" - /> -
- ); - } - - if (items.length === 0 && completed.length === 0) { - return ( -
- } - title="No recommendations" - variant="minimal" - /> -
- ); - } - - return ( - <> - {items.length > 0 ? ( -
    - {items.map((insight) => ( - - ))} -
- ) : ( -
- - Nothing needs attention right now. -
- )} - {recommendations.hasNextPage ? ( -
- -
- ) : null} - {completed.length > 0 ? ( - - ) : null} - - ); -} - -function CompletedRecommendations({ - insights, -}: { - insights: InsightRecommendation[]; -}) { - return ( - - - - Completed - - {insights.length} - - Latest verified - - -
    - {insights.map((insight) => ( - - ))} -
-
-
- ); -} - -function RecommendationSkeleton() { - return ( -
- -
- - - -
-
- ); -} - -function RecommendationRow({ - completed = false, - insight, -}: { - completed?: boolean; - insight: InsightRecommendation; -}) { - const { recommendation } = insight; - const presentation = getRecommendationPresentation(insight); - const SignalIcon = completed ? CheckCircleIcon : presentation.icon; - const action = completed ? null : recommendationAction(insight); - - return ( - -
  • - - - -
    -
    - - - {completed ? "Completed" : presentation.label} - - - {insight.websiteName ?? insight.websiteDomain} - {completed ? null : ` · ${fromNow(insight.createdAt)}`} - - -

    - {completed ? completionMessage(insight) : recommendation.action} -

    - {completed ? null : ( -

    - - {insight.impact ? "Why it matters: " : "Context: "} - - {insight.impact ?? insight.summary} -

    - )} - {!completed && isInstrumentationRecommendation(recommendation) ? ( -
      - {recommendation.events.map((event) => ( -
    • - - {event.name} - - - {event.description} -
    • - ))} -
    - ) : null} - {insight.investigationId ? ( - - View insight - - - ) : null} -
    - {action ? ( -
    - {action} -
    - ) : null} -
    -
  • -
    - ); -} - -function completionMessage(insight: InsightRecommendation) { - const { recommendation } = insight; - if (isConversionDraftRecommendation(recommendation)) { - return recommendation.kind === "goal_draft" - ? "Goal is now set up." - : "Funnel is now set up."; - } - if (isDefinitionRecommendation(recommendation)) { - const noun = insight.signal.entity.type === "funnel" ? "Funnel" : "Goal"; - return recommendation.operation === "delete" - ? `${noun} is no longer present.` - : `${noun} change is now set up.`; - } - return "This recommendation is complete."; -} - -function recommendationAction(insight: InsightRecommendation) { - const { recommendation } = insight; - if (isConversionDraftRecommendation(recommendation)) { - return ( - - ); - } - if ( - (insight.signal.entity.type === "goal" || - insight.signal.entity.type === "funnel") && - isDefinitionRecommendation(recommendation) - ) { - return ( - - ); - } - if (isInstrumentationRecommendation(recommendation)) { - return ( - - ); - } - if (isDatabuddySetupRecommendation(recommendation)) { - if (recommendation.feature === "user_identification") { - return ( - - ); - } - return ( - - ); - } - return null; -} - -function DefinitionRecommendationAction({ - definitionId, - definitionType, - recommendation, - websiteId, -}: { - definitionId: string; - definitionType: "funnel" | "goal"; - recommendation: DefinitionRecommendation; - websiteId: string; -}) { - const deleting = recommendation.operation === "delete"; - const noun = definitionType === "funnel" ? "funnel" : "goal"; - const memberRole = authClient.useActiveMemberRole(); - const accessReason = memberRole.isPending - ? "Checking access…" - : memberRole.data?.role === "viewer" - ? "You have view-only access to this website." - : memberRole.data - ? null - : `You need edit access to change this ${noun}.`; - const label = deleting ? `Delete ${noun}` : `Review ${noun} changes`; - - if (accessReason) { - return ( -
    - -

    - {accessReason} -

    -
    - ); - } - - return ( - - ); -} - -function isDefinitionRecommendation( - recommendation: Recommendation -): recommendation is DefinitionRecommendation { - return ( - "operation" in recommendation && - (recommendation.operation === "delete" || - recommendation.operation === "edit") - ); -} - -function isDatabuddySetupRecommendation( - recommendation: Recommendation -): recommendation is DatabuddySetupRecommendation { - return "kind" in recommendation && recommendation.kind === "databuddy_setup"; -} - -function isConversionDraftRecommendation( - recommendation: Recommendation -): recommendation is ConversionDraftRecommendation { - return ( - "kind" in recommendation && - (recommendation.kind === "goal_draft" || - recommendation.kind === "funnel_draft") - ); -} - -function isInstrumentationRecommendation( - recommendation: Recommendation -): recommendation is InstrumentationRecommendation { - return "kind" in recommendation && recommendation.kind === "instrumentation"; -} - -type BadgeVariant = "destructive" | "muted" | "primary" | "warning"; - -interface RecommendationPresentation { - badgeVariant: BadgeVariant; - icon: typeof WrenchIcon; - iconClassName: string; - label: string; -} - -function getRecommendationPresentation( - insight: InsightRecommendation -): RecommendationPresentation { - const { recommendation } = insight; - if (isDatabuddySetupRecommendation(recommendation)) { - const isTracking = recommendation.feature === "tracking"; - return { - badgeVariant: "warning", - icon: isTracking ? CodeIcon : IdBadge2Icon, - iconClassName: "bg-warning/10 text-warning", - label: isTracking ? "Check tracking" : "Identify users", - }; - } - if (isInstrumentationRecommendation(recommendation)) { - return { - badgeVariant: "warning", - icon: CodeIcon, - iconClassName: "bg-warning/10 text-warning", - label: "Add events", - }; - } - if (isConversionDraftRecommendation(recommendation)) { - return recommendation.kind === "goal_draft" - ? { - badgeVariant: "primary", - icon: TargetIcon, - iconClassName: "bg-brand-purple/10 text-brand-purple", - label: "Create goal", - } - : { - badgeVariant: "primary", - icon: FilterIcon, - iconClassName: "bg-brand-purple/10 text-brand-purple", - label: "Create funnel", - }; - } - if ( - (insight.signal.entity.type === "goal" || - insight.signal.entity.type === "funnel") && - isDefinitionRecommendation(recommendation) - ) { - return recommendation.operation === "delete" - ? { - badgeVariant: "destructive", - icon: TrashIcon, - iconClassName: "bg-destructive/10 text-destructive", - label: `Delete ${insight.signal.entity.type}`, - } - : { - badgeVariant: "primary", - icon: PencilSimpleIcon, - iconClassName: "bg-brand-purple/10 text-brand-purple", - label: `Edit ${insight.signal.entity.type}`, - }; - } - return { - badgeVariant: "muted", - icon: WrenchIcon, - iconClassName: "bg-muted text-muted-foreground", - label: "Suggestion", - }; -} diff --git a/apps/dashboard/app/(main)/links/_components/deep-link-sheet.tsx b/apps/dashboard/app/(main)/links/_components/deep-link-sheet.tsx index 13b10b2664..13e8496e31 100644 --- a/apps/dashboard/app/(main)/links/_components/deep-link-sheet.tsx +++ b/apps/dashboard/app/(main)/links/_components/deep-link-sheet.tsx @@ -17,12 +17,7 @@ import { createDeepLinkFormSchema, type DeepLinkFormData, } from "./link-form-schema"; -import { - ensureProtocol, - mapLinkApiError, - normalizeUrlInput, - stripProtocol, -} from "./link-utils"; +import { ensureProtocol, normalizeUrlInput, stripProtocol } from "./link-utils"; import { ArrowLeftIcon } from "@databuddy/ui/icons"; import { Button, Field, Input } from "@databuddy/ui"; import { Sheet } from "@databuddy/ui/client"; @@ -90,9 +85,7 @@ function DeepLinkForm({ }); toast.success("Deep link created"); onOpenChange(false); - } catch (error: unknown) { - toast.error(mapLinkApiError(error, false)); - } + } catch {} }; const { isValid, isDirty } = form.formState; 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-sheet.tsx b/apps/dashboard/app/(main)/links/_components/link-sheet.tsx index 8dfb9f47a1..73a583eb48 100644 --- a/apps/dashboard/app/(main)/links/_components/link-sheet.tsx +++ b/apps/dashboard/app/(main)/links/_components/link-sheet.tsx @@ -18,7 +18,6 @@ import { linkFormSchema } from "./link-form-schema"; import { LinkQrCode } from "./link-qr-code"; import { buildLinkPayload, - mapLinkApiError, normalizeUrlInput, stripProtocol, } from "./link-utils"; @@ -174,9 +173,7 @@ function LinkSheetInner({ open, onOpenChange, link, onSave }: LinkSheetProps) { toast.success("Link created"); } onOpenChange(false); - } catch (error: unknown) { - toast.error(mapLinkApiError(error, !!link?.id)); - } + } catch {} }; const { copyToClipboard } = useCopyToClipboard({ diff --git a/apps/dashboard/app/(main)/links/_components/link-utils.ts b/apps/dashboard/app/(main)/links/_components/link-utils.ts index 05530d5db8..9d7c0f6ebe 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 ""; @@ -152,33 +128,3 @@ export function buildLinkPayload({ folderId, }; } - -interface RpcError { - data?: { code?: string }; - message?: string; -} - -export function mapLinkApiError(error: unknown, isEditing: boolean): string { - const defaultMessage = `Failed to ${isEditing ? "update" : "create"} link.`; - const rpcError = error as RpcError; - - if (rpcError?.data?.code) { - switch (rpcError.data.code) { - case "CONFLICT": - return "A link with this slug already exists."; - case "FORBIDDEN": - return ( - rpcError.message || - "You do not have permission to perform this action." - ); - case "UNAUTHORIZED": - return "You must be logged in to perform this action."; - case "BAD_REQUEST": - return rpcError.message || "Invalid request. Please check your input."; - default: - return rpcError.message || defaultMessage; - } - } - - return rpcError?.message || defaultMessage; -} diff --git a/apps/dashboard/app/(main)/monitors/[id]/page.tsx b/apps/dashboard/app/(main)/monitors/[id]/page.tsx index 87d14f4b20..867defd50f 100644 --- a/apps/dashboard/app/(main)/monitors/[id]/page.tsx +++ b/apps/dashboard/app/(main)/monitors/[id]/page.tsx @@ -31,6 +31,7 @@ import { PencilIcon, PlayIcon, TrashIcon, + WarningIcon, } from "@databuddy/ui/icons"; import { DeleteDialog } from "@databuddy/ui/client"; import { @@ -67,12 +68,9 @@ const granularityLabels: Record = { interface ScheduleData { cacheBust: boolean; - cron: string; granularity: string; id: string; isPaused: boolean; - isPublic: boolean; - jsonParsingConfig?: { enabled: boolean } | null; name: string | null; organizationId: string; schedulerStatus: string; @@ -93,9 +91,6 @@ function resolveStatus(check: RecentActivityCheck | undefined) { if (check.status === 1) { return "up" as const; } - if (check.status === 2) { - return "unknown" as const; - } if (check.http_code > 0 && check.http_code < 500) { return "degraded" as const; } @@ -158,7 +153,6 @@ export default function MonitorDetailsPage() { granularity: string; timeout?: number | null; cacheBust?: boolean; - jsonParsingConfig?: { enabled: boolean } | null; } | null>(null); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [isPausing, setIsPausing] = useState(false); @@ -401,9 +395,6 @@ export default function MonitorDetailsPage() { granularity: schedule.granularity, timeout: schedule.timeout, cacheBust: schedule.cacheBust, - jsonParsingConfig: schedule.jsonParsingConfig as { - enabled: boolean; - } | null, }); setIsSheetOpen(true); }; @@ -638,6 +629,13 @@ export default function MonitorDetailsPage() { )} + {schedule.schedulerStatus === "missing" && !schedule.isPaused ? ( + + + Scheduler inactive + + ) : null} + Frequency diff --git a/apps/dashboard/app/(main)/monitors/_components/types.ts b/apps/dashboard/app/(main)/monitors/_components/types.ts index 6b7d556562..651764c4c0 100644 --- a/apps/dashboard/app/(main)/monitors/_components/types.ts +++ b/apps/dashboard/app/(main)/monitors/_components/types.ts @@ -1,13 +1,9 @@ export interface Monitor { cacheBust: boolean; createdAt: Date | string; - cron: string; granularity: string; id: string; isPaused: boolean; - jsonParsingConfig?: { - enabled: boolean; - } | null; name: string | null; organizationId: string; timeout: number | null; diff --git a/apps/dashboard/app/(main)/monitors/page.tsx b/apps/dashboard/app/(main)/monitors/page.tsx index 604db3b2f4..50eeab58a7 100644 --- a/apps/dashboard/app/(main)/monitors/page.tsx +++ b/apps/dashboard/app/(main)/monitors/page.tsx @@ -46,9 +46,6 @@ function MonitorsPageContent() { granularity: string; timeout?: number | null; cacheBust?: boolean; - jsonParsingConfig?: { - enabled: boolean; - } | null; } | null>(null); const schedulesQuery = useQuery({ @@ -77,7 +74,6 @@ function MonitorsPageContent() { granularity: schedule.granularity, timeout: schedule.timeout, cacheBust: schedule.cacheBust, - jsonParsingConfig: schedule.jsonParsingConfig, }); setIsSheetOpen(true); }; diff --git a/apps/dashboard/app/(main)/monitors/status-pages/[id]/_components/add-monitor-dialog.tsx b/apps/dashboard/app/(main)/monitors/status-pages/[id]/_components/add-monitor-dialog.tsx index d53c4cb442..c3ac80663a 100644 --- a/apps/dashboard/app/(main)/monitors/status-pages/[id]/_components/add-monitor-dialog.tsx +++ b/apps/dashboard/app/(main)/monitors/status-pages/[id]/_components/add-monitor-dialog.tsx @@ -6,6 +6,7 @@ import { useState } from "react"; import { Controller, useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; +import { uptimeGranularitySchema } from "@databuddy/shared/uptime"; import { useOrganizationsContext } from "@/components/providers/organizations-provider"; import { orpc } from "@/lib/orpc"; import { cn } from "@/lib/utils"; @@ -22,19 +23,14 @@ const GRANULARITY_OPTIONS = [ { value: "thirty_minutes", label: "30m" }, { value: "hour", label: "1h" }, { value: "six_hours", label: "6h" }, + { value: "twelve_hours", label: "12h" }, + { value: "day", label: "24h" }, ] as const; const createSchema = z.object({ name: z.string().optional(), url: z.string().url("Enter a valid URL (e.g. https://example.com)"), - granularity: z.enum([ - "minute", - "five_minutes", - "ten_minutes", - "thirty_minutes", - "hour", - "six_hours", - ]), + granularity: uptimeGranularitySchema, }); type CreateFormData = z.infer; @@ -123,9 +119,8 @@ export function AddMonitorDialog({ url: data.url, name: data.name || undefined, granularity: data.granularity, - jsonParsingConfig: { enabled: true }, }); - const scheduleId = result.scheduleId as string; + const scheduleId = result.scheduleId; await addMutation.mutateAsync({ statusPageId, uptimeScheduleId: scheduleId, diff --git a/apps/dashboard/app/(main)/onboarding/_components/step-create-website.tsx b/apps/dashboard/app/(main)/onboarding/_components/step-create-website.tsx index 858479a4da..a554b41623 100644 --- a/apps/dashboard/app/(main)/onboarding/_components/step-create-website.tsx +++ b/apps/dashboard/app/(main)/onboarding/_components/step-create-website.tsx @@ -3,6 +3,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useController, useForm } from "react-hook-form"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/user-facing-error"; import { z } from "zod"; import { useOrganizationsContext } from "@/components/providers/organizations-provider"; import { useCreateWebsite } from "@/hooks/use-websites"; @@ -73,15 +74,7 @@ export function StepCreateWebsite({ trackAppEvent(APP_EVENTS.onboardingWebsiteCreated, attribution); onComplete(result.id); } catch (error: unknown) { - const rpcError = error as { - data?: { code?: string }; - message?: string; - }; - if (rpcError?.data?.code === "CONFLICT") { - toast.error("A website with this domain already exists."); - } else { - toast.error(rpcError?.message || "Failed to create website."); - } + showErrorToast(error, "Failed to create website."); } }; diff --git a/apps/dashboard/app/(main)/onboarding/_components/step-install-tracking.tsx b/apps/dashboard/app/(main)/onboarding/_components/step-install-tracking.tsx index d894aa15ec..b00e0443bf 100644 --- a/apps/dashboard/app/(main)/onboarding/_components/step-install-tracking.tsx +++ b/apps/dashboard/app/(main)/onboarding/_components/step-install-tracking.tsx @@ -34,9 +34,6 @@ import { } from "../../websites/[id]/_components/utils/code-generators"; import { RECOMMENDED_DEFAULTS } from "../../websites/[id]/_components/utils/tracking-defaults"; -// TODO: Replace with published skill URL once available -const SKILL_URL = "https://github.com/databuddy-cc/skill"; - async function copyTextToClipboard(value: string): Promise { if (!(value && typeof window !== "undefined")) { return false; @@ -86,7 +83,6 @@ function generateAgentPrompt(websiteId: string): string { - Docs: https://www.databuddy.cc/docs/getting-started - LLMs.txt: https://www.databuddy.cc/llms.txt - Full docs: https://www.databuddy.cc/docs -- Skill (install for full context): ${SKILL_URL} ## Installation @@ -170,11 +166,11 @@ Use snake_case event names. Track decisions and milestones (signup_completed, pu **Ad blockers**: uBlock Origin, Privacy Badger, and similar extensions may block analytics scripts. Test with extensions disabled. For production, consider a custom tracking domain (proxy through your own domain). -**Localhost is ignored by default**: The SDK does not send events from localhost in production builds. During development, events only fire if the dev server is running. +**Localhost is ignored by default**: Events from localhost are not sent unless the tracker's debug build is used. Deploy or open the site on a non-localhost host to see data. **Script not loading**: Verify the script tag is in (not ), the src URL is correct, and no CSP or network error appears in the console. -**Events not appearing in dashboard**: Data typically appears within 30 seconds. Check the Network tab for failed requests to basket.databuddy.cc. Verify the Client ID matches. Check for console errors. +**Events not appearing in dashboard**: Data typically appears within a few minutes. Check the Network tab for failed requests to basket.databuddy.cc. Verify the Client ID matches. Check for console errors. **If another analytics tool is present**: Both can run in parallel. No conflicts. Optionally disable the other tool's page view tracking if Databuddy handles it.`; } 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)/organizations/settings/audit/page.tsx b/apps/dashboard/app/(main)/organizations/settings/audit/page.tsx index b7e8ecad59..a2df8b4950 100644 --- a/apps/dashboard/app/(main)/organizations/settings/audit/page.tsx +++ b/apps/dashboard/app/(main)/organizations/settings/audit/page.tsx @@ -66,6 +66,11 @@ type TargetFilter = | "member" | "invitation"; +interface AuditFilterOption { + label: string; + value: T; +} + const outcomeFilterLabels: Record = { all: "All outcomes", denied: "Denied", @@ -73,31 +78,37 @@ const outcomeFilterLabels: Record = { success: "Successful", }; -const dateRangeFilterLabels: Record = { - all: "All time", - "7d": "Last 7 days", - "30d": "Last 30 days", - "90d": "Last 90 days", -}; +const actionFilterOptions: AuditFilterOption[] = [ + { label: "All actions", value: "all" }, + ...auditActionNames.map((value) => ({ + label: getAuditActionLabel(value), + value, + })), +]; -const targetFilterLabels: Record = { - all: "All resources", - api_key: "API keys", - flag: "Feature flags", - website: "Websites", - organization: "Organizations", - member: "Members", - invitation: "Invitations", -}; +const outcomeFilterOptions: AuditFilterOption[] = [ + { label: outcomeFilterLabels.all, value: "all" }, + ...auditOutcomes.map((value) => ({ + label: outcomeFilterLabels[value], + value, + })), +]; -const targetFilterOptions: TargetFilter[] = [ - "all", - "api_key", - "flag", - "website", - "organization", - "member", - "invitation", +const targetFilterOptions: AuditFilterOption[] = [ + { label: "All resources", value: "all" }, + { label: "API keys", value: "api_key" }, + { label: "Feature flags", value: "flag" }, + { label: "Websites", value: "website" }, + { label: "Organizations", value: "organization" }, + { label: "Members", value: "member" }, + { label: "Invitations", value: "invitation" }, +]; + +const dateRangeFilterOptions: AuditFilterOption[] = [ + { label: "All time", value: "all" }, + { label: "Last 7 days", value: "7d" }, + { label: "Last 30 days", value: "30d" }, + { label: "Last 90 days", value: "90d" }, ]; const sensitiveAuditFieldPattern = /(^|_)(key|password|secret|token)(_|$)/i; @@ -114,10 +125,6 @@ function getAuditDateRange( return { from, to }; } -function getActionFilterLabel(action: ActionFilter): string { - return action === "all" ? "All actions" : getAuditActionLabel(action); -} - function getErrorCode(error: unknown): string | undefined { if (!(error && typeof error === "object")) { return; @@ -449,6 +456,53 @@ function AuditEventRow({ ); } +function AuditFilterMenu({ + onChange, + options, + value, +}: { + onChange: (value: T) => void; + options: readonly AuditFilterOption[]; + value: T; +}) { + const selectedOption = options.find((option) => option.value === value); + + return ( + + + {selectedOption?.label} + + ); +} + function AuditFilters({ actionFilter, dateRangeFilter, @@ -488,122 +542,26 @@ function AuditFilters({ Filter activity
    - - - {getActionFilterLabel(actionFilter)} - - - - {outcomeFilterLabels[outcomeFilter]} - - - - {targetFilterLabels[targetFilter]} - - - - {dateRangeFilterLabels[dateRangeFilter]} - + + + + {hasFilters ? ( - )} - - - - ); -} diff --git a/apps/dashboard/app/(main)/settings/appearance/_components/chart-type-option.tsx b/apps/dashboard/app/(main)/settings/appearance/_components/chart-type-option.tsx new file mode 100644 index 0000000000..4e08a0c32e --- /dev/null +++ b/apps/dashboard/app/(main)/settings/appearance/_components/chart-type-option.tsx @@ -0,0 +1,15 @@ +import type { ChartBarIcon } from "@databuddy/ui/icons"; + +interface ChartTypeOptionProps { + icon: typeof ChartBarIcon; + label: string; +} + +export function ChartTypeOption({ icon: Icon, label }: ChartTypeOptionProps) { + return ( + + + {label} + + ); +} diff --git a/apps/dashboard/app/(main)/settings/appearance/page.tsx b/apps/dashboard/app/(main)/settings/appearance/page.tsx index c7899c4fd0..8c3d1eed1d 100644 --- a/apps/dashboard/app/(main)/settings/appearance/page.tsx +++ b/apps/dashboard/app/(main)/settings/appearance/page.tsx @@ -34,6 +34,7 @@ import { } from "@databuddy/ui/icons"; import { Select } from "@databuddy/ui/client"; import { Card, Text, Tooltip } from "@databuddy/ui"; +import { ChartTypeOption } from "./_components/chart-type-option"; const MOCK_CHART_DATA = [ { date: "2024-01-01", value: 186 }, @@ -68,6 +69,16 @@ const STEP_TYPE_OPTIONS: { id: ChartCurveType; name: string }[] = [ { id: "stepAfter", name: "Step After" }, ]; +const CHART_TYPE_ITEMS = CHART_TYPE_OPTIONS.map(({ id, name }) => ({ + label: name, + value: id, +})); + +const STEP_TYPE_ITEMS = STEP_TYPE_OPTIONS.map(({ id, name }) => ({ + label: name, + value: id, +})); + const DEFAULT_DATE_RANGE_OPTIONS: DefaultDateRangePreset[] = [ "24h", "7d", @@ -85,6 +96,11 @@ const LOCATION_ICONS: Record = { events: CursorClickIcon, }; +const CHART_LOCATION_ITEMS = CHART_LOCATIONS.map((value) => ({ + label: CHART_LOCATION_LABELS[value], + value, +})); + export default function AppearanceSettingsPage() { const { theme, setTheme } = useTheme(); const { defaultDateRange, setDefaultDateRange } = useDefaultDateRange(); @@ -191,6 +207,7 @@ export default function AppearanceSettingsPage() { {showGranular && ( updateAllPreferences({ chartType: v as ChartSeriesKind, @@ -250,14 +268,14 @@ export default function AppearanceSettingsPage() { {CHART_TYPE_OPTIONS.map(({ id, name, icon: OptIcon }) => ( - - {name} + ))} updateLocationPreferences(location, { chartType: v as ChartSeriesKind, @@ -367,11 +386,10 @@ export default function AppearanceSettingsPage() { {CHART_TYPE_OPTIONS.map( ({ id, name, icon: OptIcon }) => ( - - {name} ) )} @@ -379,6 +397,7 @@ export default function AppearanceSettingsPage() { - updateAllPreferences({ chartType: v }) - } - value={globalPrefs.chartType} - > - - - - - {CHART_TYPE_OPTIONS.map(({ id, name, icon: OptIcon }) => ( - -
    - - {name} -
    -
    - ))} -
    - - - - - - - - {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/feature-gate.tsx b/apps/dashboard/components/feature-gate.tsx index 4ad95af8f0..9ebc093366 100644 --- a/apps/dashboard/components/feature-gate.tsx +++ b/apps/dashboard/components/feature-gate.tsx @@ -4,6 +4,7 @@ import { FEATURE_METADATA, type GatedFeatureId, getMinimumPlanForFeature, + getPlanLimitMessage, PLAN_IDS, } from "@databuddy/shared/types/features"; import Link from "next/link"; @@ -171,6 +172,21 @@ export function FeatureGate({ ); } +export function usePlanLimitMessage( + feature: GatedFeatureId, + currentUsage: number +): string | null { + const { getGatedFeatureAccess, currentPlanId, isLoading } = + useBillingContext(); + const { limit, nextPlan } = getGatedFeatureAccess(feature); + + if (isLoading || typeof limit !== "number" || currentUsage < limit) { + return null; + } + + return getPlanLimitMessage(currentPlanId, feature, limit, nextPlan); +} + export function useFeatureGate(feature: GatedFeatureId) { const { isFeatureEnabled, diff --git a/apps/dashboard/components/icon.tsx b/apps/dashboard/components/icon.tsx index 895eaf222a..8d096ccb4a 100644 --- a/apps/dashboard/components/icon.tsx +++ b/apps/dashboard/components/icon.tsx @@ -68,9 +68,7 @@ const OS_ICON_EXT: Record = { const BROWSER_ICONS = Object.keys(BROWSER_ICON_EXT); const OS_ICONS = Object.keys(OS_ICON_EXT); -export type BrowserIconName = keyof typeof BROWSER_ICON_EXT; -export type OSIconName = keyof typeof OS_ICON_EXT; -export type IconType = "browser" | "os"; +type IconType = "browser" | "os"; interface PublicIconProps { className?: string; @@ -148,7 +146,7 @@ function createFallbackIcon( ); } -export function PublicIcon({ +function PublicIcon({ type, name, size = "md", diff --git a/apps/dashboard/components/layout/help-dialog.tsx b/apps/dashboard/components/layout/help-dialog.tsx deleted file mode 100644 index 80ed58a37b..0000000000 --- a/apps/dashboard/components/layout/help-dialog.tsx +++ /dev/null @@ -1,158 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { useState } from "react"; -import { KeyboardShortcuts } from "@/components/ui/keyboard-shortcuts"; -import { cn } from "@/lib/utils"; -import { - BookOpenIcon, - ChatTextIcon as ChatCircleIcon, - CommandIcon as KeyboardIcon, - PlayIcon, -} from "@databuddy/ui/icons"; -import { Button, Text } from "@databuddy/ui"; -import { Dialog } from "@databuddy/ui/client"; - -interface HelpDialogProps { - onOpenChangeAction: (open: boolean) => void; - open: boolean; -} - -const HELP_ITEMS = [ - { - href: "https://www.databuddy.cc/docs", - icon: BookOpenIcon, - title: "Documentation", - description: "Read guides and API references", - external: true, - }, - { - href: "mailto:support@databuddy.cc", - icon: ChatCircleIcon, - title: "Contact Support", - description: "Get help from our support team", - external: false, - }, - { - href: "https://www.youtube.com/@trydatabuddy", - icon: PlayIcon, - title: "Tutorials", - description: "Learn Databuddy step by step", - external: true, - }, -] as const; - -function HelpRow({ - children, - className, - ...rest -}: React.ButtonHTMLAttributes) { - return ( - - ); -} - -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/organization-selector.tsx b/apps/dashboard/components/layout/organization-selector.tsx index 6fc22c3c4e..c9b3da5364 100644 --- a/apps/dashboard/components/layout/organization-selector.tsx +++ b/apps/dashboard/components/layout/organization-selector.tsx @@ -274,38 +274,44 @@ export function OrganizationSelector({ }} open={isOpen} > - } + - - - {isSwitching - ? "Switching organization…" - : (activeOrganization?.name ?? "Select organization")} - - {isSwitching ? ( - - ) : ( - } + > + - )} - + + {isSwitching + ? "Switching organization…" + : (activeOrganization?.name ?? "Select organization")} + + {isSwitching ? ( + + ) : ( + + )} + + ); } - -export function ProfileButtonClient({ - user, -}: { - user: ProfileButtonUser | null; -}) { - const [isOpen, setIsOpen] = useState(false); - - if (!user) { - return null; - } - - return ( - - - } - > - - - - setIsOpen(false)} - user={user} - /> - - ); -} diff --git a/apps/dashboard/components/layout/sidebar-navigation-provider.tsx b/apps/dashboard/components/layout/sidebar-navigation-provider.tsx index a2d2c7a466..8d29029ec0 100644 --- a/apps/dashboard/components/layout/sidebar-navigation-provider.tsx +++ b/apps/dashboard/components/layout/sidebar-navigation-provider.tsx @@ -1,7 +1,6 @@ "use client"; import { authClient } from "@databuddy/auth/client"; -import { useQuery } from "@tanstack/react-query"; import { usePathname } from "next/navigation"; import { createContext, @@ -13,8 +12,6 @@ import { useState, } from "react"; import { useWebsitesLight } from "@/hooks/use-websites"; -import { insightQueries } from "@/lib/insight-api"; -import { useOrganizationsContext } from "@/components/providers/organizations-provider"; import { getNavContext, getNavDirection, @@ -59,8 +56,6 @@ export function SidebarNavigationProvider({ }) { const { data: session } = authClient.useSession(); const user = session?.user ?? null; - const { activeOrganizationId, isSwitchingOrganization } = - useOrganizationsContext(); const pathname = usePathname(); @@ -81,13 +76,6 @@ export function SidebarNavigationProvider({ ); const navContext = getNavContext(pathname); - const recommendationTotal = useQuery( - insightQueries.recommendationTotal( - navContext === "main" && !isSwitchingOrganization - ? (activeOrganizationId ?? undefined) - : undefined - ) - ); const prevContextRef = useRef(navContext); const [transitionDirection, setTransitionDirection] = useState< "left" | "right" | null @@ -104,29 +92,7 @@ export function SidebarNavigationProvider({ } }, [navContext]); - const navigation = useMemo(() => { - const baseNavigation = getNavigation(pathname); - const count = recommendationTotal.data ?? 0; - if (navContext !== "main" || isSwitchingOrganization || count === 0) { - return baseNavigation; - } - - return baseNavigation.map((group) => ({ - ...group, - items: group.items.map((item) => - item.href === "/insights" - ? { - ...item, - badge: { - label: `${count} current recommendation${count === 1 ? "" : "s"}`, - text: count > 99 ? "99+" : count.toString(), - variant: "red" as const, - }, - } - : item - ), - })); - }, [isSwitchingOrganization, navContext, pathname, recommendationTotal.data]); + const navigation = useMemo(() => getNavigation(pathname), [pathname]); const currentWebsiteId = isWebsite || isDemo ? websiteId : undefined; diff --git a/apps/dashboard/components/layout/sidebar.tsx b/apps/dashboard/components/layout/sidebar.tsx index b6e713dc12..afc9feb463 100644 --- a/apps/dashboard/components/layout/sidebar.tsx +++ b/apps/dashboard/components/layout/sidebar.tsx @@ -301,22 +301,20 @@ function useGroupCollapse(groupKey: string, hasActiveChild: boolean) { }, [groupKey, hasActiveChild]); const toggle = useCallback(() => { - setIsCollapsed((prev) => { - const next = !prev; - try { - const stored = JSON.parse( - localStorage.getItem(COLLAPSED_GROUPS_KEY) || "{}" - ); - if (next) { - stored[groupKey] = true; - } else { - delete stored[groupKey]; - } - localStorage.setItem(COLLAPSED_GROUPS_KEY, JSON.stringify(stored)); - } catch {} - return next; - }); - }, [groupKey]); + const next = !isCollapsed; + setIsCollapsed(next); + try { + const stored = JSON.parse( + localStorage.getItem(COLLAPSED_GROUPS_KEY) || "{}" + ); + if (next) { + stored[groupKey] = true; + } else { + delete stored[groupKey]; + } + localStorage.setItem(COLLAPSED_GROUPS_KEY, JSON.stringify(stored)); + } catch {} + }, [groupKey, isCollapsed]); return { isCollapsed, toggle }; } diff --git a/apps/dashboard/components/layout/top-bar.tsx b/apps/dashboard/components/layout/top-bar.tsx index c8d845eaea..7dc8b1a2e7 100644 --- a/apps/dashboard/components/layout/top-bar.tsx +++ b/apps/dashboard/components/layout/top-bar.tsx @@ -89,7 +89,9 @@ function useTopBarSlot(name: string, content: ReactNode) { const store = useStore(); const id = useId(); const contentRef = useRef(content); - contentRef.current = content; + useEffect(() => { + contentRef.current = content; + }, [content]); useEffect(() => { store.setSlot(name, id, contentRef.current); diff --git a/apps/dashboard/components/logo/branding.tsx b/apps/dashboard/components/logo/branding.tsx index ebfb16ce83..348c4f2c8e 100644 --- a/apps/dashboard/components/logo/branding.tsx +++ b/apps/dashboard/components/logo/branding.tsx @@ -1,20 +1,14 @@ import Image from "next/image"; import { cn } from "@/lib/utils"; -export type BrandVariant = - | "logomark" - | "wordmark" - | "primary-logo" - | "secondary-logo"; +type BrandVariant = "logomark" | "wordmark" | "primary-logo" | "secondary-logo"; export interface BrandingProps { className?: string; - /** Height of the primary asset in pixels (width follows the SVG viewBox aspect ratio). */ heightPx?: number; imageClassName?: string; priority?: boolean; variant: BrandVariant; - /** When `variant` is `logomark`, also show the wordmark asset beside the icon. */ wordmark?: boolean; } @@ -33,7 +27,6 @@ const BRAND_PATH: Record = { }; interface ThemeBrandImageProps { - /** Primary image alt; the dark-mode twin is decorative. */ alt: string; basePath: string; className?: string; diff --git a/apps/dashboard/components/logo/section-brand-overlay.tsx b/apps/dashboard/components/logo/section-brand-overlay.tsx index edbbff03a6..5f28c30419 100644 --- a/apps/dashboard/components/logo/section-brand-overlay.tsx +++ b/apps/dashboard/components/logo/section-brand-overlay.tsx @@ -3,9 +3,7 @@ import { cn } from "@/lib/utils"; export interface SectionBrandOverlayProps { className?: string; - /** Corner overlay vs inline (e.g. chart card header). */ layout?: "overlay" | "inline"; - /** When `layout` is `overlay`: horizontal corner. */ position?: "start" | "end"; } diff --git a/apps/dashboard/components/monitors/collapsible-section.tsx b/apps/dashboard/components/monitors/collapsible-section.tsx deleted file mode 100644 index a012d0302c..0000000000 --- a/apps/dashboard/components/monitors/collapsible-section.tsx +++ /dev/null @@ -1,66 +0,0 @@ -"use client"; - -import { AnimatePresence, motion } from "motion/react"; -import { cn } from "@/lib/utils"; -import { CaretDownIcon } from "@databuddy/ui/icons"; -import { Button } from "@databuddy/ui"; - -interface CollapsibleSectionProps { - badge?: number; - children: React.ReactNode; - icon: React.ComponentType<{ size?: number; weight?: "duotone" | "fill" }>; - isExpanded: boolean; - onToggleAction: () => void; - title: string; -} - -export function CollapsibleSection({ - icon: Icon, - title, - badge, - isExpanded, - onToggleAction, - children, -}: CollapsibleSectionProps) { - return ( -
    - - - - {isExpanded && ( - -
    {children}
    -
    - )} -
    -
    - ); -} diff --git a/apps/dashboard/components/monitors/monitor-row.tsx b/apps/dashboard/components/monitors/monitor-row.tsx index 9bca9cc559..80d8cb571c 100644 --- a/apps/dashboard/components/monitors/monitor-row.tsx +++ b/apps/dashboard/components/monitors/monitor-row.tsx @@ -22,7 +22,7 @@ import { PlayIcon, TrashIcon, } from "@databuddy/ui/icons"; -import { DropdownMenu } from "@databuddy/ui/client"; +import { DeleteDialog, DropdownMenu } from "@databuddy/ui/client"; import { Badge, Skeleton, dayjs, formatDateOnly } from "@databuddy/ui"; const GRANULARITY_LABELS: Record = { @@ -49,7 +49,6 @@ interface MonitorRowProps { url: string | null; name: string | null; granularity: string; - cron: string; isPaused: boolean; createdAt: Date | string; updatedAt: Date | string; @@ -69,6 +68,7 @@ function MonitorActions({ }: MonitorRowProps) { const [isPausing, setIsPausing] = useState(false); const [isTransferOpen, setIsTransferOpen] = useState(false); + const [isDeleteOpen, setIsDeleteOpen] = useState(false); const pauseMutation = useMutation({ ...orpc.uptime.pauseSchedule.mutationOptions(), @@ -124,6 +124,7 @@ function MonitorActions({ try { await deleteMutation.mutateAsync({ scheduleId: schedule.id }); toast.success("Monitor deleted"); + setIsDeleteOpen(false); onDeleteAction(); } catch (error) { const errorMessage = @@ -198,7 +199,7 @@ function MonitorActions({ setIsDeleteOpen(true)} variant="destructive" > @@ -207,6 +208,15 @@ function MonitorActions({
    + setIsDeleteOpen(false)} + onConfirm={handleDelete} + title="Delete Monitor" + /> + {schedule.organizationId ? ( = { @@ -52,9 +50,6 @@ interface MonitorSheetProps { cacheBust?: boolean; granularity: string; id: string; - jsonParsingConfig?: { - enabled: boolean; - } | null; name?: string | null; timeout?: number | null; url: string; @@ -141,10 +136,9 @@ export function MonitorSheet({ const [name, setName] = useState(""); const [url, setUrl] = useState(""); const [granularity, setGranularity] = - useState("ten_minutes"); + useState("ten_minutes"); const [timeoutMs, setTimeoutMs] = useState(null); const [cacheBust, setCacheBust] = useState(false); - const [jsonParsingEnabled, setJsonParsingEnabled] = useState(true); const [urlError, setUrlError] = useState(null); const createMutation = useMutation({ @@ -187,9 +181,7 @@ export function MonitorSheet({ await queryClient.invalidateQueries({ queryKey: orpc.alarms.list.key(), }); - } catch { - toast.error("Failed to update alert"); - } + } catch {} }; useEffect(() => { @@ -213,11 +205,10 @@ export function MonitorSheet({ setName(initialName); setUrl(initialUrl); setGranularity( - (schedule?.granularity as GranularityValue) ?? "ten_minutes" + parseUptimeGranularity(schedule?.granularity) ?? "ten_minutes" ); setTimeoutMs(schedule?.timeout ?? null); setCacheBust(schedule?.cacheBust ?? false); - setJsonParsingEnabled(schedule?.jsonParsingConfig?.enabled ?? true); setUrlError(null); }, [open, schedule, website, isEditing]); @@ -246,8 +237,6 @@ export function MonitorSheet({ return; } - const jsonParsingConfig = { enabled: jsonParsingEnabled }; - try { if (isEditing && schedule) { await updateMutation.mutateAsync({ @@ -256,7 +245,6 @@ export function MonitorSheet({ granularity, timeout: timeoutMs, cacheBust, - jsonParsingConfig, }); toast.success("Monitor updated"); } else { @@ -272,18 +260,16 @@ export function MonitorSheet({ granularity, timeout: timeoutMs ?? undefined, cacheBust, - jsonParsingConfig, }); toast.success("Monitor created"); - onCreatedAction?.(result.scheduleId as string); + onCreatedAction?.(result.scheduleId); } onSaveAction?.(); onCloseAction(false); } catch {} }; - const advancedCount = - (timeoutMs ? 1 : 0) + (cacheBust ? 1 : 0) + (jsonParsingEnabled ? 0 : 1); + const advancedCount = (timeoutMs ? 1 : 0) + (cacheBust ? 1 : 0); return ( @@ -389,10 +375,12 @@ export function MonitorSheet({ max={120} min={1} onChange={(e) => { - const val = e.target.value; - setTimeoutMs(val ? Number(val) * 1000 : null); + const seconds = e.target.value; + setTimeoutMs( + seconds ? Number(seconds) * 1000 : null + ); }} - placeholder="30" + placeholder="60" suffix="sec" type="number" value={timeoutMs ? timeoutMs / 1000 : ""} @@ -408,16 +396,6 @@ export function MonitorSheet({ onCheckedChange={setCacheBust} /> - - - - diff --git a/apps/dashboard/components/openai-ads-pixel.tsx b/apps/dashboard/components/openai-ads-pixel.tsx index 987adac43b..eff4363c7c 100644 --- a/apps/dashboard/components/openai-ads-pixel.tsx +++ b/apps/dashboard/components/openai-ads-pixel.tsx @@ -89,7 +89,7 @@ export function OpenAiAdsPixel() { return null; } -export function measureOpenAiRegistrationCompleted(eventId?: string) { +function measureOpenAiRegistrationCompleted(eventId?: string) { if (!initOpenAiQueue()) { return; } diff --git a/apps/dashboard/components/organizations/api-key-types.ts b/apps/dashboard/components/organizations/api-key-types.ts index 48eaaf2af0..59f59c4f96 100644 --- a/apps/dashboard/components/organizations/api-key-types.ts +++ b/apps/dashboard/components/organizations/api-key-types.ts @@ -39,23 +39,6 @@ export function formatMaskedApiKey({ return `${startIncludesPrefix ? start : `${cleanPrefix}_${start}`}••••`; } -export type ApiResourceType = - | "global" - | "website" - | "ab_experiment" - | "feature_flag" - | "analytics_data" - | "error_data" - | "web_vitals" - | "custom_events" - | "export_data"; - -export interface ApiKeyAccessEntry { - resourceId?: string | null; - resourceType: ApiResourceType; - scopes: ApiScope[]; -} - export interface ApiKeyListItem { createdAt: Date; description?: string | null; @@ -79,20 +62,3 @@ export interface ApiKeyListItem { type: "user" | "sdk" | "automation"; updatedAt: Date; } - -export interface ApiKeyDetail extends ApiKeyListItem { - access: Array<{ id: string } & ApiKeyAccessEntry>; -} - -export interface CreateApiKeyInput { - access?: ApiKeyAccessEntry[]; - expiresAt?: string; - globalScopes?: ApiScope[]; - metadata?: Record; - name: string; - organizationId: string; - rateLimitEnabled?: boolean; - rateLimitMax?: number; - rateLimitTimeWindow?: number; - type?: "user" | "sdk" | "automation"; -} diff --git a/apps/dashboard/components/providers/billing-provider.tsx b/apps/dashboard/components/providers/billing-provider.tsx index 095612cd67..92536adf5a 100644 --- a/apps/dashboard/components/providers/billing-provider.tsx +++ b/apps/dashboard/components/providers/billing-provider.tsx @@ -3,9 +3,12 @@ import { FEATURE_METADATA, type FeatureId, + type FeatureLimit, type GatedFeatureId, getMinimumPlanForFeature, + getNextPlanForFeature, getPlanCapabilities as getPlanCapabilitiesForPlan, + getPlanFeatureLimit, isPlanFeatureEnabled, PLAN_IDS, type PlanCapabilities, @@ -22,7 +25,7 @@ type HookCustomer = NonNullable["data"]>; type HookPlan = NonNullable["data"]>[number]; type HookBalance = NonNullable[string]; -export interface FeatureAccess { +interface FeatureAccess { allowed: boolean; balance: number; limit: number; @@ -30,9 +33,11 @@ export interface FeatureAccess { usagePercent: number | null; } -export interface GatedFeatureAccess { +interface GatedFeatureAccess { allowed: boolean; + limit: FeatureLimit; minPlan: PlanId | null; + nextPlan: PlanId | null; upgradeMessage: string | null; } @@ -86,7 +91,9 @@ const DEMO_BILLING_VALUE: BillingContextValue = { isFeatureEnabled: () => true, getGatedFeatureAccess: () => ({ allowed: true, + limit: "unlimited", minPlan: null, + nextPlan: null, upgradeMessage: null, }), getUpgradeMessage: () => null, @@ -242,7 +249,9 @@ function AuthenticatedBillingProvider({ const allowed = isPlanFeatureEnabled(currentPlanId, feature); return { allowed, + limit: getPlanFeatureLimit(currentPlanId, feature), minPlan: getMinimumPlanForFeature(feature), + nextPlan: getNextPlanForFeature(currentPlanId, feature), upgradeMessage: allowed ? null : (FEATURE_METADATA[feature]?.upgradeMessage ?? null), diff --git a/apps/dashboard/components/table/data-table.tsx b/apps/dashboard/components/table/data-table.tsx index 1a378bc1a9..62bf43ea9a 100644 --- a/apps/dashboard/components/table/data-table.tsx +++ b/apps/dashboard/components/table/data-table.tsx @@ -98,6 +98,7 @@ export function DataTable({ const [activeTab, setActiveTab] = useState(tabs?.[0]?.id || ""); const { fullScreen, setFullScreen, hasMounted, modalRef } = useFullScreen(); + const portalTarget = typeof document === "undefined" ? null : document.body; const currentTabData = tabs?.find((tab) => tab.id === activeTab); const tableData = currentTabData?.data || data || []; @@ -175,6 +176,7 @@ export function DataTable({ {hasMounted && fullScreen && + portalTarget && ReactDOM.createPortal(
    ({ />
    , - document.body + portalTarget )} ); diff --git a/apps/dashboard/components/table/rows/icon-text-row.tsx b/apps/dashboard/components/table/rows/icon-text-row.tsx index 3d026a4401..cb0ff5c3b5 100644 --- a/apps/dashboard/components/table/rows/icon-text-row.tsx +++ b/apps/dashboard/components/table/rows/icon-text-row.tsx @@ -1,76 +1 @@ -import type { CellContext, ColumnDef } from "@tanstack/react-table"; -import type { ReactNode } from "react"; -import { formatNumber } from "@/lib/formatters"; -import { PercentageBadge } from "@databuddy/ui"; - -export interface IconTextEntry { - name: string; - pageviews?: number; - percentage?: number; - visitors: number; -} - -interface IconTextRowProps { - accessorKey?: string; - getIcon: (name: string, entry?: IconTextEntry) => ReactNode; - getSubtitle?: (entry: IconTextEntry) => string | undefined; - header: string; - includeMetrics?: boolean; -} - -export function createIconTextColumns({ - header, - accessorKey = "name", - getIcon, - getSubtitle, - includeMetrics = true, -}: IconTextRowProps): ColumnDef[] { - const columns: ColumnDef[] = [ - { - id: accessorKey, - accessorKey, - header, - cell: (info: CellContext) => { - const name = (info.getValue() as string) || ""; - const entry = info.row.original; - const subtitle = getSubtitle?.(entry); - - return ( -
    - {getIcon(name, entry)} -
    -
    {name}
    - {subtitle && ( -
    {subtitle}
    - )} -
    -
    - ); - }, - }, - ]; - - if (includeMetrics) { - columns.push( - { - id: "visitors", - accessorKey: "visitors", - header: "Visitors", - cell: (info: CellContext) => ( - {formatNumber(info.getValue())} - ), - }, - { - id: "percentage", - accessorKey: "percentage", - header: "Share", - cell: (info: CellContext) => { - const percentage = info.getValue() as number; - return ; - }, - } - ); - } - - return columns; -} +export {}; diff --git a/apps/dashboard/components/table/rows/referrer-row.tsx b/apps/dashboard/components/table/rows/referrer-row.tsx index 711b17ed39..e8e1eb78a4 100644 --- a/apps/dashboard/components/table/rows/referrer-row.tsx +++ b/apps/dashboard/components/table/rows/referrer-row.tsx @@ -30,7 +30,7 @@ const DEFAULT_REFERRER_METRICS: ReferrerMetricColumn[] = [ { id: "pageviews", header: "Views" }, ]; -export function getReferrerDisplayValue(row: ReferrerSourceCellData): string { +function getReferrerDisplayValue(row: ReferrerSourceCellData): string { return row.name || row.source || row.referrer || "Direct"; } diff --git a/apps/dashboard/components/table/table-content.tsx b/apps/dashboard/components/table/table-content.tsx index cdffb99aff..10b931090f 100644 --- a/apps/dashboard/components/table/table-content.tsx +++ b/apps/dashboard/components/table/table-content.tsx @@ -40,7 +40,7 @@ const DEFAULT_CELL_STYLE = { const COMPACT_COLUMN_WIDTHS: Record = { clicks: 88, - cls: 76, + cls: 78, current_time: 108, customers: 100, fcp: 88, diff --git a/apps/dashboard/components/ui/alert.tsx b/apps/dashboard/components/ui/alert.tsx index 240bf847df..3a01dbfb30 100644 --- a/apps/dashboard/components/ui/alert.tsx +++ b/apps/dashboard/components/ui/alert.tsx @@ -60,4 +60,4 @@ function AlertDescription({ ); } -export { Alert, AlertDescription, AlertTitle }; +export { Alert, AlertDescription, }; diff --git a/apps/dashboard/components/ui/aspect-ratio.tsx b/apps/dashboard/components/ui/aspect-ratio.tsx deleted file mode 100644 index 956e8bb05d..0000000000 --- a/apps/dashboard/components/ui/aspect-ratio.tsx +++ /dev/null @@ -1,11 +0,0 @@ -"use client"; - -import { AspectRatio as AspectRatioPrimitive } from "radix-ui"; - -function AspectRatio({ - ...props -}: React.ComponentProps) { - return ; -} - -export { AspectRatio }; diff --git a/apps/dashboard/components/ui/badge.tsx b/apps/dashboard/components/ui/badge.tsx deleted file mode 100644 index 3c6f47bfdd..0000000000 --- a/apps/dashboard/components/ui/badge.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { Slot } from "@radix-ui/react-slot"; -import { cva, type VariantProps } from "class-variance-authority"; -import type * as React from "react"; - -import { cn } from "@/lib/utils"; - -const badgeVariants = cva( - "inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded border px-2 py-0.5 font-medium text-xs transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3", - { - variants: { - variant: { - default: - "border border-brand-purple/35 bg-brand-purple text-white dark:border-brand-purple/55 dark:bg-brand-purple dark:text-white [a&]:hover:bg-brand-purple/90", - gray: "border border-border bg-muted text-muted-foreground dark:border-border dark:bg-secondary dark:text-muted-foreground [a&]:hover:bg-muted/90", - blue: "border border-brand-purple/25 bg-brand-purple/10 text-brand-purple dark:border-brand-purple/40 dark:bg-brand-purple/18 dark:text-[#C9BFE8] [a&]:hover:bg-brand-purple/15", - green: - "border border-emerald-600/25 bg-emerald-50 text-emerald-800 dark:border-emerald-500/35 dark:bg-emerald-950/50 dark:text-emerald-300 [a&]:hover:bg-emerald-100/90", - amber: - "border border-brand-amber/30 bg-brand-amber/12 text-amber-950 dark:border-brand-amber/40 dark:bg-brand-amber/14 dark:text-amber-300 [a&]:hover:bg-brand-amber/18", - secondary: - "border border-foreground/15 bg-foreground text-background dark:border-foreground/25 dark:bg-foreground dark:text-background [a&]:hover:bg-foreground/90", - destructive: - "border border-brand-coral/30 bg-brand-coral/12 text-brand-coral focus-visible:ring-brand-coral/20 dark:border-brand-coral/45 dark:bg-brand-coral/22 dark:text-[#E8A8BE] [a&]:hover:bg-brand-coral/18", - outline: - "border border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", - }, - }, - defaultVariants: { - variant: "default", - }, - } -); - -function Badge({ - className, - variant, - asChild = false, - ...props -}: React.ComponentProps<"span"> & - VariantProps & { asChild?: boolean }) { - const Comp = asChild ? Slot : "span"; - - return ( - - ); -} - -export { Badge, badgeVariants }; diff --git a/apps/dashboard/components/ui/button-group.tsx b/apps/dashboard/components/ui/button-group.tsx deleted file mode 100644 index 69c616fcd6..0000000000 --- a/apps/dashboard/components/ui/button-group.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { Slot } from "@radix-ui/react-slot"; -import { cva, type VariantProps } from "class-variance-authority"; -import { cn } from "@/lib/utils"; -import { Divider } from "@databuddy/ui"; - -const buttonGroupVariants = cva( - "flex w-fit items-stretch has-[>[data-slot=button-group]]:gap-2 [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1", - { - variants: { - orientation: { - horizontal: - "[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none", - vertical: - "flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none", - }, - }, - defaultVariants: { - orientation: "horizontal", - }, - } -); - -function ButtonGroup({ - className, - orientation, - ...props -}: React.ComponentProps<"div"> & VariantProps) { - return ( -
    - ); -} - -function ButtonGroupText({ - className, - asChild = false, - ...props -}: React.ComponentProps<"div"> & { - asChild?: boolean; -}) { - const Comp = asChild ? Slot : "div"; - - return ( - - ); -} - -function ButtonGroupSeparator({ - className, - orientation = "vertical", - ...props -}: React.ComponentProps) { - return ( - - ); -} - -export { - ButtonGroup, - ButtonGroupSeparator, - ButtonGroupText, - buttonGroupVariants, -}; diff --git a/apps/dashboard/components/ui/button.tsx b/apps/dashboard/components/ui/button.tsx index 5948bdef7d..88423af602 100644 --- a/apps/dashboard/components/ui/button.tsx +++ b/apps/dashboard/components/ui/button.tsx @@ -65,4 +65,4 @@ function Button({ ); } -export { Button, buttonVariants }; +export { Button, }; diff --git a/apps/dashboard/components/ui/card.tsx b/apps/dashboard/components/ui/card.tsx deleted file mode 100644 index aa9c6f8a63..0000000000 --- a/apps/dashboard/components/ui/card.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import type * as React from "react"; - -import { cn } from "@/lib/utils"; - -function Card({ className, ...props }: React.ComponentProps<"div">) { - return ( -
    - ); -} - -function CardHeader({ className, ...props }: React.ComponentProps<"div">) { - return ( -
    - ); -} - -function CardTitle({ className, ...props }: React.ComponentProps<"div">) { - return ( -
    - ); -} - -function CardDescription({ className, ...props }: React.ComponentProps<"div">) { - return ( -
    - ); -} - -function CardAction({ className, ...props }: React.ComponentProps<"div">) { - return ( -
    - ); -} - -function CardContent({ className, ...props }: React.ComponentProps<"div">) { - return ( -
    - ); -} - -function CardFooter({ className, ...props }: React.ComponentProps<"div">) { - return ( -
    - ); -} - -export { - Card, - CardAction, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -}; diff --git a/apps/dashboard/components/ui/carousel.tsx b/apps/dashboard/components/ui/carousel.tsx deleted file mode 100644 index b4134c2143..0000000000 --- a/apps/dashboard/components/ui/carousel.tsx +++ /dev/null @@ -1,249 +0,0 @@ -"use client"; - -import useEmblaCarousel, { - type UseEmblaCarouselType, -} from "embla-carousel-react"; -import * as React from "react"; -import { cn } from "@/lib/utils"; -import { - ArrowLeftIcon, - ArrowRightIcon, -} from "@databuddy/ui/icons"; -import { Button } from "@databuddy/ui"; - -type CarouselApi = UseEmblaCarouselType[1]; -type UseCarouselParameters = Parameters; -type CarouselOptions = UseCarouselParameters[0]; -type CarouselPlugin = UseCarouselParameters[1]; - -type CarouselProps = { - opts?: CarouselOptions; - plugins?: CarouselPlugin; - orientation?: "horizontal" | "vertical"; - setApi?: (api: CarouselApi) => void; -}; - -type CarouselContextProps = { - carouselRef: ReturnType[0]; - api: ReturnType[1]; - scrollPrev: () => void; - scrollNext: () => void; - canScrollPrev: boolean; - canScrollNext: boolean; -} & CarouselProps; - -const CarouselContext = React.createContext(null); - -function useCarousel() { - const context = React.useContext(CarouselContext); - - if (!context) { - throw new Error("useCarousel must be used within a "); - } - - return context; -} - -function Carousel({ - orientation = "horizontal", - opts, - setApi, - plugins, - className, - children, - ...props -}: React.ComponentProps<"div"> & CarouselProps) { - const [carouselRef, api] = useEmblaCarousel( - { - ...opts, - axis: orientation === "horizontal" ? "x" : "y", - }, - plugins - ); - const [canScrollPrev, setCanScrollPrev] = React.useState(false); - const [canScrollNext, setCanScrollNext] = React.useState(false); - - const onSelect = React.useCallback((api: CarouselApi) => { - if (!api) { - return; - } - setCanScrollPrev(api.canScrollPrev()); - setCanScrollNext(api.canScrollNext()); - }, []); - - const scrollPrev = React.useCallback(() => { - api?.scrollPrev(); - }, [api]); - - const scrollNext = React.useCallback(() => { - api?.scrollNext(); - }, [api]); - - const handleKeyDown = React.useCallback( - (event: React.KeyboardEvent) => { - if (event.key === "ArrowLeft") { - event.preventDefault(); - scrollPrev(); - } else if (event.key === "ArrowRight") { - event.preventDefault(); - scrollNext(); - } - }, - [scrollPrev, scrollNext] - ); - - React.useEffect(() => { - if (!(api && setApi)) { - return; - } - setApi(api); - }, [api, setApi]); - - React.useEffect(() => { - if (!api) { - return; - } - onSelect(api); - api.on("reInit", onSelect); - api.on("select", onSelect); - - return () => { - api?.off("select", onSelect); - }; - }, [api, onSelect]); - - return ( - -
    - {children} -
    -
    - ); -} - -function CarouselContent({ className, ...props }: React.ComponentProps<"div">) { - const { carouselRef, orientation } = useCarousel(); - - return ( -
    -
    -
    - ); -} - -function CarouselItem({ className, ...props }: React.ComponentProps<"div">) { - const { orientation } = useCarousel(); - - return ( -
    - ); -} - -function CarouselPrevious({ - className, - variant = "secondary", - size = "sm", - ...props -}: React.ComponentProps) { - const { orientation, scrollPrev, canScrollPrev } = useCarousel(); - - return ( - - ); -} - -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 ( -